Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f8b421d68 | ||
|
|
3769447604 | ||
|
|
7d4c400084 | ||
|
|
32bf103f15 | ||
|
|
47c3874244 | ||
|
|
b7794a91b4 | ||
|
|
470ff51579 | ||
|
|
d09ea6aeab | ||
|
|
8a4eb267f0 | ||
|
|
63db0b11bc | ||
|
|
8dfa71b81e | ||
|
|
d140fe1061 | ||
|
|
b702cd6e4c | ||
|
|
211f9f280c | ||
|
|
6d96c92233 | ||
|
|
52cf9d86c0 | ||
|
|
a2507b1373 | ||
|
|
1f8c4331c7 | ||
|
|
faadc3ea3e | ||
|
|
32b576123c | ||
|
|
5dce24e3ea | ||
|
|
bf824f8561 | ||
|
|
3a7ddfdff1 | ||
|
|
431ec1e0f5 | ||
|
|
e2368ddfd7 | ||
|
|
6f4bc7c3bb | ||
|
|
3937a403b1 | ||
|
|
f003a34dc0 | ||
|
|
dc4ceabc7b | ||
|
|
e13d7f5e0f | ||
|
|
03a1644df7 | ||
|
|
9a6a8ba7fa | ||
|
|
3b886e47d2 | ||
|
|
06201a9fc4 | ||
|
|
ef448806aa | ||
|
|
8a33f5ab55 | ||
|
|
ab3922f9e6 | ||
|
|
5dbff4c3e0 | ||
|
|
4dde62ac58 | ||
|
|
1d3335746b | ||
|
|
c6d00e8b3f | ||
|
|
9ef7d439d2 | ||
|
|
c53a231c41 | ||
|
|
705e6dac54 | ||
|
|
daef2521f1 | ||
|
|
0640edc9c9 | ||
|
|
7068588c58 | ||
|
|
de0753f0ce | ||
|
|
d027d04f64 | ||
|
|
c4ca9be7b5 | ||
|
|
180a4ccab4 | ||
|
|
78512f8039 | ||
|
|
7cdede6de8 | ||
|
|
7ec5329576 | ||
|
|
5d0232e5de | ||
|
|
15c5f742f4 | ||
|
|
b4cd8c946d | ||
|
|
ee9b9f6e14 | ||
|
|
01abe3dc02 | ||
|
|
b957d05636 | ||
|
|
2a4ccff96e | ||
|
|
b5f869ed25 | ||
|
|
50c1b0f4b3 | ||
|
|
887600c03a | ||
|
|
0fdebacc0b | ||
|
|
4d5bb7e575 | ||
|
|
2d841c0a2f | ||
|
|
e40c3488fe | ||
|
|
04686aafc8 | ||
|
|
9476afc41c | ||
|
|
ab6a1a412c | ||
|
|
2cf1e23351 | ||
|
|
0089d4a705 | ||
|
|
c726fbc379 | ||
|
|
83f6a1a9f9 | ||
|
|
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 | ||
|
|
26fa1ea98e | ||
|
|
e568e4a2b5 | ||
|
|
4a0386472d | ||
|
|
b9001c27c5 | ||
|
|
e6e62e2992 | ||
|
|
f53d333198 | ||
|
|
adcf0b6582 | ||
|
|
11c2498be6 | ||
|
|
7d41afb5f1 | ||
|
|
d4bc0bc622 | ||
|
|
5241d52b14 | ||
|
|
9887a78889 | ||
|
|
759e369d42 | ||
|
|
db487dc49d | ||
|
|
a94a9791bc | ||
|
|
473cece09e | ||
|
|
aebe95d221 | ||
|
|
08e8fe2edd | ||
|
|
bca7082bb0 | ||
|
|
d9272d6d0e | ||
|
|
f8c4a434ed | ||
|
|
237cca5680 | ||
|
|
f0735dbc1e | ||
|
|
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 | ||
|
|
8b3c4189f1 | ||
|
|
db5fb0d125 | ||
|
|
9515d88e3c | ||
|
|
2bf721974b | ||
|
|
0c53dcfa80 | ||
|
|
034c086e31 | ||
|
|
76e9eb4aa0 | ||
|
|
f22d392b21 | ||
|
|
2539710075 | ||
|
|
6bdc87aed6 | ||
|
|
268b92c59b | ||
|
|
c89bbd5098 | ||
|
|
2715f44a5e | ||
|
|
305ddef900 | ||
|
|
7e56d33bf0 | ||
|
|
80daf03fa6 | ||
|
|
883059b031 | ||
|
|
d077b5dd26 | ||
|
|
d79ccc480d | ||
|
|
7b0d6dc7e9 | ||
|
|
b8d7b8997c | ||
|
|
0bb34ca74b | ||
|
|
99c4fbc30d | ||
|
|
a44257edda | ||
|
|
ebb80df24a | ||
|
|
5165715d37 | ||
|
|
73ee6eb2f3 | ||
|
|
161d5d1e7f | ||
|
|
3cbd04b296 | ||
|
|
859f7f120c | ||
|
|
fea29f7318 | ||
|
|
f663b83ac8 | ||
|
|
ee99836285 | ||
|
|
2086c348a9 | ||
|
|
a8abf71bfe | ||
|
|
8dca670358 |
@@ -1,20 +0,0 @@
|
|||||||
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: '^_' }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
2
.github/workflows/release.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
2
.gitignore
vendored
@@ -10,6 +10,7 @@ api.md
|
|||||||
usage.json
|
usage.json
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
management-api*
|
||||||
antigravity_usage.json
|
antigravity_usage.json
|
||||||
codex_usage.json
|
codex_usage.json
|
||||||
style.md
|
style.md
|
||||||
@@ -18,6 +19,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
skills
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
settings.local.json
|
settings.local.json
|
||||||
|
|||||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Router-For.ME
|
||||||
|
|
||||||
|
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.
|
||||||
51
README.md
@@ -1,23 +1,23 @@
|
|||||||
# CLI Proxy API Management Center
|
# CLI Proxy API Management Center
|
||||||
|
|
||||||
A single-file WebUI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
|
A single-file Web UI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
|
||||||
|
|
||||||
[中文文档](README_CN.md)
|
[中文文档](README_CN.md)
|
||||||
|
|
||||||
**Main Project**: https://github.com/router-for-me/CLIProxyAPI
|
**Main Project**: https://github.com/router-for-me/CLIProxyAPI
|
||||||
**Example URL**: https://remote.router-for.me/
|
**Example URL**: https://remote.router-for.me/
|
||||||
**Minimum Required Version**: ≥ 6.3.0 (recommended ≥ 6.5.0)
|
**Minimum Required Version**: ≥ 6.8.0 (recommended ≥ 6.8.15)
|
||||||
|
|
||||||
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
|
Since version 6.0.19, the Web UI ships with the main program; access it via `/management.html` on the API port once the service is running.
|
||||||
|
|
||||||
## What this is (and isn’t)
|
## What this is (and isn’t)
|
||||||
|
|
||||||
- This repository is the WebUI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage.
|
- This repository is the Web UI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage.
|
||||||
- It is **not** a proxy and does not forward traffic.
|
- It is **not** a proxy and does not forward traffic.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
### Option A: Use the WebUI bundled in CLIProxyAPI (recommended)
|
### Option A: Use the Web UI bundled in CLI Proxy API (recommended)
|
||||||
|
|
||||||
1. Start your CLI Proxy API service.
|
1. Start your CLI Proxy API service.
|
||||||
2. Open: `http://<host>:<api_port>/management.html`
|
2. Open: `http://<host>:<api_port>/management.html`
|
||||||
@@ -32,7 +32,7 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:5173`, then connect to your CLI Proxy API instance.
|
Open `http://localhost:5173`, then connect to your CLI Proxy API backend instance.
|
||||||
|
|
||||||
### Option C: Build a single HTML file
|
### Option C: Build a single HTML file
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ npm run build
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Output: `dist/index.html` (all assets are inlined).
|
- Output: `dist/index.html` (all assets are inlined).
|
||||||
- For CLIProxyAPI bundling, the release workflow renames it to `management.html`.
|
- For CLI Proxy API bundling, the release workflow renames it to `management.html`.
|
||||||
- To preview locally: `npm run preview`
|
- To preview locally: `npm run preview`
|
||||||
|
|
||||||
Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
|
Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
|
||||||
@@ -74,19 +74,48 @@ See `api.md` for the full authentication rules, server-side limits, and edge cas
|
|||||||
## What you can manage (mapped to the UI pages)
|
## What you can manage (mapped to the UI pages)
|
||||||
|
|
||||||
- **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot.
|
- **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot.
|
||||||
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project/preview models), usage statistics, request logging, file logging, WebSocket auth.
|
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project or preview models when limits reached), usage statistics, request logging, file logging, WebSocket auth.
|
||||||
- **API Keys**: manage proxy `api-keys` (add/edit/delete).
|
- **API Keys**: manage proxy `api-keys` (add/edit/delete).
|
||||||
- **AI Providers**:
|
- **AI Providers**:
|
||||||
- Gemini/Codex/Claude key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
|
- Gemini/Codex/Claude/Vertex key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
|
||||||
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side “chat/completions” test).
|
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side "chat/completions" test).
|
||||||
- Ampcode integration (upstream URL/key, force mappings, model mapping table).
|
- Ampcode integration (upstream URL/key, force mappings, model mapping table).
|
||||||
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards).
|
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards), configure OAuth model alias mappings.
|
||||||
- **OAuth**: start OAuth/device flows for supported providers, poll status, optionally submit callback `redirect_url`; includes iFlow cookie import.
|
- **OAuth**: start OAuth/device flows for supported providers, poll status, optionally submit callback `redirect_url`; includes iFlow cookie import.
|
||||||
|
- **Quota Management**: manage quota limits and usage for Claude, Antigravity, Codex, Gemini CLI, and other providers.
|
||||||
- **Usage**: requests/tokens charts (hour/day), per-API & per-model breakdown, cached/reasoning token breakdown, RPM/TPM window, optional cost estimation with locally-saved model pricing.
|
- **Usage**: requests/tokens charts (hour/day), per-API & per-model breakdown, cached/reasoning token breakdown, RPM/TPM window, optional cost estimation with locally-saved model pricing.
|
||||||
- **Config**: edit `/config.yaml` in-browser with YAML highlighting + search, then save/reload.
|
- **Config**: edit `/config.yaml` in-browser with YAML highlighting + search, then save/reload.
|
||||||
- **Logs**: tail logs with incremental polling, auto-refresh, search, hide management traffic, clear logs; download request error log files.
|
- **Logs**: tail logs with incremental polling, auto-refresh, search, hide management traffic, clear logs; download request error log files.
|
||||||
- **System**: quick links + fetch `/v1/models` (grouped view). Requires at least one proxy API key to query models.
|
- **System**: quick links + fetch `/v1/models` (grouped view). Requires at least one proxy API key to query models.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- React 19 + TypeScript 5.9
|
||||||
|
- Vite 7 (single-file build)
|
||||||
|
- Zustand (state management)
|
||||||
|
- Axios (HTTP client)
|
||||||
|
- react-router-dom v7 (HashRouter)
|
||||||
|
- Chart.js (data visualization)
|
||||||
|
- CodeMirror 6 (YAML editor)
|
||||||
|
- SCSS Modules (styling)
|
||||||
|
- i18next (internationalization)
|
||||||
|
|
||||||
|
## Internationalization
|
||||||
|
|
||||||
|
Currently supports three languages:
|
||||||
|
|
||||||
|
- English (en)
|
||||||
|
- Simplified Chinese (zh-CN)
|
||||||
|
- Russian (ru)
|
||||||
|
|
||||||
|
The UI language is automatically detected from browser settings and can be manually switched at the bottom of the page.
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
- Build target: `ES2020`
|
||||||
|
- Supports modern browsers (Chrome, Firefox, Safari, Edge)
|
||||||
|
- Responsive layout for mobile and tablet access
|
||||||
|
|
||||||
## Build & release notes
|
## Build & release notes
|
||||||
|
|
||||||
- Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`).
|
- Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`).
|
||||||
|
|||||||
52
README_CN.md
@@ -1,14 +1,14 @@
|
|||||||
# CLI Proxy API 管理中心
|
# CLI Proxy API 管理中心
|
||||||
|
|
||||||
用于管理与排障 **CLI Proxy API** 的单文件 WebUI(React + TypeScript),通过 **Management API** 完成配置、凭据、日志与统计等运维工作。
|
用于管理与故障排查 **CLI Proxy API** 的单文件 Web UI(React + TypeScript),通过 **Management API** 完成配置、凭据、日志与统计等管理操作。
|
||||||
|
|
||||||
[English](README.md)
|
[English](README.md)
|
||||||
|
|
||||||
**主项目**: https://github.com/router-for-me/CLIProxyAPI
|
**主项目**: https://github.com/router-for-me/CLIProxyAPI
|
||||||
**示例地址**: https://remote.router-for.me/
|
**示例地址**: https://remote.router-for.me/
|
||||||
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0)
|
**最低版本要求**: ≥ 6.8.0(推荐 ≥ 6.8.15)
|
||||||
|
|
||||||
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
|
从6.0.19版本开始,Web UI 随主程序一起提供;服务运行后,通过 API 端口上的"/management.html"访问它。
|
||||||
|
|
||||||
## 这是什么(以及不是什么)
|
## 这是什么(以及不是什么)
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ Since version 6.0.19, the WebUI ships with the main program; access it via `/man
|
|||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 方式 A:使用 CLIProxyAPI 自带的 WebUI(推荐)
|
### 方式 A:使用 CLI Proxy API 自带的 Web UI(推荐)
|
||||||
|
|
||||||
1. 启动 CLI Proxy API 服务。
|
1. 启动 CLI Proxy API 服务。
|
||||||
2. 打开:`http://<host>:<api_port>/management.html`
|
2. 打开:`http://<host>:<api_port>/management.html`
|
||||||
@@ -32,7 +32,7 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
|
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 后端实例。
|
||||||
|
|
||||||
### 方式 C:构建单文件 HTML
|
### 方式 C:构建单文件 HTML
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ npm run build
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 构建产物:`dist/index.html`(资源已全部内联)。
|
- 构建产物:`dist/index.html`(资源已全部内联)。
|
||||||
- 在 CLIProxyAPI 的发布流程里会重命名为 `management.html`。
|
- 在 CLI Proxy API 的发布流程里会重命名为 `management.html`。
|
||||||
- 本地预览:`npm run preview`
|
- 本地预览:`npm run preview`
|
||||||
|
|
||||||
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
|
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
|
||||||
@@ -51,7 +51,7 @@ npm run build
|
|||||||
|
|
||||||
### API 地址怎么填
|
### API 地址怎么填
|
||||||
|
|
||||||
以下格式均可,WebUI 会自动归一化:
|
以下格式均可,Web UI 会自动归一化:
|
||||||
|
|
||||||
- `localhost:8317`
|
- `localhost:8317`
|
||||||
- `http://192.168.1.10:8317`
|
- `http://192.168.1.10:8317`
|
||||||
@@ -64,29 +64,57 @@ npm run build
|
|||||||
|
|
||||||
- `Authorization: Bearer <MANAGEMENT_KEY>`(默认)
|
- `Authorization: Bearer <MANAGEMENT_KEY>`(默认)
|
||||||
|
|
||||||
这与 WebUI 中“API Keys”页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
|
这与 Web UI 中"API Keys"页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
|
||||||
|
|
||||||
### 远程管理
|
### 远程管理
|
||||||
|
|
||||||
当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。
|
当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。
|
||||||
完整鉴权规则、限制与边界情况请查看 `api.md`。
|
|
||||||
|
|
||||||
## 功能一览(按页面对应)
|
## 功能一览(按页面对应)
|
||||||
|
|
||||||
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
|
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
|
||||||
- **基础设置**:调试开关、代理 URL、请求重试、配额回退(切项目/切预览模型)、使用统计、请求日志、文件日志、WebSocket 鉴权。
|
- **基础设置**:调试开关、代理 URL、请求重试、配额回退(达到上限时切换项目或预览模型)、使用统计、请求日志、文件日志、WebSocket 鉴权。
|
||||||
- **API Keys**:管理代理 `api-keys`(增/改/删)。
|
- **API Keys**:管理代理 `api-keys`(增/改/删)。
|
||||||
- **AI 提供商**:
|
- **AI 提供商**:
|
||||||
- Gemini/Codex/Claude 配置(Base URL、Headers、代理、模型别名、排除模型、Prefix)。
|
- Gemini/Codex/Claude/Vertex 配置(Base URL、Headers、代理、模型别名、排除模型、Prefix)。
|
||||||
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
|
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
|
||||||
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
|
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
|
||||||
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only;查看单个凭据可用模型(依赖后端支持);管理 OAuth 排除模型(支持 `*` 通配符)。
|
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only;查看单个凭据可用模型(依赖后端支持);管理 OAuth 排除模型(支持 `*` 通配符);配置 OAuth 模型别名映射。
|
||||||
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
|
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
|
||||||
|
- **配额管理**:管理 Claude、Antigravity、Codex、Gemini CLI 等提供商的配额上限与使用情况。
|
||||||
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
|
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
|
||||||
- **配置文件**:浏览器内编辑 `/config.yaml`(YAML 高亮 + 搜索),保存/重载。
|
- **配置文件**:浏览器内编辑 `/config.yaml`(YAML 高亮 + 搜索),保存/重载。
|
||||||
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
|
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
|
||||||
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
|
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- React 19 + TypeScript 5.9
|
||||||
|
- Vite 7(单文件构建)
|
||||||
|
- Zustand(状态管理)
|
||||||
|
- Axios(HTTP 客户端)
|
||||||
|
- react-router-dom v7(HashRouter)
|
||||||
|
- Chart.js(数据可视化)
|
||||||
|
- CodeMirror 6(YAML 编辑器)
|
||||||
|
- SCSS Modules(样式)
|
||||||
|
- i18next(国际化)
|
||||||
|
|
||||||
|
## 多语言支持
|
||||||
|
|
||||||
|
目前支持三种语言:
|
||||||
|
|
||||||
|
- 英文 (en)
|
||||||
|
- 简体中文 (zh-CN)
|
||||||
|
- 俄文 (ru)
|
||||||
|
|
||||||
|
界面语言会根据浏览器设置自动切换,也可在页面底部手动切换。
|
||||||
|
|
||||||
|
## 浏览器兼容性
|
||||||
|
|
||||||
|
- 构建目标:`ES2020`
|
||||||
|
- 支持 Chrome、Firefox、Safari、Edge 等现代浏览器
|
||||||
|
- 支持移动端响应式布局,可通过手机/平板访问
|
||||||
|
|
||||||
## 构建与发布说明
|
## 构建与发布说明
|
||||||
|
|
||||||
- 使用 Vite 输出 **单文件 HTML**(`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。
|
- 使用 Vite 输出 **单文件 HTML**(`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。
|
||||||
|
|||||||
54
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
|
"@codemirror/merge": "^6.12.0",
|
||||||
"@uiw/react-codemirror": "^4.25.3",
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
"react-i18next": "^16.4.0",
|
"react-i18next": "^16.4.0",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -428,6 +430,19 @@
|
|||||||
"crelt": "^1.0.5"
|
"crelt": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/merge": {
|
||||||
|
"version": "6.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.0.tgz",
|
||||||
|
"integrity": "sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"style-mod": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/search": {
|
"node_modules/@codemirror/search": {
|
||||||
"version": "6.5.11",
|
"version": "6.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
|
||||||
@@ -2383,13 +2398,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.2",
|
"version": "1.13.5",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.11",
|
||||||
"form-data": "^4.0.4",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3780,9 +3795,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.10.1",
|
"version": "7.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
|
||||||
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==",
|
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -3802,12 +3817,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.10.1",
|
"version": "7.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
|
||||||
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==",
|
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.10.1"
|
"react-router": "7.12.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -4227,6 +4242,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
|
"@codemirror/merge": "^6.12.0",
|
||||||
"@uiw/react-codemirror": "^4.25.3",
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
"react-i18next": "^16.4.0",
|
"react-i18next": "^16.4.0",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
41
src/App.tsx
@@ -1,32 +1,21 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { HashRouter, Route, Routes } from 'react-router-dom';
|
import { HashRouter, Route, Routes } from 'react-router-dom';
|
||||||
import { LoginPage } from '@/pages/LoginPage';
|
import { LoginPage } from '@/pages/LoginPage';
|
||||||
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||||
import { SplashScreen } from '@/components/common/SplashScreen';
|
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
|
||||||
import { MainLayout } from '@/components/layout/MainLayout';
|
import { MainLayout } from '@/components/layout/MainLayout';
|
||||||
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
||||||
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
|
import { useLanguageStore, useThemeStore } from '@/stores';
|
||||||
|
|
||||||
const SPLASH_DURATION = 1500;
|
|
||||||
const SPLASH_FADE_DURATION = 400;
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
||||||
const language = useLanguageStore((state) => state.language);
|
const language = useLanguageStore((state) => state.language);
|
||||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
|
||||||
|
|
||||||
const [splashReadyToFade, setSplashReadyToFade] = useState(false);
|
|
||||||
const [showSplash, setShowSplash] = useState(true);
|
|
||||||
const [authReady, setAuthReady] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanupTheme = initializeTheme();
|
const cleanupTheme = initializeTheme();
|
||||||
void restoreSession().finally(() => {
|
|
||||||
setAuthReady(true);
|
|
||||||
});
|
|
||||||
return cleanupTheme;
|
return cleanupTheme;
|
||||||
}, [initializeTheme, restoreSession]);
|
}, [initializeTheme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLanguage(language);
|
setLanguage(language);
|
||||||
@@ -37,30 +26,10 @@ function App() {
|
|||||||
document.documentElement.lang = language;
|
document.documentElement.lang = language;
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setSplashReadyToFade(true);
|
|
||||||
}, SPLASH_DURATION - SPLASH_FADE_DURATION);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSplashFinish = useCallback(() => {
|
|
||||||
setShowSplash(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (showSplash) {
|
|
||||||
return (
|
|
||||||
<SplashScreen
|
|
||||||
fadeOut={splashReadyToFade && authReady}
|
|
||||||
onFinish={handleSplashFinish}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<NotificationContainer />
|
<NotificationContainer />
|
||||||
|
<ConfirmationModal />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
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/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/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 |
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 |
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,26 +14,41 @@
|
|||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transform: translateZ(0);
|
||||||
|
|
||||||
// During animation, exit layer uses absolute positioning
|
// During animation, exit layer uses absolute positioning
|
||||||
&--exit {
|
&--exit {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
pointer-events: none;
|
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 {
|
&--animating &__layer {
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
backface-visibility: hidden;
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// When both layers exist, current layer also needs positioning
|
&--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) {
|
||||||
&--animating &__layer:not(&__layer--exit) {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import {
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { useLocation, type Location } from 'react-router-dom';
|
import { useLocation, type Location } from 'react-router-dom';
|
||||||
import gsap from 'gsap';
|
import gsap from 'gsap';
|
||||||
|
import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer';
|
||||||
import './PageTransition.scss';
|
import './PageTransition.scss';
|
||||||
|
|
||||||
interface PageTransitionProps {
|
interface PageTransitionProps {
|
||||||
render: (location: Location) => ReactNode;
|
render: (location: Location) => ReactNode;
|
||||||
getRouteOrder?: (pathname: string) => number | null;
|
getRouteOrder?: (pathname: string) => number | null;
|
||||||
|
getTransitionVariant?: (fromPathname: string, toPathname: string) => TransitionVariant;
|
||||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRANSITION_DURATION = 0.5;
|
const VERTICAL_TRANSITION_DURATION = 0.35;
|
||||||
const EXIT_DURATION = 0.45;
|
const VERTICAL_TRAVEL_DISTANCE = 60;
|
||||||
const ENTER_DELAY = 0.08;
|
const IOS_TRANSITION_DURATION = 0.42;
|
||||||
|
const IOS_ENTER_FROM_X_PERCENT = 100;
|
||||||
type LayerStatus = 'current' | 'exiting';
|
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 = {
|
type Layer = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -23,18 +34,25 @@ type Layer = {
|
|||||||
|
|
||||||
type TransitionDirection = 'forward' | 'backward';
|
type TransitionDirection = 'forward' | 'backward';
|
||||||
|
|
||||||
|
type TransitionVariant = 'vertical' | 'ios';
|
||||||
|
|
||||||
export function PageTransition({
|
export function PageTransition({
|
||||||
render,
|
render,
|
||||||
getRouteOrder,
|
getRouteOrder,
|
||||||
|
getTransitionVariant,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
}: PageTransitionProps) {
|
}: PageTransitionProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const currentLayerRef = useRef<HTMLDivElement>(null);
|
const currentLayerRef = useRef<HTMLDivElement>(null);
|
||||||
const exitingLayerRef = useRef<HTMLDivElement>(null);
|
const exitingLayerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const transitionDirectionRef = useRef<TransitionDirection>('forward');
|
||||||
|
const transitionVariantRef = useRef<TransitionVariant>('vertical');
|
||||||
const exitScrollOffsetRef = useRef(0);
|
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 [isAnimating, setIsAnimating] = useState(false);
|
||||||
const [transitionDirection, setTransitionDirection] = useState<TransitionDirection>('forward');
|
|
||||||
const [layers, setLayers] = useState<Layer[]>(() => [
|
const [layers, setLayers] = useState<Layer[]>(() => [
|
||||||
{
|
{
|
||||||
key: location.key,
|
key: location.key,
|
||||||
@@ -42,8 +60,10 @@ export function PageTransition({
|
|||||||
status: 'current',
|
status: 'current',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const currentLayerKey = layers[layers.length - 1]?.key ?? location.key;
|
const currentLayer =
|
||||||
const currentLayerPathname = layers[layers.length - 1]?.location.pathname;
|
layers.find((layer) => layer.status === 'current') ?? layers[layers.length - 1];
|
||||||
|
const currentLayerKey = currentLayer?.key ?? location.key;
|
||||||
|
const currentLayerPathname = currentLayer?.location.pathname;
|
||||||
|
|
||||||
const resolveScrollContainer = useCallback(() => {
|
const resolveScrollContainer = useCallback(() => {
|
||||||
if (scrollContainerRef?.current) return scrollContainerRef.current;
|
if (scrollContainerRef?.current) return scrollContainerRef.current;
|
||||||
@@ -51,12 +71,16 @@ export function PageTransition({
|
|||||||
return document.scrollingElement as HTMLElement | null;
|
return document.scrollingElement as HTMLElement | null;
|
||||||
}, [scrollContainerRef]);
|
}, [scrollContainerRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (isAnimating) return;
|
if (isAnimating) return;
|
||||||
if (location.key === currentLayerKey) return;
|
if (location.key === currentLayerKey) return;
|
||||||
if (currentLayerPathname === location.pathname) return;
|
if (currentLayerPathname === location.pathname) return;
|
||||||
const scrollContainer = resolveScrollContainer();
|
const scrollContainer = resolveScrollContainer();
|
||||||
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
|
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) => {
|
const resolveOrderIndex = (pathname?: string) => {
|
||||||
if (!getRouteOrder || !pathname) return null;
|
if (!getRouteOrder || !pathname) return null;
|
||||||
const index = getRouteOrder(pathname);
|
const index = getRouteOrder(pathname);
|
||||||
@@ -64,40 +88,110 @@ export function PageTransition({
|
|||||||
};
|
};
|
||||||
const fromIndex = resolveOrderIndex(currentLayerPathname);
|
const fromIndex = resolveOrderIndex(currentLayerPathname);
|
||||||
const toIndex = resolveOrderIndex(location.pathname);
|
const toIndex = resolveOrderIndex(location.pathname);
|
||||||
const nextDirection: TransitionDirection =
|
const nextVariant: TransitionVariant = getTransitionVariant
|
||||||
|
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
|
||||||
|
: 'vertical';
|
||||||
|
|
||||||
|
let nextDirection: TransitionDirection =
|
||||||
fromIndex === null || toIndex === null || fromIndex === toIndex
|
fromIndex === null || toIndex === null || fromIndex === toIndex
|
||||||
? 'forward'
|
? 'forward'
|
||||||
: toIndex > fromIndex
|
: toIndex > fromIndex
|
||||||
? 'forward'
|
? 'forward'
|
||||||
: 'backward';
|
: 'backward';
|
||||||
|
|
||||||
let cancelled = false;
|
// 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';
|
||||||
|
}
|
||||||
|
|
||||||
queueMicrotask(() => {
|
transitionDirectionRef.current = nextDirection;
|
||||||
if (cancelled) return;
|
transitionVariantRef.current = nextVariant;
|
||||||
setTransitionDirection(nextDirection);
|
|
||||||
setLayers((prev) => {
|
const shouldSkipExitLayer = (() => {
|
||||||
const prevCurrent = prev[prev.length - 1];
|
if (nextVariant !== 'ios' || nextDirection !== 'backward') return false;
|
||||||
return [
|
const normalizeSegments = (pathname: string) =>
|
||||||
prevCurrent
|
pathname
|
||||||
? { ...prevCurrent, status: 'exiting' }
|
.split('/')
|
||||||
: { key: location.key, location, status: 'exiting' },
|
.filter(Boolean)
|
||||||
{ key: location.key, location, status: 'current' },
|
.filter((segment) => segment.length > 0);
|
||||||
];
|
const fromSegments = normalizeSegments(currentLayerPathname ?? '');
|
||||||
});
|
const toSegments = normalizeSegments(location.pathname);
|
||||||
setIsAnimating(true);
|
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);
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [
|
}, [
|
||||||
isAnimating,
|
isAnimating,
|
||||||
location,
|
location,
|
||||||
currentLayerKey,
|
currentLayerKey,
|
||||||
currentLayerPathname,
|
currentLayerPathname,
|
||||||
getRouteOrder,
|
getRouteOrder,
|
||||||
|
getTransitionVariant,
|
||||||
resolveScrollContainer,
|
resolveScrollContainer,
|
||||||
|
layers,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Run GSAP animation when animating starts
|
// Run GSAP animation when animating starts
|
||||||
@@ -106,77 +200,181 @@ export function PageTransition({
|
|||||||
|
|
||||||
if (!currentLayerRef.current) return;
|
if (!currentLayerRef.current) return;
|
||||||
|
|
||||||
const scrollContainer = resolveScrollContainer();
|
const currentLayerEl = currentLayerRef.current;
|
||||||
const scrollOffset = exitScrollOffsetRef.current;
|
const exitingLayerEl = exitingLayerRef.current;
|
||||||
if (scrollContainer && scrollOffset > 0) {
|
const transitionVariant = transitionVariantRef.current;
|
||||||
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
|
||||||
|
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||||
|
if (exitingLayerEl) {
|
||||||
|
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerHeight = scrollContainer?.clientHeight ?? 0;
|
const scrollContainer = resolveScrollContainer();
|
||||||
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight;
|
const exitScrollOffset = exitScrollOffsetRef.current;
|
||||||
const travelDistance = Math.max(containerHeight, viewportHeight, 1);
|
const enterScrollOffset = enterScrollOffsetRef.current;
|
||||||
const enterFromY = transitionDirection === 'forward' ? travelDistance : -travelDistance;
|
if (scrollContainer && exitScrollOffset !== enterScrollOffset) {
|
||||||
const exitToY = transitionDirection === 'forward' ? -travelDistance : travelDistance;
|
scrollContainer.scrollTo({ top: enterScrollOffset, left: 0, behavior: 'auto' });
|
||||||
const exitBaseY = scrollOffset ? -scrollOffset : 0;
|
}
|
||||||
|
|
||||||
|
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({
|
const tl = gsap.timeline({
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting'));
|
const nextLayers = nextLayersRef.current;
|
||||||
|
nextLayersRef.current = null;
|
||||||
|
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
|
||||||
setIsAnimating(false);
|
setIsAnimating(false);
|
||||||
|
|
||||||
|
if (currentLayerEl) {
|
||||||
|
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||||
|
}
|
||||||
|
if (exitingLayerEl) {
|
||||||
|
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Exit animation: fly out to top (slow-to-fast)
|
if (transitionVariant === 'ios') {
|
||||||
if (exitingLayerRef.current) {
|
const exitToXPercent = isForward
|
||||||
gsap.set(exitingLayerRef.current, { y: exitBaseY });
|
? IOS_EXIT_TO_X_PERCENT_FORWARD
|
||||||
tl.fromTo(
|
: IOS_EXIT_TO_X_PERCENT_BACKWARD;
|
||||||
exitingLayerRef.current,
|
const enterFromXPercent = isForward
|
||||||
{ y: exitBaseY, opacity: 1 },
|
? 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,
|
||||||
{
|
{
|
||||||
y: exitBaseY + exitToY,
|
xPercent: 0,
|
||||||
opacity: 0,
|
opacity: 1,
|
||||||
duration: EXIT_DURATION,
|
duration: IOS_TRANSITION_DURATION,
|
||||||
ease: 'power2.in', // fast finish to clear screen
|
ease: 'power2.out',
|
||||||
force3D: true,
|
force3D: true,
|
||||||
},
|
},
|
||||||
0
|
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter animation: slide in from bottom (slow-to-fast)
|
|
||||||
tl.fromTo(
|
|
||||||
currentLayerRef.current,
|
|
||||||
{ y: enterFromY, opacity: 0 },
|
|
||||||
{
|
|
||||||
y: 0,
|
|
||||||
opacity: 1,
|
|
||||||
duration: TRANSITION_DURATION,
|
|
||||||
ease: 'power2.out', // smooth settle
|
|
||||||
clearProps: 'transform,opacity',
|
|
||||||
force3D: true,
|
|
||||||
},
|
|
||||||
ENTER_DELAY
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
tl.kill();
|
tl.kill();
|
||||||
gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]);
|
gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
|
||||||
};
|
};
|
||||||
}, [isAnimating, transitionDirection, resolveScrollContainer]);
|
}, [isAnimating, resolveScrollContainer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
||||||
{layers.map((layer) => (
|
{(() => {
|
||||||
|
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
|
<div
|
||||||
key={layer.key}
|
key={layer.key}
|
||||||
className={`page-transition__layer${
|
className={[
|
||||||
layer.status === 'exiting' ? ' page-transition__layer--exit' : ''
|
'page-transition__layer',
|
||||||
}`}
|
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
|
||||||
ref={layer.status === 'exiting' ? exitingLayerRef : currentLayerRef}
|
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
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{render(layer.location)}
|
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
|
||||||
|
{render(layer.location)}
|
||||||
|
</PageTransitionLayerContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
import './SplashScreen.scss';
|
import './SplashScreen.scss';
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ interface SplashScreenProps {
|
|||||||
const FADE_OUT_DURATION = 400;
|
const FADE_OUT_DURATION = 400;
|
||||||
|
|
||||||
export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
|
export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fadeOut) return;
|
if (!fadeOut) return;
|
||||||
const finishTimer = setTimeout(() => {
|
const finishTimer = setTimeout(() => {
|
||||||
@@ -25,8 +28,8 @@ export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
|
|||||||
<div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}>
|
<div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}>
|
||||||
<div className="splash-content">
|
<div className="splash-content">
|
||||||
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className="splash-logo" />
|
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className="splash-logo" />
|
||||||
<h1 className="splash-title">CLI Proxy API</h1>
|
<h1 className="splash-title">{t('splash.title')}</h1>
|
||||||
<p className="splash-subtitle">Management Center</p>
|
<p className="splash-subtitle">{t('splash.subtitle')}</p>
|
||||||
<div className="splash-loader">
|
<div className="splash-loader">
|
||||||
<div className="splash-loader-bar" />
|
<div className="splash-loader-bar" />
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
176
src/components/config/DiffModal.module.scss
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
.diffModal {
|
||||||
|
:global(.modal-body) {
|
||||||
|
padding: $spacing-md $spacing-lg;
|
||||||
|
max-height: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 70vh;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffList {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffCard {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffCardHeader {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px dashed var(--border-color);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffColumns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffColumn {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffColumnHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineMeta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineRange {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextRange {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeList {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 280px;
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLine {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--border-color) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLine:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLineChanged {
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLineNumber {
|
||||||
|
padding: 7px 10px 7px 8px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
border-right: 1px solid color-mix(in srgb, var(--border-color) 55%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 90%, transparent);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
user-select: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLineText {
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
.content {
|
||||||
|
height: 65vh;
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffColumns {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineMeta {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLine {
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLineNumber {
|
||||||
|
padding: 6px 6px 6px 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLineText {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/components/config/DiffModal.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Text } from '@codemirror/state';
|
||||||
|
import { Chunk } from '@codemirror/merge';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import styles from './DiffModal.module.scss';
|
||||||
|
|
||||||
|
type DiffModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
original: string;
|
||||||
|
modified: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiffChunkCard = {
|
||||||
|
id: string;
|
||||||
|
current: DiffSide;
|
||||||
|
modified: DiffSide;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LineRange = {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiffSideLine = {
|
||||||
|
lineNumber: number;
|
||||||
|
text: string;
|
||||||
|
changed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiffSide = {
|
||||||
|
changedRangeLabel: string;
|
||||||
|
contextRangeLabel: string;
|
||||||
|
lines: DiffSideLine[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFF_CONTEXT_LINES = 2;
|
||||||
|
|
||||||
|
const clampPos = (doc: Text, pos: number) => Math.max(0, Math.min(pos, doc.length));
|
||||||
|
|
||||||
|
const getLineRangeLabel = (range: LineRange): string => {
|
||||||
|
return range.start === range.end ? String(range.start) : `${range.start}-${range.end}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChangedLineRange = (doc: Text, from: number, to: number): LineRange => {
|
||||||
|
const start = clampPos(doc, from);
|
||||||
|
const end = clampPos(doc, to);
|
||||||
|
if (start === end) {
|
||||||
|
const linePos = Math.min(start, doc.length);
|
||||||
|
const anchorLine = doc.lineAt(linePos).number;
|
||||||
|
return { start: anchorLine, end: anchorLine };
|
||||||
|
}
|
||||||
|
const startLine = doc.lineAt(start).number;
|
||||||
|
const endLine = doc.lineAt(Math.max(start, end - 1)).number;
|
||||||
|
return { start: startLine, end: endLine };
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandContextRange = (doc: Text, range: LineRange): LineRange => ({
|
||||||
|
start: Math.max(1, range.start - DIFF_CONTEXT_LINES),
|
||||||
|
end: Math.min(doc.lines, range.end + DIFF_CONTEXT_LINES)
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildSideLines = (doc: Text, contextRange: LineRange, changedRange: LineRange): DiffSideLine[] => {
|
||||||
|
const lines: DiffSideLine[] = [];
|
||||||
|
for (let lineNumber = contextRange.start; lineNumber <= contextRange.end; lineNumber += 1) {
|
||||||
|
lines.push({
|
||||||
|
lineNumber,
|
||||||
|
text: doc.line(lineNumber).text,
|
||||||
|
changed: lineNumber >= changedRange.start && lineNumber <= changedRange.end
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DiffModal({
|
||||||
|
open,
|
||||||
|
original,
|
||||||
|
modified,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
loading = false
|
||||||
|
}: DiffModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const diffCards = useMemo<DiffChunkCard[]>(() => {
|
||||||
|
const currentDoc = Text.of(original.split('\n'));
|
||||||
|
const modifiedDoc = Text.of(modified.split('\n'));
|
||||||
|
const chunks = Chunk.build(currentDoc, modifiedDoc);
|
||||||
|
|
||||||
|
return chunks.map((chunk, index) => {
|
||||||
|
const currentChangedRange = getChangedLineRange(currentDoc, chunk.fromA, chunk.toA);
|
||||||
|
const modifiedChangedRange = getChangedLineRange(modifiedDoc, chunk.fromB, chunk.toB);
|
||||||
|
const currentContextRange = expandContextRange(currentDoc, currentChangedRange);
|
||||||
|
const modifiedContextRange = expandContextRange(modifiedDoc, modifiedChangedRange);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${index}-${chunk.fromA}-${chunk.toA}-${chunk.fromB}-${chunk.toB}`,
|
||||||
|
current: {
|
||||||
|
changedRangeLabel: getLineRangeLabel(currentChangedRange),
|
||||||
|
contextRangeLabel: getLineRangeLabel(currentContextRange),
|
||||||
|
lines: buildSideLines(currentDoc, currentContextRange, currentChangedRange)
|
||||||
|
},
|
||||||
|
modified: {
|
||||||
|
changedRangeLabel: getLineRangeLabel(modifiedChangedRange),
|
||||||
|
contextRangeLabel: getLineRangeLabel(modifiedContextRange),
|
||||||
|
lines: buildSideLines(modifiedDoc, modifiedContextRange, modifiedChangedRange)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [modified, original]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={t('config_management.diff.title')}
|
||||||
|
onClose={onCancel}
|
||||||
|
width="min(1200px, 90vw)"
|
||||||
|
className={styles.diffModal}
|
||||||
|
closeDisabled={loading}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onCancel} disabled={loading}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onConfirm} loading={loading} disabled={loading}>
|
||||||
|
{t('config_management.diff.confirm')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.content}>
|
||||||
|
{diffCards.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>{t('config_management.diff.no_changes')}</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.diffList}>
|
||||||
|
{diffCards.map((card, index) => (
|
||||||
|
<article key={card.id} className={styles.diffCard}>
|
||||||
|
<div className={styles.diffCardHeader}>#{index + 1}</div>
|
||||||
|
<div className={styles.diffColumns}>
|
||||||
|
<section className={styles.diffColumn}>
|
||||||
|
<header className={styles.diffColumnHeader}>
|
||||||
|
<span>{t('config_management.diff.current')}</span>
|
||||||
|
<span className={styles.lineMeta}>
|
||||||
|
<span className={styles.lineRange}>L{card.current.changedRangeLabel}</span>
|
||||||
|
<span className={styles.contextRange}>
|
||||||
|
±{DIFF_CONTEXT_LINES}: L{card.current.contextRangeLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className={styles.codeList}>
|
||||||
|
{card.current.lines.map((line) => (
|
||||||
|
<div
|
||||||
|
key={`${card.id}-a-${line.lineNumber}`}
|
||||||
|
className={`${styles.codeLine} ${line.changed ? styles.codeLineChanged : ''}`}
|
||||||
|
>
|
||||||
|
<span className={styles.codeLineNumber}>{line.lineNumber}</span>
|
||||||
|
<code className={styles.codeLineText}>{line.text || ' '}</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className={styles.diffColumn}>
|
||||||
|
<header className={styles.diffColumnHeader}>
|
||||||
|
<span>{t('config_management.diff.modified')}</span>
|
||||||
|
<span className={styles.lineMeta}>
|
||||||
|
<span className={styles.lineRange}>L{card.modified.changedRangeLabel}</span>
|
||||||
|
<span className={styles.contextRange}>
|
||||||
|
±{DIFF_CONTEXT_LINES}: L{card.modified.contextRangeLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className={styles.codeList}>
|
||||||
|
{card.modified.lines.map((line) => (
|
||||||
|
<div
|
||||||
|
key={`${card.id}-b-${line.lineNumber}`}
|
||||||
|
className={`${styles.codeLine} ${line.changed ? styles.codeLineChanged : ''}`}
|
||||||
|
>
|
||||||
|
<span className={styles.codeLineNumber}>{line.lineNumber}</span>
|
||||||
|
<code className={styles.codeLineText}>{line.text || ' '}</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
1094
src/components/config/VisualConfigEditor.tsx
Normal file
@@ -10,8 +10,6 @@ import {
|
|||||||
import { NavLink, useLocation } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
|
||||||
import { PageTransition } from '@/components/common/PageTransition';
|
import { PageTransition } from '@/components/common/PageTransition';
|
||||||
import { MainRoutes } from '@/router/MainRoutes';
|
import { MainRoutes } from '@/router/MainRoutes';
|
||||||
import {
|
import {
|
||||||
@@ -19,12 +17,10 @@ import {
|
|||||||
IconChartLine,
|
IconChartLine,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
IconInfo,
|
IconInfo,
|
||||||
IconKey,
|
|
||||||
IconLayoutDashboard,
|
IconLayoutDashboard,
|
||||||
IconScrollText,
|
IconScrollText,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconShield,
|
IconShield,
|
||||||
IconSlidersHorizontal,
|
|
||||||
IconTimer,
|
IconTimer,
|
||||||
} from '@/components/ui/icons';
|
} from '@/components/ui/icons';
|
||||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
@@ -35,13 +31,13 @@ import {
|
|||||||
useNotificationStore,
|
useNotificationStore,
|
||||||
useThemeStore,
|
useThemeStore,
|
||||||
} from '@/stores';
|
} from '@/stores';
|
||||||
import { configApi, versionApi } from '@/services/api';
|
import { versionApi } from '@/services/api';
|
||||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
|
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
|
||||||
|
import { isSupportedLanguage } from '@/utils/language';
|
||||||
|
|
||||||
const sidebarIcons: Record<string, ReactNode> = {
|
const sidebarIcons: Record<string, ReactNode> = {
|
||||||
dashboard: <IconLayoutDashboard size={18} />,
|
dashboard: <IconLayoutDashboard size={18} />,
|
||||||
settings: <IconSlidersHorizontal size={18} />,
|
|
||||||
apiKeys: <IconKey size={18} />,
|
|
||||||
aiProviders: <IconBot size={18} />,
|
aiProviders: <IconBot size={18} />,
|
||||||
authFiles: <IconFileText size={18} />,
|
authFiles: <IconFileText size={18} />,
|
||||||
oauth: <IconShield size={18} />,
|
oauth: <IconShield size={18} />,
|
||||||
@@ -176,44 +172,36 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const apiBase = useAuthStore((state) => state.apiBase);
|
const apiBase = useAuthStore((state) => state.apiBase);
|
||||||
const serverVersion = useAuthStore((state) => state.serverVersion);
|
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||||
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
|
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
const logout = useAuthStore((state) => state.logout);
|
const logout = useAuthStore((state) => state.logout);
|
||||||
|
|
||||||
const config = useConfigStore((state) => state.config);
|
const config = useConfigStore((state) => state.config);
|
||||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
const clearCache = useConfigStore((state) => state.clearCache);
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
|
||||||
|
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const theme = useThemeStore((state) => state.theme);
|
||||||
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
||||||
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
const language = useLanguageStore((state) => state.language);
|
||||||
|
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||||
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||||
|
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||||
const [brandExpanded, setBrandExpanded] = useState(true);
|
const [brandExpanded, setBrandExpanded] = useState(true);
|
||||||
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
|
|
||||||
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
|
||||||
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
|
||||||
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
|
||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const languageMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const headerRef = useRef<HTMLElement | null>(null);
|
const headerRef = useRef<HTMLElement | null>(null);
|
||||||
const versionTapCount = useRef(0);
|
|
||||||
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const fullBrandName = 'CLI Proxy API Management Center';
|
const fullBrandName = 'CLI Proxy API Management Center';
|
||||||
const abbrBrandName = t('title.abbr');
|
const abbrBrandName = t('title.abbr');
|
||||||
const requestLogEnabled = config?.requestLog ?? false;
|
|
||||||
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
|
||||||
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
|
|
||||||
const isLogsPage = location.pathname.startsWith('/logs');
|
const isLogsPage = location.pathname.startsWith('/logs');
|
||||||
|
|
||||||
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||||
@@ -245,6 +233,38 @@ export function MainLayout() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 将主内容区的中心点写入 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秒后自动收起品牌名称
|
// 5秒后自动收起品牌名称
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
brandCollapseTimer.current = setTimeout(() => {
|
brandCollapseTimer.current = setTimeout(() => {
|
||||||
@@ -259,18 +279,30 @@ export function MainLayout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (requestLogModalOpen && !requestLogTouched) {
|
if (!languageMenuOpen) {
|
||||||
setRequestLogDraft(requestLogEnabled);
|
return;
|
||||||
}
|
}
|
||||||
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
return () => {
|
if (!languageMenuRef.current?.contains(event.target as Node)) {
|
||||||
if (versionTapTimer.current) {
|
setLanguageMenuOpen(false);
|
||||||
clearTimeout(versionTapTimer.current);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
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(() => {
|
const handleBrandClick = useCallback(() => {
|
||||||
if (!brandExpanded) {
|
if (!brandExpanded) {
|
||||||
@@ -285,59 +317,20 @@ export function MainLayout() {
|
|||||||
}
|
}
|
||||||
}, [brandExpanded]);
|
}, [brandExpanded]);
|
||||||
|
|
||||||
const openRequestLogModal = useCallback(() => {
|
const toggleLanguageMenu = useCallback(() => {
|
||||||
setRequestLogTouched(false);
|
setLanguageMenuOpen((prev) => !prev);
|
||||||
setRequestLogDraft(requestLogEnabled);
|
|
||||||
setRequestLogModalOpen(true);
|
|
||||||
}, [requestLogEnabled]);
|
|
||||||
|
|
||||||
const handleRequestLogClose = useCallback(() => {
|
|
||||||
setRequestLogModalOpen(false);
|
|
||||||
setRequestLogTouched(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleVersionTap = useCallback(() => {
|
const handleLanguageSelect = useCallback(
|
||||||
versionTapCount.current += 1;
|
(nextLanguage: string) => {
|
||||||
if (versionTapTimer.current) {
|
if (!isSupportedLanguage(nextLanguage)) {
|
||||||
clearTimeout(versionTapTimer.current);
|
return;
|
||||||
}
|
|
||||||
versionTapTimer.current = setTimeout(() => {
|
|
||||||
versionTapCount.current = 0;
|
|
||||||
}, 1500);
|
|
||||||
|
|
||||||
if (versionTapCount.current >= 7) {
|
|
||||||
versionTapCount.current = 0;
|
|
||||||
if (versionTapTimer.current) {
|
|
||||||
clearTimeout(versionTapTimer.current);
|
|
||||||
versionTapTimer.current = null;
|
|
||||||
}
|
}
|
||||||
openRequestLogModal();
|
setLanguage(nextLanguage);
|
||||||
}
|
setLanguageMenuOpen(false);
|
||||||
}, [openRequestLogModal]);
|
},
|
||||||
|
[setLanguage]
|
||||||
const handleRequestLogSave = async () => {
|
);
|
||||||
if (!canEditRequestLog) return;
|
|
||||||
if (!requestLogDirty) {
|
|
||||||
setRequestLogModalOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previous = requestLogEnabled;
|
|
||||||
setRequestLogSaving(true);
|
|
||||||
updateConfigValue('request-log', requestLogDraft);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await configApi.updateRequestLog(requestLogDraft);
|
|
||||||
clearCache('request-log');
|
|
||||||
showNotification(t('notification.request_log_updated'), 'success');
|
|
||||||
setRequestLogModalOpen(false);
|
|
||||||
} catch (error: any) {
|
|
||||||
updateConfigValue('request-log', previous);
|
|
||||||
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setRequestLogSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig().catch(() => {
|
fetchConfig().catch(() => {
|
||||||
@@ -357,14 +350,12 @@ export function MainLayout() {
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
|
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
|
||||||
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
|
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
||||||
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
|
|
||||||
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
|
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
|
||||||
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
|
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
|
||||||
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
|
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
|
||||||
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
|
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
|
||||||
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
|
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
|
||||||
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
|
||||||
...(config?.loggingToFile
|
...(config?.loggingToFile
|
||||||
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
|
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
|
||||||
: []),
|
: []),
|
||||||
@@ -375,6 +366,31 @@ export function MainLayout() {
|
|||||||
const trimmedPath =
|
const trimmedPath =
|
||||||
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||||
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
|
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);
|
const exactIndex = navOrder.indexOf(normalizedPath);
|
||||||
if (exactIndex !== -1) return exactIndex;
|
if (exactIndex !== -1) return exactIndex;
|
||||||
const nestedIndex = navOrder.findIndex(
|
const nestedIndex = navOrder.findIndex(
|
||||||
@@ -383,6 +399,24 @@ export function MainLayout() {
|
|||||||
return nestedIndex === -1 ? null : nestedIndex;
|
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 () => {
|
const handleRefreshAll = async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
@@ -407,7 +441,8 @@ export function MainLayout() {
|
|||||||
setCheckingVersion(true);
|
setCheckingVersion(true);
|
||||||
try {
|
try {
|
||||||
const data = await versionApi.checkLatest();
|
const data = await versionApi.checkLatest();
|
||||||
const latest = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
||||||
|
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
|
||||||
const comparison = compareVersions(latest, serverVersion);
|
const comparison = compareVersions(latest, serverVersion);
|
||||||
|
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
@@ -425,8 +460,11 @@ export function MainLayout() {
|
|||||||
} else {
|
} else {
|
||||||
showNotification(t('system_info.version_is_latest'), 'success');
|
showNotification(t('system_info.version_is_latest'), 'success');
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
showNotification(`${t('system_info.version_check_error')}: ${error?.message || ''}`, 'error');
|
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 {
|
} finally {
|
||||||
setCheckingVersion(false);
|
setCheckingVersion(false);
|
||||||
}
|
}
|
||||||
@@ -498,9 +536,36 @@ export function MainLayout() {
|
|||||||
>
|
>
|
||||||
{headerIcons.update}
|
{headerIcons.update}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
|
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
|
||||||
{headerIcons.language}
|
<Button
|
||||||
</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')}>
|
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
|
||||||
{theme === 'auto'
|
{theme === 'auto'
|
||||||
? headerIcons.autoTheme
|
? headerIcons.autoTheme
|
||||||
@@ -540,60 +605,12 @@ export function MainLayout() {
|
|||||||
<PageTransition
|
<PageTransition
|
||||||
render={(location) => <MainRoutes location={location} />}
|
render={(location) => <MainRoutes location={location} />}
|
||||||
getRouteOrder={getRouteOrder}
|
getRouteOrder={getRouteOrder}
|
||||||
|
getTransitionVariant={getTransitionVariant}
|
||||||
scrollContainerRef={contentRef}
|
scrollContainerRef={contentRef}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="footer">
|
|
||||||
<span>
|
|
||||||
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
|
||||||
</span>
|
|
||||||
<span className="footer-version" onClick={handleVersionTap}>
|
|
||||||
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{t('footer.build_date')}:{' '}
|
|
||||||
{serverBuildDate
|
|
||||||
? new Date(serverBuildDate).toLocaleString(i18n.language)
|
|
||||||
: t('system_info.version_unknown')}
|
|
||||||
</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={requestLogModalOpen}
|
|
||||||
onClose={handleRequestLogClose}
|
|
||||||
title={t('basic_settings.request_log_title')}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleRequestLogSave}
|
|
||||||
loading={requestLogSaving}
|
|
||||||
disabled={!canEditRequestLog || !requestLogDirty}
|
|
||||||
>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="request-log-modal">
|
|
||||||
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
|
|
||||||
<ToggleSwitch
|
|
||||||
label={t('basic_settings.request_log_enable')}
|
|
||||||
labelPosition="left"
|
|
||||||
checked={requestLogDraft}
|
|
||||||
disabled={!canEditRequestLog || requestLogSaving}
|
|
||||||
onChange={(value) => {
|
|
||||||
setRequestLogDraft(value);
|
|
||||||
setRequestLogTouched(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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($primary-color, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = [
|
||||||
|
'#8b8680', '#10b981', '#f59e0b', '#c65746',
|
||||||
|
'#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';
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
|
||||||
import { useConfigStore, useNotificationStore } from '@/stores';
|
|
||||||
import { ampcodeApi } from '@/services/api';
|
|
||||||
import type { AmpcodeConfig } from '@/types';
|
|
||||||
import { maskApiKey } from '@/utils/format';
|
|
||||||
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '../utils';
|
|
||||||
import type { AmpcodeFormState } from '../types';
|
|
||||||
|
|
||||||
interface AmpcodeModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
disableControls: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onBusyChange?: (busy: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { showNotification } = useNotificationStore();
|
|
||||||
const config = useConfigStore((state) => state.config);
|
|
||||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
|
||||||
const clearCache = useConfigStore((state) => state.clearCache);
|
|
||||||
|
|
||||||
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const [mappingsDirty, setMappingsDirty] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const initializedRef = useRef(false);
|
|
||||||
|
|
||||||
const getErrorMessage = (err: unknown) => {
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
if (typeof err === 'string') return err;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onBusyChange?.(loading || saving);
|
|
||||||
}, [loading, saving, onBusyChange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
initializedRef.current = false;
|
|
||||||
setLoading(false);
|
|
||||||
setSaving(false);
|
|
||||||
setError('');
|
|
||||||
setLoaded(false);
|
|
||||||
setMappingsDirty(false);
|
|
||||||
setForm(buildAmpcodeFormState(null));
|
|
||||||
onBusyChange?.(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (initializedRef.current) return;
|
|
||||||
initializedRef.current = true;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setLoaded(false);
|
|
||||||
setMappingsDirty(false);
|
|
||||||
setError('');
|
|
||||||
setForm(buildAmpcodeFormState(config?.ampcode ?? null));
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const ampcode = await ampcodeApi.getAmpcode();
|
|
||||||
setLoaded(true);
|
|
||||||
updateConfigValue('ampcode', ampcode);
|
|
||||||
clearCache('ampcode');
|
|
||||||
setForm(buildAmpcodeFormState(ampcode));
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(getErrorMessage(err) || t('notification.refresh_failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
|
|
||||||
|
|
||||||
const clearAmpcodeUpstreamApiKey = async () => {
|
|
||||||
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return;
|
|
||||||
setSaving(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await ampcodeApi.clearUpstreamApiKey();
|
|
||||||
const previous = config?.ampcode ?? {};
|
|
||||||
const next: AmpcodeConfig = { ...previous };
|
|
||||||
delete next.upstreamApiKey;
|
|
||||||
updateConfigValue('ampcode', next);
|
|
||||||
clearCache('ampcode');
|
|
||||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
setError(message);
|
|
||||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveAmpcode = async () => {
|
|
||||||
if (!loaded && mappingsDirty) {
|
|
||||||
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
|
|
||||||
if (!confirmed) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const upstreamUrl = form.upstreamUrl.trim();
|
|
||||||
const overrideKey = form.upstreamApiKey.trim();
|
|
||||||
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
|
|
||||||
|
|
||||||
if (upstreamUrl) {
|
|
||||||
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
|
|
||||||
} else {
|
|
||||||
await ampcodeApi.clearUpstreamUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
|
|
||||||
|
|
||||||
if (loaded || mappingsDirty) {
|
|
||||||
if (modelMappings.length) {
|
|
||||||
await ampcodeApi.saveModelMappings(modelMappings);
|
|
||||||
} else {
|
|
||||||
await ampcodeApi.clearModelMappings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overrideKey) {
|
|
||||||
await ampcodeApi.updateUpstreamApiKey(overrideKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
const previous = config?.ampcode ?? {};
|
|
||||||
const next: AmpcodeConfig = {
|
|
||||||
upstreamUrl: upstreamUrl || undefined,
|
|
||||||
forceModelMappings: form.forceModelMappings,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (previous.upstreamApiKey) {
|
|
||||||
next.upstreamApiKey = previous.upstreamApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(previous.modelMappings)) {
|
|
||||||
next.modelMappings = previous.modelMappings;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overrideKey) {
|
|
||||||
next.upstreamApiKey = overrideKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loaded || mappingsDirty) {
|
|
||||||
if (modelMappings.length) {
|
|
||||||
next.modelMappings = modelMappings;
|
|
||||||
} else {
|
|
||||||
delete next.modelMappings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateConfigValue('ampcode', next);
|
|
||||||
clearCache('ampcode');
|
|
||||||
showNotification(t('notification.ampcode_updated'), 'success');
|
|
||||||
onClose();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
setError(message);
|
|
||||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t('ai_providers.ampcode_modal_title')}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={saving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={saveAmpcode} loading={saving} disabled={disableControls || loading}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.ampcode_upstream_url_label')}
|
|
||||||
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
|
|
||||||
value={form.upstreamUrl}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
|
|
||||||
disabled={loading || saving}
|
|
||||||
hint={t('ai_providers.ampcode_upstream_url_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.ampcode_upstream_api_key_label')}
|
|
||||||
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
|
|
||||||
type="password"
|
|
||||||
value={form.upstreamApiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
|
|
||||||
disabled={loading || saving}
|
|
||||||
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 8,
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: -8,
|
|
||||||
marginBottom: 12,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="hint" style={{ margin: 0 }}>
|
|
||||||
{t('ai_providers.ampcode_upstream_api_key_current', {
|
|
||||||
key: config?.ampcode?.upstreamApiKey
|
|
||||||
? maskApiKey(config.ampcode.upstreamApiKey)
|
|
||||||
: t('common.not_set'),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={clearAmpcodeUpstreamApiKey}
|
|
||||||
disabled={loading || saving || !config?.ampcode?.upstreamApiKey}
|
|
||||||
>
|
|
||||||
{t('ai_providers.ampcode_clear_upstream_api_key')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<ToggleSwitch
|
|
||||||
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
|
||||||
checked={form.forceModelMappings}
|
|
||||||
onChange={(value) => setForm((prev) => ({ ...prev, forceModelMappings: value }))}
|
|
||||||
disabled={loading || saving}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
|
|
||||||
<ModelInputList
|
|
||||||
entries={form.mappingEntries}
|
|
||||||
onChange={(entries) => {
|
|
||||||
setMappingsDirty(true);
|
|
||||||
setForm((prev) => ({ ...prev, mappingEntries: entries }));
|
|
||||||
}}
|
|
||||||
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
|
||||||
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
|
|
||||||
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
|
|
||||||
disabled={loading || saving}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,34 +5,24 @@ import type { AmpcodeConfig } from '@/types';
|
|||||||
import { maskApiKey } from '@/utils/format';
|
import { maskApiKey } from '@/utils/format';
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AmpcodeModal } from './AmpcodeModal';
|
|
||||||
|
|
||||||
interface AmpcodeSectionProps {
|
interface AmpcodeSectionProps {
|
||||||
config: AmpcodeConfig | null | undefined;
|
config: AmpcodeConfig | null | undefined;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
disableControls: boolean;
|
disableControls: boolean;
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
isBusy: boolean;
|
onEdit: () => void;
|
||||||
isModalOpen: boolean;
|
|
||||||
onOpen: () => void;
|
|
||||||
onCloseModal: () => void;
|
|
||||||
onBusyChange: (busy: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AmpcodeSection({
|
export function AmpcodeSection({
|
||||||
config,
|
config,
|
||||||
loading,
|
loading,
|
||||||
disableControls,
|
disableControls,
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
isSwitching,
|
||||||
isBusy,
|
onEdit,
|
||||||
isModalOpen,
|
|
||||||
onOpen,
|
|
||||||
onCloseModal,
|
|
||||||
onBusyChange,
|
|
||||||
}: AmpcodeSectionProps) {
|
}: AmpcodeSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const showLoadingPlaceholder = loading && !config;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -46,14 +36,14 @@ export function AmpcodeSection({
|
|||||||
extra={
|
extra={
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onOpen}
|
onClick={onEdit}
|
||||||
disabled={disableControls || isSaving || isBusy || isSwitching}
|
disabled={disableControls || loading || isSwitching}
|
||||||
>
|
>
|
||||||
{t('common.edit')}
|
{t('common.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{showLoadingPlaceholder ? (
|
||||||
<div className="hint">{t('common.loading')}</div>
|
<div className="hint">{t('common.loading')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -99,13 +89,6 @@ export function AmpcodeSection({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<AmpcodeModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
disableControls={disableControls}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onBusyChange={onBusyChange}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
|
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
|
||||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
|
||||||
import { excludedModelsToText } from '../utils';
|
|
||||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
|
||||||
|
|
||||||
interface ClaudeModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): ProviderFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
proxyUrl: '',
|
|
||||||
headers: {},
|
|
||||||
models: [],
|
|
||||||
excludedModels: [],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
excludedText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ClaudeModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: ClaudeModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: initialData.headers ?? {},
|
|
||||||
modelEntries: modelsToEntries(initialData.models),
|
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.claude_edit_modal_title')
|
|
||||||
: t('ai_providers.claude_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.claude_add_modal_key_label')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.claude_add_modal_url_label')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.claude_add_modal_proxy_label')}
|
|
||||||
value={form.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={headersToEntries(form.headers)}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.claude_models_label')}</label>
|
|
||||||
<ModelInputList
|
|
||||||
entries={form.modelEntries}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
|
||||||
addLabel={t('ai_providers.claude_models_add_btn')}
|
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
|
||||||
disabled={isSaving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
|
||||||
<textarea
|
|
||||||
className="input"
|
|
||||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
|
||||||
value={form.excludedText}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,13 +6,16 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
|||||||
import iconClaude from '@/assets/icons/claude.svg';
|
import iconClaude from '@/assets/icons/claude.svg';
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
import { maskApiKey } from '@/utils/format';
|
import { maskApiKey } from '@/utils/format';
|
||||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
import { ProviderList } from '../ProviderList';
|
import { ProviderList } from '../ProviderList';
|
||||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||||
import type { ProviderFormState } from '../types';
|
|
||||||
import { ClaudeModal } from './ClaudeModal';
|
|
||||||
|
|
||||||
interface ClaudeSectionProps {
|
interface ClaudeSectionProps {
|
||||||
configs: ProviderKeyConfig[];
|
configs: ProviderKeyConfig[];
|
||||||
@@ -20,16 +23,11 @@ interface ClaudeSectionProps {
|
|||||||
usageDetails: UsageDetail[];
|
usageDetails: UsageDetail[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
disableControls: boolean;
|
disableControls: boolean;
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
isModalOpen: boolean;
|
|
||||||
modalIndex: number | null;
|
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onToggle: (index: number, enabled: boolean) => void;
|
onToggle: (index: number, enabled: boolean) => void;
|
||||||
onCloseModal: () => void;
|
|
||||||
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClaudeSection({
|
export function ClaudeSection({
|
||||||
@@ -38,33 +36,34 @@ export function ClaudeSection({
|
|||||||
usageDetails,
|
usageDetails,
|
||||||
loading,
|
loading,
|
||||||
disableControls,
|
disableControls,
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
isSwitching,
|
||||||
isModalOpen,
|
|
||||||
modalIndex,
|
|
||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggle,
|
onToggle,
|
||||||
onCloseModal,
|
|
||||||
onSave,
|
|
||||||
}: ClaudeSectionProps) {
|
}: ClaudeSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
const actionsDisabled = disableControls || loading || isSwitching;
|
||||||
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
|
const toggleDisabled = disableControls || loading || isSwitching;
|
||||||
|
|
||||||
const statusBarCache = useMemo(() => {
|
const statusBarCache = useMemo(() => {
|
||||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
const allApiKeys = new Set<string>();
|
|
||||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
configs.forEach((config) => {
|
||||||
allApiKeys.forEach((apiKey) => {
|
if (!config.apiKey) return;
|
||||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
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;
|
return cache;
|
||||||
}, [configs, usageDetails]);
|
}, [configs, usageDetails]);
|
||||||
|
|
||||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
@@ -99,12 +98,11 @@ export function ClaudeSection({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderContent={(item) => {
|
renderContent={(item) => {
|
||||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||||
const headerEntries = Object.entries(item.headers || {});
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||||
const excludedModels = item.excludedModels ?? [];
|
const excludedModels = item.excludedModels ?? [];
|
||||||
const statusData =
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
@@ -188,15 +186,6 @@ export function ClaudeSection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ClaudeModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
editIndex={modalIndex}
|
|
||||||
initialData={initialData}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onSave={onSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
|
||||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
|
||||||
import { modelsToEntries } from '@/components/ui/ModelInputList';
|
|
||||||
import { excludedModelsToText } from '../utils';
|
|
||||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
|
||||||
|
|
||||||
interface CodexModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): ProviderFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
proxyUrl: '',
|
|
||||||
headers: {},
|
|
||||||
models: [],
|
|
||||||
excludedModels: [],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
excludedText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function CodexModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: CodexModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: initialData.headers ?? {},
|
|
||||||
modelEntries: modelsToEntries(initialData.models),
|
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.codex_edit_modal_title')
|
|
||||||
: t('ai_providers.codex_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.codex_add_modal_key_label')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.codex_add_modal_url_label')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.codex_add_modal_proxy_label')}
|
|
||||||
value={form.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={headersToEntries(form.headers)}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
|
||||||
<textarea
|
|
||||||
className="input"
|
|
||||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
|
||||||
value={form.excludedText}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,17 +3,20 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
import { maskApiKey } from '@/utils/format';
|
import { maskApiKey } from '@/utils/format';
|
||||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
import { ProviderList } from '../ProviderList';
|
import { ProviderList } from '../ProviderList';
|
||||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||||
import type { ProviderFormState } from '../types';
|
|
||||||
import { CodexModal } from './CodexModal';
|
|
||||||
|
|
||||||
interface CodexSectionProps {
|
interface CodexSectionProps {
|
||||||
configs: ProviderKeyConfig[];
|
configs: ProviderKeyConfig[];
|
||||||
@@ -21,17 +24,12 @@ interface CodexSectionProps {
|
|||||||
usageDetails: UsageDetail[];
|
usageDetails: UsageDetail[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
disableControls: boolean;
|
disableControls: boolean;
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
resolvedTheme: string;
|
resolvedTheme: string;
|
||||||
isModalOpen: boolean;
|
|
||||||
modalIndex: number | null;
|
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onToggle: (index: number, enabled: boolean) => void;
|
onToggle: (index: number, enabled: boolean) => void;
|
||||||
onCloseModal: () => void;
|
|
||||||
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodexSection({
|
export function CodexSection({
|
||||||
@@ -40,41 +38,42 @@ export function CodexSection({
|
|||||||
usageDetails,
|
usageDetails,
|
||||||
loading,
|
loading,
|
||||||
disableControls,
|
disableControls,
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
isSwitching,
|
||||||
resolvedTheme,
|
resolvedTheme,
|
||||||
isModalOpen,
|
|
||||||
modalIndex,
|
|
||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggle,
|
onToggle,
|
||||||
onCloseModal,
|
|
||||||
onSave,
|
|
||||||
}: CodexSectionProps) {
|
}: CodexSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
const actionsDisabled = disableControls || loading || isSwitching;
|
||||||
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
|
const toggleDisabled = disableControls || loading || isSwitching;
|
||||||
|
|
||||||
const statusBarCache = useMemo(() => {
|
const statusBarCache = useMemo(() => {
|
||||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
const allApiKeys = new Set<string>();
|
|
||||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
configs.forEach((config) => {
|
||||||
allApiKeys.forEach((apiKey) => {
|
if (!config.apiKey) return;
|
||||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
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;
|
return cache;
|
||||||
}, [configs, usageDetails]);
|
}, [configs, usageDetails]);
|
||||||
|
|
||||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<span className={styles.cardTitle}>
|
<span className={styles.cardTitle}>
|
||||||
<img
|
<img
|
||||||
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
src={resolvedTheme === 'dark' ? iconCodexDark : iconCodexLight}
|
||||||
alt=""
|
alt=""
|
||||||
className={styles.cardTitleIcon}
|
className={styles.cardTitleIcon}
|
||||||
/>
|
/>
|
||||||
@@ -106,12 +105,11 @@ export function CodexSection({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderContent={(item) => {
|
renderContent={(item) => {
|
||||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||||
const headerEntries = Object.entries(item.headers || {});
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||||
const excludedModels = item.excludedModels ?? [];
|
const excludedModels = item.excludedModels ?? [];
|
||||||
const statusData =
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
@@ -180,15 +178,6 @@ export function CodexSection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<CodexModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
editIndex={modalIndex}
|
|
||||||
initialData={initialData}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onSave={onSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import type { GeminiKeyConfig } from '@/types';
|
|
||||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
|
||||||
import { excludedModelsToText } from '../utils';
|
|
||||||
import type { GeminiFormState, ProviderModalProps } from '../types';
|
|
||||||
|
|
||||||
interface GeminiModalProps extends ProviderModalProps<GeminiKeyConfig, GeminiFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): GeminiFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
headers: {},
|
|
||||||
excludedModels: [],
|
|
||||||
excludedText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function GeminiModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: GeminiModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<GeminiFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: initialData.headers ?? {},
|
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
void onSave(form, editIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.gemini_edit_modal_title')
|
|
||||||
: t('ai_providers.gemini_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.gemini_add_modal_key_label')}
|
|
||||||
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.gemini_base_url_label')}
|
|
||||||
placeholder={t('ai_providers.gemini_base_url_placeholder')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={headersToEntries(form.headers)}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
|
||||||
<textarea
|
|
||||||
className="input"
|
|
||||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
|
||||||
value={form.excludedText}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,13 +6,16 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
|||||||
import iconGemini from '@/assets/icons/gemini.svg';
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
import type { GeminiKeyConfig } from '@/types';
|
import type { GeminiKeyConfig } from '@/types';
|
||||||
import { maskApiKey } from '@/utils/format';
|
import { maskApiKey } from '@/utils/format';
|
||||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
import type { GeminiFormState } from '../types';
|
|
||||||
import { ProviderList } from '../ProviderList';
|
import { ProviderList } from '../ProviderList';
|
||||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||||
import { GeminiModal } from './GeminiModal';
|
|
||||||
|
|
||||||
interface GeminiSectionProps {
|
interface GeminiSectionProps {
|
||||||
configs: GeminiKeyConfig[];
|
configs: GeminiKeyConfig[];
|
||||||
@@ -20,16 +23,11 @@ interface GeminiSectionProps {
|
|||||||
usageDetails: UsageDetail[];
|
usageDetails: UsageDetail[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
disableControls: boolean;
|
disableControls: boolean;
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
isModalOpen: boolean;
|
|
||||||
modalIndex: number | null;
|
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onToggle: (index: number, enabled: boolean) => void;
|
onToggle: (index: number, enabled: boolean) => void;
|
||||||
onCloseModal: () => void;
|
|
||||||
onSave: (data: GeminiFormState, index: number | null) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GeminiSection({
|
export function GeminiSection({
|
||||||
@@ -38,33 +36,34 @@ export function GeminiSection({
|
|||||||
usageDetails,
|
usageDetails,
|
||||||
loading,
|
loading,
|
||||||
disableControls,
|
disableControls,
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
isSwitching,
|
||||||
isModalOpen,
|
|
||||||
modalIndex,
|
|
||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggle,
|
onToggle,
|
||||||
onCloseModal,
|
|
||||||
onSave,
|
|
||||||
}: GeminiSectionProps) {
|
}: GeminiSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
const actionsDisabled = disableControls || loading || isSwitching;
|
||||||
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
|
const toggleDisabled = disableControls || loading || isSwitching;
|
||||||
|
|
||||||
const statusBarCache = useMemo(() => {
|
const statusBarCache = useMemo(() => {
|
||||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
const allApiKeys = new Set<string>();
|
|
||||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
configs.forEach((config) => {
|
||||||
allApiKeys.forEach((apiKey) => {
|
if (!config.apiKey) return;
|
||||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
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;
|
return cache;
|
||||||
}, [configs, usageDetails]);
|
}, [configs, usageDetails]);
|
||||||
|
|
||||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
@@ -99,12 +98,11 @@ export function GeminiSection({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderContent={(item, index) => {
|
renderContent={(item, index) => {
|
||||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||||
const headerEntries = Object.entries(item.headers || {});
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||||
const excludedModels = item.excludedModels ?? [];
|
const excludedModels = item.excludedModels ?? [];
|
||||||
const statusData =
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
@@ -127,6 +125,12 @@ export function GeminiSection({
|
|||||||
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||||
</div>
|
</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 && (
|
{headerEntries.length > 0 && (
|
||||||
<div className={styles.headerBadgeList}>
|
<div className={styles.headerBadgeList}>
|
||||||
{headerEntries.map(([key, value]) => (
|
{headerEntries.map(([key, value]) => (
|
||||||
@@ -169,15 +173,6 @@ export function GeminiSection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<GeminiModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
editIndex={modalIndex}
|
|
||||||
initialData={initialData}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onSave={onSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { modelsApi } from '@/services/api';
|
|
||||||
import type { ApiKeyEntry } from '@/types';
|
|
||||||
import type { ModelInfo } from '@/utils/models';
|
|
||||||
import { buildHeaderObject, type HeaderEntry } from '@/utils/headers';
|
|
||||||
import { buildOpenAIModelsEndpoint } from '../utils';
|
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
|
||||||
|
|
||||||
interface OpenAIDiscoveryModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
baseUrl: string;
|
|
||||||
headers: HeaderEntry[];
|
|
||||||
apiKeyEntries: ApiKeyEntry[];
|
|
||||||
onClose: () => void;
|
|
||||||
onApply: (selected: ModelInfo[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpenAIDiscoveryModal({
|
|
||||||
isOpen,
|
|
||||||
baseUrl,
|
|
||||||
headers,
|
|
||||||
apiKeyEntries,
|
|
||||||
onClose,
|
|
||||||
onApply,
|
|
||||||
}: OpenAIDiscoveryModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [endpoint, setEndpoint] = useState('');
|
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const getErrorMessage = (err: unknown) => {
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
if (typeof err === 'string') return err;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredModels = useMemo(() => {
|
|
||||||
const filter = search.trim().toLowerCase();
|
|
||||||
if (!filter) return models;
|
|
||||||
return models.filter((model) => {
|
|
||||||
const name = (model.name || '').toLowerCase();
|
|
||||||
const alias = (model.alias || '').toLowerCase();
|
|
||||||
const desc = (model.description || '').toLowerCase();
|
|
||||||
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
|
||||||
});
|
|
||||||
}, [models, search]);
|
|
||||||
|
|
||||||
const fetchOpenaiModelDiscovery = useCallback(
|
|
||||||
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
|
|
||||||
const trimmedBaseUrl = baseUrl.trim();
|
|
||||||
if (!trimmedBaseUrl) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const headerObject = buildHeaderObject(headers);
|
|
||||||
const firstKey = apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
|
|
||||||
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
|
|
||||||
const list = await modelsApi.fetchModelsViaApiCall(
|
|
||||||
trimmedBaseUrl,
|
|
||||||
hasAuthHeader ? undefined : firstKey,
|
|
||||||
headerObject
|
|
||||||
);
|
|
||||||
setModels(list);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (allowFallback) {
|
|
||||||
try {
|
|
||||||
const list = await modelsApi.fetchModelsViaApiCall(trimmedBaseUrl);
|
|
||||||
setModels(list);
|
|
||||||
return;
|
|
||||||
} catch (fallbackErr: unknown) {
|
|
||||||
const message = getErrorMessage(fallbackErr) || getErrorMessage(err);
|
|
||||||
setModels([]);
|
|
||||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setModels([]);
|
|
||||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[apiKeyEntries, baseUrl, headers, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
setEndpoint(buildOpenAIModelsEndpoint(baseUrl));
|
|
||||||
setModels([]);
|
|
||||||
setSearch('');
|
|
||||||
setSelected(new Set());
|
|
||||||
setError('');
|
|
||||||
void fetchOpenaiModelDiscovery();
|
|
||||||
}, [baseUrl, fetchOpenaiModelDiscovery, isOpen]);
|
|
||||||
|
|
||||||
const toggleSelection = (name: string) => {
|
|
||||||
setSelected((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(name)) {
|
|
||||||
next.delete(name);
|
|
||||||
} else {
|
|
||||||
next.add(name);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApply = () => {
|
|
||||||
const selectedModels = models.filter((model) => selected.has(model.name));
|
|
||||||
onApply(selectedModels);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t('ai_providers.openai_models_fetch_title')}
|
|
||||||
width={720}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
|
||||||
{t('ai_providers.openai_models_fetch_back')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleApply} disabled={loading}>
|
|
||||||
{t('ai_providers.openai_models_fetch_apply')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="hint" style={{ marginBottom: 8 }}>
|
|
||||||
{t('ai_providers.openai_models_fetch_hint')}
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<input className="input" readOnly value={endpoint} />
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
{t('ai_providers.openai_models_fetch_refresh')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.openai_models_search_label')}
|
|
||||||
placeholder={t('ai_providers.openai_models_search_placeholder')}
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
|
||||||
{loading ? (
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
|
|
||||||
) : models.length === 0 ? (
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
|
|
||||||
) : filteredModels.length === 0 ? (
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.modelDiscoveryList}>
|
|
||||||
{filteredModels.map((model) => {
|
|
||||||
const checked = selected.has(model.name);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={model.name}
|
|
||||||
className={`${styles.modelDiscoveryRow} ${checked ? styles.modelDiscoveryRowSelected : ''}`}
|
|
||||||
>
|
|
||||||
<input type="checkbox" checked={checked} onChange={() => toggleSelection(model.name)} />
|
|
||||||
<div className={styles.modelDiscoveryMeta}>
|
|
||||||
<div className={styles.modelDiscoveryName}>
|
|
||||||
{model.name}
|
|
||||||
{model.alias && <span className={styles.modelDiscoveryAlias}>{model.alias}</span>}
|
|
||||||
</div>
|
|
||||||
{model.description && (
|
|
||||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,432 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
|
|
||||||
import { useNotificationStore } from '@/stores';
|
|
||||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
|
||||||
import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types';
|
|
||||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
|
||||||
import type { ModelInfo } from '@/utils/models';
|
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
|
||||||
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '../utils';
|
|
||||||
import type { ModelEntry, OpenAIFormState, ProviderModalProps } from '../types';
|
|
||||||
import { OpenAIDiscoveryModal } from './OpenAIDiscoveryModal';
|
|
||||||
|
|
||||||
const OPENAI_TEST_TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
interface OpenAIModalProps extends ProviderModalProps<OpenAIProviderConfig, OpenAIFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): OpenAIFormState => ({
|
|
||||||
name: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
headers: [],
|
|
||||||
apiKeyEntries: [buildApiKeyEntry()],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
testModel: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function OpenAIModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: OpenAIModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { showNotification } = useNotificationStore();
|
|
||||||
const [form, setForm] = useState<OpenAIFormState>(buildEmptyForm);
|
|
||||||
const [discoveryOpen, setDiscoveryOpen] = useState(false);
|
|
||||||
const [testModel, setTestModel] = useState('');
|
|
||||||
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
|
||||||
const [testMessage, setTestMessage] = useState('');
|
|
||||||
|
|
||||||
const getErrorMessage = (err: unknown) => {
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
if (typeof err === 'string') return err;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const availableModels = useMemo(
|
|
||||||
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
|
|
||||||
[form.modelEntries]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialData) {
|
|
||||||
const modelEntries = modelsToEntries(initialData.models);
|
|
||||||
setForm({
|
|
||||||
name: initialData.name,
|
|
||||||
prefix: initialData.prefix ?? '',
|
|
||||||
baseUrl: initialData.baseUrl,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
testModel: initialData.testModel,
|
|
||||||
modelEntries,
|
|
||||||
apiKeyEntries: initialData.apiKeyEntries?.length
|
|
||||||
? initialData.apiKeyEntries
|
|
||||||
: [buildApiKeyEntry()],
|
|
||||||
});
|
|
||||||
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
|
|
||||||
const initialModel =
|
|
||||||
initialData.testModel && available.includes(initialData.testModel)
|
|
||||||
? initialData.testModel
|
|
||||||
: available[0] || '';
|
|
||||||
setTestModel(initialModel);
|
|
||||||
} else {
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
setTestModel('');
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (availableModels.length === 0) {
|
|
||||||
if (testModel) {
|
|
||||||
setTestModel('');
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!testModel || !availableModels.includes(testModel)) {
|
|
||||||
setTestModel(availableModels[0]);
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
}
|
|
||||||
}, [availableModels, isOpen, testModel]);
|
|
||||||
|
|
||||||
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
|
|
||||||
const list = entries.length ? entries : [buildApiKeyEntry()];
|
|
||||||
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
|
|
||||||
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
|
|
||||||
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeEntry = (idx: number) => {
|
|
||||||
const next = list.filter((_, i) => i !== idx);
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addEntry = () => {
|
|
||||||
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="stack">
|
|
||||||
{list.map((entry, index) => (
|
|
||||||
<div key={index} className="item-row">
|
|
||||||
<div className="item-meta">
|
|
||||||
<Input
|
|
||||||
label={`${t('common.api_key')} #${index + 1}`}
|
|
||||||
value={entry.apiKey}
|
|
||||||
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('common.proxy_url')}
|
|
||||||
value={entry.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="item-actions">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeEntry(index)}
|
|
||||||
disabled={list.length <= 1 || isSaving}
|
|
||||||
>
|
|
||||||
{t('common.delete')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button variant="secondary" size="sm" onClick={addEntry} disabled={isSaving}>
|
|
||||||
{t('ai_providers.openai_keys_add_btn')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openOpenaiModelDiscovery = () => {
|
|
||||||
const baseUrl = form.baseUrl.trim();
|
|
||||||
if (!baseUrl) {
|
|
||||||
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDiscoveryOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyOpenaiModelDiscoverySelection = (selectedModels: ModelInfo[]) => {
|
|
||||||
if (!selectedModels.length) {
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedMap = new Map<string, ModelEntry>();
|
|
||||||
form.modelEntries.forEach((entry) => {
|
|
||||||
const name = entry.name.trim();
|
|
||||||
if (!name) return;
|
|
||||||
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
|
|
||||||
});
|
|
||||||
|
|
||||||
let addedCount = 0;
|
|
||||||
selectedModels.forEach((model) => {
|
|
||||||
const name = model.name.trim();
|
|
||||||
if (!name || mergedMap.has(name)) return;
|
|
||||||
mergedMap.set(name, { name, alias: model.alias ?? '' });
|
|
||||||
addedCount += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const mergedEntries = Array.from(mergedMap.values());
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
if (addedCount > 0) {
|
|
||||||
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const testOpenaiProviderConnection = async () => {
|
|
||||||
const baseUrl = form.baseUrl.trim();
|
|
||||||
if (!baseUrl) {
|
|
||||||
const message = t('notification.openai_test_url_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
|
|
||||||
if (!endpoint) {
|
|
||||||
const message = t('notification.openai_test_url_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim());
|
|
||||||
if (!firstKeyEntry) {
|
|
||||||
const message = t('notification.openai_test_key_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelName = testModel.trim() || availableModels[0] || '';
|
|
||||||
if (!modelName) {
|
|
||||||
const message = t('notification.openai_test_model_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const customHeaders = buildHeaderObject(form.headers);
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...customHeaders,
|
|
||||||
};
|
|
||||||
if (!headers.Authorization && !headers['authorization']) {
|
|
||||||
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestStatus('loading');
|
|
||||||
setTestMessage(t('ai_providers.openai_test_running'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await apiCallApi.request(
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
url: endpoint,
|
|
||||||
header: Object.keys(headers).length ? headers : undefined,
|
|
||||||
data: JSON.stringify({
|
|
||||||
model: modelName,
|
|
||||||
messages: [{ role: 'user', content: 'Hi' }],
|
|
||||||
stream: false,
|
|
||||||
max_tokens: 5,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{ timeout: OPENAI_TEST_TIMEOUT_MS }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
|
||||||
throw new Error(getApiCallErrorMessage(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestStatus('success');
|
|
||||||
setTestMessage(t('ai_providers.openai_test_success'));
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setTestStatus('error');
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
const errorCode =
|
|
||||||
typeof err === 'object' && err !== null && 'code' in err ? String((err as { code?: string }).code) : '';
|
|
||||||
const isTimeout =
|
|
||||||
errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
|
|
||||||
if (isTimeout) {
|
|
||||||
setTestMessage(t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 }));
|
|
||||||
} else {
|
|
||||||
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.openai_edit_modal_title')
|
|
||||||
: t('ai_providers.openai_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.openai_add_modal_name_label')}
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.openai_add_modal_url_label')}
|
|
||||||
value={form.baseUrl}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>
|
|
||||||
{editIndex !== null
|
|
||||||
? t('ai_providers.openai_edit_modal_models_label')
|
|
||||||
: t('ai_providers.openai_add_modal_models_label')}
|
|
||||||
</label>
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
|
|
||||||
<ModelInputList
|
|
||||||
entries={form.modelEntries}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
|
||||||
addLabel={t('ai_providers.openai_models_add_btn')}
|
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
|
||||||
disabled={isSaving}
|
|
||||||
/>
|
|
||||||
<Button variant="secondary" size="sm" onClick={openOpenaiModelDiscovery} disabled={isSaving}>
|
|
||||||
{t('ai_providers.openai_models_fetch_button')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.openai_test_title')}</label>
|
|
||||||
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<select
|
|
||||||
className={`input ${styles.openaiTestSelect}`}
|
|
||||||
value={testModel}
|
|
||||||
onChange={(e) => {
|
|
||||||
setTestModel(e.target.value);
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
}}
|
|
||||||
disabled={isSaving || availableModels.length === 0}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{availableModels.length
|
|
||||||
? t('ai_providers.openai_test_select_placeholder')
|
|
||||||
: t('ai_providers.openai_test_select_empty')}
|
|
||||||
</option>
|
|
||||||
{form.modelEntries
|
|
||||||
.filter((entry) => entry.name.trim())
|
|
||||||
.map((entry, idx) => {
|
|
||||||
const name = entry.name.trim();
|
|
||||||
const alias = entry.alias.trim();
|
|
||||||
const label = alias && alias !== name ? `${name} (${alias})` : name;
|
|
||||||
return (
|
|
||||||
<option key={`${name}-${idx}`} value={name}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
|
||||||
className={`${styles.openaiTestButton} ${testStatus === 'success' ? styles.openaiTestButtonSuccess : ''}`}
|
|
||||||
onClick={testOpenaiProviderConnection}
|
|
||||||
loading={testStatus === 'loading'}
|
|
||||||
disabled={isSaving || availableModels.length === 0}
|
|
||||||
>
|
|
||||||
{t('ai_providers.openai_test_action')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{testMessage && (
|
|
||||||
<div
|
|
||||||
className={`status-badge ${
|
|
||||||
testStatus === 'error' ? 'error' : testStatus === 'success' ? 'success' : 'muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{testMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
|
||||||
{renderKeyEntries(form.apiKeyEntries)}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<OpenAIDiscoveryModal
|
|
||||||
isOpen={discoveryOpen}
|
|
||||||
baseUrl={form.baseUrl}
|
|
||||||
headers={form.headers}
|
|
||||||
apiKeyEntries={form.apiKeyEntries}
|
|
||||||
onClose={() => setDiscoveryOpen(false)}
|
|
||||||
onApply={applyOpenaiModelDiscoverySelection}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,13 +7,16 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
|||||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||||
import type { OpenAIProviderConfig } from '@/types';
|
import type { OpenAIProviderConfig } from '@/types';
|
||||||
import { maskApiKey } from '@/utils/format';
|
import { maskApiKey } from '@/utils/format';
|
||||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
import { ProviderList } from '../ProviderList';
|
import { ProviderList } from '../ProviderList';
|
||||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
|
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
|
||||||
import type { OpenAIFormState } from '../types';
|
|
||||||
import { OpenAIModal } from './OpenAIModal';
|
|
||||||
|
|
||||||
interface OpenAISectionProps {
|
interface OpenAISectionProps {
|
||||||
configs: OpenAIProviderConfig[];
|
configs: OpenAIProviderConfig[];
|
||||||
@@ -21,16 +24,11 @@ interface OpenAISectionProps {
|
|||||||
usageDetails: UsageDetail[];
|
usageDetails: UsageDetail[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
disableControls: boolean;
|
disableControls: boolean;
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
resolvedTheme: string;
|
resolvedTheme: string;
|
||||||
isModalOpen: boolean;
|
|
||||||
modalIndex: number | null;
|
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onCloseModal: () => void;
|
|
||||||
onSave: (data: OpenAIFormState, index: number | null) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpenAISection({
|
export function OpenAISection({
|
||||||
@@ -39,34 +37,34 @@ export function OpenAISection({
|
|||||||
usageDetails,
|
usageDetails,
|
||||||
loading,
|
loading,
|
||||||
disableControls,
|
disableControls,
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
isSwitching,
|
||||||
resolvedTheme,
|
resolvedTheme,
|
||||||
isModalOpen,
|
|
||||||
modalIndex,
|
|
||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onCloseModal,
|
|
||||||
onSave,
|
|
||||||
}: OpenAISectionProps) {
|
}: OpenAISectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
const actionsDisabled = disableControls || loading || isSwitching;
|
||||||
|
|
||||||
const statusBarCache = useMemo(() => {
|
const statusBarCache = useMemo(() => {
|
||||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
|
|
||||||
configs.forEach((provider) => {
|
configs.forEach((provider) => {
|
||||||
const allKeys = (provider.apiKeyEntries || []).map((entry) => entry.apiKey).filter(Boolean);
|
const sourceIds = new Set<string>();
|
||||||
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source));
|
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));
|
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||||
});
|
});
|
||||||
|
|
||||||
return cache;
|
return cache;
|
||||||
}, [configs, usageDetails]);
|
}, [configs, usageDetails]);
|
||||||
|
|
||||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
@@ -89,14 +87,14 @@ export function OpenAISection({
|
|||||||
<ProviderList<OpenAIProviderConfig>
|
<ProviderList<OpenAIProviderConfig>
|
||||||
items={configs}
|
items={configs}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
keyField={(item) => item.name}
|
keyField={(_, index) => `openai-provider-${index}`}
|
||||||
emptyTitle={t('ai_providers.openai_empty_title')}
|
emptyTitle={t('ai_providers.openai_empty_title')}
|
||||||
emptyDescription={t('ai_providers.openai_empty_desc')}
|
emptyDescription={t('ai_providers.openai_empty_desc')}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
actionsDisabled={actionsDisabled}
|
actionsDisabled={actionsDisabled}
|
||||||
renderContent={(item) => {
|
renderContent={(item) => {
|
||||||
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, maskApiKey);
|
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
|
||||||
const headerEntries = Object.entries(item.headers || {});
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
const apiKeyEntries = item.apiKeyEntries || [];
|
const apiKeyEntries = item.apiKeyEntries || [];
|
||||||
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
|
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
|
||||||
@@ -130,7 +128,7 @@ export function OpenAISection({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.apiKeyEntryList}>
|
<div className={styles.apiKeyEntryList}>
|
||||||
{apiKeyEntries.map((entry, entryIndex) => {
|
{apiKeyEntries.map((entry, entryIndex) => {
|
||||||
const entryStats = getStatsBySource(entry.apiKey, keyStats, maskApiKey);
|
const entryStats = getStatsBySource(entry.apiKey, keyStats);
|
||||||
return (
|
return (
|
||||||
<div key={entryIndex} className={styles.apiKeyEntryCard}>
|
<div key={entryIndex} className={styles.apiKeyEntryCard}>
|
||||||
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||||
@@ -192,15 +190,6 @@ export function OpenAISection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<OpenAIModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
editIndex={modalIndex}
|
|
||||||
initialData={initialData}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onSave={onSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { EmptyState } from '@/components/ui/EmptyState';
|
|||||||
interface ProviderListProps<T> {
|
interface ProviderListProps<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
keyField: (item: T) => string;
|
keyField: (item: T, index: number) => string;
|
||||||
renderContent: (item: T, index: number) => ReactNode;
|
renderContent: (item: T, index: number) => ReactNode;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
@@ -34,7 +34,7 @@ export function ProviderList<T>({
|
|||||||
}: ProviderListProps<T>) {
|
}: ProviderListProps<T>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (loading) {
|
if (loading && items.length === 0) {
|
||||||
return <div className="hint">{t('common.loading')}</div>;
|
return <div className="hint">{t('common.loading')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export function ProviderList<T>({
|
|||||||
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
|
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={keyField(item)}
|
key={keyField(item, index)}
|
||||||
className="item-row"
|
className="item-row"
|
||||||
style={rowDisabled ? { opacity: 0.6 } : undefined}
|
style={rowDisabled ? { opacity: 0.6 } : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
147
src/components/providers/ProviderNav/ProviderNav.module.scss
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
@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: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
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($primary-color, 0.16);
|
||||||
|
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: color-mix(in srgb, var(--text-primary) 10%, transparent);
|
||||||
|
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: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--border-color) 55%, transparent);
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
background: rgba($primary-color, 0.28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏幕进一步收紧尺寸
|
||||||
|
@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';
|
||||||
@@ -1,36 +1,143 @@
|
|||||||
import { calculateStatusBarData } from '@/utils/usage';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { StatusBarData, StatusBlockDetail } from '@/utils/usage';
|
||||||
|
import defaultStyles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
|
||||||
interface ProviderStatusBarProps {
|
/**
|
||||||
statusData: ReturnType<typeof calculateStatusBarData>;
|
* 根据成功率 (0–1) 在三个色标之间做 RGB 线性插值
|
||||||
|
* 0 → 红 (#ef4444) → 0.5 → 金黄 (#facc15) → 1 → 绿 (#22c55e)
|
||||||
|
*/
|
||||||
|
const COLOR_STOPS = [
|
||||||
|
{ r: 239, g: 68, b: 68 }, // #ef4444
|
||||||
|
{ r: 250, g: 204, b: 21 }, // #facc15
|
||||||
|
{ r: 34, g: 197, b: 94 }, // #22c55e
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function rateToColor(rate: number): string {
|
||||||
|
const t = Math.max(0, Math.min(1, rate));
|
||||||
|
const segment = t < 0.5 ? 0 : 1;
|
||||||
|
const localT = segment === 0 ? t * 2 : (t - 0.5) * 2;
|
||||||
|
const from = COLOR_STOPS[segment];
|
||||||
|
const to = COLOR_STOPS[segment + 1];
|
||||||
|
const r = Math.round(from.r + (to.r - from.r) * localT);
|
||||||
|
const g = Math.round(from.g + (to.g - from.g) * localT);
|
||||||
|
const b = Math.round(from.b + (to.b - from.b) * localT);
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderStatusBar({ statusData }: ProviderStatusBarProps) {
|
function formatTime(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const h = date.getHours().toString().padStart(2, '0');
|
||||||
|
const m = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${h}:${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StylesModule = Record<string, string>;
|
||||||
|
|
||||||
|
interface ProviderStatusBarProps {
|
||||||
|
statusData: StatusBarData;
|
||||||
|
styles?: StylesModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderStatusBar({ statusData, styles: stylesProp }: ProviderStatusBarProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const s = (stylesProp || defaultStyles) as StylesModule;
|
||||||
|
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
|
||||||
|
const blocksRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||||
const rateClass = !hasData
|
const rateClass = !hasData
|
||||||
? ''
|
? ''
|
||||||
: statusData.successRate >= 90
|
: statusData.successRate >= 90
|
||||||
? styles.statusRateHigh
|
? s.statusRateHigh
|
||||||
: statusData.successRate >= 50
|
: statusData.successRate >= 50
|
||||||
? styles.statusRateMedium
|
? s.statusRateMedium
|
||||||
: styles.statusRateLow;
|
: s.statusRateLow;
|
||||||
|
|
||||||
|
// 点击外部关闭 tooltip(移动端)
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTooltip === null) return;
|
||||||
|
const handler = (e: PointerEvent) => {
|
||||||
|
if (blocksRef.current && !blocksRef.current.contains(e.target as Node)) {
|
||||||
|
setActiveTooltip(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', handler);
|
||||||
|
return () => document.removeEventListener('pointerdown', handler);
|
||||||
|
}, [activeTooltip]);
|
||||||
|
|
||||||
|
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
|
||||||
|
if (e.pointerType === 'mouse') {
|
||||||
|
setActiveTooltip(idx);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
|
||||||
|
if (e.pointerType === 'mouse') {
|
||||||
|
setActiveTooltip(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
|
||||||
|
if (e.pointerType === 'touch') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTooltip((prev) => (prev === idx ? null : idx));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getTooltipPositionClass = (idx: number, total: number): string => {
|
||||||
|
if (idx <= 2) return s.statusTooltipLeft;
|
||||||
|
if (idx >= total - 3) return s.statusTooltipRight;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
|
||||||
|
const total = detail.success + detail.failure;
|
||||||
|
const posClass = getTooltipPositionClass(idx, statusData.blockDetails.length);
|
||||||
|
const timeRange = `${formatTime(detail.startTime)} – ${formatTime(detail.endTime)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${s.statusTooltip} ${posClass}`}>
|
||||||
|
<span className={s.tooltipTime}>{timeRange}</span>
|
||||||
|
{total > 0 ? (
|
||||||
|
<span className={s.tooltipStats}>
|
||||||
|
<span className={s.tooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
|
||||||
|
<span className={s.tooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
|
||||||
|
<span className={s.tooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={s.tooltipStats}>{t('status_bar.no_requests')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.statusBar}>
|
<div className={s.statusBar}>
|
||||||
<div className={styles.statusBlocks}>
|
<div className={s.statusBlocks} ref={blocksRef}>
|
||||||
{statusData.blocks.map((state, idx) => {
|
{statusData.blockDetails.map((detail, idx) => {
|
||||||
const blockClass =
|
const isIdle = detail.rate === -1;
|
||||||
state === 'success'
|
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||||
? styles.statusBlockSuccess
|
const isActive = activeTooltip === idx;
|
||||||
: state === 'failure'
|
|
||||||
? styles.statusBlockFailure
|
return (
|
||||||
: state === 'mixed'
|
<div
|
||||||
? styles.statusBlockMixed
|
key={idx}
|
||||||
: styles.statusBlockIdle;
|
className={`${s.statusBlockWrapper} ${isActive ? s.statusBlockActive : ''}`}
|
||||||
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
|
onPointerEnter={(e) => handlePointerEnter(e, idx)}
|
||||||
|
onPointerLeave={handlePointerLeave}
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, idx)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${s.statusBlock} ${isIdle ? s.statusBlockIdle : ''}`}
|
||||||
|
style={blockStyle}
|
||||||
|
/>
|
||||||
|
{isActive && renderTooltip(detail, idx)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<span className={`${styles.statusRate} ${rateClass}`}>
|
<span className={`${s.statusRate} ${rateClass}`}>
|
||||||
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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';
|
||||||
@@ -3,8 +3,10 @@ export { ClaudeSection } from './ClaudeSection';
|
|||||||
export { CodexSection } from './CodexSection';
|
export { CodexSection } from './CodexSection';
|
||||||
export { GeminiSection } from './GeminiSection';
|
export { GeminiSection } from './GeminiSection';
|
||||||
export { OpenAISection } from './OpenAISection';
|
export { OpenAISection } from './OpenAISection';
|
||||||
|
export { VertexSection } from './VertexSection';
|
||||||
export { ProviderList } from './ProviderList';
|
export { ProviderList } from './ProviderList';
|
||||||
export { ProviderStatusBar } from './ProviderStatusBar';
|
export { ProviderStatusBar } from './ProviderStatusBar';
|
||||||
|
export { ProviderNav } from './ProviderNav';
|
||||||
export * from './hooks/useProviderStats';
|
export * from './hooks/useProviderStats';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
|||||||
@@ -2,13 +2,6 @@ import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
|
|||||||
import type { HeaderEntry } from '@/utils/headers';
|
import type { HeaderEntry } from '@/utils/headers';
|
||||||
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
||||||
|
|
||||||
export type ProviderModal =
|
|
||||||
| { type: 'gemini'; index: number | null }
|
|
||||||
| { type: 'codex'; index: number | null }
|
|
||||||
| { type: 'claude'; index: number | null }
|
|
||||||
| { type: 'ampcode'; index: null }
|
|
||||||
| { type: 'openai'; index: number | null };
|
|
||||||
|
|
||||||
export interface ModelEntry {
|
export interface ModelEntry {
|
||||||
name: string;
|
name: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
@@ -31,13 +24,22 @@ export interface AmpcodeFormState {
|
|||||||
mappingEntries: ModelEntry[];
|
mappingEntries: ModelEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GeminiFormState = GeminiKeyConfig & { excludedText: string };
|
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers'> & {
|
||||||
|
headers: HeaderEntry[];
|
||||||
|
excludedText: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProviderFormState = ProviderKeyConfig & {
|
export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
|
||||||
|
headers: HeaderEntry[];
|
||||||
modelEntries: ModelEntry[];
|
modelEntries: ModelEntry[];
|
||||||
excludedText: string;
|
excludedText: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
|
||||||
|
headers: HeaderEntry[];
|
||||||
|
modelEntries: ModelEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
export interface ProviderSectionProps<TConfig> {
|
export interface ProviderSectionProps<TConfig> {
|
||||||
configs: TConfig[];
|
configs: TConfig[];
|
||||||
keyStats: KeyStats;
|
keyStats: KeyStats;
|
||||||
@@ -48,12 +50,3 @@ export interface ProviderSectionProps<TConfig> {
|
|||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onToggle?: (index: number, enabled: boolean) => void;
|
onToggle?: (index: number, enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderModalProps<TConfig, TPayload = TConfig> {
|
|
||||||
isOpen: boolean;
|
|
||||||
editIndex: number | null;
|
|
||||||
initialData?: TConfig;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (data: TPayload, index: number | null) => Promise<void>;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
||||||
import type { KeyStatBucket, KeyStats } from '@/utils/usage';
|
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
|
||||||
import type { AmpcodeFormState, ModelEntry } from './types';
|
import type { AmpcodeFormState, ModelEntry } from './types';
|
||||||
|
|
||||||
export const DISABLE_ALL_MODELS_RULE = '*';
|
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||||
@@ -43,10 +43,23 @@ export const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const normalizeClaudeBaseUrl = (baseUrl: string): string => {
|
||||||
|
let trimmed = String(baseUrl || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return 'https://api.anthropic.com';
|
||||||
|
}
|
||||||
|
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 => {
|
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
||||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||||
if (!trimmed) return '';
|
if (!trimmed) return '';
|
||||||
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
return `${trimmed}/models`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||||
@@ -55,40 +68,69 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
|||||||
if (trimmed.endsWith('/chat/completions')) {
|
if (trimmed.endsWith('/chat/completions')) {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
return trimmed.endsWith('/v1') ? `${trimmed}/chat/completions` : `${trimmed}/v1/chat/completions`;
|
return `${trimmed}/chat/completions`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildClaudeMessagesEndpoint = (baseUrl: string): string => {
|
||||||
|
const trimmed = normalizeClaudeBaseUrl(baseUrl);
|
||||||
|
if (!trimmed) return '';
|
||||||
|
if (trimmed.endsWith('/v1/messages')) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
if (trimmed.endsWith('/v1')) {
|
||||||
|
return `${trimmed}/messages`;
|
||||||
|
}
|
||||||
|
return `${trimmed}/v1/messages`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
|
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
|
||||||
export const getStatsBySource = (
|
export const getStatsBySource = (
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
keyStats: KeyStats,
|
keyStats: KeyStats,
|
||||||
maskFn: (key: string) => string
|
prefix?: string
|
||||||
): KeyStatBucket => {
|
): KeyStatBucket => {
|
||||||
const bySource = keyStats.bySource ?? {};
|
const bySource = keyStats.bySource ?? {};
|
||||||
const masked = maskFn(apiKey);
|
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
|
||||||
return bySource[apiKey] || bySource[masked] || { success: 0, failure: 0 };
|
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 的统计 - 与旧版逻辑一致
|
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||||
export const getOpenAIProviderStats = (
|
export const getOpenAIProviderStats = (
|
||||||
apiKeyEntries: ApiKeyEntry[] | undefined,
|
apiKeyEntries: ApiKeyEntry[] | undefined,
|
||||||
keyStats: KeyStats,
|
keyStats: KeyStats,
|
||||||
maskFn: (key: string) => string
|
providerPrefix?: string
|
||||||
): KeyStatBucket => {
|
): KeyStatBucket => {
|
||||||
const bySource = keyStats.bySource ?? {};
|
const bySource = keyStats.bySource ?? {};
|
||||||
let totalSuccess = 0;
|
|
||||||
let totalFailure = 0;
|
|
||||||
|
|
||||||
|
const sourceIds = new Set<string>();
|
||||||
|
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
|
||||||
(apiKeyEntries || []).forEach((entry) => {
|
(apiKeyEntries || []).forEach((entry) => {
|
||||||
const key = entry?.apiKey || '';
|
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
|
||||||
if (!key) return;
|
|
||||||
const masked = maskFn(key);
|
|
||||||
const stats = bySource[key] || bySource[masked] || { success: 0, failure: 0 };
|
|
||||||
totalSuccess += stats.success;
|
|
||||||
totalFailure += stats.failure;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: totalSuccess, failure: totalFailure };
|
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 => ({
|
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
|||||||
|
|
||||||
type ViewMode = 'paged' | 'all';
|
type ViewMode = 'paged' | 'all';
|
||||||
|
|
||||||
const MAX_ITEMS_PER_PAGE = 14;
|
const MAX_ITEMS_PER_PAGE = 25;
|
||||||
const MAX_SHOW_ALL_THRESHOLD = 30;
|
const MAX_SHOW_ALL_THRESHOLD = 30;
|
||||||
|
|
||||||
interface QuotaPaginationState<T> {
|
interface QuotaPaginationState<T> {
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
export { QuotaSection } from './QuotaSection';
|
export { QuotaSection } from './QuotaSection';
|
||||||
export { QuotaCard } from './QuotaCard';
|
export { QuotaCard } from './QuotaCard';
|
||||||
export { useQuotaLoader } from './useQuotaLoader';
|
export { useQuotaLoader } from './useQuotaLoader';
|
||||||
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
|
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
|
||||||
export type { QuotaConfig } from './quotaConfigs';
|
export type { QuotaConfig } from './quotaConfigs';
|
||||||
|
|||||||
@@ -10,28 +10,38 @@ import type {
|
|||||||
AntigravityModelsPayload,
|
AntigravityModelsPayload,
|
||||||
AntigravityQuotaState,
|
AntigravityQuotaState,
|
||||||
AuthFileItem,
|
AuthFileItem,
|
||||||
|
ClaudeExtraUsage,
|
||||||
|
ClaudeQuotaState,
|
||||||
|
ClaudeQuotaWindow,
|
||||||
|
ClaudeUsagePayload,
|
||||||
|
CodexRateLimitInfo,
|
||||||
CodexQuotaState,
|
CodexQuotaState,
|
||||||
CodexUsageWindow,
|
CodexUsageWindow,
|
||||||
CodexQuotaWindow,
|
CodexQuotaWindow,
|
||||||
CodexUsagePayload,
|
CodexUsagePayload,
|
||||||
GeminiCliParsedBucket,
|
GeminiCliParsedBucket,
|
||||||
GeminiCliQuotaBucketState,
|
GeminiCliQuotaBucketState,
|
||||||
GeminiCliQuotaState
|
GeminiCliQuotaState,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
|
||||||
import {
|
import {
|
||||||
ANTIGRAVITY_QUOTA_URLS,
|
ANTIGRAVITY_QUOTA_URLS,
|
||||||
ANTIGRAVITY_REQUEST_HEADERS,
|
ANTIGRAVITY_REQUEST_HEADERS,
|
||||||
|
CLAUDE_USAGE_URL,
|
||||||
|
CLAUDE_REQUEST_HEADERS,
|
||||||
|
CLAUDE_USAGE_WINDOW_KEYS,
|
||||||
CODEX_USAGE_URL,
|
CODEX_USAGE_URL,
|
||||||
CODEX_REQUEST_HEADERS,
|
CODEX_REQUEST_HEADERS,
|
||||||
GEMINI_CLI_QUOTA_URL,
|
GEMINI_CLI_QUOTA_URL,
|
||||||
GEMINI_CLI_REQUEST_HEADERS,
|
GEMINI_CLI_REQUEST_HEADERS,
|
||||||
normalizeAuthIndexValue,
|
normalizeAuthIndexValue,
|
||||||
|
normalizeGeminiCliModelId,
|
||||||
normalizeNumberValue,
|
normalizeNumberValue,
|
||||||
normalizePlanType,
|
normalizePlanType,
|
||||||
normalizeQuotaFraction,
|
normalizeQuotaFraction,
|
||||||
normalizeStringValue,
|
normalizeStringValue,
|
||||||
parseAntigravityPayload,
|
parseAntigravityPayload,
|
||||||
|
parseClaudeUsagePayload,
|
||||||
parseCodexUsagePayload,
|
parseCodexUsagePayload,
|
||||||
parseGeminiCliQuotaPayload,
|
parseGeminiCliQuotaPayload,
|
||||||
resolveCodexChatgptAccountId,
|
resolveCodexChatgptAccountId,
|
||||||
@@ -44,22 +54,28 @@ import {
|
|||||||
createStatusError,
|
createStatusError,
|
||||||
getStatusFromError,
|
getStatusFromError,
|
||||||
isAntigravityFile,
|
isAntigravityFile,
|
||||||
|
isClaudeFile,
|
||||||
isCodexFile,
|
isCodexFile,
|
||||||
|
isDisabledAuthFile,
|
||||||
isGeminiCliFile,
|
isGeminiCliFile,
|
||||||
isRuntimeOnlyAuthFile
|
isRuntimeOnlyAuthFile,
|
||||||
} from '@/utils/quota';
|
} from '@/utils/quota';
|
||||||
import type { QuotaRenderHelpers } from './QuotaCard';
|
import type { QuotaRenderHelpers } from './QuotaCard';
|
||||||
import styles from '@/pages/QuotaPage.module.scss';
|
import styles from '@/pages/QuotaPage.module.scss';
|
||||||
|
|
||||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||||
|
|
||||||
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
|
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli';
|
||||||
|
|
||||||
|
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
|
||||||
|
|
||||||
export interface QuotaStore {
|
export interface QuotaStore {
|
||||||
antigravityQuota: Record<string, AntigravityQuotaState>;
|
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||||
|
claudeQuota: Record<string, ClaudeQuotaState>;
|
||||||
codexQuota: Record<string, CodexQuotaState>;
|
codexQuota: Record<string, CodexQuotaState>;
|
||||||
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||||
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||||
|
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
|
||||||
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||||
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||||
clearQuotaCache: () => void;
|
clearQuotaCache: () => void;
|
||||||
@@ -82,6 +98,38 @@ export interface QuotaConfig<TState, TData> {
|
|||||||
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
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 (
|
const fetchAntigravityQuota = async (
|
||||||
file: AuthFileItem,
|
file: AuthFileItem,
|
||||||
t: TFunction
|
t: TFunction
|
||||||
@@ -92,6 +140,9 @@ const fetchAntigravityQuota = async (
|
|||||||
throw new Error(t('antigravity_quota.missing_auth_index'));
|
throw new Error(t('antigravity_quota.missing_auth_index'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectId = await resolveAntigravityProjectId(file);
|
||||||
|
const requestBody = JSON.stringify({ project: projectId });
|
||||||
|
|
||||||
let lastError = '';
|
let lastError = '';
|
||||||
let lastStatus: number | undefined;
|
let lastStatus: number | undefined;
|
||||||
let priorityStatus: number | undefined;
|
let priorityStatus: number | undefined;
|
||||||
@@ -104,7 +155,7 @@ const fetchAntigravityQuota = async (
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
url,
|
url,
|
||||||
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
||||||
data: '{}'
|
data: requestBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
@@ -151,13 +202,25 @@ const fetchAntigravityQuota = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
|
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 rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
|
||||||
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
|
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
|
||||||
|
const additionalRateLimits = payload.additional_rate_limits ?? payload.additionalRateLimits ?? [];
|
||||||
const windows: CodexQuotaWindow[] = [];
|
const windows: CodexQuotaWindow[] = [];
|
||||||
|
|
||||||
const addWindow = (
|
const addWindow = (
|
||||||
id: string,
|
id: string,
|
||||||
labelKey: string,
|
label: string,
|
||||||
|
labelKey: string | undefined,
|
||||||
|
labelParams: Record<string, string | number> | undefined,
|
||||||
window?: CodexUsageWindow | null,
|
window?: CodexUsageWindow | null,
|
||||||
limitReached?: boolean,
|
limitReached?: boolean,
|
||||||
allowed?: boolean
|
allowed?: boolean
|
||||||
@@ -169,35 +232,143 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
|
|||||||
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
|
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
|
||||||
windows.push({
|
windows.push({
|
||||||
id,
|
id,
|
||||||
label: t(labelKey),
|
label,
|
||||||
labelKey,
|
labelKey,
|
||||||
|
labelParams,
|
||||||
usedPercent,
|
usedPercent,
|
||||||
resetLabel
|
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,
|
||||||
|
options?: { allowOrderFallback?: boolean }
|
||||||
|
): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => {
|
||||||
|
const allowOrderFallback = options?.allowOrderFallback ?? true;
|
||||||
|
const primaryWindow = limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null;
|
||||||
|
const secondaryWindow = limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null;
|
||||||
|
const rawWindows = [primaryWindow, secondaryWindow];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For legacy payloads without window duration, fallback to primary/secondary ordering.
|
||||||
|
if (allowOrderFallback) {
|
||||||
|
if (!fiveHourWindow) {
|
||||||
|
fiveHourWindow = primaryWindow && primaryWindow !== weeklyWindow ? primaryWindow : null;
|
||||||
|
}
|
||||||
|
if (!weeklyWindow) {
|
||||||
|
weeklyWindow = secondaryWindow && secondaryWindow !== fiveHourWindow ? secondaryWindow : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fiveHourWindow, weeklyWindow };
|
||||||
|
};
|
||||||
|
|
||||||
|
const rateWindows = pickClassifiedWindows(rateLimit);
|
||||||
addWindow(
|
addWindow(
|
||||||
'primary',
|
WINDOW_META.codeFiveHour.id,
|
||||||
'codex_quota.primary_window',
|
t(WINDOW_META.codeFiveHour.labelKey),
|
||||||
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
|
WINDOW_META.codeFiveHour.labelKey,
|
||||||
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
undefined,
|
||||||
rateLimit?.allowed
|
rateWindows.fiveHourWindow,
|
||||||
|
rawLimitReached,
|
||||||
|
rawAllowed
|
||||||
);
|
);
|
||||||
addWindow(
|
addWindow(
|
||||||
'secondary',
|
WINDOW_META.codeWeekly.id,
|
||||||
'codex_quota.secondary_window',
|
t(WINDOW_META.codeWeekly.labelKey),
|
||||||
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
|
WINDOW_META.codeWeekly.labelKey,
|
||||||
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
undefined,
|
||||||
rateLimit?.allowed
|
rateWindows.weeklyWindow,
|
||||||
|
rawLimitReached,
|
||||||
|
rawAllowed
|
||||||
|
);
|
||||||
|
|
||||||
|
const codeReviewWindows = pickClassifiedWindows(codeReviewLimit);
|
||||||
|
const codeReviewLimitReached = codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached;
|
||||||
|
const codeReviewAllowed = codeReviewLimit?.allowed;
|
||||||
|
addWindow(
|
||||||
|
WINDOW_META.codeReviewFiveHour.id,
|
||||||
|
t(WINDOW_META.codeReviewFiveHour.labelKey),
|
||||||
|
WINDOW_META.codeReviewFiveHour.labelKey,
|
||||||
|
undefined,
|
||||||
|
codeReviewWindows.fiveHourWindow,
|
||||||
|
codeReviewLimitReached,
|
||||||
|
codeReviewAllowed
|
||||||
);
|
);
|
||||||
addWindow(
|
addWindow(
|
||||||
'code-review',
|
WINDOW_META.codeReviewWeekly.id,
|
||||||
'codex_quota.code_review_window',
|
t(WINDOW_META.codeReviewWeekly.labelKey),
|
||||||
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
|
WINDOW_META.codeReviewWeekly.labelKey,
|
||||||
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
|
undefined,
|
||||||
codeReviewLimit?.allowed
|
codeReviewWindows.weeklyWindow,
|
||||||
|
codeReviewLimitReached,
|
||||||
|
codeReviewAllowed
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const normalizeWindowId = (raw: string) =>
|
||||||
|
raw
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
|
||||||
|
if (Array.isArray(additionalRateLimits)) {
|
||||||
|
additionalRateLimits.forEach((limitItem, index) => {
|
||||||
|
const rateInfo = limitItem?.rate_limit ?? limitItem?.rateLimit ?? null;
|
||||||
|
if (!rateInfo) return;
|
||||||
|
|
||||||
|
const limitName =
|
||||||
|
normalizeStringValue(limitItem?.limit_name ?? limitItem?.limitName) ??
|
||||||
|
normalizeStringValue(limitItem?.metered_feature ?? limitItem?.meteredFeature) ??
|
||||||
|
`additional-${index + 1}`;
|
||||||
|
|
||||||
|
const idPrefix = normalizeWindowId(limitName) || `additional-${index + 1}`;
|
||||||
|
const additionalPrimaryWindow = rateInfo.primary_window ?? rateInfo.primaryWindow ?? null;
|
||||||
|
const additionalSecondaryWindow = rateInfo.secondary_window ?? rateInfo.secondaryWindow ?? null;
|
||||||
|
const additionalLimitReached = rateInfo.limit_reached ?? rateInfo.limitReached;
|
||||||
|
const additionalAllowed = rateInfo.allowed;
|
||||||
|
|
||||||
|
addWindow(
|
||||||
|
`${idPrefix}-five-hour-${index}`,
|
||||||
|
t('codex_quota.additional_primary_window', { name: limitName }),
|
||||||
|
'codex_quota.additional_primary_window',
|
||||||
|
{ name: limitName },
|
||||||
|
additionalPrimaryWindow,
|
||||||
|
additionalLimitReached,
|
||||||
|
additionalAllowed
|
||||||
|
);
|
||||||
|
addWindow(
|
||||||
|
`${idPrefix}-weekly-${index}`,
|
||||||
|
t('codex_quota.additional_secondary_window', { name: limitName }),
|
||||||
|
'codex_quota.additional_secondary_window',
|
||||||
|
{ name: limitName },
|
||||||
|
additionalSecondaryWindow,
|
||||||
|
additionalLimitReached,
|
||||||
|
additionalAllowed
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return windows;
|
return windows;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -219,14 +390,14 @@ const fetchCodexQuota = async (
|
|||||||
|
|
||||||
const requestHeader: Record<string, string> = {
|
const requestHeader: Record<string, string> = {
|
||||||
...CODEX_REQUEST_HEADERS,
|
...CODEX_REQUEST_HEADERS,
|
||||||
'Chatgpt-Account-Id': accountId
|
'Chatgpt-Account-Id': accountId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await apiCallApi.request({
|
const result = await apiCallApi.request({
|
||||||
authIndex,
|
authIndex,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: CODEX_USAGE_URL,
|
url: CODEX_USAGE_URL,
|
||||||
header: requestHeader
|
header: requestHeader,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
@@ -263,7 +434,7 @@ const fetchGeminiCliQuota = async (
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: GEMINI_CLI_QUOTA_URL,
|
url: GEMINI_CLI_QUOTA_URL,
|
||||||
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
||||||
data: JSON.stringify({ project: projectId })
|
data: JSON.stringify({ project: projectId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
@@ -276,13 +447,15 @@ const fetchGeminiCliQuota = async (
|
|||||||
|
|
||||||
const parsedBuckets = buckets
|
const parsedBuckets = buckets
|
||||||
.map((bucket) => {
|
.map((bucket) => {
|
||||||
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
|
const modelId = normalizeGeminiCliModelId(bucket.modelId ?? bucket.model_id);
|
||||||
if (!modelId) return null;
|
if (!modelId) return null;
|
||||||
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
||||||
const remainingFractionRaw = normalizeQuotaFraction(
|
const remainingFractionRaw = normalizeQuotaFraction(
|
||||||
bucket.remainingFraction ?? bucket.remaining_fraction
|
bucket.remainingFraction ?? bucket.remaining_fraction
|
||||||
);
|
);
|
||||||
const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount);
|
const remainingAmount = normalizeNumberValue(
|
||||||
|
bucket.remainingAmount ?? bucket.remaining_amount
|
||||||
|
);
|
||||||
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
|
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
|
||||||
let fallbackFraction: number | null = null;
|
let fallbackFraction: number | null = null;
|
||||||
if (remainingAmount !== null) {
|
if (remainingAmount !== null) {
|
||||||
@@ -296,7 +469,7 @@ const fetchGeminiCliQuota = async (
|
|||||||
tokenType,
|
tokenType,
|
||||||
remainingFraction,
|
remainingFraction,
|
||||||
remainingAmount,
|
remainingAmount,
|
||||||
resetTime
|
resetTime,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
||||||
@@ -328,11 +501,7 @@ const renderAntigravityItems = (
|
|||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{ className: styleMap.quotaRowHeader },
|
{ className: styleMap.quotaRowHeader },
|
||||||
h(
|
h('span', { className: styleMap.quotaModel, title: group.models.join(', ') }, group.label),
|
||||||
'span',
|
|
||||||
{ className: styleMap.quotaModel, title: group.models.join(', ') },
|
|
||||||
group.label
|
|
||||||
),
|
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{ className: styleMap.quotaMeta },
|
{ className: styleMap.quotaMeta },
|
||||||
@@ -365,7 +534,6 @@ const renderCodexItems = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const planLabel = getPlanLabel(planType);
|
const planLabel = getPlanLabel(planType);
|
||||||
const isFreePlan = normalizePlanType(planType) === 'free';
|
|
||||||
const nodes: ReactNode[] = [];
|
const nodes: ReactNode[] = [];
|
||||||
|
|
||||||
if (planLabel) {
|
if (planLabel) {
|
||||||
@@ -379,17 +547,6 @@ const renderCodexItems = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFreePlan) {
|
|
||||||
nodes.push(
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ key: 'warning', className: styleMap.quotaWarning },
|
|
||||||
t('codex_quota.no_access')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return h(Fragment, null, ...nodes);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (windows.length === 0) {
|
if (windows.length === 0) {
|
||||||
nodes.push(
|
nodes.push(
|
||||||
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
|
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
|
||||||
@@ -403,7 +560,9 @@ const renderCodexItems = (
|
|||||||
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
|
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 remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
|
||||||
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
|
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
|
||||||
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
|
const windowLabel = window.labelKey
|
||||||
|
? t(window.labelKey, window.labelParams as Record<string, string | number>)
|
||||||
|
: window.label;
|
||||||
|
|
||||||
return h(
|
return h(
|
||||||
'div',
|
'div',
|
||||||
@@ -449,7 +608,7 @@ const renderGeminiCliItems = (
|
|||||||
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
||||||
? null
|
? null
|
||||||
: t('gemini_cli_quota.remaining_amount', {
|
: t('gemini_cli_quota.remaining_amount', {
|
||||||
count: bucket.remainingAmount
|
count: bucket.remainingAmount,
|
||||||
});
|
});
|
||||||
const titleBase =
|
const titleBase =
|
||||||
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
|
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
|
||||||
@@ -479,10 +638,153 @@ const renderGeminiCliItems = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildClaudeQuotaWindows = (
|
||||||
|
payload: ClaudeUsagePayload,
|
||||||
|
t: TFunction
|
||||||
|
): ClaudeQuotaWindow[] => {
|
||||||
|
const windows: ClaudeQuotaWindow[] = [];
|
||||||
|
|
||||||
|
for (const { key, id, labelKey } of CLAUDE_USAGE_WINDOW_KEYS) {
|
||||||
|
const window = payload[key as keyof ClaudeUsagePayload];
|
||||||
|
if (!window || typeof window !== 'object' || !('utilization' in window)) continue;
|
||||||
|
const typedWindow = window as { utilization: number; resets_at: string };
|
||||||
|
const usedPercent = normalizeNumberValue(typedWindow.utilization);
|
||||||
|
const resetLabel = formatQuotaResetTime(typedWindow.resets_at);
|
||||||
|
windows.push({
|
||||||
|
id,
|
||||||
|
label: t(labelKey),
|
||||||
|
labelKey,
|
||||||
|
usedPercent,
|
||||||
|
resetLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return windows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchClaudeQuota = async (
|
||||||
|
file: AuthFileItem,
|
||||||
|
t: TFunction
|
||||||
|
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
if (!authIndex) {
|
||||||
|
throw new Error(t('claude_quota.missing_auth_index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiCallApi.request({
|
||||||
|
authIndex,
|
||||||
|
method: 'GET',
|
||||||
|
url: CLAUDE_USAGE_URL,
|
||||||
|
header: { ...CLAUDE_REQUEST_HEADERS },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseClaudeUsagePayload(result.body ?? result.bodyText);
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error(t('claude_quota.empty_windows'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const windows = buildClaudeQuotaWindows(payload, t);
|
||||||
|
return { windows, extraUsage: payload.extra_usage };
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderClaudeItems = (
|
||||||
|
quota: ClaudeQuotaState,
|
||||||
|
t: TFunction,
|
||||||
|
helpers: QuotaRenderHelpers
|
||||||
|
): ReactNode => {
|
||||||
|
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||||
|
const { createElement: h, Fragment } = React;
|
||||||
|
const windows = quota.windows ?? [];
|
||||||
|
const extraUsage = quota.extraUsage ?? null;
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
|
||||||
|
if (extraUsage && extraUsage.is_enabled) {
|
||||||
|
const usedLabel = `$${(extraUsage.used_credits / 100).toFixed(2)} / $${(extraUsage.monthly_limit / 100).toFixed(2)}`;
|
||||||
|
nodes.push(
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ key: 'extra', className: styleMap.codexPlan },
|
||||||
|
h('span', { className: styleMap.codexPlanLabel }, t('claude_quota.extra_usage_label')),
|
||||||
|
h('span', { className: styleMap.codexPlanValue }, usedLabel)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windows.length === 0) {
|
||||||
|
nodes.push(
|
||||||
|
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('claude_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);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CLAUDE_CONFIG: QuotaConfig<
|
||||||
|
ClaudeQuotaState,
|
||||||
|
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }
|
||||||
|
> = {
|
||||||
|
type: 'claude',
|
||||||
|
i18nPrefix: 'claude_quota',
|
||||||
|
filterFn: (file) => isClaudeFile(file) && !isDisabledAuthFile(file),
|
||||||
|
fetchQuota: fetchClaudeQuota,
|
||||||
|
storeSelector: (state) => state.claudeQuota,
|
||||||
|
storeSetter: 'setClaudeQuota',
|
||||||
|
buildLoadingState: () => ({ status: 'loading', windows: [] }),
|
||||||
|
buildSuccessState: (data) => ({
|
||||||
|
status: 'success',
|
||||||
|
windows: data.windows,
|
||||||
|
extraUsage: data.extraUsage,
|
||||||
|
}),
|
||||||
|
buildErrorState: (message, status) => ({
|
||||||
|
status: 'error',
|
||||||
|
windows: [],
|
||||||
|
error: message,
|
||||||
|
errorStatus: status,
|
||||||
|
}),
|
||||||
|
cardClassName: styles.claudeCard,
|
||||||
|
controlsClassName: styles.claudeControls,
|
||||||
|
controlClassName: styles.claudeControl,
|
||||||
|
gridClassName: styles.claudeGrid,
|
||||||
|
renderQuotaItems: renderClaudeItems,
|
||||||
|
};
|
||||||
|
|
||||||
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
|
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
|
||||||
type: 'antigravity',
|
type: 'antigravity',
|
||||||
i18nPrefix: 'antigravity_quota',
|
i18nPrefix: 'antigravity_quota',
|
||||||
filterFn: (file) => isAntigravityFile(file),
|
filterFn: (file) => isAntigravityFile(file) && !isDisabledAuthFile(file),
|
||||||
fetchQuota: fetchAntigravityQuota,
|
fetchQuota: fetchAntigravityQuota,
|
||||||
storeSelector: (state) => state.antigravityQuota,
|
storeSelector: (state) => state.antigravityQuota,
|
||||||
storeSetter: 'setAntigravityQuota',
|
storeSetter: 'setAntigravityQuota',
|
||||||
@@ -492,13 +794,13 @@ export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQ
|
|||||||
status: 'error',
|
status: 'error',
|
||||||
groups: [],
|
groups: [],
|
||||||
error: message,
|
error: message,
|
||||||
errorStatus: status
|
errorStatus: status,
|
||||||
}),
|
}),
|
||||||
cardClassName: styles.antigravityCard,
|
cardClassName: styles.antigravityCard,
|
||||||
controlsClassName: styles.antigravityControls,
|
controlsClassName: styles.antigravityControls,
|
||||||
controlClassName: styles.antigravityControl,
|
controlClassName: styles.antigravityControl,
|
||||||
gridClassName: styles.antigravityGrid,
|
gridClassName: styles.antigravityGrid,
|
||||||
renderQuotaItems: renderAntigravityItems
|
renderQuotaItems: renderAntigravityItems,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CODEX_CONFIG: QuotaConfig<
|
export const CODEX_CONFIG: QuotaConfig<
|
||||||
@@ -507,7 +809,7 @@ export const CODEX_CONFIG: QuotaConfig<
|
|||||||
> = {
|
> = {
|
||||||
type: 'codex',
|
type: 'codex',
|
||||||
i18nPrefix: 'codex_quota',
|
i18nPrefix: 'codex_quota',
|
||||||
filterFn: (file) => isCodexFile(file),
|
filterFn: (file) => isCodexFile(file) && !isDisabledAuthFile(file),
|
||||||
fetchQuota: fetchCodexQuota,
|
fetchQuota: fetchCodexQuota,
|
||||||
storeSelector: (state) => state.codexQuota,
|
storeSelector: (state) => state.codexQuota,
|
||||||
storeSetter: 'setCodexQuota',
|
storeSetter: 'setCodexQuota',
|
||||||
@@ -515,25 +817,26 @@ export const CODEX_CONFIG: QuotaConfig<
|
|||||||
buildSuccessState: (data) => ({
|
buildSuccessState: (data) => ({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
windows: data.windows,
|
windows: data.windows,
|
||||||
planType: data.planType
|
planType: data.planType,
|
||||||
}),
|
}),
|
||||||
buildErrorState: (message, status) => ({
|
buildErrorState: (message, status) => ({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
windows: [],
|
windows: [],
|
||||||
error: message,
|
error: message,
|
||||||
errorStatus: status
|
errorStatus: status,
|
||||||
}),
|
}),
|
||||||
cardClassName: styles.codexCard,
|
cardClassName: styles.codexCard,
|
||||||
controlsClassName: styles.codexControls,
|
controlsClassName: styles.codexControls,
|
||||||
controlClassName: styles.codexControl,
|
controlClassName: styles.codexControl,
|
||||||
gridClassName: styles.codexGrid,
|
gridClassName: styles.codexGrid,
|
||||||
renderQuotaItems: renderCodexItems
|
renderQuotaItems: renderCodexItems,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
||||||
type: 'gemini-cli',
|
type: 'gemini-cli',
|
||||||
i18nPrefix: 'gemini_cli_quota',
|
i18nPrefix: 'gemini_cli_quota',
|
||||||
filterFn: (file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file),
|
filterFn: (file) =>
|
||||||
|
isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file) && !isDisabledAuthFile(file),
|
||||||
fetchQuota: fetchGeminiCliQuota,
|
fetchQuota: fetchGeminiCliQuota,
|
||||||
storeSelector: (state) => state.geminiCliQuota,
|
storeSelector: (state) => state.geminiCliQuota,
|
||||||
storeSetter: 'setGeminiCliQuota',
|
storeSetter: 'setGeminiCliQuota',
|
||||||
@@ -543,11 +846,11 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaB
|
|||||||
status: 'error',
|
status: 'error',
|
||||||
buckets: [],
|
buckets: [],
|
||||||
error: message,
|
error: message,
|
||||||
errorStatus: status
|
errorStatus: status,
|
||||||
}),
|
}),
|
||||||
cardClassName: styles.geminiCliCard,
|
cardClassName: styles.geminiCliCard,
|
||||||
controlsClassName: styles.geminiCliControls,
|
controlsClassName: styles.geminiCliControls,
|
||||||
controlClassName: styles.geminiCliControl,
|
controlClassName: styles.geminiCliControl,
|
||||||
gridClassName: styles.geminiCliGrid,
|
gridClassName: styles.geminiCliGrid,
|
||||||
renderQuotaItems: renderGeminiCliItems
|
renderQuotaItems: renderGeminiCliItems,
|
||||||
};
|
};
|
||||||
|
|||||||
175
src/components/ui/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
|
||||||
|
import { IconChevronDown } from './icons';
|
||||||
|
|
||||||
|
interface AutocompleteInputProps {
|
||||||
|
label?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: string[] | { value: string; label?: string }[];
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
error?: string;
|
||||||
|
className?: string;
|
||||||
|
wrapperClassName?: string;
|
||||||
|
wrapperStyle?: React.CSSProperties;
|
||||||
|
id?: string;
|
||||||
|
rightElement?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutocompleteInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
className = '',
|
||||||
|
wrapperClassName = '',
|
||||||
|
wrapperStyle,
|
||||||
|
id,
|
||||||
|
rightElement
|
||||||
|
}: AutocompleteInputProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const normalizedOptions = options.map(opt =>
|
||||||
|
typeof opt === 'string' ? { value: opt, label: opt } : { value: opt.value, label: opt.label || opt.value }
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredOptions = normalizedOptions.filter(opt => {
|
||||||
|
const v = value.toLowerCase();
|
||||||
|
return opt.value.toLowerCase().includes(v) || (opt.label && opt.label.toLowerCase().includes(v));
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (selectedValue: string) => {
|
||||||
|
onChange(selectedValue);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isOpen) {
|
||||||
|
setIsOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHighlightedIndex(prev =>
|
||||||
|
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
||||||
|
);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect(filteredOptions[highlightedIndex].value);
|
||||||
|
} else if (isOpen) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`form-group ${wrapperClassName}`} ref={containerRef} style={wrapperStyle}>
|
||||||
|
{label && <label htmlFor={id}>{label}</label>}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
className={`input ${className}`.trim()}
|
||||||
|
value={value}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{ paddingRight: 32 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
pointerEvents: disabled ? 'none' : 'auto',
|
||||||
|
cursor: 'pointer',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
{rightElement}
|
||||||
|
<IconChevronDown size={16} style={{ opacity: 0.5, marginLeft: 4 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && filteredOptions.length > 0 && !disabled && (
|
||||||
|
<div className="autocomplete-dropdown" style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 'calc(100% + 4px)',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
|
||||||
|
}}>
|
||||||
|
{filteredOptions.map((opt, index) => (
|
||||||
|
<div
|
||||||
|
key={`${opt.value}-${index}`}
|
||||||
|
onClick={() => handleSelect(opt.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: index === highlightedIndex ? 'var(--bg-tertiary)' : 'transparent',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
fontSize: '0.9rem'
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 500 }}>{opt.value}</span>
|
||||||
|
{opt.label && opt.label !== opt.value && (
|
||||||
|
<span style={{ fontSize: '0.85em', color: 'var(--text-secondary)' }}>{opt.label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hint && <div className="hint">{hint}</div>}
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ interface HeaderInputListProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
keyPlaceholder?: string;
|
keyPlaceholder?: string;
|
||||||
valuePlaceholder?: string;
|
valuePlaceholder?: string;
|
||||||
|
removeButtonTitle?: string;
|
||||||
|
removeButtonAriaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeaderInputList({
|
export function HeaderInputList({
|
||||||
@@ -18,7 +20,9 @@ export function HeaderInputList({
|
|||||||
addLabel,
|
addLabel,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
keyPlaceholder = 'X-Custom-Header',
|
keyPlaceholder = 'X-Custom-Header',
|
||||||
valuePlaceholder = 'value'
|
valuePlaceholder = 'value',
|
||||||
|
removeButtonTitle = 'Remove',
|
||||||
|
removeButtonAriaLabel = 'Remove',
|
||||||
}: HeaderInputListProps) {
|
}: HeaderInputListProps) {
|
||||||
const currentEntries = entries.length ? entries : [{ key: '', value: '' }];
|
const currentEntries = entries.length ? entries : [{ key: '', value: '' }];
|
||||||
|
|
||||||
@@ -61,8 +65,8 @@ export function HeaderInputList({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeEntry(index)}
|
onClick={() => removeEntry(index)}
|
||||||
disabled={disabled || currentEntries.length <= 1}
|
disabled={disabled || currentEntries.length <= 1}
|
||||||
title="Remove"
|
title={removeButtonTitle}
|
||||||
aria-label="Remove"
|
aria-label={removeButtonAriaLabel}
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,17 +8,61 @@ interface ModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
width?: number | string;
|
width?: number | string;
|
||||||
|
className?: string;
|
||||||
|
closeDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLOSE_ANIMATION_DURATION = 350;
|
const CLOSE_ANIMATION_DURATION = 350;
|
||||||
const MODAL_LOCK_CLASS = 'modal-open';
|
const MODAL_LOCK_CLASS = 'modal-open';
|
||||||
let activeModalCount = 0;
|
let activeModalCount = 0;
|
||||||
|
|
||||||
|
const scrollLockSnapshot = {
|
||||||
|
scrollY: 0,
|
||||||
|
contentScrollTop: 0,
|
||||||
|
contentEl: null as HTMLElement | null,
|
||||||
|
bodyPosition: '',
|
||||||
|
bodyTop: '',
|
||||||
|
bodyLeft: '',
|
||||||
|
bodyRight: '',
|
||||||
|
bodyWidth: '',
|
||||||
|
bodyOverflow: '',
|
||||||
|
htmlOverflow: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveContentScrollContainer = () => {
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
const contentEl = document.querySelector('.content');
|
||||||
|
return contentEl instanceof HTMLElement ? contentEl : null;
|
||||||
|
};
|
||||||
|
|
||||||
const lockScroll = () => {
|
const lockScroll = () => {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
if (activeModalCount === 0) {
|
if (activeModalCount === 0) {
|
||||||
document.body?.classList.add(MODAL_LOCK_CLASS);
|
const body = document.body;
|
||||||
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
|
const html = document.documentElement;
|
||||||
|
const contentEl = resolveContentScrollContainer();
|
||||||
|
|
||||||
|
scrollLockSnapshot.scrollY = window.scrollY || window.pageYOffset || html.scrollTop || 0;
|
||||||
|
scrollLockSnapshot.contentEl = contentEl;
|
||||||
|
scrollLockSnapshot.contentScrollTop = contentEl?.scrollTop ?? 0;
|
||||||
|
scrollLockSnapshot.bodyPosition = body.style.position;
|
||||||
|
scrollLockSnapshot.bodyTop = body.style.top;
|
||||||
|
scrollLockSnapshot.bodyLeft = body.style.left;
|
||||||
|
scrollLockSnapshot.bodyRight = body.style.right;
|
||||||
|
scrollLockSnapshot.bodyWidth = body.style.width;
|
||||||
|
scrollLockSnapshot.bodyOverflow = body.style.overflow;
|
||||||
|
scrollLockSnapshot.htmlOverflow = html.style.overflow;
|
||||||
|
|
||||||
|
body.classList.add(MODAL_LOCK_CLASS);
|
||||||
|
html.classList.add(MODAL_LOCK_CLASS);
|
||||||
|
|
||||||
|
body.style.position = 'fixed';
|
||||||
|
body.style.top = `-${scrollLockSnapshot.scrollY}px`;
|
||||||
|
body.style.left = '0';
|
||||||
|
body.style.right = '0';
|
||||||
|
body.style.width = '100%';
|
||||||
|
body.style.overflow = 'hidden';
|
||||||
|
html.style.overflow = 'hidden';
|
||||||
}
|
}
|
||||||
activeModalCount += 1;
|
activeModalCount += 1;
|
||||||
};
|
};
|
||||||
@@ -27,12 +71,44 @@ const unlockScroll = () => {
|
|||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
activeModalCount = Math.max(0, activeModalCount - 1);
|
activeModalCount = Math.max(0, activeModalCount - 1);
|
||||||
if (activeModalCount === 0) {
|
if (activeModalCount === 0) {
|
||||||
document.body?.classList.remove(MODAL_LOCK_CLASS);
|
const body = document.body;
|
||||||
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
|
const html = document.documentElement;
|
||||||
|
const scrollY = scrollLockSnapshot.scrollY;
|
||||||
|
const contentScrollTop = scrollLockSnapshot.contentScrollTop;
|
||||||
|
const contentEl = scrollLockSnapshot.contentEl;
|
||||||
|
|
||||||
|
body.classList.remove(MODAL_LOCK_CLASS);
|
||||||
|
html.classList.remove(MODAL_LOCK_CLASS);
|
||||||
|
|
||||||
|
body.style.position = scrollLockSnapshot.bodyPosition;
|
||||||
|
body.style.top = scrollLockSnapshot.bodyTop;
|
||||||
|
body.style.left = scrollLockSnapshot.bodyLeft;
|
||||||
|
body.style.right = scrollLockSnapshot.bodyRight;
|
||||||
|
body.style.width = scrollLockSnapshot.bodyWidth;
|
||||||
|
body.style.overflow = scrollLockSnapshot.bodyOverflow;
|
||||||
|
html.style.overflow = scrollLockSnapshot.htmlOverflow;
|
||||||
|
|
||||||
|
if (contentEl) {
|
||||||
|
contentEl.scrollTo({ top: contentScrollTop, left: 0, behavior: 'auto' });
|
||||||
|
}
|
||||||
|
window.scrollTo({ top: scrollY, left: 0, behavior: 'auto' });
|
||||||
|
|
||||||
|
scrollLockSnapshot.scrollY = 0;
|
||||||
|
scrollLockSnapshot.contentScrollTop = 0;
|
||||||
|
scrollLockSnapshot.contentEl = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
export function Modal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
footer,
|
||||||
|
width = 520,
|
||||||
|
className,
|
||||||
|
closeDisabled = false,
|
||||||
|
children
|
||||||
|
}: PropsWithChildren<ModalProps>) {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -101,12 +177,18 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
|
|||||||
if (!open && !isVisible) return null;
|
if (!open && !isVisible) return null;
|
||||||
|
|
||||||
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
||||||
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
|
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}${className ? ` ${className}` : ''}`;
|
||||||
|
|
||||||
const modalContent = (
|
const modalContent = (
|
||||||
<div className={overlayClass}>
|
<div className={overlayClass}>
|
||||||
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
||||||
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-close-floating"
|
||||||
|
onClick={closeDisabled ? undefined : handleClose}
|
||||||
|
aria-label="Close"
|
||||||
|
disabled={closeDisabled}
|
||||||
|
>
|
||||||
<IconX size={20} />
|
<IconX size={20} />
|
||||||
</button>
|
</button>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
|
|||||||
@@ -1,54 +1,45 @@
|
|||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { IconX } from './icons';
|
import { IconX } from './icons';
|
||||||
import type { ModelAlias } from '@/types';
|
import type { ModelEntry } from './modelInputListUtils';
|
||||||
|
|
||||||
interface ModelEntry {
|
|
||||||
name: string;
|
|
||||||
alias: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelInputListProps {
|
interface ModelInputListProps {
|
||||||
entries: ModelEntry[];
|
entries: ModelEntry[];
|
||||||
onChange: (entries: ModelEntry[]) => void;
|
onChange: (entries: ModelEntry[]) => void;
|
||||||
addLabel: string;
|
addLabel?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
namePlaceholder?: string;
|
namePlaceholder?: string;
|
||||||
aliasPlaceholder?: string;
|
aliasPlaceholder?: string;
|
||||||
|
hideAddButton?: boolean;
|
||||||
|
onAdd?: () => void;
|
||||||
|
className?: string;
|
||||||
|
rowClassName?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
removeButtonClassName?: string;
|
||||||
|
removeButtonTitle?: string;
|
||||||
|
removeButtonAriaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const modelsToEntries = (models?: ModelAlias[]): ModelEntry[] => {
|
|
||||||
if (!Array.isArray(models) || models.length === 0) {
|
|
||||||
return [{ name: '', alias: '' }];
|
|
||||||
}
|
|
||||||
return models.map((m) => ({
|
|
||||||
name: m.name || '',
|
|
||||||
alias: m.alias || ''
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const entriesToModels = (entries: ModelEntry[]): ModelAlias[] => {
|
|
||||||
return entries
|
|
||||||
.filter((entry) => entry.name.trim())
|
|
||||||
.map((entry) => {
|
|
||||||
const model: ModelAlias = { name: entry.name.trim() };
|
|
||||||
const alias = entry.alias.trim();
|
|
||||||
if (alias && alias !== model.name) {
|
|
||||||
model.alias = alias;
|
|
||||||
}
|
|
||||||
return model;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ModelInputList({
|
export function ModelInputList({
|
||||||
entries,
|
entries,
|
||||||
onChange,
|
onChange,
|
||||||
addLabel,
|
addLabel,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
namePlaceholder = 'model-name',
|
namePlaceholder = 'model-name',
|
||||||
aliasPlaceholder = 'alias (optional)'
|
aliasPlaceholder = 'alias (optional)',
|
||||||
|
hideAddButton = false,
|
||||||
|
onAdd,
|
||||||
|
className = '',
|
||||||
|
rowClassName = '',
|
||||||
|
inputClassName = '',
|
||||||
|
removeButtonClassName = '',
|
||||||
|
removeButtonTitle = 'Remove',
|
||||||
|
removeButtonAriaLabel = 'Remove',
|
||||||
}: ModelInputListProps) {
|
}: ModelInputListProps) {
|
||||||
const currentEntries = entries.length ? entries : [{ name: '', alias: '' }];
|
const currentEntries = entries.length ? entries : [{ name: '', alias: '' }];
|
||||||
|
const containerClassName = ['header-input-list', className].filter(Boolean).join(' ');
|
||||||
|
const inputClassNames = ['input', inputClassName].filter(Boolean).join(' ');
|
||||||
|
const rowClassNames = ['header-input-row', rowClassName].filter(Boolean).join(' ');
|
||||||
|
|
||||||
const updateEntry = (index: number, field: 'name' | 'alias', value: string) => {
|
const updateEntry = (index: number, field: 'name' | 'alias', value: string) => {
|
||||||
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
|
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
|
||||||
@@ -56,7 +47,11 @@ export function ModelInputList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addEntry = () => {
|
const addEntry = () => {
|
||||||
onChange([...currentEntries, { name: '', alias: '' }]);
|
if (onAdd) {
|
||||||
|
onAdd();
|
||||||
|
} else {
|
||||||
|
onChange([...currentEntries, { name: '', alias: '' }]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeEntry = (index: number) => {
|
const removeEntry = (index: number) => {
|
||||||
@@ -65,12 +60,12 @@ export function ModelInputList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="header-input-list">
|
<div className={containerClassName}>
|
||||||
{currentEntries.map((entry, index) => (
|
{currentEntries.map((entry, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
<div className="header-input-row">
|
<div className={rowClassNames}>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className={inputClassNames}
|
||||||
placeholder={namePlaceholder}
|
placeholder={namePlaceholder}
|
||||||
value={entry.name}
|
value={entry.name}
|
||||||
onChange={(e) => updateEntry(index, 'name', e.target.value)}
|
onChange={(e) => updateEntry(index, 'name', e.target.value)}
|
||||||
@@ -78,7 +73,7 @@ export function ModelInputList({
|
|||||||
/>
|
/>
|
||||||
<span className="header-separator">→</span>
|
<span className="header-separator">→</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className={inputClassNames}
|
||||||
placeholder={aliasPlaceholder}
|
placeholder={aliasPlaceholder}
|
||||||
value={entry.alias}
|
value={entry.alias}
|
||||||
onChange={(e) => updateEntry(index, 'alias', e.target.value)}
|
onChange={(e) => updateEntry(index, 'alias', e.target.value)}
|
||||||
@@ -89,17 +84,20 @@ export function ModelInputList({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeEntry(index)}
|
onClick={() => removeEntry(index)}
|
||||||
disabled={disabled || currentEntries.length <= 1}
|
disabled={disabled || currentEntries.length <= 1}
|
||||||
title="Remove"
|
className={removeButtonClassName}
|
||||||
aria-label="Remove"
|
title={removeButtonTitle}
|
||||||
|
aria-label={removeButtonAriaLabel}
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
|
{!hideAddButton && addLabel && (
|
||||||
{addLabel}
|
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
|
||||||
</Button>
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
110
src/components/ui/Select.module.scss
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapFullWidth {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
text-align: left;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[aria-expanded='true'] {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerText {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
[aria-expanded='true'] > & {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionActive {
|
||||||
|
border-color: rgba($primary-color, 0.5);
|
||||||
|
background: rgba($primary-color, 0.1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
94
src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { IconChevronDown } from './icons';
|
||||||
|
import styles from './Select.module.scss';
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps {
|
||||||
|
value: string;
|
||||||
|
options: ReadonlyArray<SelectOption>;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
disabled = false,
|
||||||
|
ariaLabel,
|
||||||
|
fullWidth = true
|
||||||
|
}: SelectProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || disabled) return;
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [disabled, open]);
|
||||||
|
|
||||||
|
const isOpen = open && !disabled;
|
||||||
|
|
||||||
|
const selected = options.find((o) => o.value === value);
|
||||||
|
const displayText = selected?.label ?? placeholder ?? '';
|
||||||
|
const isPlaceholder = !selected && placeholder;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
|
||||||
|
ref={wrapRef}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.trigger}
|
||||||
|
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
|
||||||
|
{displayText}
|
||||||
|
</span>
|
||||||
|
<span className={styles.triggerIcon} aria-hidden="true">
|
||||||
|
<IconChevronDown size={14} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className={styles.dropdown} role="listbox" aria-label={ariaLabel}>
|
||||||
|
{options.map((opt) => {
|
||||||
|
const active = opt.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
className={`${styles.option} ${active ? styles.optionActive : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(opt.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/ui/ToggleSwitch.module.scss
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.labelLeft {
|
||||||
|
.label {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root input {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: $radius-full;
|
||||||
|
position: relative;
|
||||||
|
transition: background $transition-fast;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
transition: transform $transition-fast;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root input:checked + .track {
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root input:checked + .track .thumb {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { ChangeEvent, ReactNode } from 'react';
|
import type { ChangeEvent, ReactNode } from 'react';
|
||||||
|
import styles from './ToggleSwitch.module.scss';
|
||||||
|
|
||||||
interface ToggleSwitchProps {
|
interface ToggleSwitchProps {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (value: boolean) => void;
|
onChange: (value: boolean) => void;
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
|
ariaLabel?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
labelPosition?: 'left' | 'right';
|
labelPosition?: 'left' | 'right';
|
||||||
}
|
}
|
||||||
@@ -12,6 +14,7 @@ export function ToggleSwitch({
|
|||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
|
ariaLabel,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
labelPosition = 'right'
|
labelPosition = 'right'
|
||||||
}: ToggleSwitchProps) {
|
}: ToggleSwitchProps) {
|
||||||
@@ -19,17 +22,27 @@ export function ToggleSwitch({
|
|||||||
onChange(event.target.checked);
|
onChange(event.target.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
const className = ['switch', labelPosition === 'left' ? 'switch-label-left' : '']
|
const className = [
|
||||||
|
styles.root,
|
||||||
|
labelPosition === 'left' ? styles.labelLeft : '',
|
||||||
|
disabled ? styles.disabled : '',
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={className}>
|
<label className={className}>
|
||||||
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
|
<input
|
||||||
<span className="track">
|
type="checkbox"
|
||||||
<span className="thumb" />
|
checked={checked}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>
|
||||||
|
<span className={styles.track}>
|
||||||
|
<span className={styles.thumb} />
|
||||||
</span>
|
</span>
|
||||||
{label && <span className="label">{label}</span>}
|
{label && <span className={styles.label}>{label}</span>}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,14 @@ export function IconChevronDown({ size = 20, ...props }: IconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IconChevronLeft({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function IconSearch({ size = 20, ...props }: IconProps) {
|
export function IconSearch({ size = 20, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
|||||||
29
src/components/ui/modelInputListUtils.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ModelAlias } from '@/types';
|
||||||
|
|
||||||
|
export interface ModelEntry {
|
||||||
|
name: string;
|
||||||
|
alias: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modelsToEntries = (models?: ModelAlias[]): ModelEntry[] => {
|
||||||
|
if (!Array.isArray(models) || models.length === 0) {
|
||||||
|
return [{ name: '', alias: '' }];
|
||||||
|
}
|
||||||
|
return models.map((model) => ({
|
||||||
|
name: model.name || '',
|
||||||
|
alias: model.alias || ''
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const entriesToModels = (entries: ModelEntry[]): ModelAlias[] => {
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.name.trim())
|
||||||
|
.map((entry) => {
|
||||||
|
const model: ModelAlias = { name: entry.name.trim() };
|
||||||
|
const alias = entry.alias.trim();
|
||||||
|
if (alias && alias !== model.name) {
|
||||||
|
model.alias = alias;
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage';
|
import { formatCompactNumber, formatUsd, type ApiStats } from '@/utils/usage';
|
||||||
import styles from '@/pages/UsagePage.module.scss';
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
export interface ApiDetailsCardProps {
|
export interface ApiDetailsCardProps {
|
||||||
@@ -10,9 +10,14 @@ export interface ApiDetailsCardProps {
|
|||||||
hasPrices: boolean;
|
hasPrices: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiSortKey = 'endpoint' | 'requests' | 'tokens' | 'cost';
|
||||||
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
|
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
|
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
|
||||||
|
const [sortKey, setSortKey] = useState<ApiSortKey>('requests');
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||||
|
|
||||||
const toggleExpand = (endpoint: string) => {
|
const toggleExpand = (endpoint: string) => {
|
||||||
setExpandedApis((prev) => {
|
setExpandedApis((prev) => {
|
||||||
@@ -26,51 +31,125 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (key: ApiSortKey) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDir(key === 'endpoint' ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const list = [...apiStats];
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
|
list.sort((a, b) => {
|
||||||
|
switch (sortKey) {
|
||||||
|
case 'endpoint': return dir * a.endpoint.localeCompare(b.endpoint);
|
||||||
|
case 'requests': return dir * (a.totalRequests - b.totalRequests);
|
||||||
|
case 'tokens': return dir * (a.totalTokens - b.totalTokens);
|
||||||
|
case 'cost': return dir * (a.totalCost - b.totalCost);
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}, [apiStats, sortKey, sortDir]);
|
||||||
|
|
||||||
|
const arrow = (key: ApiSortKey) =>
|
||||||
|
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title={t('usage_stats.api_details')}>
|
<Card title={t('usage_stats.api_details')} className={styles.detailsFixedCard}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className={styles.hint}>{t('common.loading')}</div>
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
) : apiStats.length > 0 ? (
|
) : sorted.length > 0 ? (
|
||||||
<div className={styles.apiList}>
|
<>
|
||||||
{apiStats.map((api) => (
|
<div className={styles.apiSortBar}>
|
||||||
<div key={api.endpoint} className={styles.apiItem}>
|
{([
|
||||||
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}>
|
['endpoint', 'usage_stats.api_endpoint'],
|
||||||
<div className={styles.apiInfo}>
|
['requests', 'usage_stats.requests_count'],
|
||||||
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
['tokens', 'usage_stats.tokens_count'],
|
||||||
<div className={styles.apiStats}>
|
...(hasPrices ? [['cost', 'usage_stats.total_cost']] : []),
|
||||||
<span className={styles.apiBadge}>
|
] as [ApiSortKey, string][]).map(([key, labelKey]) => (
|
||||||
{t('usage_stats.requests_count')}: {api.totalRequests}
|
<button
|
||||||
</span>
|
key={key}
|
||||||
<span className={styles.apiBadge}>
|
type="button"
|
||||||
Tokens: {formatTokensInMillions(api.totalTokens)}
|
aria-pressed={sortKey === key}
|
||||||
</span>
|
className={`${styles.apiSortBtn} ${sortKey === key ? styles.apiSortBtnActive : ''}`}
|
||||||
{hasPrices && api.totalCost > 0 && (
|
onClick={() => handleSort(key)}
|
||||||
<span className={styles.apiBadge}>
|
>
|
||||||
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
|
{t(labelKey)}{arrow(key)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailsScroll}>
|
||||||
|
<div className={styles.apiList}>
|
||||||
|
{sorted.map((api, index) => {
|
||||||
|
const isExpanded = expandedApis.has(api.endpoint);
|
||||||
|
const panelId = `api-models-${index}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={api.endpoint} className={styles.apiItem}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.apiHeader}
|
||||||
|
onClick={() => toggleExpand(api.endpoint)}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-controls={panelId}
|
||||||
|
>
|
||||||
|
<div className={styles.apiInfo}>
|
||||||
|
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
||||||
|
<div className={styles.apiStats}>
|
||||||
|
<span className={styles.apiBadge}>
|
||||||
|
<span className={styles.requestCountCell}>
|
||||||
|
<span>
|
||||||
|
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className={styles.requestBreakdown}>
|
||||||
|
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
|
||||||
|
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={styles.apiBadge}>
|
||||||
|
{t('usage_stats.tokens_count')}: {formatCompactNumber(api.totalTokens)}
|
||||||
|
</span>
|
||||||
|
{hasPrices && api.totalCost > 0 && (
|
||||||
|
<span className={styles.apiBadge}>
|
||||||
|
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={styles.expandIcon}>
|
||||||
|
{isExpanded ? '▼' : '▶'}
|
||||||
</span>
|
</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div id={panelId} className={styles.apiModels}>
|
||||||
|
{Object.entries(api.models).map(([model, stats]) => (
|
||||||
|
<div key={model} className={styles.modelRow}>
|
||||||
|
<span className={styles.modelName}>{model}</span>
|
||||||
|
<span className={styles.modelStat}>
|
||||||
|
<span className={styles.requestCountCell}>
|
||||||
|
<span>{stats.requests.toLocaleString()}</span>
|
||||||
|
<span className={styles.requestBreakdown}>
|
||||||
|
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
|
||||||
|
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={styles.modelStat}>{formatCompactNumber(stats.tokens)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
<span className={styles.expandIcon}>
|
})}
|
||||||
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{expandedApis.has(api.endpoint) && (
|
|
||||||
<div className={styles.apiModels}>
|
|
||||||
{Object.entries(api.models).map(([model, stats]) => (
|
|
||||||
<div key={model} className={styles.modelRow}>
|
|
||||||
<span className={styles.modelName}>{model}</span>
|
|
||||||
<span className={styles.modelStat}>
|
|
||||||
{stats.requests} {t('usage_stats.requests_count')}
|
|
||||||
</span>
|
|
||||||
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
import styles from '@/pages/UsagePage.module.scss';
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
export interface ChartLineSelectorProps {
|
export interface ChartLineSelectorProps {
|
||||||
@@ -41,6 +43,14 @@ export function ChartLineSelector({
|
|||||||
onChange(newLines);
|
onChange(newLines);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'all', label: t('usage_stats.chart_line_all') },
|
||||||
|
...modelNames.map((name) => ({ value: name, label: name }))
|
||||||
|
],
|
||||||
|
[modelNames, t]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title={t('usage_stats.chart_line_actions_label')}
|
title={t('usage_stats.chart_line_actions_label')}
|
||||||
@@ -66,18 +76,11 @@ export function ChartLineSelector({
|
|||||||
<span className={styles.chartLineLabel}>
|
<span className={styles.chartLineLabel}>
|
||||||
{t(`usage_stats.chart_line_label_${index + 1}`)}
|
{t(`usage_stats.chart_line_label_${index + 1}`)}
|
||||||
</span>
|
</span>
|
||||||
<select
|
<Select
|
||||||
value={line}
|
value={line}
|
||||||
onChange={(e) => handleChange(index, e.target.value)}
|
options={options}
|
||||||
className={styles.select}
|
onChange={(value) => handleChange(index, value)}
|
||||||
>
|
/>
|
||||||
<option value="all">{t('usage_stats.chart_line_all')}</option>
|
|
||||||
{modelNames.map((name) => (
|
|
||||||
<option key={name} value={name}>
|
|
||||||
{name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{chartLines.length > 1 && (
|
{chartLines.length > 1 && (
|
||||||
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
|
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
|
||||||
{t('usage_stats.chart_line_delete')}
|
{t('usage_stats.chart_line_delete')}
|
||||||
|
|||||||
144
src/components/usage/CostTrendChart.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { ScriptableContext } from 'chart.js';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
buildHourlyCostSeries,
|
||||||
|
buildDailyCostSeries,
|
||||||
|
formatUsd,
|
||||||
|
type ModelPrice
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
|
||||||
|
import type { UsagePayload } from './hooks/useUsageData';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface CostTrendChartProps {
|
||||||
|
usage: UsagePayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
isDark: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
modelPrices: Record<string, ModelPrice>;
|
||||||
|
hourWindowHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COST_COLOR = '#f59e0b';
|
||||||
|
const COST_BG = 'rgba(245, 158, 11, 0.15)';
|
||||||
|
|
||||||
|
function buildGradient(ctx: ScriptableContext<'line'>) {
|
||||||
|
const chart = ctx.chart;
|
||||||
|
const area = chart.chartArea;
|
||||||
|
if (!area) return COST_BG;
|
||||||
|
const gradient = chart.ctx.createLinearGradient(0, area.top, 0, area.bottom);
|
||||||
|
gradient.addColorStop(0, 'rgba(245, 158, 11, 0.28)');
|
||||||
|
gradient.addColorStop(0.6, 'rgba(245, 158, 11, 0.12)');
|
||||||
|
gradient.addColorStop(1, 'rgba(245, 158, 11, 0.02)');
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CostTrendChart({
|
||||||
|
usage,
|
||||||
|
loading,
|
||||||
|
isDark,
|
||||||
|
isMobile,
|
||||||
|
modelPrices,
|
||||||
|
hourWindowHours
|
||||||
|
}: CostTrendChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
|
||||||
|
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||||
|
|
||||||
|
const { chartData, chartOptions, hasData } = useMemo(() => {
|
||||||
|
if (!hasPrices || !usage) {
|
||||||
|
return { chartData: { labels: [], datasets: [] }, chartOptions: {}, hasData: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const series =
|
||||||
|
period === 'hour'
|
||||||
|
? buildHourlyCostSeries(usage, modelPrices, hourWindowHours)
|
||||||
|
: buildDailyCostSeries(usage, modelPrices);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: series.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: t('usage_stats.total_cost'),
|
||||||
|
data: series.data,
|
||||||
|
borderColor: COST_COLOR,
|
||||||
|
backgroundColor: buildGradient,
|
||||||
|
pointBackgroundColor: COST_COLOR,
|
||||||
|
pointBorderColor: COST_COLOR,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.35
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
|
||||||
|
const options = {
|
||||||
|
...baseOptions,
|
||||||
|
scales: {
|
||||||
|
...baseOptions.scales,
|
||||||
|
y: {
|
||||||
|
...baseOptions.scales?.y,
|
||||||
|
ticks: {
|
||||||
|
...(baseOptions.scales?.y && 'ticks' in baseOptions.scales.y ? baseOptions.scales.y.ticks : {}),
|
||||||
|
callback: (value: string | number) => formatUsd(Number(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { chartData: data, chartOptions: options, hasData: series.hasData };
|
||||||
|
}, [usage, period, isDark, isMobile, modelPrices, hasPrices, hourWindowHours, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t('usage_stats.cost_trend')}
|
||||||
|
extra={
|
||||||
|
<div className={styles.periodButtons}>
|
||||||
|
<Button
|
||||||
|
variant={period === 'hour' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPeriod('hour')}
|
||||||
|
>
|
||||||
|
{t('usage_stats.by_hour')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={period === 'day' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPeriod('day')}
|
||||||
|
>
|
||||||
|
{t('usage_stats.by_day')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
|
) : !hasPrices ? (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.cost_need_price')}</div>
|
||||||
|
) : !hasData ? (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.cost_no_data')}</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.chartWrapper}>
|
||||||
|
<div className={styles.chartArea}>
|
||||||
|
<div className={styles.chartScroller}>
|
||||||
|
<div
|
||||||
|
className={styles.chartCanvas}
|
||||||
|
style={
|
||||||
|
period === 'hour'
|
||||||
|
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Line data={chartData} options={chartOptions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
336
src/components/usage/CredentialStatsCard.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import { useMemo, useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import {
|
||||||
|
computeKeyStats,
|
||||||
|
collectUsageDetails,
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
formatCompactNumber
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import { authFilesApi } from '@/services/api/authFiles';
|
||||||
|
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
|
||||||
|
import type { AuthFileItem } from '@/types/authFile';
|
||||||
|
import type { UsagePayload } from './hooks/useUsageData';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface CredentialStatsCardProps {
|
||||||
|
usage: UsagePayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
geminiKeys: GeminiKeyConfig[];
|
||||||
|
claudeConfigs: ProviderKeyConfig[];
|
||||||
|
codexConfigs: ProviderKeyConfig[];
|
||||||
|
vertexConfigs: ProviderKeyConfig[];
|
||||||
|
openaiProviders: OpenAIProviderConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialInfo {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialRow {
|
||||||
|
key: string;
|
||||||
|
displayName: string;
|
||||||
|
type: string;
|
||||||
|
success: number;
|
||||||
|
failure: number;
|
||||||
|
total: number;
|
||||||
|
successRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialBucket {
|
||||||
|
success: number;
|
||||||
|
failure: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAuthIndexValue(value: unknown): string | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CredentialStatsCard({
|
||||||
|
usage,
|
||||||
|
loading,
|
||||||
|
geminiKeys,
|
||||||
|
claudeConfigs,
|
||||||
|
codexConfigs,
|
||||||
|
vertexConfigs,
|
||||||
|
openaiProviders,
|
||||||
|
}: CredentialStatsCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
|
||||||
|
|
||||||
|
// Fetch auth files for auth_index-based matching
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
authFilesApi
|
||||||
|
.list()
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
|
||||||
|
if (!Array.isArray(files)) return;
|
||||||
|
const map = new Map<string, CredentialInfo>();
|
||||||
|
files.forEach((file) => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const key = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
if (key) {
|
||||||
|
map.set(key, {
|
||||||
|
name: file.name || key,
|
||||||
|
type: (file.type || file.provider || '').toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setAuthFileMap(map);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Aggregate rows: all from bySource only (no separate byAuthIndex rows to avoid duplicates).
|
||||||
|
// Auth files are used purely for name resolution of unmatched source IDs.
|
||||||
|
const rows = useMemo((): CredentialRow[] => {
|
||||||
|
if (!usage) return [];
|
||||||
|
const { bySource } = computeKeyStats(usage);
|
||||||
|
const details = collectUsageDetails(usage);
|
||||||
|
const result: CredentialRow[] = [];
|
||||||
|
const consumedSourceIds = new Set<string>();
|
||||||
|
const authIndexToRowIndex = new Map<string, number>();
|
||||||
|
const sourceToAuthIndex = new Map<string, string>();
|
||||||
|
const fallbackByAuthIndex = new Map<string, CredentialBucket>();
|
||||||
|
|
||||||
|
const mergeBucketToRow = (index: number, bucket: CredentialBucket) => {
|
||||||
|
const target = result[index];
|
||||||
|
if (!target) return;
|
||||||
|
target.success += bucket.success;
|
||||||
|
target.failure += bucket.failure;
|
||||||
|
target.total = target.success + target.failure;
|
||||||
|
target.successRate = target.total > 0 ? (target.success / target.total) * 100 : 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aggregate all candidate source IDs for one provider config into a single row
|
||||||
|
const addConfigRow = (
|
||||||
|
apiKey: string,
|
||||||
|
prefix: string | undefined,
|
||||||
|
name: string,
|
||||||
|
type: string,
|
||||||
|
rowKey: string,
|
||||||
|
) => {
|
||||||
|
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
|
||||||
|
let success = 0;
|
||||||
|
let failure = 0;
|
||||||
|
candidates.forEach((id) => {
|
||||||
|
const bucket = bySource[id];
|
||||||
|
if (bucket) {
|
||||||
|
success += bucket.success;
|
||||||
|
failure += bucket.failure;
|
||||||
|
consumedSourceIds.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const total = success + failure;
|
||||||
|
if (total > 0) {
|
||||||
|
result.push({
|
||||||
|
key: rowKey,
|
||||||
|
displayName: name,
|
||||||
|
type,
|
||||||
|
success,
|
||||||
|
failure,
|
||||||
|
total,
|
||||||
|
successRate: (success / total) * 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Provider rows — one row per config, stats merged across all its candidate source IDs
|
||||||
|
geminiKeys.forEach((c, i) =>
|
||||||
|
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Gemini #${i + 1}`, 'gemini', `gemini:${i}`));
|
||||||
|
claudeConfigs.forEach((c, i) =>
|
||||||
|
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Claude #${i + 1}`, 'claude', `claude:${i}`));
|
||||||
|
codexConfigs.forEach((c, i) =>
|
||||||
|
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Codex #${i + 1}`, 'codex', `codex:${i}`));
|
||||||
|
vertexConfigs.forEach((c, i) =>
|
||||||
|
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Vertex #${i + 1}`, 'vertex', `vertex:${i}`));
|
||||||
|
// OpenAI compatibility providers — one row per provider, merged across all apiKey entries (prefix counted once).
|
||||||
|
openaiProviders.forEach((provider, providerIndex) => {
|
||||||
|
const prefix = provider.prefix;
|
||||||
|
const displayName = prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
|
||||||
|
|
||||||
|
const candidates = new Set<string>();
|
||||||
|
buildCandidateUsageSourceIds({ prefix }).forEach((id) => candidates.add(id));
|
||||||
|
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||||
|
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
let success = 0;
|
||||||
|
let failure = 0;
|
||||||
|
candidates.forEach((id) => {
|
||||||
|
const bucket = bySource[id];
|
||||||
|
if (bucket) {
|
||||||
|
success += bucket.success;
|
||||||
|
failure += bucket.failure;
|
||||||
|
consumedSourceIds.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = success + failure;
|
||||||
|
if (total > 0) {
|
||||||
|
result.push({
|
||||||
|
key: `openai:${providerIndex}`,
|
||||||
|
displayName,
|
||||||
|
type: 'openai',
|
||||||
|
success,
|
||||||
|
failure,
|
||||||
|
total,
|
||||||
|
successRate: (success / total) * 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build source → auth file name mapping for remaining unmatched entries.
|
||||||
|
// Also collect fallback stats for details without source but with auth_index.
|
||||||
|
const sourceToAuthFile = new Map<string, CredentialInfo>();
|
||||||
|
details.forEach((d) => {
|
||||||
|
const authIdx = normalizeAuthIndexValue(d.auth_index);
|
||||||
|
if (!d.source) {
|
||||||
|
if (!authIdx) return;
|
||||||
|
const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 };
|
||||||
|
if (d.failed === true) {
|
||||||
|
fallback.failure += 1;
|
||||||
|
} else {
|
||||||
|
fallback.success += 1;
|
||||||
|
}
|
||||||
|
fallbackByAuthIndex.set(authIdx, fallback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authIdx || consumedSourceIds.has(d.source)) return;
|
||||||
|
if (!sourceToAuthIndex.has(d.source)) {
|
||||||
|
sourceToAuthIndex.set(d.source, authIdx);
|
||||||
|
}
|
||||||
|
if (!sourceToAuthFile.has(d.source)) {
|
||||||
|
const mapped = authFileMap.get(authIdx);
|
||||||
|
if (mapped) sourceToAuthFile.set(d.source, mapped);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remaining unmatched bySource entries — resolve name from auth files if possible
|
||||||
|
Object.entries(bySource).forEach(([key, bucket]) => {
|
||||||
|
if (consumedSourceIds.has(key)) return;
|
||||||
|
const total = bucket.success + bucket.failure;
|
||||||
|
const authFile = sourceToAuthFile.get(key);
|
||||||
|
const row = {
|
||||||
|
key,
|
||||||
|
displayName: authFile?.name || (key.startsWith('t:') ? key.slice(2) : key),
|
||||||
|
type: authFile?.type || '',
|
||||||
|
success: bucket.success,
|
||||||
|
failure: bucket.failure,
|
||||||
|
total,
|
||||||
|
successRate: total > 0 ? (bucket.success / total) * 100 : 100,
|
||||||
|
};
|
||||||
|
const rowIndex = result.push(row) - 1;
|
||||||
|
const authIdx = sourceToAuthIndex.get(key);
|
||||||
|
if (authIdx && !authIndexToRowIndex.has(authIdx)) {
|
||||||
|
authIndexToRowIndex.set(authIdx, rowIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Include requests that have auth_index but missing source.
|
||||||
|
fallbackByAuthIndex.forEach((bucket, authIdx) => {
|
||||||
|
if (bucket.success + bucket.failure === 0) return;
|
||||||
|
|
||||||
|
const mapped = authFileMap.get(authIdx);
|
||||||
|
let targetRowIndex = authIndexToRowIndex.get(authIdx);
|
||||||
|
if (targetRowIndex === undefined && mapped) {
|
||||||
|
const matchedIndex = result.findIndex(
|
||||||
|
(row) => row.displayName === mapped.name && row.type === mapped.type
|
||||||
|
);
|
||||||
|
if (matchedIndex >= 0) {
|
||||||
|
targetRowIndex = matchedIndex;
|
||||||
|
authIndexToRowIndex.set(authIdx, matchedIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetRowIndex !== undefined) {
|
||||||
|
mergeBucketToRow(targetRowIndex, bucket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = bucket.success + bucket.failure;
|
||||||
|
const rowIndex = result.push({
|
||||||
|
key: `auth:${authIdx}`,
|
||||||
|
displayName: mapped?.name || authIdx,
|
||||||
|
type: mapped?.type || '',
|
||||||
|
success: bucket.success,
|
||||||
|
failure: bucket.failure,
|
||||||
|
total,
|
||||||
|
successRate: (bucket.success / total) * 100
|
||||||
|
}) - 1;
|
||||||
|
authIndexToRowIndex.set(authIdx, rowIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.sort((a, b) => b.total - a.total);
|
||||||
|
}, [usage, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs, openaiProviders, authFileMap]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={t('usage_stats.credential_stats')} className={styles.detailsFixedCard}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
|
) : rows.length > 0 ? (
|
||||||
|
<div className={styles.detailsScroll}>
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('usage_stats.credential_name')}</th>
|
||||||
|
<th>{t('usage_stats.requests_count')}</th>
|
||||||
|
<th>{t('usage_stats.success_rate')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.key}>
|
||||||
|
<td className={styles.modelCell}>
|
||||||
|
<span>{row.displayName}</span>
|
||||||
|
{row.type && (
|
||||||
|
<span className={styles.credentialType}>{row.type}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.requestCountCell}>
|
||||||
|
<span>{formatCompactNumber(row.total)}</span>
|
||||||
|
<span className={styles.requestBreakdown}>
|
||||||
|
(<span className={styles.statSuccess}>{row.success.toLocaleString()}</span>{' '}
|
||||||
|
<span className={styles.statFailure}>{row.failure.toLocaleString()}</span>)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
row.successRate >= 95
|
||||||
|
? styles.statSuccess
|
||||||
|
: row.successRate >= 80
|
||||||
|
? styles.statNeutral
|
||||||
|
: styles.statFailure
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.successRate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { formatTokensInMillions, formatUsd } from '@/utils/usage';
|
import { formatCompactNumber, formatUsd } from '@/utils/usage';
|
||||||
import styles from '@/pages/UsagePage.module.scss';
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
export interface ModelStat {
|
export interface ModelStat {
|
||||||
model: string;
|
model: string;
|
||||||
requests: number;
|
requests: number;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
tokens: number;
|
tokens: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
}
|
}
|
||||||
@@ -16,35 +19,137 @@ export interface ModelStatsCardProps {
|
|||||||
hasPrices: boolean;
|
hasPrices: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SortKey = 'model' | 'requests' | 'tokens' | 'cost' | 'successRate';
|
||||||
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
|
interface ModelStatWithRate extends ModelStat {
|
||||||
|
successRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
|
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>('requests');
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||||
|
|
||||||
|
const handleSort = (key: SortKey) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDir(key === 'model' ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = useMemo((): ModelStatWithRate[] => {
|
||||||
|
const list: ModelStatWithRate[] = modelStats.map((s) => ({
|
||||||
|
...s,
|
||||||
|
successRate: s.requests > 0 ? (s.successCount / s.requests) * 100 : 100,
|
||||||
|
}));
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
|
list.sort((a, b) => {
|
||||||
|
if (sortKey === 'model') return dir * a.model.localeCompare(b.model);
|
||||||
|
return dir * ((a[sortKey] as number) - (b[sortKey] as number));
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}, [modelStats, sortKey, sortDir]);
|
||||||
|
|
||||||
|
const arrow = (key: SortKey) =>
|
||||||
|
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||||
|
const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' =>
|
||||||
|
sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title={t('usage_stats.models')}>
|
<Card title={t('usage_stats.models')} className={styles.detailsFixedCard}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className={styles.hint}>{t('common.loading')}</div>
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
) : modelStats.length > 0 ? (
|
) : sorted.length > 0 ? (
|
||||||
<div className={styles.tableWrapper}>
|
<div className={styles.detailsScroll}>
|
||||||
<table className={styles.table}>
|
<div className={styles.tableWrapper}>
|
||||||
<thead>
|
<table className={styles.table}>
|
||||||
<tr>
|
<thead>
|
||||||
<th>{t('usage_stats.model_name')}</th>
|
<tr>
|
||||||
<th>{t('usage_stats.requests_count')}</th>
|
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
|
||||||
<th>{t('usage_stats.tokens_count')}</th>
|
<button
|
||||||
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
|
type="button"
|
||||||
</tr>
|
className={styles.sortHeaderButton}
|
||||||
</thead>
|
onClick={() => handleSort('model')}
|
||||||
<tbody>
|
>
|
||||||
{modelStats.map((stat) => (
|
{t('usage_stats.model_name')}{arrow('model')}
|
||||||
<tr key={stat.model}>
|
</button>
|
||||||
<td className={styles.modelCell}>{stat.model}</td>
|
</th>
|
||||||
<td>{stat.requests.toLocaleString()}</td>
|
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
|
||||||
<td>{formatTokensInMillions(stat.tokens)}</td>
|
<button
|
||||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
type="button"
|
||||||
|
className={styles.sortHeaderButton}
|
||||||
|
onClick={() => handleSort('requests')}
|
||||||
|
>
|
||||||
|
{t('usage_stats.requests_count')}{arrow('requests')}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.sortHeaderButton}
|
||||||
|
onClick={() => handleSort('tokens')}
|
||||||
|
>
|
||||||
|
{t('usage_stats.tokens_count')}{arrow('tokens')}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.sortHeaderButton}
|
||||||
|
onClick={() => handleSort('successRate')}
|
||||||
|
>
|
||||||
|
{t('usage_stats.success_rate')}{arrow('successRate')}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{hasPrices && (
|
||||||
|
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.sortHeaderButton}
|
||||||
|
onClick={() => handleSort('cost')}
|
||||||
|
>
|
||||||
|
{t('usage_stats.total_cost')}{arrow('cost')}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{sorted.map((stat) => (
|
||||||
|
<tr key={stat.model}>
|
||||||
|
<td className={styles.modelCell}>{stat.model}</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.requestCountCell}>
|
||||||
|
<span>{stat.requests.toLocaleString()}</span>
|
||||||
|
<span className={styles.requestBreakdown}>
|
||||||
|
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
|
||||||
|
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatCompactNumber(stat.tokens)}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
stat.successRate >= 95
|
||||||
|
? styles.statSuccess
|
||||||
|
: stat.successRate >= 80
|
||||||
|
? styles.statNeutral
|
||||||
|
: styles.statFailure
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{stat.successRate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
import type { ModelPrice } from '@/utils/usage';
|
import type { ModelPrice } from '@/utils/usage';
|
||||||
import styles from '@/pages/UsagePage.module.scss';
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
@@ -19,11 +21,18 @@ export function PriceSettingsCard({
|
|||||||
}: PriceSettingsCardProps) {
|
}: PriceSettingsCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Add form state
|
||||||
const [selectedModel, setSelectedModel] = useState('');
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
const [promptPrice, setPromptPrice] = useState('');
|
const [promptPrice, setPromptPrice] = useState('');
|
||||||
const [completionPrice, setCompletionPrice] = useState('');
|
const [completionPrice, setCompletionPrice] = useState('');
|
||||||
const [cachePrice, setCachePrice] = useState('');
|
const [cachePrice, setCachePrice] = useState('');
|
||||||
|
|
||||||
|
// Edit modal state
|
||||||
|
const [editModel, setEditModel] = useState<string | null>(null);
|
||||||
|
const [editPrompt, setEditPrompt] = useState('');
|
||||||
|
const [editCompletion, setEditCompletion] = useState('');
|
||||||
|
const [editCache, setEditCache] = useState('');
|
||||||
|
|
||||||
const handleSavePrice = () => {
|
const handleSavePrice = () => {
|
||||||
if (!selectedModel) return;
|
if (!selectedModel) return;
|
||||||
const prompt = parseFloat(promptPrice) || 0;
|
const prompt = parseFloat(promptPrice) || 0;
|
||||||
@@ -43,12 +52,22 @@ export function PriceSettingsCard({
|
|||||||
onPricesChange(newPrices);
|
onPricesChange(newPrices);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditPrice = (model: string) => {
|
const handleOpenEdit = (model: string) => {
|
||||||
const price = modelPrices[model];
|
const price = modelPrices[model];
|
||||||
setSelectedModel(model);
|
setEditModel(model);
|
||||||
setPromptPrice(price?.prompt?.toString() || '');
|
setEditPrompt(price?.prompt?.toString() || '');
|
||||||
setCompletionPrice(price?.completion?.toString() || '');
|
setEditCompletion(price?.completion?.toString() || '');
|
||||||
setCachePrice(price?.cache?.toString() || '');
|
setEditCache(price?.cache?.toString() || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = () => {
|
||||||
|
if (!editModel) return;
|
||||||
|
const prompt = parseFloat(editPrompt) || 0;
|
||||||
|
const completion = parseFloat(editCompletion) || 0;
|
||||||
|
const cache = editCache.trim() === '' ? prompt : parseFloat(editCache) || 0;
|
||||||
|
const newPrices = { ...modelPrices, [editModel]: { prompt, completion, cache } };
|
||||||
|
onPricesChange(newPrices);
|
||||||
|
setEditModel(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModelSelect = (value: string) => {
|
const handleModelSelect = (value: string) => {
|
||||||
@@ -65,6 +84,14 @@ export function PriceSettingsCard({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: '', label: t('usage_stats.model_price_select_placeholder') },
|
||||||
|
...modelNames.map((name) => ({ value: name, label: name }))
|
||||||
|
],
|
||||||
|
[modelNames, t]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title={t('usage_stats.model_price_settings')}>
|
<Card title={t('usage_stats.model_price_settings')}>
|
||||||
<div className={styles.pricingSection}>
|
<div className={styles.pricingSection}>
|
||||||
@@ -73,18 +100,12 @@ export function PriceSettingsCard({
|
|||||||
<div className={styles.formRow}>
|
<div className={styles.formRow}>
|
||||||
<div className={styles.formField}>
|
<div className={styles.formField}>
|
||||||
<label>{t('usage_stats.model_name')}</label>
|
<label>{t('usage_stats.model_name')}</label>
|
||||||
<select
|
<Select
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
onChange={(e) => handleModelSelect(e.target.value)}
|
options={options}
|
||||||
className={styles.select}
|
onChange={handleModelSelect}
|
||||||
>
|
placeholder={t('usage_stats.model_price_select_placeholder')}
|
||||||
<option value="">{t('usage_stats.model_price_select_placeholder')}</option>
|
/>
|
||||||
{modelNames.map((name) => (
|
|
||||||
<option key={name} value={name}>
|
|
||||||
{name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.formField}>
|
<div className={styles.formField}>
|
||||||
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
|
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
|
||||||
@@ -144,7 +165,7 @@ export function PriceSettingsCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.priceActions}>
|
<div className={styles.priceActions}>
|
||||||
<Button variant="secondary" size="sm" onClick={() => handleEditPrice(model)}>
|
<Button variant="secondary" size="sm" onClick={() => handleOpenEdit(model)}>
|
||||||
{t('common.edit')}
|
{t('common.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
|
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
|
||||||
@@ -159,6 +180,57 @@ export function PriceSettingsCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
open={editModel !== null}
|
||||||
|
title={editModel ?? ''}
|
||||||
|
onClose={() => setEditModel(null)}
|
||||||
|
footer={
|
||||||
|
<div className={styles.priceActions}>
|
||||||
|
<Button variant="secondary" onClick={() => setEditModel(null)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleSaveEdit}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
width={420}
|
||||||
|
>
|
||||||
|
<div className={styles.editModalBody}>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editPrompt}
|
||||||
|
onChange={(e) => setEditPrompt(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.0001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editCompletion}
|
||||||
|
onChange={(e) => setEditCompletion(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.0001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_price_cache')} ($/1M)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editCache}
|
||||||
|
onChange={(e) => setEditCache(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.0001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
180
src/components/usage/ServiceHealthCard.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
collectUsageDetails,
|
||||||
|
calculateServiceHealthData,
|
||||||
|
type ServiceHealthData,
|
||||||
|
type StatusBlockDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import type { UsagePayload } from './hooks/useUsageData';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
const COLOR_STOPS = [
|
||||||
|
{ r: 239, g: 68, b: 68 }, // #ef4444
|
||||||
|
{ r: 250, g: 204, b: 21 }, // #facc15
|
||||||
|
{ r: 34, g: 197, b: 94 }, // #22c55e
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function rateToColor(rate: number): string {
|
||||||
|
const t = Math.max(0, Math.min(1, rate));
|
||||||
|
const segment = t < 0.5 ? 0 : 1;
|
||||||
|
const localT = segment === 0 ? t * 2 : (t - 0.5) * 2;
|
||||||
|
const from = COLOR_STOPS[segment];
|
||||||
|
const to = COLOR_STOPS[segment + 1];
|
||||||
|
const r = Math.round(from.r + (to.r - from.r) * localT);
|
||||||
|
const g = Math.round(from.g + (to.g - from.g) * localT);
|
||||||
|
const b = Math.round(from.b + (to.b - from.b) * localT);
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const day = date.getDate().toString().padStart(2, '0');
|
||||||
|
const h = date.getHours().toString().padStart(2, '0');
|
||||||
|
const m = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${month}/${day} ${h}:${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceHealthCardProps {
|
||||||
|
usage: UsagePayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
|
||||||
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const healthData: ServiceHealthData = useMemo(() => {
|
||||||
|
const details = usage ? collectUsageDetails(usage) : [];
|
||||||
|
return calculateServiceHealthData(details);
|
||||||
|
}, [usage]);
|
||||||
|
|
||||||
|
const hasData = healthData.totalSuccess + healthData.totalFailure > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTooltip === null) return;
|
||||||
|
const handler = (e: PointerEvent) => {
|
||||||
|
if (gridRef.current && !gridRef.current.contains(e.target as Node)) {
|
||||||
|
setActiveTooltip(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', handler);
|
||||||
|
return () => document.removeEventListener('pointerdown', handler);
|
||||||
|
}, [activeTooltip]);
|
||||||
|
|
||||||
|
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
|
||||||
|
if (e.pointerType === 'mouse') {
|
||||||
|
setActiveTooltip(idx);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
|
||||||
|
if (e.pointerType === 'mouse') {
|
||||||
|
setActiveTooltip(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
|
||||||
|
if (e.pointerType === 'touch') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTooltip((prev) => (prev === idx ? null : idx));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getTooltipPositionClass = (idx: number): string => {
|
||||||
|
const col = Math.floor(idx / healthData.rows);
|
||||||
|
if (col <= 2) return styles.healthTooltipLeft;
|
||||||
|
if (col >= healthData.cols - 3) return styles.healthTooltipRight;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTooltipVerticalClass = (idx: number): string => {
|
||||||
|
const row = idx % healthData.rows;
|
||||||
|
if (row <= 1) return styles.healthTooltipBelow;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
|
||||||
|
const total = detail.success + detail.failure;
|
||||||
|
const posClass = getTooltipPositionClass(idx);
|
||||||
|
const vertClass = getTooltipVerticalClass(idx);
|
||||||
|
const timeRange = `${formatDateTime(detail.startTime)} – ${formatDateTime(detail.endTime)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.healthTooltip} ${posClass} ${vertClass}`}>
|
||||||
|
<span className={styles.healthTooltipTime}>{timeRange}</span>
|
||||||
|
{total > 0 ? (
|
||||||
|
<span className={styles.healthTooltipStats}>
|
||||||
|
<span className={styles.healthTooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
|
||||||
|
<span className={styles.healthTooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
|
||||||
|
<span className={styles.healthTooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={styles.healthTooltipStats}>{t('status_bar.no_requests')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rateClass = !hasData
|
||||||
|
? ''
|
||||||
|
: healthData.successRate >= 90
|
||||||
|
? styles.healthRateHigh
|
||||||
|
: healthData.successRate >= 50
|
||||||
|
? styles.healthRateMedium
|
||||||
|
: styles.healthRateLow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.healthCard}>
|
||||||
|
<div className={styles.healthHeader}>
|
||||||
|
<h3 className={styles.healthTitle}>{t('service_health.title')}</h3>
|
||||||
|
<div className={styles.healthMeta}>
|
||||||
|
<span className={styles.healthWindow}>{t('service_health.window')}</span>
|
||||||
|
<span className={`${styles.healthRate} ${rateClass}`}>
|
||||||
|
{loading ? '--' : hasData ? `${healthData.successRate.toFixed(1)}%` : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.healthGridScroller}>
|
||||||
|
<div
|
||||||
|
className={styles.healthGrid}
|
||||||
|
ref={gridRef}
|
||||||
|
>
|
||||||
|
{healthData.blockDetails.map((detail, idx) => {
|
||||||
|
const isIdle = detail.rate === -1;
|
||||||
|
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||||
|
const isActive = activeTooltip === idx;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
|
||||||
|
onPointerEnter={(e) => handlePointerEnter(e, idx)}
|
||||||
|
onPointerLeave={handlePointerLeave}
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, idx)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
|
||||||
|
style={blockStyle}
|
||||||
|
/>
|
||||||
|
{isActive && renderTooltip(detail, idx)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.healthLegend}>
|
||||||
|
<span className={styles.healthLegendLabel}>{t('service_health.oldest')}</span>
|
||||||
|
<div className={styles.healthLegendColors}>
|
||||||
|
<div className={`${styles.healthLegendBlock} ${styles.healthBlockIdle}`} />
|
||||||
|
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#ef4444' }} />
|
||||||
|
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#facc15' }} />
|
||||||
|
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#22c55e' }} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.healthLegendLabel}>{t('service_health.newest')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||||
import {
|
import {
|
||||||
formatTokensInMillions,
|
formatCompactNumber,
|
||||||
formatPerMinuteValue,
|
formatPerMinuteValue,
|
||||||
formatUsd,
|
formatUsd,
|
||||||
calculateTokenBreakdown,
|
calculateTokenBreakdown,
|
||||||
@@ -56,9 +56,9 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
|||||||
key: 'requests',
|
key: 'requests',
|
||||||
label: t('usage_stats.total_requests'),
|
label: t('usage_stats.total_requests'),
|
||||||
icon: <IconSatellite size={16} />,
|
icon: <IconSatellite size={16} />,
|
||||||
accent: '#3b82f6',
|
accent: '#8b8680',
|
||||||
accentSoft: 'rgba(59, 130, 246, 0.18)',
|
accentSoft: 'rgba(139, 134, 128, 0.18)',
|
||||||
accentBorder: 'rgba(59, 130, 246, 0.35)',
|
accentBorder: 'rgba(139, 134, 128, 0.35)',
|
||||||
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
|
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
|
||||||
meta: (
|
meta: (
|
||||||
<>
|
<>
|
||||||
@@ -67,7 +67,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
|||||||
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
|
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.statMetaItem}>
|
<span className={styles.statMetaItem}>
|
||||||
<span className={styles.statMetaDot} style={{ backgroundColor: '#ef4444' }} />
|
<span className={styles.statMetaDot} style={{ backgroundColor: '#c65746' }} />
|
||||||
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
|
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
@@ -81,14 +81,14 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
|||||||
accent: '#8b5cf6',
|
accent: '#8b5cf6',
|
||||||
accentSoft: 'rgba(139, 92, 246, 0.18)',
|
accentSoft: 'rgba(139, 92, 246, 0.18)',
|
||||||
accentBorder: 'rgba(139, 92, 246, 0.35)',
|
accentBorder: 'rgba(139, 92, 246, 0.35)',
|
||||||
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
|
value: loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0),
|
||||||
meta: (
|
meta: (
|
||||||
<>
|
<>
|
||||||
<span className={styles.statMetaItem}>
|
<span className={styles.statMetaItem}>
|
||||||
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
|
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.statMetaItem}>
|
<span className={styles.statMetaItem}>
|
||||||
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
|
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -119,7 +119,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
|||||||
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
|
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
|
||||||
meta: (
|
meta: (
|
||||||
<span className={styles.statMetaItem}>
|
<span className={styles.statMetaItem}>
|
||||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)}
|
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
trend: sparklines.tpm
|
trend: sparklines.tpm
|
||||||
@@ -135,7 +135,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
|||||||
meta: (
|
meta: (
|
||||||
<>
|
<>
|
||||||
<span className={styles.statMetaItem}>
|
<span className={styles.statMetaItem}>
|
||||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
|
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
|
||||||
</span>
|
</span>
|
||||||
{!hasPrices && (
|
{!hasPrices && (
|
||||||
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
|
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
|
||||||
|
|||||||
145
src/components/usage/TokenBreakdownChart.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
buildHourlyTokenBreakdown,
|
||||||
|
buildDailyTokenBreakdown,
|
||||||
|
type TokenCategory
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
|
||||||
|
import type { UsagePayload } from './hooks/useUsageData';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
const TOKEN_COLORS: Record<TokenCategory, { border: string; bg: string }> = {
|
||||||
|
input: { border: '#8b8680', bg: 'rgba(139, 134, 128, 0.25)' },
|
||||||
|
output: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.25)' },
|
||||||
|
cached: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.25)' },
|
||||||
|
reasoning: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.25)' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning'];
|
||||||
|
|
||||||
|
export interface TokenBreakdownChartProps {
|
||||||
|
usage: UsagePayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
isDark: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
hourWindowHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TokenBreakdownChart({
|
||||||
|
usage,
|
||||||
|
loading,
|
||||||
|
isDark,
|
||||||
|
isMobile,
|
||||||
|
hourWindowHours
|
||||||
|
}: TokenBreakdownChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
|
||||||
|
|
||||||
|
const { chartData, chartOptions } = useMemo(() => {
|
||||||
|
const series =
|
||||||
|
period === 'hour'
|
||||||
|
? buildHourlyTokenBreakdown(usage, hourWindowHours)
|
||||||
|
: buildDailyTokenBreakdown(usage);
|
||||||
|
const categoryLabels: Record<TokenCategory, string> = {
|
||||||
|
input: t('usage_stats.input_tokens'),
|
||||||
|
output: t('usage_stats.output_tokens'),
|
||||||
|
cached: t('usage_stats.cached_tokens'),
|
||||||
|
reasoning: t('usage_stats.reasoning_tokens')
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: series.labels,
|
||||||
|
datasets: CATEGORIES.map((cat) => ({
|
||||||
|
label: categoryLabels[cat],
|
||||||
|
data: series.dataByCategory[cat],
|
||||||
|
borderColor: TOKEN_COLORS[cat].border,
|
||||||
|
backgroundColor: TOKEN_COLORS[cat].bg,
|
||||||
|
pointBackgroundColor: TOKEN_COLORS[cat].border,
|
||||||
|
pointBorderColor: TOKEN_COLORS[cat].border,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.35
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
|
||||||
|
const options = {
|
||||||
|
...baseOptions,
|
||||||
|
scales: {
|
||||||
|
...baseOptions.scales,
|
||||||
|
y: {
|
||||||
|
...baseOptions.scales?.y,
|
||||||
|
stacked: true
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
...baseOptions.scales?.x,
|
||||||
|
stacked: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { chartData: data, chartOptions: options };
|
||||||
|
}, [usage, period, isDark, isMobile, hourWindowHours, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t('usage_stats.token_breakdown')}
|
||||||
|
extra={
|
||||||
|
<div className={styles.periodButtons}>
|
||||||
|
<Button
|
||||||
|
variant={period === 'hour' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPeriod('hour')}
|
||||||
|
>
|
||||||
|
{t('usage_stats.by_hour')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={period === 'day' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPeriod('day')}
|
||||||
|
>
|
||||||
|
{t('usage_stats.by_day')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
|
) : chartData.labels.length > 0 ? (
|
||||||
|
<div className={styles.chartWrapper}>
|
||||||
|
<div className={styles.chartLegend} aria-label="Chart legend">
|
||||||
|
{chartData.datasets.map((dataset, index) => (
|
||||||
|
<div
|
||||||
|
key={`${dataset.label}-${index}`}
|
||||||
|
className={styles.legendItem}
|
||||||
|
title={dataset.label}
|
||||||
|
>
|
||||||
|
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
|
||||||
|
<span className={styles.legendLabel}>{dataset.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartArea}>
|
||||||
|
<div className={styles.chartScroller}>
|
||||||
|
<div
|
||||||
|
className={styles.chartCanvas}
|
||||||
|
style={
|
||||||
|
period === 'hour'
|
||||||
|
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Line data={chartData} options={chartOptions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export interface UseChartDataOptions {
|
|||||||
chartLines: string[];
|
chartLines: string[];
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
hourWindowHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseChartDataReturn {
|
export interface UseChartDataReturn {
|
||||||
@@ -26,20 +27,21 @@ export function useChartData({
|
|||||||
usage,
|
usage,
|
||||||
chartLines,
|
chartLines,
|
||||||
isDark,
|
isDark,
|
||||||
isMobile
|
isMobile,
|
||||||
|
hourWindowHours
|
||||||
}: UseChartDataOptions): UseChartDataReturn {
|
}: UseChartDataOptions): UseChartDataReturn {
|
||||||
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
|
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
|
||||||
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
|
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
|
||||||
|
|
||||||
const requestsChartData = useMemo(() => {
|
const requestsChartData = useMemo(() => {
|
||||||
if (!usage) return { labels: [], datasets: [] };
|
if (!usage) return { labels: [], datasets: [] };
|
||||||
return buildChartData(usage, requestsPeriod, 'requests', chartLines);
|
return buildChartData(usage, requestsPeriod, 'requests', chartLines, { hourWindowHours });
|
||||||
}, [usage, requestsPeriod, chartLines]);
|
}, [usage, requestsPeriod, chartLines, hourWindowHours]);
|
||||||
|
|
||||||
const tokensChartData = useMemo(() => {
|
const tokensChartData = useMemo(() => {
|
||||||
if (!usage) return { labels: [], datasets: [] };
|
if (!usage) return { labels: [], datasets: [] };
|
||||||
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
|
return buildChartData(usage, tokensPeriod, 'tokens', chartLines, { hourWindowHours });
|
||||||
}, [usage, tokensPeriod, chartLines]);
|
}, [usage, tokensPeriod, chartLines, hourWindowHours]);
|
||||||
|
|
||||||
const requestsChartOptions = useMemo(
|
const requestsChartOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSpar
|
|||||||
);
|
);
|
||||||
|
|
||||||
const requestsSparkline = useMemo(
|
const requestsSparkline = useMemo(
|
||||||
() => buildSparkline(buildLastHourSeries('requests'), '#3b82f6', 'rgba(59, 130, 246, 0.18)'),
|
() => buildSparkline(buildLastHourSeries('requests'), '#8b8680', 'rgba(139, 134, 128, 0.18)'),
|
||||||
[buildLastHourSeries, buildSparkline]
|
[buildLastHourSeries, buildSparkline]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface UseUsageDataReturn {
|
|||||||
usage: UsagePayload | null;
|
usage: UsagePayload | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
|
lastRefreshedAt: Date | null;
|
||||||
modelPrices: Record<string, ModelPrice>;
|
modelPrices: Record<string, ModelPrice>;
|
||||||
setModelPrices: (prices: Record<string, ModelPrice>) => void;
|
setModelPrices: (prices: Record<string, ModelPrice>) => void;
|
||||||
loadUsage: () => Promise<void>;
|
loadUsage: () => Promise<void>;
|
||||||
@@ -38,6 +39,7 @@ export function useUsageData(): UseUsageDataReturn {
|
|||||||
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
|
||||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const loadUsage = useCallback(async () => {
|
const loadUsage = useCallback(async () => {
|
||||||
@@ -45,8 +47,9 @@ export function useUsageData(): UseUsageDataReturn {
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = await usageApi.getUsage();
|
const data = await usageApi.getUsage();
|
||||||
const payload = data?.usage ?? data;
|
const payload = (data?.usage ?? data) as unknown;
|
||||||
setUsage(payload);
|
setUsage(payload && typeof payload === 'object' ? (payload as UsagePayload) : null);
|
||||||
|
setLastRefreshedAt(new Date());
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
||||||
setError(message);
|
setError(message);
|
||||||
@@ -140,6 +143,7 @@ export function useUsageData(): UseUsageDataReturn {
|
|||||||
usage,
|
usage,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
lastRefreshedAt,
|
||||||
modelPrices,
|
modelPrices,
|
||||||
setModelPrices: handleSetModelPrices,
|
setModelPrices: handleSetModelPrices,
|
||||||
loadUsage,
|
loadUsage,
|
||||||
|
|||||||
@@ -26,3 +26,15 @@ export type { ModelStatsCardProps, ModelStat } from './ModelStatsCard';
|
|||||||
|
|
||||||
export { PriceSettingsCard } from './PriceSettingsCard';
|
export { PriceSettingsCard } from './PriceSettingsCard';
|
||||||
export type { PriceSettingsCardProps } from './PriceSettingsCard';
|
export type { PriceSettingsCardProps } from './PriceSettingsCard';
|
||||||
|
|
||||||
|
export { CredentialStatsCard } from './CredentialStatsCard';
|
||||||
|
export type { CredentialStatsCardProps } from './CredentialStatsCard';
|
||||||
|
|
||||||
|
export { TokenBreakdownChart } from './TokenBreakdownChart';
|
||||||
|
export type { TokenBreakdownChartProps } from './TokenBreakdownChart';
|
||||||
|
|
||||||
|
export { CostTrendChart } from './CostTrendChart';
|
||||||
|
export type { CostTrendChartProps } from './CostTrendChart';
|
||||||
|
|
||||||
|
export { ServiceHealthCard } from './ServiceHealthCard';
|
||||||
|
export type { ServiceHealthCardProps } from './ServiceHealthCard';
|
||||||
|
|||||||
232
src/features/authFiles/components/AuthFileCard.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import { IconBot, IconCheck, IconCode, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
|
||||||
|
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { resolveAuthProvider } from '@/utils/quota';
|
||||||
|
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||||
|
import { formatFileSize } from '@/utils/format';
|
||||||
|
import {
|
||||||
|
QUOTA_PROVIDER_TYPES,
|
||||||
|
formatModified,
|
||||||
|
getTypeColor,
|
||||||
|
getTypeLabel,
|
||||||
|
isRuntimeOnlyAuthFile,
|
||||||
|
normalizeAuthIndexValue,
|
||||||
|
resolveAuthFileStats,
|
||||||
|
type QuotaProviderType,
|
||||||
|
type ResolvedTheme
|
||||||
|
} from '@/features/authFiles/constants';
|
||||||
|
import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
|
||||||
|
import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type AuthFileCardProps = {
|
||||||
|
file: AuthFileItem;
|
||||||
|
selected: boolean;
|
||||||
|
resolvedTheme: ResolvedTheme;
|
||||||
|
disableControls: boolean;
|
||||||
|
deleting: string | null;
|
||||||
|
statusUpdating: Record<string, boolean>;
|
||||||
|
quotaFilterType: QuotaProviderType | null;
|
||||||
|
keyStats: KeyStats;
|
||||||
|
statusBarCache: Map<string, AuthFileStatusBarData>;
|
||||||
|
onShowModels: (file: AuthFileItem) => void;
|
||||||
|
onShowDetails: (file: AuthFileItem) => void;
|
||||||
|
onDownload: (name: string) => void;
|
||||||
|
onOpenPrefixProxyEditor: (name: string) => void;
|
||||||
|
onDelete: (name: string) => void;
|
||||||
|
onToggleStatus: (file: AuthFileItem, enabled: boolean) => void;
|
||||||
|
onToggleSelect: (name: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
|
||||||
|
const provider = resolveAuthProvider(file);
|
||||||
|
if (!QUOTA_PROVIDER_TYPES.has(provider as QuotaProviderType)) return null;
|
||||||
|
return provider as QuotaProviderType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFileCard(props: AuthFileCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
file,
|
||||||
|
selected,
|
||||||
|
resolvedTheme,
|
||||||
|
disableControls,
|
||||||
|
deleting,
|
||||||
|
statusUpdating,
|
||||||
|
quotaFilterType,
|
||||||
|
keyStats,
|
||||||
|
statusBarCache,
|
||||||
|
onShowModels,
|
||||||
|
onShowDetails,
|
||||||
|
onDownload,
|
||||||
|
onOpenPrefixProxyEditor,
|
||||||
|
onDelete,
|
||||||
|
onToggleStatus,
|
||||||
|
onToggleSelect
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const fileStats = resolveAuthFileStats(file, keyStats);
|
||||||
|
const isRuntimeOnly = isRuntimeOnlyAuthFile(file);
|
||||||
|
const isAistudio = (file.type || '').toLowerCase() === 'aistudio';
|
||||||
|
const showModelsButton = !isRuntimeOnly || isAistudio;
|
||||||
|
const typeColor = getTypeColor(file.type || 'unknown', resolvedTheme);
|
||||||
|
|
||||||
|
const quotaType =
|
||||||
|
quotaFilterType && resolveQuotaType(file) === quotaFilterType ? quotaFilterType : null;
|
||||||
|
|
||||||
|
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
|
||||||
|
|
||||||
|
const providerCardClass =
|
||||||
|
quotaType === 'antigravity'
|
||||||
|
? styles.antigravityCard
|
||||||
|
: quotaType === 'codex'
|
||||||
|
? styles.codexCard
|
||||||
|
: quotaType === 'gemini-cli'
|
||||||
|
? styles.geminiCliCard
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
const statusData =
|
||||||
|
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.fileCard} ${providerCardClass} ${selected ? styles.fileCardSelected : ''} ${file.disabled ? styles.fileCardDisabled : ''}`}
|
||||||
|
>
|
||||||
|
<div className={styles.fileCardLayout}>
|
||||||
|
<div className={styles.fileCardMain}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
{!isRuntimeOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.selectionToggle} ${selected ? styles.selectionToggleActive : ''}`}
|
||||||
|
onClick={() => onToggleSelect(file.name)}
|
||||||
|
aria-label={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
|
||||||
|
aria-pressed={selected}
|
||||||
|
title={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
|
||||||
|
>
|
||||||
|
{selected && <IconCheck size={12} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={styles.typeBadge}
|
||||||
|
style={{
|
||||||
|
backgroundColor: typeColor.bg,
|
||||||
|
color: typeColor.text,
|
||||||
|
...(typeColor.border ? { border: typeColor.border } : {})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getTypeLabel(t, file.type || 'unknown')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.fileName}>{file.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cardMeta}>
|
||||||
|
<span>
|
||||||
|
{t('auth_files.file_size')}: {file.size ? formatFileSize(file.size) : '-'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('auth_files.file_modified')}: {formatModified(file)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cardStats}>
|
||||||
|
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||||
|
{t('stats.success')}: {fileStats.success}
|
||||||
|
</span>
|
||||||
|
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||||
|
{t('stats.failure')}: {fileStats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProviderStatusBar statusData={statusData} styles={styles} />
|
||||||
|
|
||||||
|
{showQuotaLayout && quotaType && (
|
||||||
|
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.cardActions}>
|
||||||
|
{showModelsButton && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onShowModels(file)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.models_button', { defaultValue: '模型' })}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconBot className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isRuntimeOnly && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onShowDetails(file)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('common.info', { defaultValue: '关于' })}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconInfo className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDownload(file.name)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.download_button')}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconDownload className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onOpenPrefixProxyEditor(file.name)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.prefix_proxy_button')}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconCode className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(file.name)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.delete_button')}
|
||||||
|
disabled={disableControls || deleting === file.name}
|
||||||
|
>
|
||||||
|
{deleting === file.name ? (
|
||||||
|
<LoadingSpinner size={14} />
|
||||||
|
) : (
|
||||||
|
<IconTrash2 className={styles.actionIcon} size={16} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isRuntimeOnly && (
|
||||||
|
<div className={styles.statusToggle}>
|
||||||
|
<ToggleSwitch
|
||||||
|
ariaLabel={t('auth_files.status_toggle_label')}
|
||||||
|
checked={!file.disabled}
|
||||||
|
disabled={disableControls || statusUpdating[file.name] === true}
|
||||||
|
onChange={(value) => onToggleStatus(file, value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isRuntimeOnly && (
|
||||||
|
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/features/authFiles/components/AuthFileDetailModal.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type AuthFileDetailModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
file: AuthFileItem | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onCopyText: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFileDetailModal({ open, file, onClose, onCopyText }: AuthFileDetailModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={file?.name || t('auth_files.title_section')}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!file) return;
|
||||||
|
const text = JSON.stringify(file, null, 2);
|
||||||
|
onCopyText(text);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.copy')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{file && (
|
||||||
|
<div className={styles.detailContent}>
|
||||||
|
<pre className={styles.jsonContent}>{JSON.stringify(file, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
91
src/features/authFiles/components/AuthFileModelsModal.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||||
|
import { isModelExcluded } from '@/features/authFiles/constants';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type AuthFileModelsModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
fileName: string;
|
||||||
|
fileType: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: 'unsupported' | null;
|
||||||
|
models: AuthFileModelItem[];
|
||||||
|
excluded: Record<string, string[]>;
|
||||||
|
onClose: () => void;
|
||||||
|
onCopyText: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFileModelsModal(props: AuthFileModelsModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { open, fileName, fileType, loading, error, models, excluded, onClose, onCopyText } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('auth_files.models_title', { defaultValue: '支持的模型' }) + ` - ${fileName}`}
|
||||||
|
footer={
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>
|
||||||
|
{t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })}
|
||||||
|
</div>
|
||||||
|
) : error === 'unsupported' ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('auth_files.models_unsupported', { defaultValue: '当前版本不支持此功能' })}
|
||||||
|
description={t('auth_files.models_unsupported_desc', {
|
||||||
|
defaultValue: '请更新 CLI Proxy API 到最新版本后重试'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : models.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('auth_files.models_empty', { defaultValue: '该凭证暂无可用模型' })}
|
||||||
|
description={t('auth_files.models_empty_desc', {
|
||||||
|
defaultValue: '该认证凭证可能尚未被服务器加载或没有绑定任何模型'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={styles.modelsList}>
|
||||||
|
{models.map((model) => {
|
||||||
|
const excludedModel = isModelExcluded(model.id, fileType, excluded);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={model.id}
|
||||||
|
className={`${styles.modelItem} ${excludedModel ? styles.modelItemExcluded : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
onCopyText(model.id);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
excludedModel
|
||||||
|
? t('auth_files.models_excluded_hint', {
|
||||||
|
defaultValue: '此 OAuth 模型已被禁用'
|
||||||
|
})
|
||||||
|
: t('common.copy', { defaultValue: '点击复制' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={styles.modelId}>{model.id}</span>
|
||||||
|
{model.display_name && model.display_name !== model.id && (
|
||||||
|
<span className={styles.modelDisplayName}>{model.display_name}</span>
|
||||||
|
)}
|
||||||
|
{model.type && <span className={styles.modelType}>{model.type}</span>}
|
||||||
|
{excludedModel && (
|
||||||
|
<span className={styles.modelExcludedBadge}>
|
||||||
|
{t('auth_files.models_excluded_badge', { defaultValue: '已禁用' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
124
src/features/authFiles/components/AuthFileQuotaSection.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useCallback, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota';
|
||||||
|
import { useNotificationStore, useQuotaStore } from '@/stores';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { getStatusFromError } from '@/utils/quota';
|
||||||
|
import {
|
||||||
|
isRuntimeOnlyAuthFile,
|
||||||
|
resolveQuotaErrorMessage,
|
||||||
|
type QuotaProviderType
|
||||||
|
} from '@/features/authFiles/constants';
|
||||||
|
import { QuotaProgressBar } from '@/features/authFiles/components/QuotaProgressBar';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
type QuotaState = { status?: string; error?: string; errorStatus?: number } | undefined;
|
||||||
|
|
||||||
|
const getQuotaConfig = (type: QuotaProviderType) => {
|
||||||
|
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
|
||||||
|
if (type === 'codex') return CODEX_CONFIG;
|
||||||
|
return GEMINI_CLI_CONFIG;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthFileQuotaSectionProps = {
|
||||||
|
file: AuthFileItem;
|
||||||
|
quotaType: QuotaProviderType;
|
||||||
|
disableControls: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
|
||||||
|
const { file, quotaType, disableControls } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||||
|
|
||||||
|
const quota = useQuotaStore((state) => {
|
||||||
|
if (quotaType === 'antigravity') return state.antigravityQuota[file.name] as QuotaState;
|
||||||
|
if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaState;
|
||||||
|
return state.geminiCliQuota[file.name] as QuotaState;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateQuotaState = useQuotaStore((state) => {
|
||||||
|
if (quotaType === 'antigravity') return state.setAntigravityQuota as unknown as (updater: unknown) => void;
|
||||||
|
if (quotaType === 'codex') return state.setCodexQuota as unknown as (updater: unknown) => void;
|
||||||
|
return state.setGeminiCliQuota as unknown as (updater: unknown) => void;
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshQuotaForFile = useCallback(async () => {
|
||||||
|
if (disableControls) return;
|
||||||
|
if (isRuntimeOnlyAuthFile(file)) return;
|
||||||
|
if (file.disabled) return;
|
||||||
|
if (quota?.status === 'loading') return;
|
||||||
|
|
||||||
|
const config = getQuotaConfig(quotaType) as unknown as {
|
||||||
|
i18nPrefix: string;
|
||||||
|
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<unknown>;
|
||||||
|
buildLoadingState: () => unknown;
|
||||||
|
buildSuccessState: (data: unknown) => unknown;
|
||||||
|
buildErrorState: (message: string, status?: number) => unknown;
|
||||||
|
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
updateQuotaState((prev: Record<string, unknown>) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: config.buildLoadingState()
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await config.fetchQuota(file, t);
|
||||||
|
updateQuotaState((prev: Record<string, unknown>) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: config.buildSuccessState(data)
|
||||||
|
}));
|
||||||
|
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
||||||
|
const status = getStatusFromError(err);
|
||||||
|
updateQuotaState((prev: Record<string, unknown>) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: config.buildErrorState(message, status)
|
||||||
|
}));
|
||||||
|
showNotification(t('auth_files.quota_refresh_failed', { name: file.name, message }), 'error');
|
||||||
|
}
|
||||||
|
}, [disableControls, file, quota?.status, quotaType, showNotification, t, updateQuotaState]);
|
||||||
|
|
||||||
|
const config = getQuotaConfig(quotaType) as unknown as {
|
||||||
|
i18nPrefix: string;
|
||||||
|
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const quotaStatus = quota?.status ?? 'idle';
|
||||||
|
const canRefreshQuota = !disableControls && !file.disabled;
|
||||||
|
const quotaErrorMessage = resolveQuotaErrorMessage(
|
||||||
|
t,
|
||||||
|
quota?.errorStatus,
|
||||||
|
quota?.error || t('common.unknown_error')
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.quotaSection}>
|
||||||
|
{quotaStatus === 'loading' ? (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.loading`)}</div>
|
||||||
|
) : quotaStatus === 'idle' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.quotaMessage} ${styles.quotaMessageAction}`}
|
||||||
|
onClick={() => void refreshQuotaForFile()}
|
||||||
|
disabled={!canRefreshQuota}
|
||||||
|
>
|
||||||
|
{t(`${config.i18nPrefix}.idle`)}
|
||||||
|
</button>
|
||||||
|
) : quotaStatus === 'error' ? (
|
||||||
|
<div className={styles.quotaError}>
|
||||||
|
{t(`${config.i18nPrefix}.load_failed`, {
|
||||||
|
message: quotaErrorMessage
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : quota ? (
|
||||||
|
(config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode)
|
||||||
|
) : (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import type {
|
||||||
|
PrefixProxyEditorField,
|
||||||
|
PrefixProxyEditorState
|
||||||
|
} from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type AuthFilesPrefixProxyEditorModalProps = {
|
||||||
|
disableControls: boolean;
|
||||||
|
editor: PrefixProxyEditorState | null;
|
||||||
|
updatedText: string;
|
||||||
|
dirty: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onChange: (field: PrefixProxyEditorField, value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEditorModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { disableControls, editor, updatedText, dirty, onClose, onSave, onChange } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={Boolean(editor)}
|
||||||
|
onClose={onClose}
|
||||||
|
closeDisabled={editor?.saving === true}
|
||||||
|
width={720}
|
||||||
|
title={
|
||||||
|
editor?.fileName
|
||||||
|
? t('auth_files.auth_field_editor_title', { name: editor.fileName })
|
||||||
|
: t('auth_files.prefix_proxy_button')
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={editor?.saving === true}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
loading={editor?.saving === true}
|
||||||
|
disabled={
|
||||||
|
disableControls || editor?.saving === true || !dirty || !editor?.json
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{editor && (
|
||||||
|
<div className={styles.prefixProxyEditor}>
|
||||||
|
{editor.loading ? (
|
||||||
|
<div className={styles.prefixProxyLoading}>
|
||||||
|
<LoadingSpinner size={14} />
|
||||||
|
<span>{t('auth_files.prefix_proxy_loading')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{editor.error && <div className={styles.prefixProxyError}>{editor.error}</div>}
|
||||||
|
<div className={styles.prefixProxyJsonWrapper}>
|
||||||
|
<label className={styles.prefixProxyLabel}>
|
||||||
|
{t('auth_files.prefix_proxy_source_label')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className={styles.prefixProxyTextarea}
|
||||||
|
rows={10}
|
||||||
|
readOnly
|
||||||
|
value={updatedText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.prefixProxyFields}>
|
||||||
|
<Input
|
||||||
|
label={t('auth_files.prefix_label')}
|
||||||
|
value={editor.prefix}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('prefix', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('auth_files.proxy_url_label')}
|
||||||
|
value={editor.proxyUrl}
|
||||||
|
placeholder={t('auth_files.proxy_url_placeholder')}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('proxyUrl', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('auth_files.priority_label')}
|
||||||
|
value={editor.priority}
|
||||||
|
placeholder={t('auth_files.priority_placeholder')}
|
||||||
|
hint={t('auth_files.priority_hint')}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('priority', e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('auth_files.excluded_models_label')}</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
value={editor.excludedModelsText}
|
||||||
|
placeholder={t('auth_files.excluded_models_placeholder')}
|
||||||
|
rows={4}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('excludedModelsText', e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label={t('auth_files.disable_cooling_label')}
|
||||||
|
value={editor.disableCooling}
|
||||||
|
placeholder={t('auth_files.disable_cooling_placeholder')}
|
||||||
|
hint={t('auth_files.disable_cooling_hint')}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('disableCooling', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
65
src/features/authFiles/components/OAuthExcludedCard.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
type UnsupportedError = 'unsupported' | null;
|
||||||
|
|
||||||
|
export type OAuthExcludedCardProps = {
|
||||||
|
disableControls: boolean;
|
||||||
|
excludedError: UnsupportedError;
|
||||||
|
excluded: Record<string, string[]>;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (provider: string) => void;
|
||||||
|
onDelete: (provider: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OAuthExcludedCard(props: OAuthExcludedCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { disableControls, excludedError, excluded, onAdd, onEdit, onDelete } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t('oauth_excluded.title')}
|
||||||
|
extra={
|
||||||
|
<Button size="sm" onClick={onAdd} disabled={disableControls || excludedError === 'unsupported'}>
|
||||||
|
{t('oauth_excluded.add')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{excludedError === 'unsupported' ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('oauth_excluded.upgrade_required_title')}
|
||||||
|
description={t('oauth_excluded.upgrade_required_desc')}
|
||||||
|
/>
|
||||||
|
) : Object.keys(excluded).length === 0 ? (
|
||||||
|
<EmptyState title={t('oauth_excluded.list_empty_all')} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.excludedList}>
|
||||||
|
{Object.entries(excluded).map(([provider, models]) => (
|
||||||
|
<div key={provider} className={styles.excludedItem}>
|
||||||
|
<div className={styles.excludedInfo}>
|
||||||
|
<div className={styles.excludedProvider}>{provider}</div>
|
||||||
|
<div className={styles.excludedModels}>
|
||||||
|
{models?.length
|
||||||
|
? t('oauth_excluded.model_count', { count: models.length })
|
||||||
|
: t('oauth_excluded.no_models')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.excludedActions}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onEdit(provider)}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => onDelete(provider)}>
|
||||||
|
{t('oauth_excluded.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
152
src/features/authFiles/components/OAuthModelAliasCard.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useRef } 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 { ModelMappingDiagram, type ModelMappingDiagramRef } from '@/components/modelAlias';
|
||||||
|
import { IconChevronUp } from '@/components/ui/icons';
|
||||||
|
import type { OAuthModelAliasEntry } from '@/types';
|
||||||
|
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
type UnsupportedError = 'unsupported' | null;
|
||||||
|
type ViewMode = 'diagram' | 'list';
|
||||||
|
|
||||||
|
export type OAuthModelAliasCardProps = {
|
||||||
|
disableControls: boolean;
|
||||||
|
viewMode: ViewMode;
|
||||||
|
onViewModeChange: (mode: ViewMode) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEditProvider: (provider?: string) => void;
|
||||||
|
onDeleteProvider: (provider: string) => void;
|
||||||
|
modelAliasError: UnsupportedError;
|
||||||
|
modelAlias: Record<string, OAuthModelAliasEntry[]>;
|
||||||
|
allProviderModels: Record<string, AuthFileModelItem[]>;
|
||||||
|
onUpdate: (provider: string, sourceModel: string, newAlias: string) => Promise<void>;
|
||||||
|
onDeleteLink: (provider: string, sourceModel: string, alias: string) => void;
|
||||||
|
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => Promise<void>;
|
||||||
|
onRenameAlias: (oldAlias: string, newAlias: string) => Promise<void>;
|
||||||
|
onDeleteAlias: (aliasName: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OAuthModelAliasCard(props: OAuthModelAliasCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const diagramRef = useRef<ModelMappingDiagramRef | null>(null);
|
||||||
|
const {
|
||||||
|
disableControls,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
onAdd,
|
||||||
|
onEditProvider,
|
||||||
|
onDeleteProvider,
|
||||||
|
modelAliasError,
|
||||||
|
modelAlias,
|
||||||
|
allProviderModels,
|
||||||
|
onUpdate,
|
||||||
|
onDeleteLink,
|
||||||
|
onToggleFork,
|
||||||
|
onRenameAlias,
|
||||||
|
onDeleteAlias
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t('oauth_model_alias.title')}
|
||||||
|
extra={
|
||||||
|
<div className={styles.cardExtraButtons}>
|
||||||
|
<div className={styles.viewModeSwitch}>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewModeChange('list')}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
>
|
||||||
|
{t('oauth_model_alias.view_mode_list')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'diagram' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewModeChange('diagram')}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
>
|
||||||
|
{t('oauth_model_alias.view_mode_diagram')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onAdd}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
>
|
||||||
|
{t('oauth_model_alias.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{modelAliasError === 'unsupported' ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('oauth_model_alias.upgrade_required_title')}
|
||||||
|
description={t('oauth_model_alias.upgrade_required_desc')}
|
||||||
|
/>
|
||||||
|
) : viewMode === 'diagram' ? (
|
||||||
|
Object.keys(modelAlias).length === 0 ? (
|
||||||
|
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.aliasChartSection}>
|
||||||
|
<div className={styles.aliasChartHeader}>
|
||||||
|
<h4 className={styles.aliasChartTitle}>{t('oauth_model_alias.chart_title')}</h4>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => diagramRef.current?.collapseAll()}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
title={t('oauth_model_alias.diagram_collapse')}
|
||||||
|
aria-label={t('oauth_model_alias.diagram_collapse')}
|
||||||
|
>
|
||||||
|
<IconChevronUp size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ModelMappingDiagram
|
||||||
|
ref={diagramRef}
|
||||||
|
modelAlias={modelAlias}
|
||||||
|
allProviderModels={allProviderModels}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onDeleteLink={onDeleteLink}
|
||||||
|
onToggleFork={onToggleFork}
|
||||||
|
onRenameAlias={onRenameAlias}
|
||||||
|
onDeleteAlias={onDeleteAlias}
|
||||||
|
onEditProvider={onEditProvider}
|
||||||
|
onDeleteProvider={onDeleteProvider}
|
||||||
|
className={styles.aliasChart}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : Object.keys(modelAlias).length === 0 ? (
|
||||||
|
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.excludedList}>
|
||||||
|
{Object.entries(modelAlias).map(([provider, mappings]) => (
|
||||||
|
<div key={provider} className={styles.excludedItem}>
|
||||||
|
<div className={styles.excludedInfo}>
|
||||||
|
<div className={styles.excludedProvider}>{provider}</div>
|
||||||
|
<div className={styles.excludedModels}>
|
||||||
|
{mappings?.length
|
||||||
|
? t('oauth_model_alias.model_count', { count: mappings.length })
|
||||||
|
: t('oauth_model_alias.no_models')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.excludedActions}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onEditProvider(provider)}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => onDeleteProvider(provider)}>
|
||||||
|
{t('oauth_model_alias.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
28
src/features/authFiles/components/QuotaProgressBar.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
235
src/features/authFiles/constants.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import {
|
||||||
|
normalizeUsageSourceId,
|
||||||
|
type KeyStatBucket,
|
||||||
|
type KeyStats
|
||||||
|
} from '@/utils/usage';
|
||||||
|
|
||||||
|
export type ThemeColors = { bg: string; text: string; border?: string };
|
||||||
|
export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
||||||
|
export type ResolvedTheme = 'light' | 'dark';
|
||||||
|
export type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
|
||||||
|
|
||||||
|
export type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli';
|
||||||
|
|
||||||
|
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli']);
|
||||||
|
|
||||||
|
export const MIN_CARD_PAGE_SIZE = 3;
|
||||||
|
export const MAX_CARD_PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
export const INTEGER_STRING_PATTERN = /^[+-]?\d+$/;
|
||||||
|
export const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']);
|
||||||
|
export const FALSY_TEXT_VALUES = new Set(['false', '0', 'no', 'n', 'off']);
|
||||||
|
|
||||||
|
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
|
||||||
|
export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||||
|
qwen: {
|
||||||
|
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||||
|
dark: { bg: '#1b5e20', text: '#81c784' }
|
||||||
|
},
|
||||||
|
kimi: {
|
||||||
|
light: { bg: '#fff4e5', text: '#ad6800' },
|
||||||
|
dark: { bg: '#7c4a03', text: '#ffd591' }
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
light: { bg: '#e3f2fd', text: '#1565c0' },
|
||||||
|
dark: { bg: '#0d47a1', text: '#64b5f6' }
|
||||||
|
},
|
||||||
|
'gemini-cli': {
|
||||||
|
light: { bg: '#e7efff', text: '#1e4fa3' },
|
||||||
|
dark: { bg: '#1c3f73', text: '#a8c7ff' }
|
||||||
|
},
|
||||||
|
aistudio: {
|
||||||
|
light: { bg: '#f0f2f5', text: '#2f343c' },
|
||||||
|
dark: { bg: '#373c42', text: '#cfd3db' }
|
||||||
|
},
|
||||||
|
claude: {
|
||||||
|
light: { bg: '#fce4ec', text: '#c2185b' },
|
||||||
|
dark: { bg: '#880e4f', text: '#f48fb1' }
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
light: { bg: '#fff3e0', text: '#ef6c00' },
|
||||||
|
dark: { bg: '#e65100', text: '#ffb74d' }
|
||||||
|
},
|
||||||
|
antigravity: {
|
||||||
|
light: { bg: '#e0f7fa', text: '#006064' },
|
||||||
|
dark: { bg: '#004d40', text: '#80deea' }
|
||||||
|
},
|
||||||
|
iflow: {
|
||||||
|
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
||||||
|
dark: { bg: '#4a148c', text: '#ce93d8' }
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
light: { bg: '#f5f5f5', text: '#616161' },
|
||||||
|
dark: { bg: '#424242', text: '#bdbdbd' }
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
|
||||||
|
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clampCardPageSize = (value: number) =>
|
||||||
|
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
|
export const getTypeLabel = (t: TFunction, 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTypeColor = (type: string, resolvedTheme: ResolvedTheme): ThemeColors => {
|
||||||
|
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
|
||||||
|
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parsePriorityValue = (value: unknown): number | undefined => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isInteger(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || !INTEGER_STRING_PATTERN.test(trimmed)) return undefined;
|
||||||
|
const parsed = Number.parseInt(trimmed, 10);
|
||||||
|
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeExcludedModels = (value: unknown): string[] => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const normalized: string[] = [];
|
||||||
|
value.forEach((entry) => {
|
||||||
|
const model = String(entry ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!model || seen.has(model)) return;
|
||||||
|
seen.add(model);
|
||||||
|
normalized.push(model);
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalized.sort((a, b) => a.localeCompare(b));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseExcludedModelsText = (value: string): string[] =>
|
||||||
|
normalizeExcludedModels(value.split(/[\n,]+/));
|
||||||
|
|
||||||
|
export const parseDisableCoolingValue = (value: unknown): boolean | undefined => {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value !== 0;
|
||||||
|
if (typeof value !== 'string') return undefined;
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
if (TRUTHY_TEXT_VALUES.has(normalized)) return true;
|
||||||
|
if (FALSY_TEXT_VALUES.has(normalized)) return false;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
|
||||||
|
export function normalizeAuthIndexValue(value: unknown): string | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
|
||||||
|
const raw = file['runtime_only'] ?? file.runtimeOnly;
|
||||||
|
if (typeof raw === 'boolean') return raw;
|
||||||
|
if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuthFileStats(file: AuthFileItem, stats: KeyStats): KeyStatBucket {
|
||||||
|
const defaultStats: KeyStatBucket = { success: 0, failure: 0 };
|
||||||
|
const rawFileName = file?.name || '';
|
||||||
|
|
||||||
|
// 兼容 auth_index 和 authIndex 两种字段名(API 返回的是 auth_index)
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
|
||||||
|
// 尝试根据 authIndex 匹配
|
||||||
|
if (authIndexKey && stats.byAuthIndex?.[authIndexKey]) {
|
||||||
|
return stats.byAuthIndex[authIndexKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试根据 source (文件名) 匹配
|
||||||
|
const fileNameId = rawFileName ? normalizeUsageSourceId(rawFileName) : '';
|
||||||
|
if (fileNameId && stats.bySource?.[fileNameId]) {
|
||||||
|
const fromName = stats.bySource[fileNameId];
|
||||||
|
if (fromName.success > 0 || fromName.failure > 0) {
|
||||||
|
return fromName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试去掉扩展名后匹配
|
||||||
|
if (rawFileName) {
|
||||||
|
const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, '');
|
||||||
|
if (nameWithoutExt && nameWithoutExt !== rawFileName) {
|
||||||
|
const nameWithoutExtId = normalizeUsageSourceId(nameWithoutExt);
|
||||||
|
const fromNameWithoutExt = nameWithoutExtId ? stats.bySource?.[nameWithoutExtId] : undefined;
|
||||||
|
if (
|
||||||
|
fromNameWithoutExt &&
|
||||||
|
(fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0)
|
||||||
|
) {
|
||||||
|
return fromNameWithoutExt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatModified = (item: AuthFileItem): string => {
|
||||||
|
const raw = item['modtime'] ?? item.modified;
|
||||||
|
if (!raw) return '-';
|
||||||
|
const asNumber = Number(raw);
|
||||||
|
const date =
|
||||||
|
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
|
||||||
|
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
|
||||||
|
: new Date(String(raw));
|
||||||
|
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查模型是否被 OAuth 排除
|
||||||
|
export const isModelExcluded = (
|
||||||
|
modelId: string,
|
||||||
|
providerType: string,
|
||||||
|
excluded: Record<string, string[]>
|
||||||
|
): boolean => {
|
||||||
|
const providerKey = normalizeProviderKey(providerType);
|
||||||
|
const excludedModels = excluded[providerKey] || excluded[providerType] || [];
|
||||||
|
return excludedModels.some((pattern) => {
|
||||||
|
if (pattern.includes('*')) {
|
||||||
|
// 支持通配符匹配:先转义正则特殊字符,再将 * 视为通配符
|
||||||
|
const regexSafePattern = pattern
|
||||||
|
.split('*')
|
||||||
|
.map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||||
|
.join('.*');
|
||||||
|
const regex = new RegExp(`^${regexSafePattern}$`, 'i');
|
||||||
|
return regex.test(modelId);
|
||||||
|
}
|
||||||
|
return pattern.toLowerCase() === modelId.toLowerCase();
|
||||||
|
});
|
||||||
|
};
|
||||||
519
src/features/authFiles/hooks/useAuthFilesData.ts
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type RefObject } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { authFilesApi } from '@/services/api';
|
||||||
|
import { apiClient } from '@/services/api/client';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { formatFileSize } from '@/utils/format';
|
||||||
|
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
|
||||||
|
import { getTypeLabel, isRuntimeOnlyAuthFile } from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
type DeleteAllOptions = {
|
||||||
|
filter: string;
|
||||||
|
onResetFilterToAll: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesDataResult = {
|
||||||
|
files: AuthFileItem[];
|
||||||
|
selectedFiles: Set<string>;
|
||||||
|
selectionCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
uploading: boolean;
|
||||||
|
deleting: string | null;
|
||||||
|
deletingAll: boolean;
|
||||||
|
statusUpdating: Record<string, boolean>;
|
||||||
|
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
loadFiles: () => Promise<void>;
|
||||||
|
handleUploadClick: () => void;
|
||||||
|
handleFileChange: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
|
||||||
|
handleDelete: (name: string) => void;
|
||||||
|
handleDeleteAll: (options: DeleteAllOptions) => void;
|
||||||
|
handleDownload: (name: string) => Promise<void>;
|
||||||
|
handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise<void>;
|
||||||
|
toggleSelect: (name: string) => void;
|
||||||
|
selectAllVisible: (visibleFiles: AuthFileItem[]) => void;
|
||||||
|
deselectAll: () => void;
|
||||||
|
batchSetStatus: (names: string[], enabled: boolean) => Promise<void>;
|
||||||
|
batchDelete: (names: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesDataOptions = {
|
||||||
|
refreshKeyStats: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFilesDataResult {
|
||||||
|
const { refreshKeyStats } = options;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
|
const [statusUpdating, setStatusUpdating] = useState<Record<string, boolean>>({});
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const selectionCount = selectedFiles.size;
|
||||||
|
|
||||||
|
const toggleSelect = useCallback((name: string) => {
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(name)) {
|
||||||
|
next.delete(name);
|
||||||
|
} else {
|
||||||
|
next.add(name);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectAllVisible = useCallback((visibleFiles: AuthFileItem[]) => {
|
||||||
|
const nextSelected = visibleFiles
|
||||||
|
.filter((file) => !isRuntimeOnlyAuthFile(file))
|
||||||
|
.map((file) => file.name);
|
||||||
|
setSelectedFiles(new Set(nextSelected));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deselectAll = useCallback(() => {
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFiles.size === 0) return;
|
||||||
|
const existingNames = new Set(files.map((file) => file.name));
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = new Set<string>();
|
||||||
|
prev.forEach((name) => {
|
||||||
|
if (existingNames.has(name)) {
|
||||||
|
next.add(name);
|
||||||
|
} else {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [files, selectedFiles.size]);
|
||||||
|
|
||||||
|
const loadFiles = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await authFilesApi.list();
|
||||||
|
setFiles(data?.files || []);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleUploadClick = useCallback(() => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const fileList = event.target.files;
|
||||||
|
if (!fileList || fileList.length === 0) return;
|
||||||
|
|
||||||
|
const filesToUpload = Array.from(fileList);
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
const invalidFiles: string[] = [];
|
||||||
|
const oversizedFiles: string[] = [];
|
||||||
|
|
||||||
|
filesToUpload.forEach((file) => {
|
||||||
|
if (!file.name.endsWith('.json')) {
|
||||||
|
invalidFiles.push(file.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_AUTH_FILE_SIZE) {
|
||||||
|
oversizedFiles.push(file.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
validFiles.push(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
|
showNotification(t('auth_files.upload_error_json'), 'error');
|
||||||
|
}
|
||||||
|
if (oversizedFiles.length > 0) {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
let successCount = 0;
|
||||||
|
const failed: { name: string; message: string }[] = [];
|
||||||
|
|
||||||
|
for (const file of validFiles) {
|
||||||
|
try {
|
||||||
|
await authFilesApi.upload(file);
|
||||||
|
successCount++;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
failed.push({ name: file.name, message: errorMessage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
|
||||||
|
showNotification(
|
||||||
|
`${t('auth_files.upload_success')}${suffix}`,
|
||||||
|
failed.length ? 'warning' : 'success'
|
||||||
|
);
|
||||||
|
await loadFiles();
|
||||||
|
await refreshKeyStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed.length > 0) {
|
||||||
|
const details = failed.map((item) => `${item.name}: ${item.message}`).join('; ');
|
||||||
|
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(false);
|
||||||
|
event.target.value = '';
|
||||||
|
},
|
||||||
|
[loadFiles, refreshKeyStats, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
showConfirmation({
|
||||||
|
title: t('auth_files.delete_title', { defaultValue: 'Delete File' }),
|
||||||
|
message: `${t('auth_files.delete_confirm')} "${name}" ?`,
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
setDeleting(name);
|
||||||
|
try {
|
||||||
|
await authFilesApi.deleteFile(name);
|
||||||
|
showNotification(t('auth_files.delete_success'), 'success');
|
||||||
|
setFiles((prev) => prev.filter((item) => item.name !== name));
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
if (!prev.has(name)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteAll = useCallback(
|
||||||
|
(deleteAllOptions: DeleteAllOptions) => {
|
||||||
|
const { filter, onResetFilterToAll } = deleteAllOptions;
|
||||||
|
const isFiltered = filter !== 'all';
|
||||||
|
const typeLabel = isFiltered ? getTypeLabel(t, filter) : t('auth_files.filter_all');
|
||||||
|
const confirmMessage = isFiltered
|
||||||
|
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
|
||||||
|
: t('auth_files.delete_all_confirm');
|
||||||
|
|
||||||
|
showConfirmation({
|
||||||
|
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
|
||||||
|
message: confirmMessage,
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
setDeletingAll(true);
|
||||||
|
try {
|
||||||
|
if (!isFiltered) {
|
||||||
|
await authFilesApi.deleteAll();
|
||||||
|
showNotification(t('auth_files.delete_all_success'), 'success');
|
||||||
|
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
|
||||||
|
deselectAll();
|
||||||
|
} else {
|
||||||
|
const filesToDelete = files.filter(
|
||||||
|
(f) => f.type === filter && !isRuntimeOnlyAuthFile(f)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filesToDelete.length === 0) {
|
||||||
|
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
|
||||||
|
setDeletingAll(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const deletedNames: string[] = [];
|
||||||
|
|
||||||
|
for (const file of filesToDelete) {
|
||||||
|
try {
|
||||||
|
await authFilesApi.deleteFile(file.name);
|
||||||
|
success++;
|
||||||
|
deletedNames.push(file.name);
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name)));
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
if (prev.size === 0) return prev;
|
||||||
|
const deletedSet = new Set(deletedNames);
|
||||||
|
let changed = false;
|
||||||
|
const next = new Set<string>();
|
||||||
|
prev.forEach((name) => {
|
||||||
|
if (deletedSet.has(name)) {
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
next.add(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (failed === 0) {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }),
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onResetFilterToAll();
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setDeletingAll(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[deselectAll, files, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.getRaw(
|
||||||
|
`/auth-files/download?name=${encodeURIComponent(name)}`,
|
||||||
|
{ responseType: 'blob' }
|
||||||
|
);
|
||||||
|
const blob = new Blob([response.data]);
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
showNotification(t('auth_files.download_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStatusToggle = useCallback(
|
||||||
|
async (item: AuthFileItem, enabled: boolean) => {
|
||||||
|
const name = item.name;
|
||||||
|
const nextDisabled = !enabled;
|
||||||
|
const previousDisabled = item.disabled === true;
|
||||||
|
|
||||||
|
setStatusUpdating((prev) => ({ ...prev, [name]: true }));
|
||||||
|
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authFilesApi.setStatus(name, nextDisabled);
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f))
|
||||||
|
);
|
||||||
|
showNotification(
|
||||||
|
enabled
|
||||||
|
? t('auth_files.status_enabled_success', { name })
|
||||||
|
: t('auth_files.status_disabled_success', { name }),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f))
|
||||||
|
);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${errorMessage}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setStatusUpdating((prev) => {
|
||||||
|
if (!prev[name]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[name];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const batchSetStatus = useCallback(
|
||||||
|
async (names: string[], enabled: boolean) => {
|
||||||
|
const uniqueNames = Array.from(new Set(names));
|
||||||
|
if (uniqueNames.length === 0) return;
|
||||||
|
|
||||||
|
const targetNames = new Set(uniqueNames);
|
||||||
|
const nextDisabled = !enabled;
|
||||||
|
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((file) =>
|
||||||
|
targetNames.has(file.name) ? { ...file, disabled: nextDisabled } : file
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
uniqueNames.map((name) => authFilesApi.setStatus(name, nextDisabled))
|
||||||
|
);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
const failedNames = new Set<string>();
|
||||||
|
const confirmedDisabled = new Map<string, boolean>();
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
const name = uniqueNames[index];
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
successCount++;
|
||||||
|
confirmedDisabled.set(name, result.value.disabled);
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
failedNames.add(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((file) => {
|
||||||
|
if (failedNames.has(file.name)) {
|
||||||
|
return { ...file, disabled: !nextDisabled };
|
||||||
|
}
|
||||||
|
if (confirmedDisabled.has(file.name)) {
|
||||||
|
return { ...file, disabled: confirmedDisabled.get(file.name) };
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failCount === 0) {
|
||||||
|
showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success');
|
||||||
|
} else {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.batch_status_partial', { success: successCount, failed: failCount }),
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deselectAll();
|
||||||
|
},
|
||||||
|
[deselectAll, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const batchDelete = useCallback(
|
||||||
|
(names: string[]) => {
|
||||||
|
const uniqueNames = Array.from(new Set(names));
|
||||||
|
if (uniqueNames.length === 0) return;
|
||||||
|
|
||||||
|
showConfirmation({
|
||||||
|
title: t('auth_files.batch_delete_title'),
|
||||||
|
message: t('auth_files.batch_delete_confirm', { count: uniqueNames.length }),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
uniqueNames.map((name) => authFilesApi.deleteFile(name))
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleted: string[] = [];
|
||||||
|
let failCount = 0;
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
deleted.push(uniqueNames[index]);
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleted.length > 0) {
|
||||||
|
const deletedSet = new Set(deleted);
|
||||||
|
setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
if (prev.size === 0) return prev;
|
||||||
|
const deletedSet = new Set(deleted);
|
||||||
|
let changed = false;
|
||||||
|
const next = new Set<string>();
|
||||||
|
prev.forEach((name) => {
|
||||||
|
if (deletedSet.has(name)) {
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
next.add(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (failCount === 0) {
|
||||||
|
showNotification(`${t('auth_files.delete_all_success')} (${deleted.length})`, 'success');
|
||||||
|
} else {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.delete_filtered_partial', {
|
||||||
|
success: deleted.length,
|
||||||
|
failed: failCount,
|
||||||
|
type: t('auth_files.filter_all')
|
||||||
|
}),
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
selectedFiles,
|
||||||
|
selectionCount,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
uploading,
|
||||||
|
deleting,
|
||||||
|
deletingAll,
|
||||||
|
statusUpdating,
|
||||||
|
fileInputRef,
|
||||||
|
loadFiles,
|
||||||
|
handleUploadClick,
|
||||||
|
handleFileChange,
|
||||||
|
handleDelete,
|
||||||
|
handleDeleteAll,
|
||||||
|
handleDownload,
|
||||||
|
handleStatusToggle,
|
||||||
|
toggleSelect,
|
||||||
|
selectAllVisible,
|
||||||
|
deselectAll,
|
||||||
|
batchSetStatus,
|
||||||
|
batchDelete
|
||||||
|
};
|
||||||
|
}
|
||||||
86
src/features/authFiles/hooks/useAuthFilesModels.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { authFilesApi } from '@/services/api';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
type ModelsError = 'unsupported' | null;
|
||||||
|
|
||||||
|
export type UseAuthFilesModelsResult = {
|
||||||
|
modelsModalOpen: boolean;
|
||||||
|
modelsLoading: boolean;
|
||||||
|
modelsList: AuthFileModelItem[];
|
||||||
|
modelsFileName: string;
|
||||||
|
modelsFileType: string;
|
||||||
|
modelsError: ModelsError;
|
||||||
|
showModels: (item: AuthFileItem) => Promise<void>;
|
||||||
|
closeModelsModal: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesModels(): UseAuthFilesModelsResult {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||||
|
|
||||||
|
const [modelsModalOpen, setModelsModalOpen] = useState(false);
|
||||||
|
const [modelsLoading, setModelsLoading] = useState(false);
|
||||||
|
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
|
||||||
|
const [modelsFileName, setModelsFileName] = useState('');
|
||||||
|
const [modelsFileType, setModelsFileType] = useState('');
|
||||||
|
const [modelsError, setModelsError] = useState<ModelsError>(null);
|
||||||
|
const modelsCacheRef = useRef<Map<string, AuthFileModelItem[]>>(new Map());
|
||||||
|
|
||||||
|
const closeModelsModal = useCallback(() => {
|
||||||
|
setModelsModalOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showModels = useCallback(
|
||||||
|
async (item: AuthFileItem) => {
|
||||||
|
setModelsFileName(item.name);
|
||||||
|
setModelsFileType(item.type || '');
|
||||||
|
setModelsList([]);
|
||||||
|
setModelsError(null);
|
||||||
|
setModelsModalOpen(true);
|
||||||
|
|
||||||
|
const cached = modelsCacheRef.current.get(item.name);
|
||||||
|
if (cached) {
|
||||||
|
setModelsList(cached);
|
||||||
|
setModelsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setModelsLoading(true);
|
||||||
|
try {
|
||||||
|
const models = await authFilesApi.getModelsForAuthFile(item.name);
|
||||||
|
modelsCacheRef.current.set(item.name, models);
|
||||||
|
setModelsList(models);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
if (
|
||||||
|
errorMessage.includes('404') ||
|
||||||
|
errorMessage.includes('not found') ||
|
||||||
|
errorMessage.includes('Not Found')
|
||||||
|
) {
|
||||||
|
setModelsError('unsupported');
|
||||||
|
} else {
|
||||||
|
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setModelsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
modelsModalOpen,
|
||||||
|
modelsLoading,
|
||||||
|
modelsList,
|
||||||
|
modelsFileName,
|
||||||
|
modelsFileType,
|
||||||
|
modelsError,
|
||||||
|
showModels,
|
||||||
|
closeModelsModal
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
504
src/features/authFiles/hooks/useAuthFilesOauth.tsx
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
import { authFilesApi } from '@/services/api';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
|
||||||
|
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||||
|
import { normalizeProviderKey } from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
type UnsupportedError = 'unsupported' | null;
|
||||||
|
type ViewMode = 'diagram' | 'list';
|
||||||
|
|
||||||
|
export type UseAuthFilesOauthResult = {
|
||||||
|
excluded: Record<string, string[]>;
|
||||||
|
excludedError: UnsupportedError;
|
||||||
|
modelAlias: Record<string, OAuthModelAliasEntry[]>;
|
||||||
|
modelAliasError: UnsupportedError;
|
||||||
|
allProviderModels: Record<string, AuthFileModelItem[]>;
|
||||||
|
providerList: string[];
|
||||||
|
loadExcluded: () => Promise<void>;
|
||||||
|
loadModelAlias: () => Promise<void>;
|
||||||
|
deleteExcluded: (provider: string) => void;
|
||||||
|
deleteModelAlias: (provider: string) => void;
|
||||||
|
handleMappingUpdate: (provider: string, sourceModel: string, newAlias: string) => Promise<void>;
|
||||||
|
handleDeleteLink: (provider: string, sourceModel: string, alias: string) => void;
|
||||||
|
handleToggleFork: (
|
||||||
|
provider: string,
|
||||||
|
sourceModel: string,
|
||||||
|
alias: string,
|
||||||
|
fork: boolean
|
||||||
|
) => Promise<void>;
|
||||||
|
handleRenameAlias: (oldAlias: string, newAlias: string) => Promise<void>;
|
||||||
|
handleDeleteAlias: (aliasName: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesOauthOptions = {
|
||||||
|
viewMode: ViewMode;
|
||||||
|
files: AuthFileItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesOauth(options: UseAuthFilesOauthOptions): UseAuthFilesOauthResult {
|
||||||
|
const { viewMode, files } = options;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
|
|
||||||
|
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
||||||
|
const [excludedError, setExcludedError] = useState<UnsupportedError>(null);
|
||||||
|
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
|
||||||
|
const [modelAliasError, setModelAliasError] = useState<UnsupportedError>(null);
|
||||||
|
const [allProviderModels, setAllProviderModels] = useState<Record<string, AuthFileModelItem[]>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const excludedUnsupportedRef = useRef(false);
|
||||||
|
const mappingsUnsupportedRef = useRef(false);
|
||||||
|
|
||||||
|
const providerList = useMemo(() => {
|
||||||
|
const providers = new Set<string>();
|
||||||
|
|
||||||
|
Object.keys(modelAlias).forEach((provider) => {
|
||||||
|
const key = provider.trim().toLowerCase();
|
||||||
|
if (key) providers.add(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (typeof file.type === 'string') {
|
||||||
|
const key = file.type.trim().toLowerCase();
|
||||||
|
if (key) providers.add(key);
|
||||||
|
}
|
||||||
|
if (typeof file.provider === 'string') {
|
||||||
|
const key = file.provider.trim().toLowerCase();
|
||||||
|
if (key) providers.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(providers);
|
||||||
|
}, [files, modelAlias]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewMode !== 'diagram') return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadAllModels = async () => {
|
||||||
|
if (providerList.length === 0) {
|
||||||
|
if (!cancelled) setAllProviderModels({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
providerList.map(async (provider) => {
|
||||||
|
try {
|
||||||
|
const models = await authFilesApi.getModelDefinitions(provider);
|
||||||
|
return { provider, models };
|
||||||
|
} catch {
|
||||||
|
return { provider, models: [] as AuthFileModelItem[] };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const nextModels: Record<string, AuthFileModelItem[]> = {};
|
||||||
|
results.forEach(({ provider, models }) => {
|
||||||
|
if (models.length > 0) {
|
||||||
|
nextModels[provider] = models;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setAllProviderModels(nextModels);
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadAllModels();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [providerList, viewMode]);
|
||||||
|
|
||||||
|
const loadExcluded = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFilesApi.getOauthExcludedModels();
|
||||||
|
excludedUnsupportedRef.current = false;
|
||||||
|
setExcluded(res || {});
|
||||||
|
setExcludedError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status =
|
||||||
|
typeof err === 'object' && err !== null && 'status' in err
|
||||||
|
? (err as { status?: unknown }).status
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
setExcluded({});
|
||||||
|
setExcludedError('unsupported');
|
||||||
|
if (!excludedUnsupportedRef.current) {
|
||||||
|
excludedUnsupportedRef.current = true;
|
||||||
|
showNotification(t('oauth_excluded.upgrade_required'), 'warning');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
|
}, [showNotification, t]);
|
||||||
|
|
||||||
|
const loadModelAlias = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFilesApi.getOauthModelAlias();
|
||||||
|
mappingsUnsupportedRef.current = false;
|
||||||
|
setModelAlias(res || {});
|
||||||
|
setModelAliasError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status =
|
||||||
|
typeof err === 'object' && err !== null && 'status' in err
|
||||||
|
? (err as { status?: unknown }).status
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
setModelAlias({});
|
||||||
|
setModelAliasError('unsupported');
|
||||||
|
if (!mappingsUnsupportedRef.current) {
|
||||||
|
mappingsUnsupportedRef.current = true;
|
||||||
|
showNotification(t('oauth_model_alias.upgrade_required'), 'warning');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
|
}, [showNotification, t]);
|
||||||
|
|
||||||
|
const deleteExcluded = useCallback(
|
||||||
|
(provider: string) => {
|
||||||
|
const providerLabel = provider.trim() || provider;
|
||||||
|
showConfirmation({
|
||||||
|
title: t('oauth_excluded.delete_title', { defaultValue: 'Delete Exclusion' }),
|
||||||
|
message: t('oauth_excluded.delete_confirm', { provider: providerLabel }),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
const providerKey = normalizeProviderKey(provider);
|
||||||
|
if (!providerKey) {
|
||||||
|
showNotification(t('oauth_excluded.provider_required'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await authFilesApi.deleteOauthExcludedEntry(providerKey);
|
||||||
|
await loadExcluded();
|
||||||
|
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
try {
|
||||||
|
const current = await authFilesApi.getOauthExcludedModels();
|
||||||
|
const next: Record<string, string[]> = {};
|
||||||
|
Object.entries(current).forEach(([key, models]) => {
|
||||||
|
if (normalizeProviderKey(key) === providerKey) return;
|
||||||
|
next[key] = models;
|
||||||
|
});
|
||||||
|
await authFilesApi.replaceOauthExcludedModels(next);
|
||||||
|
await loadExcluded();
|
||||||
|
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||||||
|
} catch (fallbackErr: unknown) {
|
||||||
|
const errorMessage =
|
||||||
|
fallbackErr instanceof Error
|
||||||
|
? fallbackErr.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: '';
|
||||||
|
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadExcluded, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteModelAlias = useCallback(
|
||||||
|
(provider: string) => {
|
||||||
|
showConfirmation({
|
||||||
|
title: t('oauth_model_alias.delete_title', { defaultValue: 'Delete Mappings' }),
|
||||||
|
message: t('oauth_model_alias.delete_confirm', { provider }),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await authFilesApi.deleteOauthModelAlias(provider);
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.delete_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadModelAlias, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMappingUpdate = useCallback(
|
||||||
|
async (provider: string, sourceModel: string, newAlias: string) => {
|
||||||
|
if (!provider || !sourceModel || !newAlias) return;
|
||||||
|
const normalizedProvider = normalizeProviderKey(provider);
|
||||||
|
if (!normalizedProvider) return;
|
||||||
|
|
||||||
|
const providerKey = Object.keys(modelAlias).find(
|
||||||
|
(key) => normalizeProviderKey(key) === normalizedProvider
|
||||||
|
);
|
||||||
|
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||||||
|
|
||||||
|
const nameTrim = sourceModel.trim();
|
||||||
|
const aliasTrim = newAlias.trim();
|
||||||
|
const nameKey = nameTrim.toLowerCase();
|
||||||
|
const aliasKey = aliasTrim.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentMappings.some(
|
||||||
|
(m) =>
|
||||||
|
(m.name ?? '').trim().toLowerCase() === nameKey &&
|
||||||
|
(m.alias ?? '').trim().toLowerCase() === aliasKey
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMappings: OAuthModelAliasEntry[] = [
|
||||||
|
...currentMappings,
|
||||||
|
{ name: nameTrim, alias: aliasTrim, fork: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteLink = useCallback(
|
||||||
|
(provider: string, sourceModel: string, alias: string) => {
|
||||||
|
const nameTrim = sourceModel.trim();
|
||||||
|
const aliasTrim = alias.trim();
|
||||||
|
if (!provider || !nameTrim || !aliasTrim) return;
|
||||||
|
|
||||||
|
showConfirmation({
|
||||||
|
title: t('oauth_model_alias.delete_link_title', { defaultValue: 'Unlink mapping' }),
|
||||||
|
message: (
|
||||||
|
<Trans
|
||||||
|
i18nKey="oauth_model_alias.delete_link_confirm"
|
||||||
|
values={{ provider, sourceModel: nameTrim, alias: aliasTrim }}
|
||||||
|
components={{ code: <code /> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
const normalizedProvider = normalizeProviderKey(provider);
|
||||||
|
const providerKey = Object.keys(modelAlias).find(
|
||||||
|
(key) => normalizeProviderKey(key) === normalizedProvider
|
||||||
|
);
|
||||||
|
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||||||
|
const nameKey = nameTrim.toLowerCase();
|
||||||
|
const aliasKey = aliasTrim.toLowerCase();
|
||||||
|
const nextMappings = currentMappings.filter(
|
||||||
|
(m) =>
|
||||||
|
(m.name ?? '').trim().toLowerCase() !== nameKey ||
|
||||||
|
(m.alias ?? '').trim().toLowerCase() !== aliasKey
|
||||||
|
);
|
||||||
|
if (nextMappings.length === currentMappings.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (nextMappings.length === 0) {
|
||||||
|
await authFilesApi.deleteOauthModelAlias(normalizedProvider);
|
||||||
|
} else {
|
||||||
|
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||||||
|
}
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleFork = useCallback(
|
||||||
|
async (provider: string, sourceModel: string, alias: string, fork: boolean) => {
|
||||||
|
const normalizedProvider = normalizeProviderKey(provider);
|
||||||
|
if (!normalizedProvider) return;
|
||||||
|
|
||||||
|
const providerKey = Object.keys(modelAlias).find(
|
||||||
|
(key) => normalizeProviderKey(key) === normalizedProvider
|
||||||
|
);
|
||||||
|
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||||||
|
const nameKey = sourceModel.trim().toLowerCase();
|
||||||
|
const aliasKey = alias.trim().toLowerCase();
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const nextMappings = currentMappings.map((m) => {
|
||||||
|
const mName = (m.name ?? '').trim().toLowerCase();
|
||||||
|
const mAlias = (m.alias ?? '').trim().toLowerCase();
|
||||||
|
if (mName === nameKey && mAlias === aliasKey) {
|
||||||
|
changed = true;
|
||||||
|
return fork ? { ...m, fork: true } : { name: m.name, alias: m.alias };
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!changed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRenameAlias = useCallback(
|
||||||
|
async (oldAlias: string, newAlias: string) => {
|
||||||
|
const oldTrim = oldAlias.trim();
|
||||||
|
const newTrim = newAlias.trim();
|
||||||
|
if (!oldTrim || !newTrim || oldTrim === newTrim) return;
|
||||||
|
|
||||||
|
const oldKey = oldTrim.toLowerCase();
|
||||||
|
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
|
||||||
|
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === oldKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (providersToUpdate.length === 0) return;
|
||||||
|
|
||||||
|
let hadFailure = false;
|
||||||
|
let failureMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
providersToUpdate.map(([provider, mappings]) => {
|
||||||
|
const nextMappings = mappings.map((m) =>
|
||||||
|
(m.alias ?? '').trim().toLowerCase() === oldKey ? { ...m, alias: newTrim } : m
|
||||||
|
);
|
||||||
|
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const failures = results.filter(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === 'rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
hadFailure = true;
|
||||||
|
const reason = failures[0].reason;
|
||||||
|
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await loadModelAlias();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadFailure) {
|
||||||
|
showNotification(
|
||||||
|
failureMessage
|
||||||
|
? `${t('oauth_model_alias.save_failed')}: ${failureMessage}`
|
||||||
|
: t('oauth_model_alias.save_failed'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteAlias = useCallback(
|
||||||
|
(aliasName: string) => {
|
||||||
|
const aliasTrim = aliasName.trim();
|
||||||
|
if (!aliasTrim) return;
|
||||||
|
const aliasKey = aliasTrim.toLowerCase();
|
||||||
|
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
|
||||||
|
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === aliasKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (providersToUpdate.length === 0) return;
|
||||||
|
|
||||||
|
showConfirmation({
|
||||||
|
title: t('oauth_model_alias.delete_alias_title', { defaultValue: 'Delete Alias' }),
|
||||||
|
message: (
|
||||||
|
<Trans
|
||||||
|
i18nKey="oauth_model_alias.delete_alias_confirm"
|
||||||
|
values={{ alias: aliasTrim }}
|
||||||
|
components={{ code: <code /> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
let hadFailure = false;
|
||||||
|
let failureMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
providersToUpdate.map(([provider, mappings]) => {
|
||||||
|
const nextMappings = mappings.filter(
|
||||||
|
(m) => (m.alias ?? '').trim().toLowerCase() !== aliasKey
|
||||||
|
);
|
||||||
|
if (nextMappings.length === 0) {
|
||||||
|
return authFilesApi.deleteOauthModelAlias(provider);
|
||||||
|
}
|
||||||
|
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const failures = results.filter(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === 'rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
hadFailure = true;
|
||||||
|
const reason = failures[0].reason;
|
||||||
|
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await loadModelAlias();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadFailure) {
|
||||||
|
showNotification(
|
||||||
|
failureMessage
|
||||||
|
? `${t('oauth_model_alias.delete_failed')}: ${failureMessage}`
|
||||||
|
: t('oauth_model_alias.delete_failed'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showNotification(t('oauth_model_alias.delete_success'), 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
excluded,
|
||||||
|
excludedError,
|
||||||
|
modelAlias,
|
||||||
|
modelAliasError,
|
||||||
|
allProviderModels,
|
||||||
|
providerList,
|
||||||
|
loadExcluded,
|
||||||
|
loadModelAlias,
|
||||||
|
deleteExcluded,
|
||||||
|
deleteModelAlias,
|
||||||
|
handleMappingUpdate,
|
||||||
|
handleDeleteLink,
|
||||||
|
handleToggleFork,
|
||||||
|
handleRenameAlias,
|
||||||
|
handleDeleteAlias
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
254
src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { authFilesApi } from '@/services/api';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import { formatFileSize } from '@/utils/format';
|
||||||
|
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
|
||||||
|
import {
|
||||||
|
normalizeExcludedModels,
|
||||||
|
parseDisableCoolingValue,
|
||||||
|
parseExcludedModelsText,
|
||||||
|
parsePriorityValue
|
||||||
|
} from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
export type PrefixProxyEditorField =
|
||||||
|
| 'prefix'
|
||||||
|
| 'proxyUrl'
|
||||||
|
| 'priority'
|
||||||
|
| 'excludedModelsText'
|
||||||
|
| 'disableCooling';
|
||||||
|
|
||||||
|
export type PrefixProxyEditorState = {
|
||||||
|
fileName: string;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
originalText: string;
|
||||||
|
rawText: string;
|
||||||
|
json: Record<string, unknown> | null;
|
||||||
|
prefix: string;
|
||||||
|
proxyUrl: string;
|
||||||
|
priority: string;
|
||||||
|
excludedModelsText: string;
|
||||||
|
disableCooling: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesPrefixProxyEditorOptions = {
|
||||||
|
disableControls: boolean;
|
||||||
|
loadFiles: () => Promise<void>;
|
||||||
|
loadKeyStats: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesPrefixProxyEditorResult = {
|
||||||
|
prefixProxyEditor: PrefixProxyEditorState | null;
|
||||||
|
prefixProxyUpdatedText: string;
|
||||||
|
prefixProxyDirty: boolean;
|
||||||
|
openPrefixProxyEditor: (name: string) => Promise<void>;
|
||||||
|
closePrefixProxyEditor: () => void;
|
||||||
|
handlePrefixProxyChange: (field: PrefixProxyEditorField, value: string) => void;
|
||||||
|
handlePrefixProxySave: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): string => {
|
||||||
|
if (!editor?.json) return editor?.rawText ?? '';
|
||||||
|
const next: Record<string, unknown> = { ...editor.json };
|
||||||
|
if ('prefix' in next || editor.prefix.trim()) {
|
||||||
|
next.prefix = editor.prefix;
|
||||||
|
}
|
||||||
|
if ('proxy_url' in next || editor.proxyUrl.trim()) {
|
||||||
|
next.proxy_url = editor.proxyUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedPriority = parsePriorityValue(editor.priority);
|
||||||
|
if (parsedPriority !== undefined) {
|
||||||
|
next.priority = parsedPriority;
|
||||||
|
} else if ('priority' in next) {
|
||||||
|
delete next.priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
const excludedModels = parseExcludedModelsText(editor.excludedModelsText);
|
||||||
|
if (excludedModels.length > 0) {
|
||||||
|
next.excluded_models = excludedModels;
|
||||||
|
} else if ('excluded_models' in next) {
|
||||||
|
delete next.excluded_models;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDisableCooling = parseDisableCoolingValue(editor.disableCooling);
|
||||||
|
if (parsedDisableCooling !== undefined) {
|
||||||
|
next.disable_cooling = parsedDisableCooling;
|
||||||
|
} else if ('disable_cooling' in next) {
|
||||||
|
delete next.disable_cooling;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesPrefixProxyEditor(
|
||||||
|
options: UseAuthFilesPrefixProxyEditorOptions
|
||||||
|
): UseAuthFilesPrefixProxyEditorResult {
|
||||||
|
const { disableControls, loadFiles, loadKeyStats } = options;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||||
|
|
||||||
|
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
|
||||||
|
|
||||||
|
const prefixProxyUpdatedText = buildPrefixProxyUpdatedText(prefixProxyEditor);
|
||||||
|
const prefixProxyDirty =
|
||||||
|
Boolean(prefixProxyEditor?.json) &&
|
||||||
|
Boolean(prefixProxyEditor?.originalText) &&
|
||||||
|
prefixProxyUpdatedText !== prefixProxyEditor?.originalText;
|
||||||
|
|
||||||
|
const closePrefixProxyEditor = () => {
|
||||||
|
setPrefixProxyEditor(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPrefixProxyEditor = async (name: string) => {
|
||||||
|
if (disableControls) return;
|
||||||
|
if (prefixProxyEditor?.fileName === name) {
|
||||||
|
setPrefixProxyEditor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrefixProxyEditor({
|
||||||
|
fileName: name,
|
||||||
|
loading: true,
|
||||||
|
saving: false,
|
||||||
|
error: null,
|
||||||
|
originalText: '',
|
||||||
|
rawText: '',
|
||||||
|
json: null,
|
||||||
|
prefix: '',
|
||||||
|
proxyUrl: '',
|
||||||
|
priority: '',
|
||||||
|
excludedModelsText: '',
|
||||||
|
disableCooling: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawText = await authFilesApi.downloadText(name);
|
||||||
|
const trimmed = rawText.trim();
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: t('auth_files.prefix_proxy_invalid_json'),
|
||||||
|
rawText: trimmed,
|
||||||
|
originalText: trimmed
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: t('auth_files.prefix_proxy_invalid_json'),
|
||||||
|
rawText: trimmed,
|
||||||
|
originalText: trimmed
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = parsed as Record<string, unknown>;
|
||||||
|
const originalText = JSON.stringify(json);
|
||||||
|
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
|
||||||
|
const proxyUrl = typeof json.proxy_url === 'string' ? json.proxy_url : '';
|
||||||
|
const priority = parsePriorityValue(json.priority);
|
||||||
|
const excludedModels = normalizeExcludedModels(json.excluded_models);
|
||||||
|
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
|
||||||
|
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
originalText,
|
||||||
|
rawText: originalText,
|
||||||
|
json,
|
||||||
|
prefix,
|
||||||
|
proxyUrl,
|
||||||
|
priority: priority !== undefined ? String(priority) : '',
|
||||||
|
excludedModelsText: excludedModels.join('\n'),
|
||||||
|
disableCooling:
|
||||||
|
disableCoolingValue === undefined ? '' : disableCoolingValue ? 'true' : 'false',
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : t('notification.download_failed');
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return { ...prev, loading: false, error: errorMessage, rawText: '' };
|
||||||
|
});
|
||||||
|
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrefixProxyChange = (field: PrefixProxyEditorField, value: string) => {
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
if (field === 'prefix') return { ...prev, prefix: value };
|
||||||
|
if (field === 'proxyUrl') return { ...prev, proxyUrl: value };
|
||||||
|
if (field === 'priority') return { ...prev, priority: value };
|
||||||
|
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: value };
|
||||||
|
return { ...prev, disableCooling: value };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrefixProxySave = async () => {
|
||||||
|
if (!prefixProxyEditor?.json) return;
|
||||||
|
if (!prefixProxyDirty) return;
|
||||||
|
|
||||||
|
const name = prefixProxyEditor.fileName;
|
||||||
|
const payload = prefixProxyUpdatedText;
|
||||||
|
const fileSize = new Blob([payload]).size;
|
||||||
|
if (fileSize > MAX_AUTH_FILE_SIZE) {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return { ...prev, saving: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = new File([payload], name, { type: 'application/json' });
|
||||||
|
await authFilesApi.upload(file);
|
||||||
|
showNotification(t('auth_files.prefix_proxy_saved_success', { name }), 'success');
|
||||||
|
await loadFiles();
|
||||||
|
await loadKeyStats();
|
||||||
|
setPrefixProxyEditor(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error');
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return { ...prev, saving: false };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefixProxyEditor,
|
||||||
|
prefixProxyUpdatedText,
|
||||||
|
prefixProxyDirty,
|
||||||
|
openPrefixProxyEditor,
|
||||||
|
closePrefixProxyEditor,
|
||||||
|
handlePrefixProxyChange,
|
||||||
|
handlePrefixProxySave
|
||||||
|
};
|
||||||
|
}
|
||||||
35
src/features/authFiles/hooks/useAuthFilesStats.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { usageApi } from '@/services/api';
|
||||||
|
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||||
|
|
||||||
|
export type UseAuthFilesStatsResult = {
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
loadKeyStats: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesStats(): UseAuthFilesStatsResult {
|
||||||
|
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
||||||
|
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||||
|
const loadingKeyStatsRef = useRef(false);
|
||||||
|
|
||||||
|
const loadKeyStats = useCallback(async () => {
|
||||||
|
if (loadingKeyStatsRef.current) return;
|
||||||
|
loadingKeyStatsRef.current = true;
|
||||||
|
try {
|
||||||
|
const usageResponse = await usageApi.getUsage();
|
||||||
|
const usageData = usageResponse?.usage ?? usageResponse;
|
||||||
|
const stats = await usageApi.getKeyStats(usageData);
|
||||||
|
setKeyStats(stats);
|
||||||
|
const details = collectUsageDetails(usageData);
|
||||||
|
setUsageDetails(details);
|
||||||
|
} catch {
|
||||||
|
// 静默失败
|
||||||
|
} finally {
|
||||||
|
loadingKeyStatsRef.current = false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { keyStats, usageDetails, loadKeyStats };
|
||||||
|
}
|
||||||
|
|
||||||
28
src/features/authFiles/hooks/useAuthFilesStatusBarCache.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { calculateStatusBarData, type UsageDetail } from '@/utils/usage';
|
||||||
|
import { normalizeAuthIndexValue } from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
export type AuthFileStatusBarData = ReturnType<typeof calculateStatusBarData>;
|
||||||
|
|
||||||
|
export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails: UsageDetail[]) {
|
||||||
|
return useMemo(() => {
|
||||||
|
const cache = new Map<string, AuthFileStatusBarData>();
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
|
||||||
|
if (authIndexKey) {
|
||||||
|
const filteredDetails = usageDetails.filter((detail) => {
|
||||||
|
const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index);
|
||||||
|
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
|
||||||
|
});
|
||||||
|
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}, [files, usageDetails]);
|
||||||
|
}
|
||||||
|
|
||||||
30
src/features/authFiles/uiState.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export type AuthFilesUiState = {
|
||||||
|
filter?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
|
||||||
|
|
||||||
|
export const readAuthFilesUiState = (): AuthFilesUiState | null => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(AUTH_FILES_UI_STATE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as AuthFilesUiState;
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeAuthFilesUiState = (state: AuthFilesUiState) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(AUTH_FILES_UI_STATE_KEY, JSON.stringify(state));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||