Compare commits

...

100 Commits
v1.4.2 ... main

Author SHA1 Message Date
Supra4E8C
4f8b421d68 feat(claude-edit): implement draft state management for Claude provider editor 2026-02-17 13:31:24 +08:00
Supra4E8C
3769447604 feat(ai-providers): add Claude model discovery and connectivity test 2026-02-17 01:22:45 +08:00
Supra4E8C
7d4c400084 fix(config): show diff chunks with line numbers and context 2026-02-17 00:01:54 +08:00
Supra4E8C
32bf103f15 fix(ui): add batch bar exit animation and chunked diff cards 2026-02-16 23:51:23 +08:00
Supra4E8C
47c3874244 fix(auth-files): polish selection UI and animate batch action bar 2026-02-16 23:13:26 +08:00
Supra4E8C
b7794a91b4 feat(config): add YAML diff confirmation before save 2026-02-16 22:04:55 +08:00
Supra4E8C
470ff51579 feat(auth-files): add bulk select, status toggle, and delete actions 2026-02-16 21:58:22 +08:00
Supra4E8C
d09ea6aeab feat(config): add secure API key generator in visual editor 2026-02-16 21:49:45 +08:00
Supra4E8C
8a4eb267f0 fix(QuotaSection): update MAX_ITEMS_PER_PAGE from 14 to 25 for improved pagination 2026-02-16 19:49:53 +08:00
Supra4E8C
63db0b11bc chore(release): update Node.js version from 18 to 20 in workflow 2026-02-15 12:33:57 +08:00
Supra4E8C
8dfa71b81e feat(AiProviders): refactor layout for upstream API key display 2026-02-15 11:59:59 +08:00
Supra4E8C
d140fe1061 feat(AiProviders): improve layout and styling for OpenAI edit and models pages 2026-02-14 20:35:16 +08:00
Supra4E8C
b702cd6e4c feat(OAuthPage): enhance layout and styling for better user experience 2026-02-14 20:32:31 +08:00
Supra4E8C
211f9f280c feat(usage): increase font size of health title for better visibility 2026-02-14 20:18:53 +08:00
Supra4E8C
6d96c92233 feat(edit-page): replace select element with custom Select component for model testing 2026-02-14 20:11:12 +08:00
Supra4E8C
52cf9d86c0 feat(usage): enhance ServiceHealthCard with scrollable health grid 2026-02-14 15:03:02 +08:00
Supra4E8C
a2507b1373 feat(usage): add service health card with 7-day contribution grid 2026-02-14 14:57:06 +08:00
Supra4E8C
1f8c4331c7 feat(status-bar): add gradient colors and tooltip with mobile support 2026-02-14 13:24:53 +08:00
Supra4E8C
faadc3ea3e refactor(select): unify dropdown implementations 2026-02-14 12:50:03 +08:00
Supra4E8C
32b576123c feat(usage): use modal dialog for editing model prices 2026-02-14 12:09:02 +08:00
Supra4E8C
5dce24e3ea feat(select): implement custom Select component with dropdown functionality 2026-02-14 12:01:11 +08:00
Supra4E8C
bf824f8561 fix(clipboard): add shared helper and remove lint warnings 2026-02-14 03:33:09 +08:00
Supra4E8C
3a7ddfdff1 fix(clipboard): add fallback helper and unify copy actions 2026-02-14 03:25:33 +08:00
Supra4E8C
431ec1e0f5 fix(theme): improve dark mode contrast and enforce white button text 2026-02-14 03:06:07 +08:00
Supra4E8C
e2368ddfd7 Refactor color variables and styles across components for a cohesive design update
- Updated active state colors in ToastSelect component for better visibility.
- Adjusted box-shadow and border colors in ModelMappingDiagram styles.
- Changed provider colors in ModelMappingDiagram for improved aesthetics.
- Modified background and border styles in ProviderNav for a more modern look.
- Updated accent colors in StatCards to align with new color scheme.
- Refined token colors in TokenBreakdownChart for consistency.
- Adjusted sparkline colors in useSparklines hook to match new design.
- Changed error icon color in AiProvidersOpenAIEditPage for better contrast.
- Updated failure badge styles in AiProvidersPage for a cleaner appearance.
- Refined various status styles in AuthFilesPage for improved clarity.
- Updated colors in ConfigPage to use new variable definitions.
- Refined error and warning styles in LoginPage for better user feedback.
- Adjusted log status colors in LogsPage for consistency with new theme.
- Updated OAuthPage styles to reflect new color variables.
- Refined quota styles in QuotaPage for better visual hierarchy.
- Updated system page styles for improved user experience.
- Adjusted usage page styles to align with new design language.
- Refactored component styles to use new color variables in components.scss.
- Updated layout styles to reflect new primary color definitions.
- Refined theme colors in themes.scss for a more cohesive look.
- Updated color variables in variables.scss to reflect new design choices.
- Adjusted chart colors in usage.ts for consistency with new color scheme.
2026-02-14 02:25:58 +08:00
Supra4E8C
6f4bc7c3bb fix(format): use page locale by default 2026-02-14 00:26:54 +08:00
Supra4E8C
3937a403b1 fix(i18n): localize splash strings 2026-02-14 00:24:52 +08:00
Supra4E8C
f003a34dc0 fix(auth-files): unify max auth file size 2026-02-14 00:19:04 +08:00
Supra4E8C
dc4ceabc7b refactor(api): centralize url normalization 2026-02-14 00:16:14 +08:00
Supra4E8C
e13d7f5e0f refactor(auth-files): split AuthFilesPage 2026-02-14 00:11:41 +08:00
Supra4E8C
03a1644df7 chore(build): bump Vite build target to ES2020 and update compatibility docs 2026-02-13 22:41:53 +08:00
Supra4E8C
9a6a8ba7fa docs: update README for v6.8.x and add missing section 2026-02-13 20:56:29 +08:00
Supra4E8C
3b886e47d2 chore: add MIT License file 2026-02-13 20:41:10 +08:00
Supra4E8C
06201a9fc4 feat(ai-providers): add Gemini proxy URL support in provider edit UI 2026-02-13 20:38:54 +08:00
Supra4E8C
ef448806aa Merge pull request #104 from moxi000/dev
feat(quota): support dynamic Codex additional limits and i18n
2026-02-13 20:22:19 +08:00
moxi
8a33f5ab55 fix(quota): use i18n params for additional limits and keep primary/secondary mapping 2026-02-13 19:52:38 +08:00
Supra4E8C
ab3922f9e6 fix(usage): make api details card scrollable 2026-02-13 16:13:15 +08:00
Supra4E8C
5dbff4c3e0 fix(usage): make model stats card scrollable 2026-02-13 16:11:28 +08:00
Supra4E8C
4dde62ac58 chore(usage): remove unused formatTokensInMillions 2026-02-13 15:59:07 +08:00
Supra4E8C
1d3335746b fix(usage): aggregate openai provider credential stats 2026-02-13 15:58:01 +08:00
Supra4E8C
c6d00e8b3f fix(usage): make sorting and api expansion keyboard accessible 2026-02-13 15:27:16 +08:00
Supra4E8C
9ef7d439d2 fix(usage): update chart labels when locale changes 2026-02-13 15:24:00 +08:00
Supra4E8C
c53a231c41 fix(usage): include auth-index-only usage in credential stats 2026-02-13 15:21:16 +08:00
Supra4E8C
705e6dac54 feat(usage): match credentials by source ID using config store props 2026-02-13 15:06:31 +08:00
Supra4E8C
daef2521f1 feat(usage): resolve provider-based auth_index via SHA-256 matching
Fetch all provider configs (Gemini, Claude, Codex, Vertex, OpenAI) and
compute SHA-256 auth_index from their API keys to map unresolved
credential entries to friendly provider names.
2026-02-13 14:08:25 +08:00
moxi
0640edc9c9 fix(quota): avoid fallback mislabeling for additional codex limits 2026-02-13 13:58:49 +08:00
moxi
7068588c58 feat(quota): support dynamic codex additional limits with i18n 2026-02-13 13:52:41 +08:00
Supra4E8C
de0753f0ce feat(usage): resolve credential names from auth files by auth_index 2026-02-13 13:44:12 +08:00
Supra4E8C
d027d04f64 feat(usage): use adaptive token format instead of fixed millions 2026-02-13 13:35:33 +08:00
Supra4E8C
c4ca9be7b5 feat(usage): add last refresh timestamp in header 2026-02-13 13:33:47 +08:00
Supra4E8C
180a4ccab4 feat(usage): add cost trend chart with hourly/daily toggle 2026-02-13 13:31:36 +08:00
Supra4E8C
78512f8039 feat(usage): add token type breakdown stacked chart 2026-02-13 13:29:21 +08:00
Supra4E8C
7cdede6de8 feat(usage): add success rate column to model stats table 2026-02-13 13:26:27 +08:00
Supra4E8C
7ec5329576 feat(usage): add column sorting to model stats and API details tables 2026-02-13 13:25:03 +08:00
Supra4E8C
5d0232e5de feat(usage): add credential (auth index) breakdown card 2026-02-13 13:23:09 +08:00
Supra4E8C
15c5f742f4 feat(auth-files): support editing priority/excluded_models/disable_cooling and localize auth field editor 2026-02-13 12:13:20 +08:00
Supra4E8C
b4cd8c946d Improve AuthFilesPage filter tag alignment and count typography 2026-02-13 00:55:25 +08:00
Supra4E8C
ee9b9f6e14 Align status bar comments with implemented time window 2026-02-12 23:58:23 +08:00
Supra4E8C
01abe3dc02 Handle clipboard copy failures in auth files page 2026-02-12 23:58:11 +08:00
Supra4E8C
b957d05636 Localize visual config select option labels 2026-02-12 23:57:02 +08:00
Supra4E8C
2a4ccff96e Prevent overlapping log auto-refresh requests 2026-02-12 23:54:26 +08:00
Supra4E8C
b5f869ed25 Fix wildcard exclusion regex escaping in auth files 2026-02-12 23:53:44 +08:00
Supra4E8C
50c1b0f4b3 feat(usage): replace time-range select with custom dropdown 2026-02-12 22:25:38 +08:00
Supra4E8C
887600c03a feat(usage): add time range filter for stats and charts 2026-02-12 21:35:59 +08:00
Supra4E8C
0fdebacc0b feat(usage): persist chart line selections in localStorage 2026-02-12 20:45:56 +08:00
Supra4E8C
4d5bb7e575 fix(config-editor): preserve comments when saving config.yaml in visual mode 2026-02-12 20:26:38 +08:00
Supra4E8C
2d841c0a2f fix(provider-list): Modify the keyField function to support index parameters and ensure uniqueness
fix(ai-providers): Optimize configuration synchronization logic in OpenAI editing layout
2026-02-12 16:36:44 +08:00
Supra4E8C
e40c3488fe Merge pull request #98 from razorback16/main
feat(quota): add Claude OAuth usage quota detection
2026-02-12 15:50:42 +08:00
Supra4E8C
04686aafc8 fix(ai-providers): stabilize OpenAI key test state during editing 2026-02-12 15:46:00 +08:00
Supra4E8C
9476afc41c Merge pull request #102 from moxi000/feat/openai-ui-ux-optimization
feat(ai-providers): 优化 OpenAI 编辑页 UI,支持批量与按 Key 单独测试模型连通性
2026-02-12 15:23:11 +08:00
moxi
ab6a1a412c fix(ai-providers): 统一 OpenAI key 表头与内容居中对齐 2026-02-12 00:08:10 +08:00
moxi
2cf1e23351 fix(ai-providers): 修复 OpenAI 密钥测试状态与共享样式回归 2026-02-11 23:51:53 +08:00
moxi
0089d4a705 chore: 同步 package-lock 以匹配依赖变更 2026-02-11 23:34:45 +08:00
moxi
c726fbc379 feat(ai-providers): 优化 OpenAI 编辑页 UI 交互与对齐 2026-02-11 23:31:43 +08:00
Razorback16
83f6a1a9f9 feat(quota): add Claude OAuth usage quota detection
Add Claude quota section to the Quota Management page, using the
Anthropic OAuth usage API (api.anthropic.com/api/oauth/usage) to
display utilization across all rate limit windows (5-hour, 7-day,
Opus, Sonnet, etc.) and extra usage credits.
2026-02-09 14:12:07 -08:00
LTbinglingfeng
027ab483d4 refactor(providers): remove deprecated AI provider modal implementations and unused modal types 2026-02-09 00:54:24 +08:00
LTbinglingfeng
535c303aec fix(ai-providers): enforce required provider name for OpenAI-compatible save 2026-02-09 00:21:56 +08:00
LTbinglingfeng
6c2cd761ba refactor(core): harden API parsing and improve type safety 2026-02-08 09:42:00 +08:00
LTbinglingfeng
3783bec983 fix(auth-files): refresh OAuth excluded/model-alias state when returning to Auth Files page 2026-02-07 22:37:12 +08:00
Supra4E8C
b90239d39c Merge pull request #95 from router-for-me/revert-93-claude-quota
Revert "feat(ui): added claude quota display"
2026-02-07 14:01:16 +08:00
Supra4E8C
f8d66917fd Revert "feat(ui): added claude quota display" 2026-02-07 14:00:50 +08:00
LTbinglingfeng
36bfd0fa6a chore(i18n): align en/ru OAuth disablement wording with updated zh-CN copy 2026-02-07 12:43:56 +08:00
LTbinglingfeng
709ce4c8dd feat(config): warn restart required when commercial mode changes 2026-02-07 12:31:17 +08:00
LTbinglingfeng
525b152a76 fix(config): preserve mobile scroll after API key modal close and add one-click key copy 2026-02-07 12:22:16 +08:00
LTbinglingfeng
e053854544 feat(system): redesign system info page and move request-log controls from layout footer 2026-02-07 12:03:40 +08:00
LTbinglingfeng
0b54b6de64 fix(auth-files): add Kimi to OAuth quick-fill provider tags 2026-02-07 10:57:52 +08:00
hkfires
0c8686cefa feat(i18n): update OAuth exclusion terminology to "禁用" for clarity 2026-02-07 07:52:12 +08:00
LTbinglingfeng
385117d01a fix(i18n): switch language via popover menu and complete Russian Kimi translations 2026-02-07 01:13:11 +08:00
LTbinglingfeng
700bff1d03 fix(i18n): harden language switching and enforce language list consistency 2026-02-07 00:43:36 +08:00
Supra4E8C
680b24026c Merge pull request #91 from unchase/feat/ru-localization
Feat: Add Russian localization
2026-02-07 00:24:35 +08:00
LTbinglingfeng
2da4099d0b feat(oauth): add kimi provider support 2026-02-06 23:35:47 +08:00
LTbinglingfeng
8acef95e5a add .gitignore 2026-02-06 22:43:50 +08:00
LTbinglingfeng
c892d939c7 feat(quota-ui): normalize Gemini vertex quota groups and streamline auth card refresh UX 2026-02-06 22:28:01 +08:00
Chebotov Nickolay
50ab96c3ed feat: add language dropdown 2026-02-06 15:20:25 +03:00
Chebotov Nickolay
0bb8090686 fix: address language review feedback 2026-02-06 15:08:53 +03:00
LTbinglingfeng
cade2647d6 feat(quota): add normalization for Gemini CLI model IDs and update quota groups 2026-02-06 19:11:57 +08:00
LTbinglingfeng
3661530f5f fix(ui): make payload visual editor responsive on mobile 2026-02-06 18:38:37 +08:00
Chebotov Nickolay
d5ccef8b24 chore: restore package lock 2026-02-06 12:29:23 +03:00
Chebotov Nickolay
ad6a3bd732 feat: expand Russian localization 2026-02-06 12:26:46 +03:00
Chebotov Nickolay
ad1387d076 feat(i18n): add Russian locale and enable 'ru' language; translate core keys to Russian 2026-02-06 11:26:32 +03:00
155 changed files with 12977 additions and 5547 deletions

View File

@@ -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: '^_' }],
},
};

View File

@@ -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
View File

@@ -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
View 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.

View File

@@ -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 isnt) ## What this is (and isnt)
- 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`).

View File

@@ -1,14 +1,14 @@
# CLI Proxy API 管理中心 # CLI Proxy API 管理中心
用于管理与排障 **CLI Proxy API** 的单文件 WebUIReact + TypeScript通过 **Management API** 完成配置、凭据、日志与统计等运维工作。 用于管理与故障排查 **CLI Proxy API** 的单文件 Web UIReact + 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状态管理
- AxiosHTTP 客户端)
- react-router-dom v7HashRouter
- Chart.js数据可视化
- CodeMirror 6YAML 编辑器)
- 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`)。

53
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-yaml": "^6.1.2",
"@openai/codex": "^0.98.0", "@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",
@@ -73,7 +73,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -431,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",
@@ -468,7 +480,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"crelt": "^1.0.6", "crelt": "^1.0.6",
@@ -1244,18 +1255,6 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@openai/codex": {
"version": "0.98.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.98.0.tgz",
"integrity": "sha512-CKjrhAmzTvWn7Vbsi27iZRKBAJw9a7ZTTkWQDbLgQZP1weGbDIBk1r6wiLEp1ZmDO7w0fHPLYgnVspiOrYgcxg==",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@parcel/watcher": { "node_modules/@parcel/watcher": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
@@ -1946,7 +1945,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -2034,7 +2032,6 @@
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/scope-manager": "8.48.1",
"@typescript-eslint/types": "8.48.1", "@typescript-eslint/types": "8.48.1",
@@ -2352,7 +2349,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2402,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"
} }
}, },
@@ -2564,7 +2560,6 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@kurkle/color": "^0.3.0" "@kurkle/color": "^0.3.0"
}, },
@@ -2829,7 +2824,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3306,7 +3300,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4" "@babel/runtime": "^7.28.4"
}, },
@@ -3636,7 +3629,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3743,7 +3735,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -3761,7 +3752,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -3870,7 +3860,6 @@
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -4053,7 +4042,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -4130,7 +4118,6 @@
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -4260,7 +4247,6 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC", "license": "ISC",
"peer": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },
@@ -4288,7 +4274,6 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -13,7 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-yaml": "^6.1.2",
"@openai/codex": "^0.98.0", "@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",

View File

@@ -1,14 +1,13 @@
import { import {
ReactNode, ReactNode,
createContext,
useCallback, useCallback,
useContext,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, useState,
} from 'react'; } 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 {
@@ -27,8 +26,6 @@ const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30; const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
const IOS_EXIT_DIM_OPACITY = 0.72; const IOS_EXIT_DIM_OPACITY = 0.72;
type LayerStatus = 'current' | 'exiting' | 'stacked';
type Layer = { type Layer = {
key: string; key: string;
location: Location; location: Location;
@@ -39,16 +36,6 @@ type TransitionDirection = 'forward' | 'backward';
type TransitionVariant = 'vertical' | 'ios'; type TransitionVariant = 'vertical' | 'ios';
type PageTransitionLayerContextValue = {
status: LayerStatus;
};
const PageTransitionLayerContext = createContext<PageTransitionLayerContextValue | null>(null);
export function usePageTransitionLayer() {
return useContext(PageTransitionLayerContext);
}
export function PageTransition({ export function PageTransition({
render, render,
getRouteOrder, getRouteOrder,

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

View File

@@ -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>

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

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

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

View File

@@ -1,11 +1,14 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconChevronDown } from '@/components/ui/icons';
import { ConfigSection } from '@/components/config/ConfigSection'; import { ConfigSection } from '@/components/config/ConfigSection';
import { useNotificationStore } from '@/stores';
import styles from './VisualConfigEditor.module.scss';
import { copyToClipboard } from '@/utils/clipboard';
import type { import type {
PayloadFilterRule, PayloadFilterRule,
PayloadModelEntry, PayloadModelEntry,
@@ -78,118 +81,6 @@ function Divider() {
return <div style={{ height: 1, background: 'var(--border-color)', margin: '16px 0' }} />; return <div style={{ height: 1, background: 'var(--border-color)', margin: '16px 0' }} />;
} }
type ToastSelectOption = { value: string; label: string };
function ToastSelect({
value,
options,
disabled,
ariaLabel,
onChange,
}: {
value: string;
options: ReadonlyArray<ToastSelectOption>;
disabled?: boolean;
ariaLabel: string;
onChange: (value: string) => void;
}) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const selectedOption = options.find((opt) => opt.value === value) ?? options[0];
useEffect(() => {
if (!open) return;
const handleClickOutside = (event: MouseEvent) => {
if (!containerRef.current) return;
if (!containerRef.current.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
return (
<div ref={containerRef} style={{ position: 'relative' }}>
<button
type="button"
className="input"
disabled={disabled}
onClick={() => setOpen((prev) => !prev)}
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
cursor: disabled ? 'not-allowed' : 'pointer',
textAlign: 'left',
width: '100%',
appearance: 'none',
}}
>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
{selectedOption?.label ?? ''}
</span>
<IconChevronDown size={16} style={{ opacity: 0.6, flex: '0 0 auto' }} />
</button>
{open && !disabled && (
<div
role="listbox"
aria-label={ariaLabel}
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
left: 0,
right: 0,
zIndex: 1000,
background: 'var(--bg-primary)',
border: '1px solid var(--border-color)',
borderRadius: 12,
padding: 6,
boxShadow: 'var(--shadow)',
display: 'flex',
flexDirection: 'column',
gap: 6,
maxHeight: 260,
overflowY: 'auto',
}}
>
{options.map((opt) => {
const active = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
style={{
padding: '10px 12px',
borderRadius: 10,
border: active ? '1px solid rgba(59, 130, 246, 0.5)' : '1px solid var(--border-color)',
background: active ? 'rgba(59, 130, 246, 0.10)' : 'var(--bg-primary)',
color: 'var(--text-primary)',
cursor: 'pointer',
textAlign: 'left',
fontWeight: 600,
}}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
);
}
function ApiKeysCardEditor({ function ApiKeysCardEditor({
value, value,
disabled, disabled,
@@ -200,6 +91,7 @@ function ApiKeysCardEditor({
onChange: (nextValue: string) => void; onChange: (nextValue: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const apiKeys = useMemo( const apiKeys = useMemo(
() => () =>
value value
@@ -214,6 +106,13 @@ function ApiKeysCardEditor({
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [formError, setFormError] = useState(''); const [formError, setFormError] = useState('');
function generateSecureApiKey(): string {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const array = new Uint8Array(17);
crypto.getRandomValues(array);
return 'sk-' + Array.from(array, (b) => charset[b % charset.length]).join('');
}
const openAddModal = () => { const openAddModal = () => {
setEditingIndex(null); setEditingIndex(null);
setInputValue(''); setInputValue('');
@@ -262,6 +161,19 @@ function ApiKeysCardEditor({
closeModal(); closeModal();
}; };
const handleCopy = async (apiKey: string) => {
const copied = await copyToClipboard(apiKey);
showNotification(
t(copied ? 'notification.link_copied' : 'notification.copy_failed'),
copied ? 'success' : 'error'
);
};
const handleGenerate = () => {
setInputValue(generateSecureApiKey());
setFormError('');
};
return ( return (
<div className="form-group" style={{ marginBottom: 0 }}> <div className="form-group" style={{ marginBottom: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
@@ -293,6 +205,9 @@ function ApiKeysCardEditor({
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div> <div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
</div> </div>
<div className="item-actions"> <div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => handleCopy(key)} disabled={disabled}>
{t('common.copy')}
</Button>
<Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disabled}> <Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disabled}>
{t('config_management.visual.common.edit')} {t('config_management.visual.common.edit')}
</Button> </Button>
@@ -330,6 +245,18 @@ function ApiKeysCardEditor({
disabled={disabled} disabled={disabled}
error={formError || undefined} error={formError || undefined}
hint={t('config_management.visual.api_keys.input_hint')} hint={t('config_management.visual.api_keys.input_hint')}
style={{ paddingRight: 148 }}
rightElement={
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleGenerate}
disabled={disabled}
>
{t('config_management.visual.api_keys.generate')}
</Button>
}
/> />
</Modal> </Modal>
</div> </div>
@@ -358,7 +285,7 @@ function StringListEditor({
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item, index) => ( {items.map((item, index) => (
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div key={index} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input <input
className="input" className="input"
placeholder={placeholder} placeholder={placeholder}
@@ -394,6 +321,22 @@ function PayloadRulesEditor({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const rules = value.length ? value : []; const rules = value.length ? value : [];
const protocolOptions = useMemo(
() =>
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t]
);
const payloadValueTypeOptions = useMemo(
() =>
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t]
);
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]); const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex)); const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
@@ -471,7 +414,15 @@ function PayloadRulesEditor({
gap: 12, gap: 12,
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}> <div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
}}
>
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div> <div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}> <Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
{t('config_management.visual.common.delete')} {t('config_management.visual.common.delete')}
@@ -483,17 +434,15 @@ function PayloadRulesEditor({
{(rule.models.length ? rule.models : []).map((model, modelIndex) => ( {(rule.models.length ? rule.models : []).map((model, modelIndex) => (
<div <div
key={model.id} key={model.id}
style={{ className={[styles.payloadRuleModelRow, protocolFirst ? styles.payloadRuleModelRowProtocolFirst : '']
display: 'grid', .filter(Boolean)
gridTemplateColumns: protocolFirst ? '160px 1fr auto' : '1fr 160px auto', .join(' ')}
gap: 8,
}}
> >
{protocolFirst ? ( {protocolFirst ? (
<> <>
<ToastSelect <Select
value={model.protocol ?? ''} value={model.protocol ?? ''}
options={VISUAL_CONFIG_PROTOCOL_OPTIONS} options={protocolOptions}
disabled={disabled} disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')} ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) => onChange={(nextValue) =>
@@ -519,9 +468,9 @@ function PayloadRulesEditor({
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })} onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
disabled={disabled} disabled={disabled}
/> />
<ToastSelect <Select
value={model.protocol ?? ''} value={model.protocol ?? ''}
options={VISUAL_CONFIG_PROTOCOL_OPTIONS} options={protocolOptions}
disabled={disabled} disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')} ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) => onChange={(nextValue) =>
@@ -532,7 +481,13 @@ function PayloadRulesEditor({
/> />
</> </>
)} )}
<Button variant="ghost" size="sm" onClick={() => removeModel(ruleIndex, modelIndex)} disabled={disabled}> <Button
variant="ghost"
size="sm"
className={styles.payloadRowActionButton}
onClick={() => removeModel(ruleIndex, modelIndex)}
disabled={disabled}
>
{t('config_management.visual.common.delete')} {t('config_management.visual.common.delete')}
</Button> </Button>
</div> </div>
@@ -547,7 +502,7 @@ function PayloadRulesEditor({
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.params')}</div> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.params')}</div>
{(rule.params.length ? rule.params : []).map((param, paramIndex) => ( {(rule.params.length ? rule.params : []).map((param, paramIndex) => (
<div key={param.id} style={{ display: 'grid', gridTemplateColumns: '1fr 140px 1fr auto', gap: 8 }}> <div key={param.id} className={styles.payloadRuleParamRow}>
<input <input
className="input" className="input"
placeholder={t('config_management.visual.payload_rules.json_path')} placeholder={t('config_management.visual.payload_rules.json_path')}
@@ -555,9 +510,9 @@ function PayloadRulesEditor({
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })} onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
disabled={disabled} disabled={disabled}
/> />
<ToastSelect <Select
value={param.valueType} value={param.valueType}
options={VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS} options={payloadValueTypeOptions}
disabled={disabled} disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.param_type')} ariaLabel={t('config_management.visual.payload_rules.param_type')}
onChange={(nextValue) => onChange={(nextValue) =>
@@ -571,7 +526,13 @@ function PayloadRulesEditor({
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })} onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
disabled={disabled} disabled={disabled}
/> />
<Button variant="ghost" size="sm" onClick={() => removeParam(ruleIndex, paramIndex)} disabled={disabled}> <Button
variant="ghost"
size="sm"
className={styles.payloadRowActionButton}
onClick={() => removeParam(ruleIndex, paramIndex)}
disabled={disabled}
>
{t('config_management.visual.common.delete')} {t('config_management.visual.common.delete')}
</Button> </Button>
</div> </div>
@@ -619,6 +580,14 @@ function PayloadFilterRulesEditor({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const rules = value.length ? value : []; const rules = value.length ? value : [];
const protocolOptions = useMemo(
() =>
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t]
);
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]); const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex)); const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
@@ -658,7 +627,15 @@ function PayloadFilterRulesEditor({
gap: 12, gap: 12,
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}> <div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
}}
>
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div> <div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}> <Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
{t('config_management.visual.common.delete')} {t('config_management.visual.common.delete')}
@@ -668,7 +645,7 @@ function PayloadFilterRulesEditor({
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.models')}</div> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.models')}</div>
{rule.models.map((model, modelIndex) => ( {rule.models.map((model, modelIndex) => (
<div key={model.id} style={{ display: 'grid', gridTemplateColumns: '1fr 160px auto', gap: 8 }}> <div key={model.id} className={styles.payloadFilterModelRow}>
<input <input
className="input" className="input"
placeholder={t('config_management.visual.payload_rules.model_name')} placeholder={t('config_management.visual.payload_rules.model_name')}
@@ -676,9 +653,9 @@ function PayloadFilterRulesEditor({
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })} onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
disabled={disabled} disabled={disabled}
/> />
<ToastSelect <Select
value={model.protocol ?? ''} value={model.protocol ?? ''}
options={VISUAL_CONFIG_PROTOCOL_OPTIONS} options={protocolOptions}
disabled={disabled} disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')} ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) => onChange={(nextValue) =>
@@ -687,7 +664,13 @@ function PayloadFilterRulesEditor({
}) })
} }
/> />
<Button variant="ghost" size="sm" onClick={() => removeModel(ruleIndex, modelIndex)} disabled={disabled}> <Button
variant="ghost"
size="sm"
className={styles.payloadRowActionButton}
onClick={() => removeModel(ruleIndex, modelIndex)}
disabled={disabled}
>
{t('config_management.visual.common.delete')} {t('config_management.visual.common.delete')}
</Button> </Button>
</div> </div>
@@ -923,7 +906,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
/> />
<div className="form-group"> <div className="form-group">
<label>{t('config_management.visual.sections.network.routing_strategy')}</label> <label>{t('config_management.visual.sections.network.routing_strategy')}</label>
<ToastSelect <Select
value={values.routingStrategy} value={values.routingStrategy}
options={[ options={[
{ value: 'round-robin', label: t('config_management.visual.sections.network.strategy_round_robin') }, { value: 'round-robin', label: t('config_management.visual.sections.network.strategy_round_robin') },

View 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 {
@@ -33,8 +31,10 @@ 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} />,
@@ -172,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 变量,确保侧栏/内容区计算一致,防止滚动时抖动
@@ -241,7 +233,7 @@ export function MainLayout() {
}; };
}, []); }, []);
// 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏)对齐到内容区而非整窗 // 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏、提供商导航)对齐到内容区
useLayoutEffect(() => { useLayoutEffect(() => {
const updateContentCenter = () => { const updateContentCenter = () => {
const el = contentRef.current; const el = contentRef.current;
@@ -269,6 +261,7 @@ export function MainLayout() {
resizeObserver.disconnect(); resizeObserver.disconnect();
} }
window.removeEventListener('resize', updateContentCenter); window.removeEventListener('resize', updateContentCenter);
document.documentElement.style.removeProperty('--content-center-x');
}; };
}, []); }, []);
@@ -286,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) {
@@ -312,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(() => {
@@ -475,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) {
@@ -493,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);
} }
@@ -566,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
@@ -612,57 +609,8 @@ export function MainLayout() {
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>
); );
} }

View File

@@ -114,7 +114,7 @@
&.selected { &.selected {
border-color: var(--primary-color); border-color: var(--primary-color);
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); box-shadow: 0 0 0 2px rgba($primary-color, 0.18);
} }
} }

View File

@@ -33,7 +33,7 @@ export interface ModelMappingDiagramProps {
} }
const PROVIDER_COLORS = [ const PROVIDER_COLORS = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b8680', '#10b981', '#f59e0b', '#c65746',
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16' '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'
]; ];
@@ -285,7 +285,6 @@ export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappi
useLayoutEffect(() => { useLayoutEffect(() => {
// updateLines is called after layout is calculated, ensuring elements are in place. // updateLines is called after layout is calculated, ensuring elements are in place.
updateLines();
const raf = requestAnimationFrame(updateLines); const raf = requestAnimationFrame(updateLines);
window.addEventListener('resize', updateLines); window.addEventListener('resize', updateLines);
return () => { return () => {
@@ -295,7 +294,6 @@ export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappi
}, [updateLines, aliasNodes]); }, [updateLines, aliasNodes]);
useLayoutEffect(() => { useLayoutEffect(() => {
updateLines();
const raf = requestAnimationFrame(updateLines); const raf = requestAnimationFrame(updateLines);
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf);
}, [providerGroupHeights, updateLines]); }, [providerGroupHeights, updateLines]);

View File

@@ -1,281 +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, showConfirmation } = 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 () => {
showConfirmation({
title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
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 performSaveAmpcode = async () => {
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);
}
};
const saveAmpcode = async () => {
if (!loaded && mappingsDirty) {
showConfirmation({
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
variant: 'secondary', // Not dangerous, just a warning
confirmText: t('common.confirm'),
onConfirm: performSaveAmpcode,
});
return;
}
await performSaveAmpcode();
};
return (
<Modal
open={isOpen}
onClose={onClose}
title={t('ai_providers.ampcode_modal_title')}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={saveAmpcode} loading={saving} disabled={disableControls || loading}>
{t('common.save')}
</Button>
</>
}
>
{error && <div className="error-box">{error}</div>}
<Input
label={t('ai_providers.ampcode_upstream_url_label')}
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
value={form.upstreamUrl}
onChange={(e) => setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
disabled={loading || saving}
hint={t('ai_providers.ampcode_upstream_url_hint')}
/>
<Input
label={t('ai_providers.ampcode_upstream_api_key_label')}
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
type="password"
value={form.upstreamApiKey}
onChange={(e) => setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
disabled={loading || saving}
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
/>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
marginTop: -8,
marginBottom: 12,
flexWrap: 'wrap',
}}
>
<div className="hint" style={{ margin: 0 }}>
{t('ai_providers.ampcode_upstream_api_key_current', {
key: config?.ampcode?.upstreamApiKey
? maskApiKey(config.ampcode.upstreamApiKey)
: t('common.not_set'),
})}
</div>
<Button
variant="danger"
size="sm"
onClick={clearAmpcodeUpstreamApiKey}
disabled={loading || saving || !config?.ampcode?.upstreamApiKey}
>
{t('ai_providers.ampcode_clear_upstream_api_key')}
</Button>
</div>
<div className="form-group">
<ToggleSwitch
label={t('ai_providers.ampcode_force_model_mappings_label')}
checked={form.forceModelMappings}
onChange={(value) => setForm((prev) => ({ ...prev, forceModelMappings: value }))}
disabled={loading || saving}
/>
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
</div>
<div className="form-group">
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
<ModelInputList
entries={form.mappingEntries}
onChange={(entries) => {
setMappingsDirty(true);
setForm((prev) => ({ ...prev, mappingEntries: entries }));
}}
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
disabled={loading || saving}
/>
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -1,129 +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 } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import type { ProviderKeyConfig } from '@/types';
import { 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: headersToEntries(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={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>{t('ai_providers.claude_models_label')}</label>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.claude_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={isSaving}
/>
</div>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -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 { headersToEntries } from '@/utils/headers';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
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: headersToEntries(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={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>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -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 { 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: headersToEntries(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={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>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -125,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]) => (

View File

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

View File

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

View File

@@ -87,7 +87,7 @@ 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}

View File

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

View File

@@ -17,12 +17,12 @@
flex-direction: row; flex-direction: row;
gap: 6px; gap: 6px;
padding: 10px 12px; padding: 10px 12px;
background: rgba(255, 255, 255, 0.7); background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
border-radius: 999px; border-radius: 999px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
overflow-x: auto; overflow-x: auto;
scrollbar-width: none; scrollbar-width: none;
max-width: inherit; max-width: inherit;
@@ -39,7 +39,7 @@
pointer-events: none; pointer-events: none;
opacity: 0; opacity: 0;
border-radius: 999px; border-radius: 999px;
background: rgba(59, 130, 246, 0.15); background: rgba($primary-color, 0.16);
box-shadow: inset 0 0 0 2px var(--primary-color); box-shadow: inset 0 0 0 2px var(--primary-color);
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
width 220ms cubic-bezier(0.22, 1, 0.36, 1), width 220ms cubic-bezier(0.22, 1, 0.36, 1),
@@ -73,7 +73,7 @@
flex: 0 0 auto; flex: 0 0 auto;
&:hover { &:hover {
background: rgba(0, 0, 0, 0.06); background: color-mix(in srgb, var(--text-primary) 10%, transparent);
transform: scale(1.08); transform: scale(1.08);
} }
@@ -104,19 +104,13 @@
// 暗色主题适配 // 暗色主题适配
:global([data-theme='dark']) { :global([data-theme='dark']) {
.navList { .navList {
background: rgba(30, 30, 30, 0.7); background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
border-color: rgba(255, 255, 255, 0.1); border-color: color-mix(in srgb, var(--border-color) 55%, transparent);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
} }
.navItem {
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
.indicator { .indicator {
background: rgba(59, 130, 246, 0.25); background: rgba($primary-color, 0.28);
} }
} }

View File

@@ -1,7 +1,7 @@
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { usePageTransitionLayer } from '@/components/common/PageTransition'; import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { useThemeStore } from '@/stores'; import { useThemeStore } from '@/stores';
import iconGemini from '@/assets/icons/gemini.svg'; import iconGemini from '@/assets/icons/gemini.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg'; import iconOpenaiLight from '@/assets/icons/openai-light.svg';
@@ -135,8 +135,9 @@ export function ProviderNav() {
window.addEventListener('scroll', handleScroll, { passive: true }); window.addEventListener('scroll', handleScroll, { passive: true });
contentScroller?.addEventListener('scroll', handleScroll, { passive: true }); contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll); window.addEventListener('resize', handleScroll);
handleScroll(); const raf = requestAnimationFrame(handleScroll);
return () => { return () => {
cancelAnimationFrame(raf);
window.removeEventListener('scroll', handleScroll); window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll); window.removeEventListener('resize', handleScroll);
contentScroller?.removeEventListener('scroll', handleScroll); contentScroller?.removeEventListener('scroll', handleScroll);
@@ -168,7 +169,8 @@ export function ProviderNav() {
useLayoutEffect(() => { useLayoutEffect(() => {
if (!shouldShow) return; if (!shouldShow) return;
updateIndicator(activeProvider); const raf = requestAnimationFrame(() => updateIndicator(activeProvider));
return () => cancelAnimationFrame(raf);
}, [activeProvider, shouldShow, updateIndicator]); }, [activeProvider, shouldShow, updateIndicator]);
// Expose overlay height to the page, so it can reserve bottom padding and avoid being covered. // Expose overlay height to the page, so it can reserve bottom padding and avoid being covered.

View File

@@ -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>; * 根据成功率 (01) 在三个色标之间做 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>

View File

@@ -1,118 +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 } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import type { ProviderKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import type { ProviderModalProps, VertexFormState } from '../types';
interface VertexModalProps extends ProviderModalProps<ProviderKeyConfig, VertexFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): VertexFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
modelEntries: [{ name: '', alias: '' }],
});
export function VertexModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: VertexModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<VertexFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.vertex_edit_modal_title')
: t('ai_providers.vertex_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.vertex_add_modal_key_label')}
placeholder={t('ai_providers.vertex_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.vertex_add_modal_url_label')}
placeholder={t('ai_providers.vertex_add_modal_url_placeholder')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.vertex_add_modal_proxy_label')}
placeholder={t('ai_providers.vertex_add_modal_proxy_placeholder')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: 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>{t('ai_providers.vertex_models_label')}</label>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.vertex_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={isSaving}
/>
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -2,14 +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: 'vertex'; 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;
@@ -58,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;
}

View File

@@ -43,6 +43,19 @@ 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 '';
@@ -58,6 +71,18 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
return `${trimmed}/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,

View File

@@ -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> {

View File

@@ -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';

View File

@@ -10,6 +10,10 @@ import type {
AntigravityModelsPayload, AntigravityModelsPayload,
AntigravityQuotaState, AntigravityQuotaState,
AuthFileItem, AuthFileItem,
ClaudeExtraUsage,
ClaudeQuotaState,
ClaudeQuotaWindow,
ClaudeUsagePayload,
CodexRateLimitInfo, CodexRateLimitInfo,
CodexQuotaState, CodexQuotaState,
CodexUsageWindow, CodexUsageWindow,
@@ -23,16 +27,21 @@ 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,
@@ -45,6 +54,7 @@ import {
createStatusError, createStatusError,
getStatusFromError, getStatusFromError,
isAntigravityFile, isAntigravityFile,
isClaudeFile,
isCodexFile, isCodexFile,
isDisabledAuthFile, isDisabledAuthFile,
isGeminiCliFile, isGeminiCliFile,
@@ -55,15 +65,17 @@ 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'; 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;
@@ -201,11 +213,14 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
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
@@ -217,8 +232,9 @@ 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,
}); });
@@ -233,12 +249,13 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
const rawAllowed = rateLimit?.allowed; const rawAllowed = rateLimit?.allowed;
const pickClassifiedWindows = ( const pickClassifiedWindows = (
limitInfo?: CodexRateLimitInfo | null limitInfo?: CodexRateLimitInfo | null,
options?: { allowOrderFallback?: boolean }
): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => { ): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => {
const rawWindows = [ const allowOrderFallback = options?.allowOrderFallback ?? true;
limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null, const primaryWindow = limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null;
limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null, const secondaryWindow = limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null;
]; const rawWindows = [primaryWindow, secondaryWindow];
let fiveHourWindow: CodexUsageWindow | null = null; let fiveHourWindow: CodexUsageWindow | null = null;
let weeklyWindow: CodexUsageWindow | null = null; let weeklyWindow: CodexUsageWindow | null = null;
@@ -253,20 +270,34 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
} }
} }
// 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 }; return { fiveHourWindow, weeklyWindow };
}; };
const rateWindows = pickClassifiedWindows(rateLimit); const rateWindows = pickClassifiedWindows(rateLimit);
addWindow( addWindow(
WINDOW_META.codeFiveHour.id, WINDOW_META.codeFiveHour.id,
t(WINDOW_META.codeFiveHour.labelKey),
WINDOW_META.codeFiveHour.labelKey, WINDOW_META.codeFiveHour.labelKey,
undefined,
rateWindows.fiveHourWindow, rateWindows.fiveHourWindow,
rawLimitReached, rawLimitReached,
rawAllowed rawAllowed
); );
addWindow( addWindow(
WINDOW_META.codeWeekly.id, WINDOW_META.codeWeekly.id,
t(WINDOW_META.codeWeekly.labelKey),
WINDOW_META.codeWeekly.labelKey, WINDOW_META.codeWeekly.labelKey,
undefined,
rateWindows.weeklyWindow, rateWindows.weeklyWindow,
rawLimitReached, rawLimitReached,
rawAllowed rawAllowed
@@ -277,19 +308,67 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
const codeReviewAllowed = codeReviewLimit?.allowed; const codeReviewAllowed = codeReviewLimit?.allowed;
addWindow( addWindow(
WINDOW_META.codeReviewFiveHour.id, WINDOW_META.codeReviewFiveHour.id,
t(WINDOW_META.codeReviewFiveHour.labelKey),
WINDOW_META.codeReviewFiveHour.labelKey, WINDOW_META.codeReviewFiveHour.labelKey,
undefined,
codeReviewWindows.fiveHourWindow, codeReviewWindows.fiveHourWindow,
codeReviewLimitReached, codeReviewLimitReached,
codeReviewAllowed codeReviewAllowed
); );
addWindow( addWindow(
WINDOW_META.codeReviewWeekly.id, WINDOW_META.codeReviewWeekly.id,
t(WINDOW_META.codeReviewWeekly.labelKey),
WINDOW_META.codeReviewWeekly.labelKey, WINDOW_META.codeReviewWeekly.labelKey,
undefined,
codeReviewWindows.weeklyWindow, codeReviewWindows.weeklyWindow,
codeReviewLimitReached, codeReviewLimitReached,
codeReviewAllowed 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;
}; };
@@ -368,7 +447,7 @@ 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(
@@ -481,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',
@@ -557,6 +638,149 @@ 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',

View File

@@ -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>

View File

@@ -16,11 +16,53 @@ 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;
}; };
@@ -29,8 +71,31 @@ 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;
} }
}; };

View File

@@ -6,10 +6,18 @@ import type { ModelEntry } from './modelInputListUtils';
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 function ModelInputList({ export function ModelInputList({
@@ -18,9 +26,20 @@ export function ModelInputList({
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));
@@ -28,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) => {
@@ -37,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)}
@@ -50,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)}
@@ -61,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>
); );
} }

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

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

View File

@@ -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,65 +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]) => (
<span className={styles.requestCountCell}> <button
<span> key={key}
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()} type="button"
</span> aria-pressed={sortKey === key}
<span className={styles.requestBreakdown}> className={`${styles.apiSortBtn} ${sortKey === key ? styles.apiSortBtnActive : ''}`}
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '} onClick={() => handleSort(key)}
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>) >
</span> {t(labelKey)}{arrow(key)}
</span> </button>
</span> ))}
<span className={styles.apiBadge}> </div>
{t('usage_stats.tokens_count')}: {formatTokensInMillions(api.totalTokens)} <div className={styles.detailsScroll}>
</span> <div className={styles.apiList}>
{hasPrices && api.totalCost > 0 && ( {sorted.map((api, index) => {
<span className={styles.apiBadge}> const isExpanded = expandedApis.has(api.endpoint);
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)} 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}>
<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}>{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>
)} )}

View File

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

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

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

View File

@@ -1,6 +1,7 @@
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 {
@@ -18,43 +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> <th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
<span className={styles.requestCountCell}> <button
<span>{stat.requests.toLocaleString()}</span> type="button"
<span className={styles.requestBreakdown}> className={styles.sortHeaderButton}
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '} onClick={() => handleSort('requests')}
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>) >
</span> {t('usage_stats.requests_count')}{arrow('requests')}
</span> </button>
</td> </th>
<td>{formatTokensInMillions(stat.tokens)}</td> <th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>} <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>

View File

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

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

View File

@@ -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}`}>

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

View File

@@ -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(
() => () =>

View File

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

View File

@@ -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,

View File

@@ -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';

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

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

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

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

View File

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

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

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

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

View 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();
});
};

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

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

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

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

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

View 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]);
}

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

View File

@@ -13,7 +13,7 @@ interface UseApiOptions<T> {
successMessage?: string; successMessage?: string;
} }
export function useApi<T = any, Args extends any[] = any[]>( export function useApi<T = unknown, Args extends unknown[] = unknown[]>(
apiFunction: (...args: Args) => Promise<T>, apiFunction: (...args: Args) => Promise<T>,
options: UseApiOptions<T> = {} options: UseApiOptions<T> = {}
) { ) {
@@ -38,8 +38,9 @@ export function useApi<T = any, Args extends any[] = any[]>(
options.onSuccess?.(result); options.onSuccess?.(result);
return result; return result;
} catch (err) { } catch (err: unknown) {
const errorObj = err as Error; const errorObj =
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error');
setError(errorObj); setError(errorObj);
if (options.showErrorNotification !== false) { if (options.showErrorNotification !== false) {

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { isMap, parse as parseYaml, parseDocument } from 'yaml';
import type { import type {
PayloadFilterRule, PayloadFilterRule,
PayloadParamValueType, PayloadParamValueType,
@@ -8,10 +8,6 @@ import type {
} from '@/types/visualConfig'; } from '@/types/visualConfig';
import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig'; import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig';
function hasOwn(obj: unknown, key: string): obj is Record<string, unknown> {
return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key);
}
function asRecord(value: unknown): Record<string, unknown> | null { function asRecord(value: unknown): Record<string, unknown> | null {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>; return value as Record<string, unknown>;
@@ -48,53 +44,58 @@ function parseApiKeysText(raw: unknown): string {
return keys.join('\n'); return keys.join('\n');
} }
function ensureRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> { type YamlDocument = ReturnType<typeof parseDocument>;
const existing = asRecord(parent[key]); type YamlPath = string[];
if (existing) return existing;
const next: Record<string, unknown> = {}; function docHas(doc: YamlDocument, path: YamlPath): boolean {
parent[key] = next; return doc.hasIn(path);
return next;
} }
function deleteIfEmpty(parent: Record<string, unknown>, key: string): void { function ensureMapInDoc(doc: YamlDocument, path: YamlPath): void {
const value = asRecord(parent[key]); const existing = doc.getIn(path, true);
if (!value) return; if (isMap(existing)) return;
if (Object.keys(value).length === 0) delete parent[key]; doc.setIn(path, {});
} }
function setBoolean(obj: Record<string, unknown>, key: string, value: boolean): void { function deleteIfMapEmpty(doc: YamlDocument, path: YamlPath): void {
const value = doc.getIn(path, true);
if (!isMap(value)) return;
if (value.items.length === 0) doc.deleteIn(path);
}
function setBooleanInDoc(doc: YamlDocument, path: YamlPath, value: boolean): void {
if (value) { if (value) {
obj[key] = true; doc.setIn(path, true);
return; return;
} }
if (hasOwn(obj, key)) obj[key] = false; if (docHas(doc, path)) doc.setIn(path, false);
} }
function setString(obj: Record<string, unknown>, key: string, value: unknown): void { function setStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
const safe = typeof value === 'string' ? value : ''; const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim(); const trimmed = safe.trim();
if (trimmed !== '') { if (trimmed !== '') {
obj[key] = safe; doc.setIn(path, safe);
return; return;
} }
if (hasOwn(obj, key)) delete obj[key]; if (docHas(doc, path)) doc.deleteIn(path);
} }
function setIntFromString(obj: Record<string, unknown>, key: string, value: unknown): void { function setIntFromStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
const safe = typeof value === 'string' ? value : ''; const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim(); const trimmed = safe.trim();
if (trimmed === '') { if (trimmed === '') {
if (hasOwn(obj, key)) delete obj[key]; if (docHas(doc, path)) doc.deleteIn(path);
return; return;
} }
const parsed = Number.parseInt(trimmed, 10); const parsed = Number.parseInt(trimmed, 10);
if (Number.isFinite(parsed)) { if (Number.isFinite(parsed)) {
obj[key] = parsed; doc.setIn(path, parsed);
return; return;
} }
if (hasOwn(obj, key)) delete obj[key]; if (docHas(doc, path)) doc.deleteIn(path);
} }
function deepClone<T>(value: T): T { function deepClone<T>(value: T): T {
@@ -123,20 +124,47 @@ function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueTyp
return { valueType: 'string', value: String(raw ?? '') }; return { valueType: 'string', value: String(raw ?? '') };
} }
const PAYLOAD_PROTOCOL_VALUES = [
'openai',
'openai-response',
'gemini',
'claude',
'codex',
'antigravity',
] as const;
type PayloadProtocol = (typeof PAYLOAD_PROTOCOL_VALUES)[number];
function parsePayloadProtocol(raw: unknown): PayloadProtocol | undefined {
if (typeof raw !== 'string') return undefined;
return PAYLOAD_PROTOCOL_VALUES.includes(raw as PayloadProtocol)
? (raw as PayloadProtocol)
: undefined;
}
function parsePayloadRules(rules: unknown): PayloadRule[] { function parsePayloadRules(rules: unknown): PayloadRule[] {
if (!Array.isArray(rules)) return []; if (!Array.isArray(rules)) return [];
return rules.map((rule, index) => ({ return rules.map((rule, index) => {
id: `payload-rule-${index}`, const record = asRecord(rule) ?? {};
models: Array.isArray((rule as any)?.models)
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({ const modelsRaw = record.models;
id: `model-${index}-${modelIndex}`, const models = Array.isArray(modelsRaw)
name: typeof model === 'string' ? model : model?.name || '', ? modelsRaw.map((model, modelIndex) => {
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined, const modelRecord = asRecord(model);
})) const nameRaw =
: [], typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
params: (rule as any)?.params const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
? Object.entries((rule as any).params as Record<string, unknown>).map(([path, value], pIndex) => { return {
id: `model-${index}-${modelIndex}`,
name,
protocol: parsePayloadProtocol(modelRecord?.protocol),
};
})
: [];
const paramsRecord = asRecord(record.params);
const params = paramsRecord
? Object.entries(paramsRecord).map(([path, value], pIndex) => {
const parsedValue = parsePayloadParamValue(value); const parsedValue = parsePayloadParamValue(value);
return { return {
id: `param-${index}-${pIndex}`, id: `param-${index}-${pIndex}`,
@@ -145,41 +173,55 @@ function parsePayloadRules(rules: unknown): PayloadRule[] {
value: parsedValue.value, value: parsedValue.value,
}; };
}) })
: [], : [];
}));
return { id: `payload-rule-${index}`, models, params };
});
} }
function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] { function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] {
if (!Array.isArray(rules)) return []; if (!Array.isArray(rules)) return [];
return rules.map((rule, index) => ({ return rules.map((rule, index) => {
id: `payload-filter-rule-${index}`, const record = asRecord(rule) ?? {};
models: Array.isArray((rule as any)?.models)
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({ const modelsRaw = record.models;
id: `filter-model-${index}-${modelIndex}`, const models = Array.isArray(modelsRaw)
name: typeof model === 'string' ? model : model?.name || '', ? modelsRaw.map((model, modelIndex) => {
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined, const modelRecord = asRecord(model);
})) const nameRaw =
: [], typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
params: Array.isArray((rule as any)?.params) ? ((rule as any).params as unknown[]).map(String) : [], const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
})); return {
id: `filter-model-${index}-${modelIndex}`,
name,
protocol: parsePayloadProtocol(modelRecord?.protocol),
};
})
: [];
const paramsRaw = record.params;
const params = Array.isArray(paramsRaw) ? paramsRaw.map(String) : [];
return { id: `payload-filter-rule-${index}`, models, params };
});
} }
function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] { function serializePayloadRulesForYaml(rules: PayloadRule[]): Array<Record<string, unknown>> {
return rules return rules
.map((rule) => { .map((rule) => {
const models = (rule.models || []) const models = (rule.models || [])
.filter((m) => m.name?.trim()) .filter((m) => m.name?.trim())
.map((m) => { .map((m) => {
const obj: Record<string, any> = { name: m.name.trim() }; const obj: Record<string, unknown> = { name: m.name.trim() };
if (m.protocol) obj.protocol = m.protocol; if (m.protocol) obj.protocol = m.protocol;
return obj; return obj;
}); });
const params: Record<string, any> = {}; const params: Record<string, unknown> = {};
for (const param of rule.params || []) { for (const param of rule.params || []) {
if (!param.path?.trim()) continue; if (!param.path?.trim()) continue;
let value: any = param.value; let value: unknown = param.value;
if (param.valueType === 'number') { if (param.valueType === 'number') {
const num = Number(param.value); const num = Number(param.value);
value = Number.isFinite(num) ? num : param.value; value = Number.isFinite(num) ? num : param.value;
@@ -200,13 +242,15 @@ function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] {
.filter((rule) => rule.models.length > 0); .filter((rule) => rule.models.length > 0);
} }
function serializePayloadFilterRulesForYaml(rules: PayloadFilterRule[]): any[] { function serializePayloadFilterRulesForYaml(
rules: PayloadFilterRule[]
): Array<Record<string, unknown>> {
return rules return rules
.map((rule) => { .map((rule) => {
const models = (rule.models || []) const models = (rule.models || [])
.filter((m) => m.name?.trim()) .filter((m) => m.name?.trim())
.map((m) => { .map((m) => {
const obj: Record<string, any> = { name: m.name.trim() }; const obj: Record<string, unknown> = { name: m.name.trim() };
if (m.protocol) obj.protocol = m.protocol; if (m.protocol) obj.protocol = m.protocol;
return obj; return obj;
}); });
@@ -225,33 +269,45 @@ export function useVisualConfig() {
...DEFAULT_VISUAL_VALUES, ...DEFAULT_VISUAL_VALUES,
}); });
const baselineValues = useRef<VisualConfigValues>({ ...DEFAULT_VISUAL_VALUES }); const [baselineValues, setBaselineValues] = useState<VisualConfigValues>({
...DEFAULT_VISUAL_VALUES,
});
const visualDirty = useMemo(() => { const visualDirty = useMemo(() => {
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues.current); return JSON.stringify(visualValues) !== JSON.stringify(baselineValues);
}, [visualValues]); }, [baselineValues, visualValues]);
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => { const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
try { try {
const parsed: any = parseYaml(yamlContent) || {}; const parsedRaw: unknown = parseYaml(yamlContent) || {};
const parsed = asRecord(parsedRaw) ?? {};
const tls = asRecord(parsed.tls);
const remoteManagement = asRecord(parsed['remote-management']);
const quotaExceeded = asRecord(parsed['quota-exceeded']);
const routing = asRecord(parsed.routing);
const payload = asRecord(parsed.payload);
const streaming = asRecord(parsed.streaming);
const newValues: VisualConfigValues = { const newValues: VisualConfigValues = {
host: parsed.host || '', host: typeof parsed.host === 'string' ? parsed.host : '',
port: String(parsed.port ?? ''), port: String(parsed.port ?? ''),
tlsEnable: Boolean(parsed.tls?.enable), tlsEnable: Boolean(tls?.enable),
tlsCert: parsed.tls?.cert || '', tlsCert: typeof tls?.cert === 'string' ? tls.cert : '',
tlsKey: parsed.tls?.key || '', tlsKey: typeof tls?.key === 'string' ? tls.key : '',
rmAllowRemote: Boolean(parsed['remote-management']?.['allow-remote']), rmAllowRemote: Boolean(remoteManagement?.['allow-remote']),
rmSecretKey: parsed['remote-management']?.['secret-key'] || '', rmSecretKey:
rmDisableControlPanel: Boolean(parsed['remote-management']?.['disable-control-panel']), typeof remoteManagement?.['secret-key'] === 'string' ? remoteManagement['secret-key'] : '',
rmDisableControlPanel: Boolean(remoteManagement?.['disable-control-panel']),
rmPanelRepo: rmPanelRepo:
parsed['remote-management']?.['panel-github-repository'] ?? typeof remoteManagement?.['panel-github-repository'] === 'string'
parsed['remote-management']?.['panel-repo'] ?? ? remoteManagement['panel-github-repository']
'', : typeof remoteManagement?.['panel-repo'] === 'string'
? remoteManagement['panel-repo']
: '',
authDir: parsed['auth-dir'] || '', authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
apiKeysText: parseApiKeysText(parsed['api-keys']), apiKeysText: parseApiKeysText(parsed['api-keys']),
debug: Boolean(parsed.debug), debug: Boolean(parsed.debug),
@@ -260,113 +316,131 @@ export function useVisualConfig() {
logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''), logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''),
usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']), usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']),
proxyUrl: parsed['proxy-url'] || '', proxyUrl: typeof parsed['proxy-url'] === 'string' ? parsed['proxy-url'] : '',
forceModelPrefix: Boolean(parsed['force-model-prefix']), forceModelPrefix: Boolean(parsed['force-model-prefix']),
requestRetry: String(parsed['request-retry'] ?? ''), requestRetry: String(parsed['request-retry'] ?? ''),
maxRetryInterval: String(parsed['max-retry-interval'] ?? ''), maxRetryInterval: String(parsed['max-retry-interval'] ?? ''),
wsAuth: Boolean(parsed['ws-auth']), wsAuth: Boolean(parsed['ws-auth']),
quotaSwitchProject: Boolean(parsed['quota-exceeded']?.['switch-project'] ?? true), quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
quotaSwitchPreviewModel: Boolean( quotaSwitchPreviewModel: Boolean(
parsed['quota-exceeded']?.['switch-preview-model'] ?? true quotaExceeded?.['switch-preview-model'] ?? true
), ),
routingStrategy: (parsed.routing?.strategy || 'round-robin') as 'round-robin' | 'fill-first', routingStrategy:
routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
payloadDefaultRules: parsePayloadRules(parsed.payload?.default), payloadDefaultRules: parsePayloadRules(payload?.default),
payloadOverrideRules: parsePayloadRules(parsed.payload?.override), payloadOverrideRules: parsePayloadRules(payload?.override),
payloadFilterRules: parsePayloadFilterRules(parsed.payload?.filter), payloadFilterRules: parsePayloadFilterRules(payload?.filter),
streaming: { streaming: {
keepaliveSeconds: String(parsed.streaming?.['keepalive-seconds'] ?? ''), keepaliveSeconds: String(streaming?.['keepalive-seconds'] ?? ''),
bootstrapRetries: String(parsed.streaming?.['bootstrap-retries'] ?? ''), bootstrapRetries: String(streaming?.['bootstrap-retries'] ?? ''),
nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''), nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''),
}, },
}; };
setVisualValuesState(newValues); setVisualValuesState(newValues);
baselineValues.current = deepClone(newValues); setBaselineValues(deepClone(newValues));
} catch { } catch {
setVisualValuesState({ ...DEFAULT_VISUAL_VALUES }); setVisualValuesState({ ...DEFAULT_VISUAL_VALUES });
baselineValues.current = deepClone(DEFAULT_VISUAL_VALUES); setBaselineValues(deepClone(DEFAULT_VISUAL_VALUES));
} }
}, []); }, []);
const applyVisualChangesToYaml = useCallback( const applyVisualChangesToYaml = useCallback(
(currentYaml: string): string => { (currentYaml: string): string => {
try { try {
const parsed = (parseYaml(currentYaml) || {}) as Record<string, unknown>; const doc = parseDocument(currentYaml);
if (doc.errors.length > 0) return currentYaml;
if (!isMap(doc.contents)) {
doc.contents = doc.createNode({}) as unknown as typeof doc.contents;
}
const values = visualValues; const values = visualValues;
setString(parsed, 'host', values.host); setStringInDoc(doc, ['host'], values.host);
setIntFromString(parsed, 'port', values.port); setIntFromStringInDoc(doc, ['port'], values.port);
if ( if (
hasOwn(parsed, 'tls') || docHas(doc, ['tls']) ||
values.tlsEnable || values.tlsEnable ||
values.tlsCert.trim() || values.tlsCert.trim() ||
values.tlsKey.trim() values.tlsKey.trim()
) { ) {
const tls = ensureRecord(parsed, 'tls'); ensureMapInDoc(doc, ['tls']);
setBoolean(tls, 'enable', values.tlsEnable); setBooleanInDoc(doc, ['tls', 'enable'], values.tlsEnable);
setString(tls, 'cert', values.tlsCert); setStringInDoc(doc, ['tls', 'cert'], values.tlsCert);
setString(tls, 'key', values.tlsKey); setStringInDoc(doc, ['tls', 'key'], values.tlsKey);
deleteIfEmpty(parsed, 'tls'); deleteIfMapEmpty(doc, ['tls']);
} }
if ( if (
hasOwn(parsed, 'remote-management') || docHas(doc, ['remote-management']) ||
values.rmAllowRemote || values.rmAllowRemote ||
values.rmSecretKey.trim() || values.rmSecretKey.trim() ||
values.rmDisableControlPanel || values.rmDisableControlPanel ||
values.rmPanelRepo.trim() values.rmPanelRepo.trim()
) { ) {
const rm = ensureRecord(parsed, 'remote-management'); ensureMapInDoc(doc, ['remote-management']);
setBoolean(rm, 'allow-remote', values.rmAllowRemote); setBooleanInDoc(doc, ['remote-management', 'allow-remote'], values.rmAllowRemote);
setString(rm, 'secret-key', values.rmSecretKey); setStringInDoc(doc, ['remote-management', 'secret-key'], values.rmSecretKey);
setBoolean(rm, 'disable-control-panel', values.rmDisableControlPanel); setBooleanInDoc(
setString(rm, 'panel-github-repository', values.rmPanelRepo); doc,
if (hasOwn(rm, 'panel-repo')) delete rm['panel-repo']; ['remote-management', 'disable-control-panel'],
deleteIfEmpty(parsed, 'remote-management'); values.rmDisableControlPanel
);
setStringInDoc(doc, ['remote-management', 'panel-github-repository'], values.rmPanelRepo);
if (docHas(doc, ['remote-management', 'panel-repo'])) {
doc.deleteIn(['remote-management', 'panel-repo']);
}
deleteIfMapEmpty(doc, ['remote-management']);
} }
setString(parsed, 'auth-dir', values.authDir); setStringInDoc(doc, ['auth-dir'], values.authDir);
if (values.apiKeysText !== baselineValues.current.apiKeysText) { if (values.apiKeysText !== baselineValues.apiKeysText) {
const apiKeys = values.apiKeysText const apiKeys = values.apiKeysText
.split('\n') .split('\n')
.map((key) => key.trim()) .map((key) => key.trim())
.filter(Boolean); .filter(Boolean);
if (apiKeys.length > 0) { if (apiKeys.length > 0) {
parsed['api-keys'] = apiKeys; doc.setIn(['api-keys'], apiKeys);
} else if (hasOwn(parsed, 'api-keys')) { } else if (docHas(doc, ['api-keys'])) {
delete parsed['api-keys']; doc.deleteIn(['api-keys']);
} }
} }
setBoolean(parsed, 'debug', values.debug); setBooleanInDoc(doc, ['debug'], values.debug);
setBoolean(parsed, 'commercial-mode', values.commercialMode); setBooleanInDoc(doc, ['commercial-mode'], values.commercialMode);
setBoolean(parsed, 'logging-to-file', values.loggingToFile); setBooleanInDoc(doc, ['logging-to-file'], values.loggingToFile);
setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb); setIntFromStringInDoc(doc, ['logs-max-total-size-mb'], values.logsMaxTotalSizeMb);
setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled); setBooleanInDoc(doc, ['usage-statistics-enabled'], values.usageStatisticsEnabled);
setString(parsed, 'proxy-url', values.proxyUrl); setStringInDoc(doc, ['proxy-url'], values.proxyUrl);
setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix); setBooleanInDoc(doc, ['force-model-prefix'], values.forceModelPrefix);
setIntFromString(parsed, 'request-retry', values.requestRetry); setIntFromStringInDoc(doc, ['request-retry'], values.requestRetry);
setIntFromString(parsed, 'max-retry-interval', values.maxRetryInterval); setIntFromStringInDoc(doc, ['max-retry-interval'], values.maxRetryInterval);
setBoolean(parsed, 'ws-auth', values.wsAuth); setBooleanInDoc(doc, ['ws-auth'], values.wsAuth);
if (hasOwn(parsed, 'quota-exceeded') || !values.quotaSwitchProject || !values.quotaSwitchPreviewModel) { if (
const quota = ensureRecord(parsed, 'quota-exceeded'); docHas(doc, ['quota-exceeded']) ||
quota['switch-project'] = values.quotaSwitchProject; !values.quotaSwitchProject ||
quota['switch-preview-model'] = values.quotaSwitchPreviewModel; !values.quotaSwitchPreviewModel
deleteIfEmpty(parsed, 'quota-exceeded'); ) {
ensureMapInDoc(doc, ['quota-exceeded']);
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
doc.setIn(
['quota-exceeded', 'switch-preview-model'],
values.quotaSwitchPreviewModel
);
deleteIfMapEmpty(doc, ['quota-exceeded']);
} }
if (hasOwn(parsed, 'routing') || values.routingStrategy !== 'round-robin') { if (docHas(doc, ['routing']) || values.routingStrategy !== 'round-robin') {
const routing = ensureRecord(parsed, 'routing'); ensureMapInDoc(doc, ['routing']);
routing.strategy = values.routingStrategy; doc.setIn(['routing', 'strategy'], values.routingStrategy);
deleteIfEmpty(parsed, 'routing'); deleteIfMapEmpty(doc, ['routing']);
} }
const keepaliveSeconds = const keepaliveSeconds =
@@ -379,47 +453,60 @@ export function useVisualConfig() {
: ''; : '';
const streamingDefined = const streamingDefined =
hasOwn(parsed, 'streaming') || keepaliveSeconds.trim() || bootstrapRetries.trim(); docHas(doc, ['streaming']) || keepaliveSeconds.trim() || bootstrapRetries.trim();
if (streamingDefined) { if (streamingDefined) {
const streaming = ensureRecord(parsed, 'streaming'); ensureMapInDoc(doc, ['streaming']);
setIntFromString(streaming, 'keepalive-seconds', keepaliveSeconds); setIntFromStringInDoc(doc, ['streaming', 'keepalive-seconds'], keepaliveSeconds);
setIntFromString(streaming, 'bootstrap-retries', bootstrapRetries); setIntFromStringInDoc(doc, ['streaming', 'bootstrap-retries'], bootstrapRetries);
deleteIfEmpty(parsed, 'streaming'); deleteIfMapEmpty(doc, ['streaming']);
} }
setIntFromString(parsed, 'nonstream-keepalive-interval', nonstreamKeepaliveInterval); setIntFromStringInDoc(
doc,
['nonstream-keepalive-interval'],
nonstreamKeepaliveInterval
);
if ( if (
hasOwn(parsed, 'payload') || docHas(doc, ['payload']) ||
values.payloadDefaultRules.length > 0 || values.payloadDefaultRules.length > 0 ||
values.payloadOverrideRules.length > 0 || values.payloadOverrideRules.length > 0 ||
values.payloadFilterRules.length > 0 values.payloadFilterRules.length > 0
) { ) {
const payload = ensureRecord(parsed, 'payload'); ensureMapInDoc(doc, ['payload']);
if (values.payloadDefaultRules.length > 0) { if (values.payloadDefaultRules.length > 0) {
payload.default = serializePayloadRulesForYaml(values.payloadDefaultRules); doc.setIn(
} else if (hasOwn(payload, 'default')) { ['payload', 'default'],
delete payload.default; serializePayloadRulesForYaml(values.payloadDefaultRules)
);
} else if (docHas(doc, ['payload', 'default'])) {
doc.deleteIn(['payload', 'default']);
} }
if (values.payloadOverrideRules.length > 0) { if (values.payloadOverrideRules.length > 0) {
payload.override = serializePayloadRulesForYaml(values.payloadOverrideRules); doc.setIn(
} else if (hasOwn(payload, 'override')) { ['payload', 'override'],
delete payload.override; serializePayloadRulesForYaml(values.payloadOverrideRules)
);
} else if (docHas(doc, ['payload', 'override'])) {
doc.deleteIn(['payload', 'override']);
} }
if (values.payloadFilterRules.length > 0) { if (values.payloadFilterRules.length > 0) {
payload.filter = serializePayloadFilterRulesForYaml(values.payloadFilterRules); doc.setIn(
} else if (hasOwn(payload, 'filter')) { ['payload', 'filter'],
delete payload.filter; serializePayloadFilterRulesForYaml(values.payloadFilterRules)
);
} else if (docHas(doc, ['payload', 'filter'])) {
doc.deleteIn(['payload', 'filter']);
} }
deleteIfEmpty(parsed, 'payload'); deleteIfMapEmpty(doc, ['payload']);
} }
return stringifyYaml(parsed, { indent: 2, lineWidth: 120, minContentWidth: 0 }); return doc.toString({ indent: 2, lineWidth: 120, minContentWidth: 0 });
} catch { } catch {
return currentYaml; return currentYaml;
} }
}, },
[visualValues] [baselineValues, visualValues]
); );
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => { const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
@@ -442,17 +529,66 @@ export function useVisualConfig() {
} }
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [ export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
{ value: '', label: '默认' }, {
{ value: 'openai', label: 'OpenAI' }, value: '',
{ value: 'gemini', label: 'Gemini' }, labelKey: 'config_management.visual.payload_rules.provider_default',
{ value: 'claude', label: 'Claude' }, defaultLabel: 'Default',
{ value: 'codex', label: 'Codex' }, },
{ value: 'antigravity', label: 'Antigravity' }, {
value: 'openai',
labelKey: 'config_management.visual.payload_rules.provider_openai',
defaultLabel: 'OpenAI',
},
{
value: 'openai-response',
labelKey: 'config_management.visual.payload_rules.provider_openai_response',
defaultLabel: 'OpenAI Response',
},
{
value: 'gemini',
labelKey: 'config_management.visual.payload_rules.provider_gemini',
defaultLabel: 'Gemini',
},
{
value: 'claude',
labelKey: 'config_management.visual.payload_rules.provider_claude',
defaultLabel: 'Claude',
},
{
value: 'codex',
labelKey: 'config_management.visual.payload_rules.provider_codex',
defaultLabel: 'Codex',
},
{
value: 'antigravity',
labelKey: 'config_management.visual.payload_rules.provider_antigravity',
defaultLabel: 'Antigravity',
},
] as const; ] as const;
export const VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS = [ export const VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS = [
{ value: 'string', label: '字符串' }, {
{ value: 'number', label: '数字' }, value: 'string',
{ value: 'boolean', label: '布尔' }, labelKey: 'config_management.visual.payload_rules.value_type_string',
{ value: 'json', label: 'JSON' }, defaultLabel: 'String',
] as const satisfies ReadonlyArray<{ value: PayloadParamValueType; label: string }>; },
{
value: 'number',
labelKey: 'config_management.visual.payload_rules.value_type_number',
defaultLabel: 'Number',
},
{
value: 'boolean',
labelKey: 'config_management.visual.payload_rules.value_type_boolean',
defaultLabel: 'Boolean',
},
{
value: 'json',
labelKey: 'config_management.visual.payload_rules.value_type_json',
defaultLabel: 'JSON',
},
] as const satisfies ReadonlyArray<{
value: PayloadParamValueType;
labelKey: string;
defaultLabel: string;
}>;

View File

@@ -6,12 +6,14 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import zhCN from './locales/zh-CN.json'; import zhCN from './locales/zh-CN.json';
import en from './locales/en.json'; import en from './locales/en.json';
import ru from './locales/ru.json';
import { getInitialLanguage } from '@/utils/language'; import { getInitialLanguage } from '@/utils/language';
i18n.use(initReactI18next).init({ i18n.use(initReactI18next).init({
resources: { resources: {
'zh-CN': { translation: zhCN }, 'zh-CN': { translation: zhCN },
en: { translation: en } en: { translation: en },
ru: { translation: ru }
}, },
lng: getInitialLanguage(), lng: getInitialLanguage(),
fallbackLng: 'zh-CN', fallbackLng: 'zh-CN',

View File

@@ -38,19 +38,26 @@
"quota_update_required": "Please update the CPA version or check for updates", "quota_update_required": "Please update the CPA version or check for updates",
"quota_check_credential": "Please check the credential status", "quota_check_credential": "Please check the credential status",
"copy": "Copy", "copy": "Copy",
"status": "Status",
"action": "Action",
"custom_headers_label": "Custom Headers", "custom_headers_label": "Custom Headers",
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.", "custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
"custom_headers_add": "Add Header", "custom_headers_add": "Add Header",
"custom_headers_key_placeholder": "Header name, e.g. X-Custom-Header", "custom_headers_key_placeholder": "Header name, e.g. X-Custom-Header",
"custom_headers_value_placeholder": "Header value", "custom_headers_value_placeholder": "Header value",
"model_name_placeholder": "Model name, e.g. claude-3-5-sonnet-20241022", "model_name_placeholder": "Model name, e.g. claude-3-5-sonnet-20241022",
"model_alias_placeholder": "Model alias (optional)" "model_alias_placeholder": "Model alias (optional)",
"invalid_provider_index": "Invalid provider index."
}, },
"title": { "title": {
"main": "CLI Proxy API Management Center", "main": "CLI Proxy API Management Center",
"login": "CLI Proxy API Management Center", "login": "CLI Proxy API Management Center",
"abbr": "CPAMC" "abbr": "CPAMC"
}, },
"splash": {
"title": "CLI Proxy API",
"subtitle": "Management Center"
},
"auto_login": { "auto_login": {
"title": "Auto Login in Progress...", "title": "Auto Login in Progress...",
"message": "Attempting to connect to server using locally saved connection information" "message": "Attempting to connect to server using locally saved connection information"
@@ -191,6 +198,8 @@
"gemini_keys_add_btn": "Add Key", "gemini_keys_add_btn": "Add Key",
"gemini_base_url_label": "Base URL (Optional):", "gemini_base_url_label": "Base URL (Optional):",
"gemini_base_url_placeholder": "e.g.: https://generativelanguage.googleapis.com", "gemini_base_url_placeholder": "e.g.: https://generativelanguage.googleapis.com",
"gemini_add_modal_proxy_label": "Proxy URL (Optional):",
"gemini_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
"gemini_edit_modal_title": "Edit Gemini API Key", "gemini_edit_modal_title": "Edit Gemini API Key",
"gemini_edit_modal_key_label": "API Key:", "gemini_edit_modal_key_label": "API Key:",
"gemini_delete_confirm": "Are you sure you want to delete this Gemini key?", "gemini_delete_confirm": "Are you sure you want to delete this Gemini key?",
@@ -241,6 +250,31 @@
"claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.", "claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
"claude_models_add_btn": "Add Model", "claude_models_add_btn": "Add Model",
"claude_models_count": "Models Count", "claude_models_count": "Models Count",
"claude_models_fetch_button": "Fetch via /v1/models",
"claude_models_fetch_title": "Pick Models from Claude /v1/models",
"claude_models_fetch_hint": "Call GET /v1/models with Anthropic headers. By default, this sends x-api-key and anthropic-version: 2023-06-01, merged with your custom headers.",
"claude_models_fetch_url_label": "Request URL",
"claude_models_fetch_refresh": "Refresh",
"claude_models_fetch_loading": "Fetching models from Claude /v1/models...",
"claude_models_fetch_empty": "No models returned. Please check Base URL, API key, or headers.",
"claude_models_fetch_error": "Failed to fetch Claude models",
"claude_models_fetch_apply": "Add selected models",
"claude_models_search_label": "Search models",
"claude_models_search_placeholder": "Filter by name, alias, or description",
"claude_models_search_empty": "No models match your search. Try a different keyword.",
"claude_models_fetch_added": "{{count}} new models added",
"claude_test_title": "Connection Test",
"claude_test_hint": "Send a test request to /v1/messages using Anthropic headers to verify this configuration.",
"claude_test_select_placeholder": "Choose from current models",
"claude_test_select_empty": "No models configured. Add models first",
"claude_test_action": "Test",
"claude_test_running": "Sending Claude test request...",
"claude_test_timeout": "Test request timed out after {{seconds}} seconds.",
"claude_test_success": "Test succeeded. Claude model responded.",
"claude_test_failed": "Test failed",
"claude_test_key_required": "Please provide a Claude API key or set x-api-key in custom headers",
"claude_test_model_required": "Please select a model to test",
"claude_test_endpoint_invalid": "Unable to build a valid Claude /v1/messages endpoint",
"vertex_title": "Vertex API Configuration", "vertex_title": "Vertex API Configuration",
"vertex_add_button": "Add Configuration", "vertex_add_button": "Add Configuration",
"vertex_empty_title": "No Vertex Configuration", "vertex_empty_title": "No Vertex Configuration",
@@ -333,7 +367,13 @@
"openai_test_success": "Test succeeded. The model responded.", "openai_test_success": "Test succeeded. The model responded.",
"openai_test_failed": "Test failed", "openai_test_failed": "Test failed",
"openai_test_select_placeholder": "Choose from current models", "openai_test_select_placeholder": "Choose from current models",
"openai_test_select_empty": "No models configured. Add models first" "openai_test_select_empty": "No models configured. Add models first",
"openai_test_single_action": "Test",
"openai_test_all_action": "Test All Keys",
"openai_test_all_hint": "Test connection status for all keys",
"openai_test_all_success": "All {{count}} keys passed the test",
"openai_test_all_failed": "All {{count}} keys failed the test",
"openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed"
}, },
"auth_files": { "auth_files": {
"title": "Auth Files Management", "title": "Auth Files Management",
@@ -376,6 +416,7 @@
"filter_qwen": "Qwen", "filter_qwen": "Qwen",
"filter_gemini": "Gemini", "filter_gemini": "Gemini",
"filter_gemini-cli": "GeminiCLI", "filter_gemini-cli": "GeminiCLI",
"filter_kimi": "Kimi",
"filter_aistudio": "AIStudio", "filter_aistudio": "AIStudio",
"filter_claude": "Claude", "filter_claude": "Claude",
"filter_codex": "Codex", "filter_codex": "Codex",
@@ -387,6 +428,7 @@
"type_qwen": "Qwen", "type_qwen": "Qwen",
"type_gemini": "Gemini", "type_gemini": "Gemini",
"type_gemini-cli": "GeminiCLI", "type_gemini-cli": "GeminiCLI",
"type_kimi": "Kimi",
"type_aistudio": "AIStudio", "type_aistudio": "AIStudio",
"type_claude": "Claude", "type_claude": "Claude",
"type_codex": "Codex", "type_codex": "Codex",
@@ -403,23 +445,39 @@
"models_empty_desc": "This credential may not be loaded by the server yet, or no models are bound to it.", "models_empty_desc": "This credential may not be loaded by the server yet, or no models are bound to it.",
"models_unsupported": "This feature is not supported in the current version", "models_unsupported": "This feature is not supported in the current version",
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again", "models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
"models_excluded_badge": "Excluded", "models_excluded_badge": "Disabled",
"models_excluded_hint": "This model is excluded by OAuth", "models_excluded_hint": "This OAuth model is disabled",
"status_toggle_label": "Enabled", "status_toggle_label": "Enabled",
"status_enabled_success": "\"{{name}}\" enabled", "status_enabled_success": "\"{{name}}\" enabled",
"status_disabled_success": "\"{{name}}\" disabled", "status_disabled_success": "\"{{name}}\" disabled",
"prefix_proxy_button": "Edit prefix/proxy_url", "batch_status_success": "{{count}} files updated successfully",
"prefix_proxy_loading": "Loading credential...", "batch_status_partial": "{{success}} updated, {{failed}} failed",
"prefix_proxy_source_label": "Credential JSON", "batch_delete_title": "Delete Selected Files",
"prefix_label": "prefix", "batch_delete_confirm": "Are you sure you want to delete {{count}} files?",
"proxy_url_label": "proxy_url", "batch_selected": "{{count}} selected",
"batch_select_all": "Select All",
"batch_deselect": "Deselect",
"batch_enable": "Enable",
"batch_disable": "Disable",
"prefix_proxy_button": "Edit Auth Fields",
"auth_field_editor_title": "Edit Auth Fields - {{name}}",
"prefix_proxy_loading": "Loading auth file...",
"prefix_proxy_source_label": "Auth file JSON (preview)",
"prefix_label": "Prefix (prefix)",
"proxy_url_label": "Proxy URL (proxy_url)",
"prefix_placeholder": "", "prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/", "proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.", "priority_label": "Priority (priority)",
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully", "priority_placeholder": "e.g. 10 or -1",
"card_tools_title": "Tools", "priority_hint": "Integers only. Invalid values are ignored. Larger value means higher priority.",
"quota_refresh_single": "Refresh quota", "excluded_models_label": "Excluded models (excluded_models)",
"quota_refresh_hint": "Refresh quota for this credential only", "excluded_models_placeholder": "Comma or newline separated, e.g. model-a, gpt-5-*, *-preview",
"excluded_models_hint": "Saved as an array and normalized by trim/lowercase/dedup/sort.",
"disable_cooling_label": "Disable cooling (disable_cooling)",
"disable_cooling_placeholder": "e.g. true / false / 1 / 0",
"disable_cooling_hint": "Supports booleans, numeric 0/non-0, and strings like true/false/1/0; unparseable values are ignored.",
"prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.",
"prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully",
"quota_refresh_success": "Quota refreshed for \"{{name}}\"", "quota_refresh_success": "Quota refreshed for \"{{name}}\"",
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}" "quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
}, },
@@ -427,7 +485,7 @@
"title": "Antigravity Quota", "title": "Antigravity Quota",
"empty_title": "No Antigravity Auth Files", "empty_title": "No Antigravity Auth Files",
"empty_desc": "Upload an Antigravity credential to view remaining quota.", "empty_desc": "Upload an Antigravity credential to view remaining quota.",
"idle": "Not loaded. Click Refresh Button.", "idle": "Click here to refresh quota",
"loading": "Loading quota...", "loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}", "load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index", "missing_auth_index": "Auth file missing auth_index",
@@ -435,11 +493,31 @@
"refresh_button": "Refresh Quota", "refresh_button": "Refresh Quota",
"fetch_all": "Fetch All" "fetch_all": "Fetch All"
}, },
"claude_quota": {
"title": "Claude Quota",
"empty_title": "No Claude OAuth Files",
"empty_desc": "Log in with Claude OAuth to view quota.",
"idle": "Click here to refresh quota",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"empty_windows": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"five_hour": "5-hour limit",
"seven_day": "7-day limit",
"seven_day_oauth_apps": "7-day OAuth apps",
"seven_day_opus": "7-day Opus",
"seven_day_sonnet": "7-day Sonnet",
"seven_day_cowork": "7-day Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "Extra Usage"
},
"codex_quota": { "codex_quota": {
"title": "Codex Quota", "title": "Codex Quota",
"empty_title": "No Codex Auth Files", "empty_title": "No Codex Auth Files",
"empty_desc": "Upload a Codex credential to view quota.", "empty_desc": "Upload a Codex credential to view quota.",
"idle": "Not loaded. Click Refresh Button.", "idle": "Click here to refresh quota",
"loading": "Loading quota...", "loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}", "load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index", "missing_auth_index": "Auth file missing auth_index",
@@ -452,6 +530,8 @@
"secondary_window": "Weekly limit", "secondary_window": "Weekly limit",
"code_review_primary_window": "Code review 5-hour limit", "code_review_primary_window": "Code review 5-hour limit",
"code_review_secondary_window": "Code review weekly limit", "code_review_secondary_window": "Code review weekly limit",
"additional_primary_window": "{{name}} 5-hour limit",
"additional_secondary_window": "{{name}} weekly limit",
"plan_label": "Plan", "plan_label": "Plan",
"plan_plus": "Plus", "plan_plus": "Plus",
"plan_team": "Team", "plan_team": "Team",
@@ -461,7 +541,7 @@
"title": "Gemini CLI Quota", "title": "Gemini CLI Quota",
"empty_title": "No Gemini CLI Auth Files", "empty_title": "No Gemini CLI Auth Files",
"empty_desc": "Upload a Gemini CLI credential to view remaining quota.", "empty_desc": "Upload a Gemini CLI credential to view remaining quota.",
"idle": "Not loaded. Click Refresh Button.", "idle": "Click here to refresh quota",
"loading": "Loading quota...", "loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}", "load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index", "missing_auth_index": "Auth file missing auth_index",
@@ -491,43 +571,43 @@
"result_file": "Persisted file" "result_file": "Persisted file"
}, },
"oauth_excluded": { "oauth_excluded": {
"title": "OAuth Excluded Models", "title": "OAuth Model Disablement",
"description": "Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.", "description": "Per-provider model disablement is shown as cards; click a card to edit or delete. Wildcards * are supported and the scope follows the auth file filter.",
"add": "Add Exclusion", "add": "Add Disablement",
"add_title": "Add provider exclusion", "add_title": "Add provider model disablement",
"edit_title": "Edit exclusions for {{provider}}", "edit_title": "Edit model disablement for {{provider}}",
"refresh": "Refresh", "refresh": "Refresh",
"refreshing": "Refreshing...", "refreshing": "Refreshing...",
"provider_label": "Provider", "provider_label": "Provider",
"provider_auto": "Follow current filter", "provider_auto": "Follow current filter",
"provider_placeholder": "e.g. gemini-cli", "provider_placeholder": "e.g. gemini-cli",
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.", "provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
"models_label": "Models to exclude", "models_label": "Models to disable",
"models_loading": "Loading models...", "models_loading": "Loading models...",
"models_unsupported": "Current CPA version does not support fetching model lists.", "models_unsupported": "Current CPA version does not support fetching model lists.",
"models_loaded": "{{count}} models loaded. Check the models to exclude.", "models_loaded": "{{count}} models loaded. Check the models to disable.",
"no_models_available": "No models available for this provider.", "no_models_available": "No models available for this provider.",
"save": "Save/Update", "save": "Save/Update",
"saving": "Saving...", "saving": "Saving...",
"save_success": "Excluded models updated", "save_success": "Model disablement updated",
"save_failed": "Failed to update excluded models", "save_failed": "Failed to update model disablement",
"delete": "Delete Provider", "delete": "Delete Provider",
"delete_confirm": "Delete the exclusion list for {{provider}}?", "delete_confirm": "Delete model disablement for {{provider}}?",
"delete_success": "Exclusion list removed", "delete_success": "Provider model disablement removed",
"delete_failed": "Failed to delete exclusion list", "delete_failed": "Failed to delete model disablement",
"deleting": "Deleting...", "deleting": "Deleting...",
"no_models": "No excluded models", "no_models": "No disabled models configured",
"model_count": "{{count}} models excluded", "model_count": "{{count}} models disabled",
"list_empty_all": "No exclusions yet—use “Add Exclusion” to create one.", "list_empty_all": "No provider model disablement yet; click “Add Disablement” to create one.",
"list_empty_filtered": "No exclusions in this scope; click “Add Exclusion” to add.", "list_empty_filtered": "No disabled items in this scope; click “Add Disablement” to add.",
"disconnected": "Connect to the server to view exclusions", "disconnected": "Connect to the server to view model disablement",
"load_failed": "Failed to load exclusion list", "load_failed": "Failed to load model disablement",
"provider_required": "Please enter a provider first", "provider_required": "Please enter a provider first",
"scope_all": "Scope: All providers", "scope_all": "Scope: All providers",
"scope_provider": "Scope: {{provider}}", "scope_provider": "Scope: {{provider}}",
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.", "upgrade_required": "Current CPA version does not support OAuth model disablement. Please upgrade.",
"upgrade_required_title": "Please upgrade CLI Proxy API", "upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version." "upgrade_required_desc": "The current server version does not support fetching OAuth model disablement. Please upgrade to the latest CPA (CLI Proxy API) version and try again."
}, },
"oauth_model_alias": { "oauth_model_alias": {
"title": "OAuth Model Aliases", "title": "OAuth Model Aliases",
@@ -640,6 +720,17 @@
"gemini_cli_oauth_status_error": "Authentication failed:", "gemini_cli_oauth_status_error": "Authentication failed:",
"gemini_cli_oauth_start_error": "Failed to start Gemini CLI OAuth:", "gemini_cli_oauth_start_error": "Failed to start Gemini CLI OAuth:",
"gemini_cli_oauth_polling_error": "Failed to check authentication status:", "gemini_cli_oauth_polling_error": "Failed to check authentication status:",
"kimi_oauth_title": "Kimi OAuth",
"kimi_oauth_button": "Start Kimi Login",
"kimi_oauth_hint": "Login to Kimi service through OAuth device flow, automatically obtain and save authentication files.",
"kimi_oauth_url_label": "Authorization URL:",
"kimi_open_link": "Open Link",
"kimi_copy_link": "Copy Link",
"kimi_oauth_status_waiting": "Waiting for authentication...",
"kimi_oauth_status_success": "Authentication successful!",
"kimi_oauth_status_error": "Authentication failed:",
"kimi_oauth_start_error": "Failed to start Kimi OAuth:",
"kimi_oauth_polling_error": "Failed to check authentication status:",
"qwen_oauth_title": "Qwen OAuth", "qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "Start Qwen Login", "qwen_oauth_button": "Start Qwen Login",
"qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.", "qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.",
@@ -711,6 +802,11 @@
"api_details": "API Details", "api_details": "API Details",
"by_hour": "By Hour", "by_hour": "By Hour",
"by_day": "By Day", "by_day": "By Day",
"range_filter": "Time Range",
"range_all": "All Time",
"range_7h": "Last 7 Hours",
"range_24h": "Last 24 Hours",
"range_7d": "Last 7 Days",
"refresh": "Refresh", "refresh": "Refresh",
"export": "Export", "export": "Export",
"import": "Import", "import": "Import",
@@ -758,12 +854,29 @@
"cost_axis_label": "Cost ($)", "cost_axis_label": "Cost ($)",
"cost_need_price": "Set a model price to view cost stats", "cost_need_price": "Set a model price to view cost stats",
"cost_need_usage": "No usage data available to calculate cost", "cost_need_usage": "No usage data available to calculate cost",
"cost_no_data": "No cost data yet" "cost_no_data": "No cost data yet",
"credential_stats": "Credential Statistics",
"credential_name": "Credential",
"token_breakdown": "Token Type Breakdown",
"input_tokens": "Input Tokens",
"output_tokens": "Output Tokens",
"last_updated": "Updated"
}, },
"stats": { "stats": {
"success": "Success", "success": "Success",
"failure": "Failure" "failure": "Failure"
}, },
"status_bar": {
"success_short": "✓",
"failure_short": "✗",
"no_requests": "No requests"
},
"service_health": {
"title": "Service Health",
"window": "Last 7 days",
"oldest": "Oldest",
"newest": "Latest"
},
"logs": { "logs": {
"title": "Logs Viewer", "title": "Logs Viewer",
"refresh_button": "Refresh Logs", "refresh_button": "Refresh Logs",
@@ -834,6 +947,13 @@
"search_no_results": "No results", "search_no_results": "No results",
"search_prev": "Previous", "search_prev": "Previous",
"search_next": "Next", "search_next": "Next",
"diff": {
"title": "Review Changes",
"current": "Current",
"modified": "Modified",
"confirm": "Confirm Save",
"no_changes": "No changes detected"
},
"tabs": { "tabs": {
"visual": "Visual Editor", "visual": "Visual Editor",
"source": "Source File Editor" "source": "Source File Editor"
@@ -877,9 +997,9 @@
"debug": "Debug Mode", "debug": "Debug Mode",
"debug_desc": "Enable verbose debug logging", "debug_desc": "Enable verbose debug logging",
"commercial_mode": "Commercial Mode", "commercial_mode": "Commercial Mode",
"commercial_mode_desc": "Disable high-overhead middleware to reduce memory under high concurrency", "commercial_mode_desc": "Disable high-overhead middleware to support high concurrency",
"logging_to_file": "Log to File", "logging_to_file": "Log to File",
"logging_to_file_desc": "Save logs to rotating files", "logging_to_file_desc": "Save logs to files",
"usage_statistics": "Usage Statistics", "usage_statistics": "Usage Statistics",
"usage_statistics_desc": "Collect usage statistics", "usage_statistics_desc": "Collect usage statistics",
"logs_max_size": "Log File Size Limit (MB)" "logs_max_size": "Log File Size Limit (MB)"
@@ -932,6 +1052,7 @@
"api_keys": { "api_keys": {
"label": "API Keys List (api-keys)", "label": "API Keys List (api-keys)",
"add": "Add API Key", "add": "Add API Key",
"generate": "Generate",
"empty": "No API keys", "empty": "No API keys",
"hint": "Each entry represents an API key (consistent with 'API Key Management' page style)", "hint": "Each entry represents an API key (consistent with 'API Key Management' page style)",
"edit_title": "Edit API Key", "edit_title": "Edit API Key",
@@ -956,6 +1077,17 @@
"add_param": "Add Parameter", "add_param": "Add Parameter",
"no_rules": "No rules", "no_rules": "No rules",
"add_rule": "Add Rule", "add_rule": "Add Rule",
"provider_default": "Default",
"provider_openai": "OpenAI",
"provider_openai_response": "OpenAI Response",
"provider_gemini": "Gemini",
"provider_claude": "Claude",
"provider_codex": "Codex",
"provider_antigravity": "Antigravity",
"value_type_string": "String",
"value_type_number": "Number",
"value_type_boolean": "Boolean",
"value_type_json": "JSON",
"value_string": "String value", "value_string": "String value",
"value_number": "Number value (e.g., 0.7)", "value_number": "Number value (e.g., 0.7)",
"value_boolean": "true or false", "value_boolean": "true or false",
@@ -979,6 +1111,7 @@
}, },
"system_info": { "system_info": {
"title": "Management Center Info", "title": "Management Center Info",
"about_title": "CLI Proxy API Management Center",
"connection_status_title": "Connection Status", "connection_status_title": "Connection Status",
"api_status_label": "API Status:", "api_status_label": "API Status:",
"config_status_label": "Config Status:", "config_status_label": "Config Status:",
@@ -1083,12 +1216,15 @@
"gemini_api_key": "Gemini API key", "gemini_api_key": "Gemini API key",
"codex_api_key": "Codex API key", "codex_api_key": "Codex API key",
"claude_api_key": "Claude API key", "claude_api_key": "Claude API key",
"commercial_mode_restart_required": "Commercial mode setting changed. Please restart the service for it to take effect",
"copy_failed": "Copy failed",
"link_copied": "Link copied to clipboard" "link_copied": "Link copied to clipboard"
}, },
"language": { "language": {
"switch": "Language", "switch": "Language",
"chinese": "中文", "chinese": "中文",
"english": "English" "english": "English",
"russian": "Русский"
}, },
"theme": { "theme": {
"switch": "Theme", "switch": "Theme",

1252
src/i18n/locales/ru.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -38,19 +38,26 @@
"quota_update_required": "请更新 CPA 版本或检查更新", "quota_update_required": "请更新 CPA 版本或检查更新",
"quota_check_credential": "请检查凭证状态", "quota_check_credential": "请检查凭证状态",
"copy": "复制", "copy": "复制",
"status": "状态",
"action": "操作",
"custom_headers_label": "自定义请求头", "custom_headers_label": "自定义请求头",
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。", "custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
"custom_headers_add": "添加请求头", "custom_headers_add": "添加请求头",
"custom_headers_key_placeholder": "Header 名称,例如 X-Custom-Header", "custom_headers_key_placeholder": "Header 名称,例如 X-Custom-Header",
"custom_headers_value_placeholder": "Header 值", "custom_headers_value_placeholder": "Header 值",
"model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022", "model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022",
"model_alias_placeholder": "模型别名 (可选)" "model_alias_placeholder": "模型别名 (可选)",
"invalid_provider_index": "无效的提供商索引。"
}, },
"title": { "title": {
"main": "CLI Proxy API Management Center", "main": "CLI Proxy API Management Center",
"login": "CLI Proxy API Management Center", "login": "CLI Proxy API Management Center",
"abbr": "CPAMC" "abbr": "CPAMC"
}, },
"splash": {
"title": "CLI Proxy API",
"subtitle": "管理中心"
},
"auto_login": { "auto_login": {
"title": "正在自动登录...", "title": "正在自动登录...",
"message": "正在使用本地保存的连接信息尝试连接服务器" "message": "正在使用本地保存的连接信息尝试连接服务器"
@@ -191,6 +198,8 @@
"gemini_keys_add_btn": "添加密钥", "gemini_keys_add_btn": "添加密钥",
"gemini_base_url_label": "Base URL (可选)", "gemini_base_url_label": "Base URL (可选)",
"gemini_base_url_placeholder": "例如: https://generativelanguage.googleapis.com", "gemini_base_url_placeholder": "例如: https://generativelanguage.googleapis.com",
"gemini_add_modal_proxy_label": "代理 URL (可选):",
"gemini_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
"gemini_edit_modal_title": "编辑Gemini API密钥", "gemini_edit_modal_title": "编辑Gemini API密钥",
"gemini_edit_modal_key_label": "API密钥:", "gemini_edit_modal_key_label": "API密钥:",
"gemini_delete_confirm": "确定要删除这个Gemini密钥吗", "gemini_delete_confirm": "确定要删除这个Gemini密钥吗",
@@ -241,6 +250,31 @@
"claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。", "claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
"claude_models_add_btn": "添加模型", "claude_models_add_btn": "添加模型",
"claude_models_count": "模型数量", "claude_models_count": "模型数量",
"claude_models_fetch_button": "从 /v1/models 获取",
"claude_models_fetch_title": "从 Claude /v1/models 选择模型",
"claude_models_fetch_hint": "按 Anthropic 规范请求 GET /v1/models默认附带 x-api-key 与 anthropic-version: 2023-06-01也会合并你配置的自定义请求头。",
"claude_models_fetch_url_label": "请求地址",
"claude_models_fetch_refresh": "重新获取",
"claude_models_fetch_loading": "正在从 Claude /v1/models 获取模型列表...",
"claude_models_fetch_empty": "未获取到模型,请检查 Base URL、API Key 或请求头。",
"claude_models_fetch_error": "获取 Claude 模型失败",
"claude_models_fetch_apply": "添加所选模型",
"claude_models_search_label": "搜索模型",
"claude_models_search_placeholder": "按名称、别名或描述筛选",
"claude_models_search_empty": "没有匹配的模型,请更换关键字试试。",
"claude_models_fetch_added": "已添加 {{count}} 个新模型",
"claude_test_title": "连通性测试",
"claude_test_hint": "按 Anthropic 规范向 /v1/messages 发送测试请求,验证当前配置是否可用。",
"claude_test_select_placeholder": "从当前模型列表选择",
"claude_test_select_empty": "当前未配置模型,请先添加模型",
"claude_test_action": "测试",
"claude_test_running": "正在发送 Claude 测试请求...",
"claude_test_timeout": "测试请求超时({{seconds}}秒)。",
"claude_test_success": "测试成功Claude 模型可用。",
"claude_test_failed": "测试失败",
"claude_test_key_required": "请先填写 Claude API Key 或在自定义请求头中设置 x-api-key",
"claude_test_model_required": "请选择要测试的模型",
"claude_test_endpoint_invalid": "无法构造有效的 Claude /v1/messages 请求地址",
"vertex_title": "Vertex API 配置", "vertex_title": "Vertex API 配置",
"vertex_add_button": "添加配置", "vertex_add_button": "添加配置",
"vertex_empty_title": "暂无Vertex配置", "vertex_empty_title": "暂无Vertex配置",
@@ -333,7 +367,13 @@
"openai_test_success": "测试成功,模型可用。", "openai_test_success": "测试成功,模型可用。",
"openai_test_failed": "测试失败", "openai_test_failed": "测试失败",
"openai_test_select_placeholder": "从当前模型列表选择", "openai_test_select_placeholder": "从当前模型列表选择",
"openai_test_select_empty": "当前未配置模型,请先添加模型" "openai_test_select_empty": "当前未配置模型,请先添加模型",
"openai_test_single_action": "测试",
"openai_test_all_action": "一键测试全部密钥",
"openai_test_all_hint": "测试所有密钥的连接状态",
"openai_test_all_success": "所有 {{count}} 个密钥测试通过",
"openai_test_all_failed": "所有 {{count}} 个密钥测试失败",
"openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败"
}, },
"auth_files": { "auth_files": {
"title": "认证文件管理", "title": "认证文件管理",
@@ -376,6 +416,7 @@
"filter_qwen": "Qwen", "filter_qwen": "Qwen",
"filter_gemini": "Gemini", "filter_gemini": "Gemini",
"filter_gemini-cli": "GeminiCLI", "filter_gemini-cli": "GeminiCLI",
"filter_kimi": "Kimi",
"filter_aistudio": "AIStudio", "filter_aistudio": "AIStudio",
"filter_claude": "Claude", "filter_claude": "Claude",
"filter_codex": "Codex", "filter_codex": "Codex",
@@ -387,6 +428,7 @@
"type_qwen": "Qwen", "type_qwen": "Qwen",
"type_gemini": "Gemini", "type_gemini": "Gemini",
"type_gemini-cli": "GeminiCLI", "type_gemini-cli": "GeminiCLI",
"type_kimi": "Kimi",
"type_aistudio": "AIStudio", "type_aistudio": "AIStudio",
"type_claude": "Claude", "type_claude": "Claude",
"type_codex": "Codex", "type_codex": "Codex",
@@ -403,23 +445,39 @@
"models_empty_desc": "该认证凭证可能尚未被服务器加载或没有绑定任何模型", "models_empty_desc": "该认证凭证可能尚未被服务器加载或没有绑定任何模型",
"models_unsupported": "当前版本不支持此功能", "models_unsupported": "当前版本不支持此功能",
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试", "models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
"models_excluded_badge": "已排除", "models_excluded_badge": "已禁用",
"models_excluded_hint": "此模型已被 OAuth 排除", "models_excluded_hint": "此 OAuth 模型已被禁用",
"status_toggle_label": "启用", "status_toggle_label": "启用",
"status_enabled_success": "已启用 \"{{name}}\"", "status_enabled_success": "已启用 \"{{name}}\"",
"status_disabled_success": "已停用 \"{{name}}\"", "status_disabled_success": "已停用 \"{{name}}\"",
"prefix_proxy_button": "配置 prefix/proxy_url", "batch_status_success": "已成功更新 {{count}} 个文件",
"prefix_proxy_loading": "正在加载凭证文件...", "batch_status_partial": "成功 {{success}} 个,失败 {{failed}} 个",
"prefix_proxy_source_label": "凭证 JSON", "batch_delete_title": "删除选中文件",
"prefix_label": "prefix", "batch_delete_confirm": "确定要删除 {{count}} 个文件吗?",
"proxy_url_label": "proxy_url", "batch_selected": "已选 {{count}} 项",
"batch_select_all": "全选",
"batch_deselect": "取消选择",
"batch_enable": "启用",
"batch_disable": "禁用",
"prefix_proxy_button": "编辑认证文件字段",
"auth_field_editor_title": "编辑认证文件字段 - {{name}}",
"prefix_proxy_loading": "正在加载认证文件...",
"prefix_proxy_source_label": "认证文件 JSON预览",
"prefix_label": "前缀prefix",
"proxy_url_label": "代理 URLproxy_url",
"prefix_placeholder": "", "prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/", "proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。", "priority_label": "优先级priority",
"prefix_proxy_saved_success": "已更新 \"{{name}}\"", "priority_placeholder": "例如: 10 或 -1",
"card_tools_title": "配置面板", "priority_hint": "仅支持整数;非法值会被忽略。数值越大优先级越高。",
"quota_refresh_single": "刷新额度", "excluded_models_label": "排除模型excluded_models",
"quota_refresh_hint": "仅刷新当前凭证的额度数据", "excluded_models_placeholder": "用逗号或换行分隔,例如: model-a, gpt-5-*, *-preview",
"excluded_models_hint": "保存为数组;会自动 trim、小写、去重并排序。",
"disable_cooling_label": "禁用冷却disable_cooling",
"disable_cooling_placeholder": "例如: true / false / 1 / 0",
"disable_cooling_hint": "支持布尔值、0/非0 数字或字符串 true/false/1/0无法解析时忽略。",
"prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。",
"prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"",
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度", "quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}" "quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
}, },
@@ -427,7 +485,7 @@
"title": "Antigravity 额度", "title": "Antigravity 额度",
"empty_title": "暂无 Antigravity 认证", "empty_title": "暂无 Antigravity 认证",
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。", "empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。", "idle": "点击此处刷新额度",
"loading": "正在加载额度...", "loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}", "load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index", "missing_auth_index": "认证文件缺少 auth_index",
@@ -435,11 +493,31 @@
"refresh_button": "刷新额度", "refresh_button": "刷新额度",
"fetch_all": "获取全部" "fetch_all": "获取全部"
}, },
"claude_quota": {
"title": "Claude 额度",
"empty_title": "暂无 Claude OAuth 认证",
"empty_desc": "使用 Claude OAuth 登录后即可查看额度。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"empty_windows": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"five_hour": "5 小时限额",
"seven_day": "7 天限额",
"seven_day_oauth_apps": "7 天 OAuth 应用",
"seven_day_opus": "7 天 Opus",
"seven_day_sonnet": "7 天 Sonnet",
"seven_day_cowork": "7 天 Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "额外用量"
},
"codex_quota": { "codex_quota": {
"title": "Codex 额度", "title": "Codex 额度",
"empty_title": "暂无 Codex 认证", "empty_title": "暂无 Codex 认证",
"empty_desc": "上传 Codex 认证文件后即可查看额度。", "empty_desc": "上传 Codex 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。", "idle": "点击此处刷新额度",
"loading": "正在加载额度...", "loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}", "load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index", "missing_auth_index": "认证文件缺少 auth_index",
@@ -452,6 +530,8 @@
"secondary_window": "周限额", "secondary_window": "周限额",
"code_review_primary_window": "代码审查 5 小时限额", "code_review_primary_window": "代码审查 5 小时限额",
"code_review_secondary_window": "代码审查周限额", "code_review_secondary_window": "代码审查周限额",
"additional_primary_window": "{{name}} 5 小时限额",
"additional_secondary_window": "{{name}} 周限额",
"plan_label": "套餐", "plan_label": "套餐",
"plan_plus": "Plus", "plan_plus": "Plus",
"plan_team": "Team", "plan_team": "Team",
@@ -461,7 +541,7 @@
"title": "Gemini CLI 额度", "title": "Gemini CLI 额度",
"empty_title": "暂无 Gemini CLI 认证", "empty_title": "暂无 Gemini CLI 认证",
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。", "empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。", "idle": "点击此处刷新额度",
"loading": "正在加载额度...", "loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}", "load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index", "missing_auth_index": "认证文件缺少 auth_index",
@@ -491,43 +571,43 @@
"result_file": "存储文件" "result_file": "存储文件"
}, },
"oauth_excluded": { "oauth_excluded": {
"title": "OAuth 排除列表", "title": "OAuth 模型禁用",
"description": "按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。", "description": "按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。",
"add": "新增排除", "add": "新增禁用",
"add_title": "新增提供商排除列表", "add_title": "新增提供商模型禁用",
"edit_title": "编辑 {{provider}} 的排除列表", "edit_title": "编辑 {{provider}} 的模型禁用",
"refresh": "刷新", "refresh": "刷新",
"refreshing": "刷新中...", "refreshing": "刷新中...",
"provider_label": "提供商", "provider_label": "提供商",
"provider_auto": "跟随当前过滤", "provider_auto": "跟随当前过滤",
"provider_placeholder": "例如 gemini-cli / openai", "provider_placeholder": "例如 gemini-cli / openai",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。", "provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"models_label": "排除的模型", "models_label": "禁用的模型",
"models_loading": "正在加载模型列表...", "models_loading": "正在加载模型列表...",
"models_unsupported": "当前 CPA 版本不支持获取模型列表。", "models_unsupported": "当前 CPA 版本不支持获取模型列表。",
"models_loaded": "已加载 {{count}} 个模型,勾选要排除的模型。", "models_loaded": "已加载 {{count}} 个模型,勾选要禁用的模型。",
"no_models_available": "该提供商暂无可用模型列表。", "no_models_available": "该提供商暂无可用模型列表。",
"save": "保存/更新", "save": "保存/更新",
"saving": "正在保存...", "saving": "正在保存...",
"save_success": "排除列表已更新", "save_success": "模型禁用已更新",
"save_failed": "更新排除列表失败", "save_failed": "更新模型禁用失败",
"delete": "删除提供商", "delete": "删除提供商",
"delete_confirm": "确定要删除 {{provider}} 的排除列表吗?", "delete_confirm": "确定要删除 {{provider}} 的模型禁用吗?",
"delete_success": "已删除该提供商的排除列表", "delete_success": "已删除该提供商的模型禁用",
"delete_failed": "删除排除列表失败", "delete_failed": "删除模型禁用失败",
"deleting": "正在删除...", "deleting": "正在删除...",
"no_models": "未配置排除模型", "no_models": "未配置禁用模型",
"model_count": "排除 {{count}} 个模型", "model_count": "禁用 {{count}} 个模型",
"list_empty_all": "暂无任何提供商的排除列表,点击“新增排除”创建。", "list_empty_all": "暂无任何提供商的模型禁用,点击“新增禁用”创建。",
"list_empty_filtered": "当前筛选下没有排除项,点击“新增排除”添加。", "list_empty_filtered": "当前筛选下没有禁用项,点击“新增禁用”添加。",
"disconnected": "请先连接服务器以查看排除列表", "disconnected": "请先连接服务器以查看模型禁用",
"load_failed": "加载排除列表失败", "load_failed": "加载模型禁用失败",
"provider_required": "请先填写提供商名称", "provider_required": "请先填写提供商名称",
"scope_all": "当前范围:全局(显示所有提供商)", "scope_all": "当前范围:全局(显示所有提供商)",
"scope_provider": "当前范围:{{provider}}", "scope_provider": "当前范围:{{provider}}",
"upgrade_required": "当前 CPA 版本不支持模型排除列表,请升级 CPA 版本", "upgrade_required": "当前 CPA 版本不支持 OAuth 模型禁用,请升级 CPA 版本",
"upgrade_required_title": "需要升级 CPA 版本", "upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。" "upgrade_required_desc": "当前服务器版本不支持获取 OAuth 模型禁用功能,请升级到最新版本的 CPACLI Proxy API后重试。"
}, },
"oauth_model_alias": { "oauth_model_alias": {
"title": "OAuth 模型别名", "title": "OAuth 模型别名",
@@ -640,6 +720,17 @@
"gemini_cli_oauth_status_error": "认证失败:", "gemini_cli_oauth_status_error": "认证失败:",
"gemini_cli_oauth_start_error": "启动 Gemini CLI OAuth 失败:", "gemini_cli_oauth_start_error": "启动 Gemini CLI OAuth 失败:",
"gemini_cli_oauth_polling_error": "检查认证状态失败:", "gemini_cli_oauth_polling_error": "检查认证状态失败:",
"kimi_oauth_title": "Kimi OAuth",
"kimi_oauth_button": "开始 Kimi 登录",
"kimi_oauth_hint": "通过设备授权流程登录 Kimi 服务,自动获取并保存认证文件。",
"kimi_oauth_url_label": "授权链接:",
"kimi_open_link": "打开链接",
"kimi_copy_link": "复制链接",
"kimi_oauth_status_waiting": "等待认证中...",
"kimi_oauth_status_success": "认证成功!",
"kimi_oauth_status_error": "认证失败:",
"kimi_oauth_start_error": "启动 Kimi OAuth 失败:",
"kimi_oauth_polling_error": "检查认证状态失败:",
"qwen_oauth_title": "Qwen OAuth", "qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "开始 Qwen 登录", "qwen_oauth_button": "开始 Qwen 登录",
"qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。", "qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。",
@@ -711,6 +802,11 @@
"api_details": "API 详细统计", "api_details": "API 详细统计",
"by_hour": "按小时", "by_hour": "按小时",
"by_day": "按天", "by_day": "按天",
"range_filter": "时间范围",
"range_all": "全部时间",
"range_7h": "最近7小时",
"range_24h": "最近24小时",
"range_7d": "最近7天",
"refresh": "刷新", "refresh": "刷新",
"export": "导出数据", "export": "导出数据",
"import": "导入数据", "import": "导入数据",
@@ -758,12 +854,29 @@
"cost_axis_label": "花费 ($)", "cost_axis_label": "花费 ($)",
"cost_need_price": "请先设置模型价格", "cost_need_price": "请先设置模型价格",
"cost_need_usage": "暂无使用数据,无法计算花费", "cost_need_usage": "暂无使用数据,无法计算花费",
"cost_no_data": "没有可计算的花费数据" "cost_no_data": "没有可计算的花费数据",
"credential_stats": "凭证统计",
"credential_name": "凭证",
"token_breakdown": "Token 类型分布",
"input_tokens": "输入 Tokens",
"output_tokens": "输出 Tokens",
"last_updated": "更新于"
}, },
"stats": { "stats": {
"success": "成功", "success": "成功",
"failure": "失败" "failure": "失败"
}, },
"status_bar": {
"success_short": "✓",
"failure_short": "✗",
"no_requests": "无请求"
},
"service_health": {
"title": "服务健康监测",
"window": "最近 7 天",
"oldest": "最早",
"newest": "最新"
},
"logs": { "logs": {
"title": "日志查看", "title": "日志查看",
"refresh_button": "刷新日志", "refresh_button": "刷新日志",
@@ -834,6 +947,13 @@
"search_no_results": "无结果", "search_no_results": "无结果",
"search_prev": "上一个", "search_prev": "上一个",
"search_next": "下一个", "search_next": "下一个",
"diff": {
"title": "确认变更",
"current": "当前配置",
"modified": "修改后",
"confirm": "确认保存",
"no_changes": "未检测到变更"
},
"tabs": { "tabs": {
"visual": "可视化编辑", "visual": "可视化编辑",
"source": "源文件编辑" "source": "源文件编辑"
@@ -877,9 +997,9 @@
"debug": "调试模式", "debug": "调试模式",
"debug_desc": "启用详细的调试日志", "debug_desc": "启用详细的调试日志",
"commercial_mode": "商业模式", "commercial_mode": "商业模式",
"commercial_mode_desc": "禁用高开销中间件以减少高并发内存", "commercial_mode_desc": "禁用高开销中间件以支持高并发",
"logging_to_file": "写入日志文件", "logging_to_file": "写入日志文件",
"logging_to_file_desc": "将日志保存到滚动文件", "logging_to_file_desc": "将日志保存到文件",
"usage_statistics": "使用统计", "usage_statistics": "使用统计",
"usage_statistics_desc": "收集使用统计信息", "usage_statistics_desc": "收集使用统计信息",
"logs_max_size": "日志文件大小限制 (MB)" "logs_max_size": "日志文件大小限制 (MB)"
@@ -932,6 +1052,7 @@
"api_keys": { "api_keys": {
"label": "API 密钥列表 (api-keys)", "label": "API 密钥列表 (api-keys)",
"add": "添加 API 密钥", "add": "添加 API 密钥",
"generate": "生成",
"empty": "暂无 API 密钥", "empty": "暂无 API 密钥",
"hint": "每个条目代表一个 API 密钥(与 「API 密钥管理」 页面样式一致)", "hint": "每个条目代表一个 API 密钥(与 「API 密钥管理」 页面样式一致)",
"edit_title": "编辑 API 密钥", "edit_title": "编辑 API 密钥",
@@ -956,6 +1077,17 @@
"add_param": "添加参数", "add_param": "添加参数",
"no_rules": "暂无规则", "no_rules": "暂无规则",
"add_rule": "添加规则", "add_rule": "添加规则",
"provider_default": "默认",
"provider_openai": "OpenAI",
"provider_openai_response": "OpenAI Response",
"provider_gemini": "Gemini",
"provider_claude": "Claude",
"provider_codex": "Codex",
"provider_antigravity": "Antigravity",
"value_type_string": "字符串",
"value_type_number": "数字",
"value_type_boolean": "布尔",
"value_type_json": "JSON",
"value_string": "字符串值", "value_string": "字符串值",
"value_number": "数字值 (如 0.7)", "value_number": "数字值 (如 0.7)",
"value_boolean": "true 或 false", "value_boolean": "true 或 false",
@@ -979,6 +1111,7 @@
}, },
"system_info": { "system_info": {
"title": "管理中心信息", "title": "管理中心信息",
"about_title": "CLI Proxy API Management Center",
"connection_status_title": "连接状态", "connection_status_title": "连接状态",
"api_status_label": "API 状态:", "api_status_label": "API 状态:",
"config_status_label": "配置状态:", "config_status_label": "配置状态:",
@@ -1083,12 +1216,15 @@
"gemini_api_key": "Gemini API密钥", "gemini_api_key": "Gemini API密钥",
"codex_api_key": "Codex API密钥", "codex_api_key": "Codex API密钥",
"claude_api_key": "Claude API密钥", "claude_api_key": "Claude API密钥",
"commercial_mode_restart_required": "商业模式开关已变更,请重启服务后生效",
"copy_failed": "复制失败",
"link_copied": "已复制" "link_copied": "已复制"
}, },
"language": { "language": {
"switch": "语言", "switch": "语言",
"chinese": "中文", "chinese": "中文",
"english": "English" "english": "English",
"russian": "Русский"
}, },
"theme": { "theme": {
"switch": "主题", "switch": "主题",

View File

@@ -254,17 +254,8 @@ export function AiProvidersAmpcodeEditPage() {
disabled={loading || saving || disableControls} disabled={loading || saving || disableControls}
hint={t('ai_providers.ampcode_upstream_api_key_hint')} hint={t('ai_providers.ampcode_upstream_api_key_hint')}
/> />
<div <div className={layoutStyles.upstreamApiKeyRow}>
style={{ <div className={layoutStyles.upstreamApiKeyHint}>
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', { {t('ai_providers.ampcode_upstream_api_key_current', {
key: config?.ampcode?.upstreamApiKey key: config?.ampcode?.upstreamApiKey
? maskApiKey(config.ampcode.upstreamApiKey) ? maskApiKey(config.ampcode.upstreamApiKey)
@@ -302,6 +293,8 @@ export function AiProvidersAmpcodeEditPage() {
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')} addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')} namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')} aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={loading || saving || disableControls} disabled={loading || saving || disableControls}
/> />
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div> <div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>

View File

@@ -0,0 +1,357 @@
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { providersApi } from '@/services/api';
import { useAuthStore, useClaudeEditDraftStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
import type { ModelEntry, ProviderFormState } from '@/components/providers/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
type LocationState = { fromAiProviders?: boolean } | null;
type TestStatus = 'idle' | 'loading' | 'success' | 'error';
export type ClaudeEditOutletContext = {
hasIndexParam: boolean;
editIndex: number | null;
invalidIndexParam: boolean;
invalidIndex: boolean;
disableControls: boolean;
loading: boolean;
saving: boolean;
form: ProviderFormState;
setForm: Dispatch<SetStateAction<ProviderFormState>>;
testModel: string;
setTestModel: Dispatch<SetStateAction<string>>;
testStatus: TestStatus;
setTestStatus: Dispatch<SetStateAction<TestStatus>>;
testMessage: string;
setTestMessage: Dispatch<SetStateAction<string>>;
availableModels: string[];
handleBack: () => void;
handleSave: () => Promise<void>;
mergeDiscoveredModels: (selectedModels: ModelInfo[]) => void;
};
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
excludedText: '',
});
const parseIndexParam = (value: string | undefined) => {
if (!value) return null;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
};
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
export function AiProvidersClaudeEditLayout() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { showNotification } = useNotificationStore();
const params = useParams<{ index?: string }>();
const hasIndexParam = typeof params.index === 'string';
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
const invalidIndexParam = hasIndexParam && editIndex === null;
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const disableControls = connectionStatus !== 'connected';
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const isCacheValid = useConfigStore((state) => state.isCacheValid);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const [configs, setConfigs] = useState<ProviderKeyConfig[]>(() => config?.claudeApiKeys ?? []);
const [loading, setLoading] = useState(() => !isCacheValid('claude-api-key'));
const [saving, setSaving] = useState(false);
const draftKey = useMemo(() => {
if (invalidIndexParam) return `claude:invalid:${params.index ?? 'unknown'}`;
if (editIndex === null) return 'claude:new';
return `claude:${editIndex}`;
}, [editIndex, invalidIndexParam, params.index]);
const draft = useClaudeEditDraftStore((state) => state.drafts[draftKey]);
const ensureDraft = useClaudeEditDraftStore((state) => state.ensureDraft);
const initDraft = useClaudeEditDraftStore((state) => state.initDraft);
const clearDraft = useClaudeEditDraftStore((state) => state.clearDraft);
const setDraftForm = useClaudeEditDraftStore((state) => state.setDraftForm);
const setDraftTestModel = useClaudeEditDraftStore((state) => state.setDraftTestModel);
const setDraftTestStatus = useClaudeEditDraftStore((state) => state.setDraftTestStatus);
const setDraftTestMessage = useClaudeEditDraftStore((state) => state.setDraftTestMessage);
const form = draft?.form ?? buildEmptyForm();
const testModel = draft?.testModel ?? '';
const testStatus = draft?.testStatus ?? 'idle';
const testMessage = draft?.testMessage ?? '';
const setForm: Dispatch<SetStateAction<ProviderFormState>> = useCallback(
(action) => {
setDraftForm(draftKey, action);
},
[draftKey, setDraftForm]
);
const setTestModel: Dispatch<SetStateAction<string>> = useCallback(
(action) => {
setDraftTestModel(draftKey, action);
},
[draftKey, setDraftTestModel]
);
const setTestStatus: Dispatch<SetStateAction<TestStatus>> = useCallback(
(action) => {
setDraftTestStatus(draftKey, action);
},
[draftKey, setDraftTestStatus]
);
const setTestMessage: Dispatch<SetStateAction<string>> = useCallback(
(action) => {
setDraftTestMessage(draftKey, action);
},
[draftKey, setDraftTestMessage]
);
const initialData = useMemo(() => {
if (editIndex === null) return undefined;
return configs[editIndex];
}, [configs, editIndex]);
const invalidIndex = editIndex !== null && !initialData;
const availableModels = useMemo(
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
[form.modelEntries]
);
useEffect(() => {
ensureDraft(draftKey);
}, [draftKey, ensureDraft]);
const handleBack = useCallback(() => {
clearDraft(draftKey);
const state = location.state as LocationState;
if (state?.fromAiProviders) {
navigate(-1);
return;
}
navigate('/ai-providers', { replace: true });
}, [clearDraft, draftKey, location.state, navigate]);
useEffect(() => {
let cancelled = false;
const hasValidCache = isCacheValid('claude-api-key');
if (!hasValidCache) {
setLoading(true);
}
fetchConfig('claude-api-key')
.then((value) => {
if (cancelled) return;
setConfigs(Array.isArray(value) ? (value as ProviderKeyConfig[]) : []);
})
.catch((err: unknown) => {
if (cancelled) return;
const message = getErrorMessage(err) || t('notification.refresh_failed');
showNotification(`${t('notification.load_failed')}: ${message}`, 'error');
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [fetchConfig, isCacheValid, showNotification, t]);
useEffect(() => {
if (loading) return;
if (draft?.initialized) return;
if (initialData) {
const seededForm: ProviderFormState = {
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
};
const available = seededForm.modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
initDraft(draftKey, {
form: seededForm,
testModel: available[0] || '',
testStatus: 'idle',
testMessage: '',
});
return;
}
initDraft(draftKey, {
form: buildEmptyForm(),
testModel: '',
testStatus: 'idle',
testMessage: '',
});
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
const resolvedLoading = !draft?.initialized;
useEffect(() => {
if (resolvedLoading) return;
if (availableModels.length === 0) {
if (testModel) {
setTestModel('');
setTestStatus('idle');
setTestMessage('');
}
return;
}
if (!testModel || !availableModels.includes(testModel)) {
setTestModel(availableModels[0]);
setTestStatus('idle');
setTestMessage('');
}
}, [availableModels, resolvedLoading, setTestMessage, setTestModel, setTestStatus, testModel]);
const mergeDiscoveredModels = useCallback(
(selectedModels: ModelInfo[]) => {
if (!selectedModels.length) return;
let addedCount = 0;
setForm((prev) => {
const mergedMap = new Map<string, ModelEntry>();
prev.modelEntries.forEach((entry) => {
const name = entry.name.trim();
if (!name) return;
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
});
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());
return {
...prev,
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
};
});
if (addedCount > 0) {
showNotification(t('ai_providers.claude_models_fetch_added', { count: addedCount }), 'success');
}
},
[setForm, showNotification, t]
);
const handleSave = useCallback(async () => {
const canSave =
!disableControls && !saving && !resolvedLoading && !invalidIndexParam && !invalidIndex;
if (!canSave) return;
setSaving(true);
try {
const payload: ProviderKeyConfig = {
apiKey: form.apiKey.trim(),
prefix: form.prefix?.trim() || undefined,
baseUrl: (form.baseUrl ?? '').trim() || undefined,
proxyUrl: form.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(form.headers),
models: form.modelEntries
.map((entry) => {
const name = entry.name.trim();
if (!name) return null;
const alias = entry.alias.trim();
return { name, alias: alias || name };
})
.filter(Boolean) as ProviderKeyConfig['models'],
excludedModels: parseExcludedModels(form.excludedText),
};
const nextList =
editIndex !== null
? configs.map((item, idx) => (idx === editIndex ? payload : item))
: [...configs, payload];
await providersApi.saveClaudeConfigs(nextList);
setConfigs(nextList);
updateConfigValue('claude-api-key', nextList);
clearCache('claude-api-key');
showNotification(
editIndex !== null ? t('notification.claude_config_updated') : t('notification.claude_config_added'),
'success'
);
handleBack();
} catch (err: unknown) {
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
} finally {
setSaving(false);
}
}, [
clearCache,
configs,
disableControls,
editIndex,
form,
handleBack,
invalidIndex,
invalidIndexParam,
resolvedLoading,
saving,
showNotification,
t,
updateConfigValue,
]);
return (
<Outlet
context={{
hasIndexParam,
editIndex,
invalidIndexParam,
invalidIndex,
disableControls,
loading: resolvedLoading,
saving,
form,
setForm,
testModel,
setTestModel,
testStatus,
setTestStatus,
testMessage,
setTestMessage,
availableModels,
handleBack,
handleSave,
mergeDiscoveredModels,
} satisfies ClaudeEditOutletContext}
/>
);
}

View File

@@ -1,88 +1,75 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useOutletContext } from 'react-router-dom';
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 { Select } from '@/components/ui/Select';
import { HeaderInputList } from '@/components/ui/HeaderInputList'; import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { ModelInputList } from '@/components/ui/ModelInputList'; import { ModelInputList } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack'; import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell'; import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { providersApi } from '@/services/api'; import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import { useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types'; import { buildHeaderObject } from '@/utils/headers';
import { buildHeaderObject, headersToEntries } from '@/utils/headers'; import { buildClaudeMessagesEndpoint } from '@/components/providers/utils';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils'; import type { ClaudeEditOutletContext } from './AiProvidersClaudeEditLayout';
import type { ProviderFormState } from '@/components/providers'; import styles from './AiProvidersPage.module.scss';
import layoutStyles from './AiProvidersEditLayout.module.scss'; import layoutStyles from './AiProvidersEditLayout.module.scss';
type LocationState = { fromAiProviders?: boolean } | null; const CLAUDE_TEST_TIMEOUT_MS = 30_000;
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
const buildEmptyForm = (): ProviderFormState => ({ const getErrorMessage = (err: unknown) => {
apiKey: '', if (err instanceof Error) return err.message;
prefix: '', if (typeof err === 'string') return err;
baseUrl: '', return '';
proxyUrl: '', };
headers: [],
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
excludedText: '',
});
const parseIndexParam = (value: string | undefined) => { const hasHeader = (headers: Record<string, string>, name: string) => {
if (!value) return null; const target = name.toLowerCase();
const parsed = Number.parseInt(value, 10); return Object.keys(headers).some((key) => key.toLowerCase() === target);
return Number.isFinite(parsed) ? parsed : null; };
const resolveBearerTokenFromAuthorization = (headers: Record<string, string>): string => {
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === 'authorization');
if (!entry) return '';
const value = String(entry[1] ?? '').trim();
if (!value) return '';
const match = value.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || '';
}; };
export function AiProvidersClaudeEditPage() { export function AiProvidersClaudeEditPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const params = useParams<{ index?: string }>();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const {
const disableControls = connectionStatus !== 'connected'; hasIndexParam,
invalidIndexParam,
invalidIndex,
disableControls,
loading,
saving,
form,
setForm,
testModel,
setTestModel,
testStatus,
setTestStatus,
testMessage,
setTestMessage,
availableModels,
handleBack,
handleSave,
} = useOutletContext<ClaudeEditOutletContext>();
const fetchConfig = useConfigStore((state) => state.fetchConfig); const title = hasIndexParam
const updateConfigValue = useConfigStore((state) => state.updateConfigValue); ? t('ai_providers.claude_edit_modal_title')
const clearCache = useConfigStore((state) => state.clearCache); : t('ai_providers.claude_add_modal_title');
const [configs, setConfigs] = useState<ProviderKeyConfig[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
const hasIndexParam = typeof params.index === 'string';
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
const invalidIndexParam = hasIndexParam && editIndex === null;
const initialData = useMemo(() => {
if (editIndex === null) return undefined;
return configs[editIndex];
}, [configs, editIndex]);
const invalidIndex = editIndex !== null && !initialData;
const title =
editIndex !== null
? t('ai_providers.claude_edit_modal_title')
: t('ai_providers.claude_add_modal_title');
const handleBack = useCallback(() => {
const state = location.state as LocationState;
if (state?.fromAiProviders) {
navigate(-1);
return;
}
navigate('/ai-providers', { replace: true });
}, [location.state, navigate]);
const swipeRef = useEdgeSwipeBack({ onBack: handleBack }); const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
const [isTesting, setIsTesting] = useState(false);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -94,101 +81,163 @@ export function AiProvidersClaudeEditPage() {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleBack]); }, [handleBack]);
useEffect(() => { const canSave =
let cancelled = false; !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex && !isTesting;
setLoading(true);
setError('');
fetchConfig('claude-api-key') const modelSelectOptions = useMemo(() => {
.then((value) => { const seen = new Set<string>();
if (cancelled) return; return form.modelEntries.reduce<Array<{ value: string; label: string }>>((acc, entry) => {
setConfigs(Array.isArray(value) ? (value as ProviderKeyConfig[]) : []); const name = entry.name.trim();
}) if (!name || seen.has(name)) return acc;
.catch((err: unknown) => { seen.add(name);
if (cancelled) return; const alias = entry.alias.trim();
const message = err instanceof Error ? err.message : ''; acc.push({
setError(message || t('notification.refresh_failed')); value: name,
}) label: alias && alias !== name ? `${name} (${alias})` : name,
.finally(() => {
if (cancelled) return;
setLoading(false);
}); });
return acc;
}, []);
}, [form.modelEntries]);
return () => { const connectivityConfigSignature = useMemo(() => {
cancelled = true; const headersSignature = form.headers
}; .map((entry) => `${entry.key.trim()}:${entry.value.trim()}`)
}, [fetchConfig, t]); .join('|');
const modelsSignature = form.modelEntries
.map((entry) => `${entry.name.trim()}:${entry.alias.trim()}`)
.join('|');
return [
form.apiKey.trim(),
form.baseUrl?.trim() ?? '',
testModel.trim(),
headersSignature,
modelsSignature,
].join('||');
}, [form.apiKey, form.baseUrl, form.headers, form.modelEntries, testModel]);
const previousConnectivityConfigRef = useRef(connectivityConfigSignature);
useEffect(() => { useEffect(() => {
if (loading) return; if (previousConnectivityConfigRef.current === connectivityConfigSignature) {
if (initialData) {
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return; return;
} }
setForm(buildEmptyForm()); previousConnectivityConfigRef.current = connectivityConfigSignature;
}, [initialData, loading]); setTestStatus('idle');
setTestMessage('');
}, [connectivityConfigSignature, setTestMessage, setTestStatus]);
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex; const openClaudeModelDiscovery = () => {
navigate('models');
};
const handleSave = useCallback(async () => { const runClaudeConnectivityTest = useCallback(async () => {
if (!canSave) return; if (isTesting) return;
const modelName = testModel.trim() || availableModels[0] || '';
if (!modelName) {
const message = t('ai_providers.claude_test_model_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const customHeaders = buildHeaderObject(form.headers);
const apiKey = form.apiKey.trim();
const hasApiKeyHeader = hasHeader(customHeaders, 'x-api-key');
const apiKeyFromAuthorization = resolveBearerTokenFromAuthorization(customHeaders);
const resolvedApiKey = apiKey || apiKeyFromAuthorization;
if (!resolvedApiKey && !hasApiKeyHeader) {
const message = t('ai_providers.claude_test_key_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const endpoint = buildClaudeMessagesEndpoint(form.baseUrl ?? '');
if (!endpoint) {
const message = t('ai_providers.claude_test_endpoint_invalid');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...customHeaders,
};
if (!hasHeader(headers, 'anthropic-version')) {
headers['anthropic-version'] = DEFAULT_ANTHROPIC_VERSION;
}
if (!Object.prototype.hasOwnProperty.call(headers, 'Anthropic-Version')) {
headers['Anthropic-Version'] = headers['anthropic-version'] ?? DEFAULT_ANTHROPIC_VERSION;
}
if (!hasApiKeyHeader && resolvedApiKey) {
headers['x-api-key'] = resolvedApiKey;
}
if (!Object.prototype.hasOwnProperty.call(headers, 'X-Api-Key') && resolvedApiKey) {
headers['X-Api-Key'] = resolvedApiKey;
}
setIsTesting(true);
setTestStatus('loading');
setTestMessage(t('ai_providers.claude_test_running'));
setSaving(true);
setError('');
try { try {
const payload: ProviderKeyConfig = { const result = await apiCallApi.request(
apiKey: form.apiKey.trim(), {
prefix: form.prefix?.trim() || undefined, method: 'POST',
baseUrl: (form.baseUrl ?? '').trim() || undefined, url: endpoint,
proxyUrl: form.proxyUrl?.trim() || undefined, header: headers,
headers: buildHeaderObject(form.headers), data: JSON.stringify({
models: form.modelEntries model: modelName,
.map((entry) => { max_tokens: 8,
const name = entry.name.trim(); messages: [{ role: 'user', content: 'Hi' }],
if (!name) return null; }),
const alias = entry.alias.trim(); },
return { name, alias: alias || name }; { timeout: CLAUDE_TEST_TIMEOUT_MS }
})
.filter(Boolean) as ProviderKeyConfig['models'],
excludedModels: parseExcludedModels(form.excludedText),
};
const nextList =
editIndex !== null
? configs.map((item, idx) => (idx === editIndex ? payload : item))
: [...configs, payload];
await providersApi.saveClaudeConfigs(nextList);
updateConfigValue('claude-api-key', nextList);
clearCache('claude-api-key');
showNotification(
editIndex !== null ? t('notification.claude_config_updated') : t('notification.claude_config_added'),
'success'
); );
handleBack();
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
const message = t('ai_providers.claude_test_success');
setTestStatus('success');
setTestMessage(message);
showNotification(message, 'success');
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : ''; const message = getErrorMessage(err);
setError(message); const errorCode =
showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); typeof err === 'object' && err !== null && 'code' in err
? String((err as { code?: string }).code)
: '';
const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
const resolvedMessage = isTimeout
? t('ai_providers.claude_test_timeout', { seconds: CLAUDE_TEST_TIMEOUT_MS / 1000 })
: `${t('ai_providers.claude_test_failed')}: ${message || t('common.unknown_error')}`;
setTestStatus('error');
setTestMessage(resolvedMessage);
showNotification(resolvedMessage, 'error');
} finally { } finally {
setSaving(false); setIsTesting(false);
} }
}, [ }, [
canSave, availableModels,
clearCache, form.apiKey,
configs, form.baseUrl,
editIndex, form.headers,
form, isTesting,
handleBack, setTestMessage,
setTestStatus,
showNotification, showNotification,
t, t,
updateConfigValue, testModel,
]); ]);
return ( return (
@@ -200,7 +249,7 @@ export function AiProvidersClaudeEditPage() {
backLabel={t('common.back')} backLabel={t('common.back')}
backAriaLabel={t('common.back')} backAriaLabel={t('common.back')}
rightAction={ rightAction={
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}> <Button size="sm" onClick={() => void handleSave()} loading={saving} disabled={!canSave}>
{t('common.save')} {t('common.save')}
</Button> </Button>
} }
@@ -208,16 +257,15 @@ export function AiProvidersClaudeEditPage() {
loadingLabel={t('common.loading')} loadingLabel={t('common.loading')}
> >
<Card> <Card>
{error && <div className="error-box">{error}</div>}
{invalidIndexParam || invalidIndex ? ( {invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div> <div className={styles.sectionHint}>{t('common.invalid_provider_index')}</div>
) : ( ) : (
<> <div className={styles.openaiEditForm}>
<Input <Input
label={t('ai_providers.claude_add_modal_key_label')} label={t('ai_providers.claude_add_modal_key_label')}
value={form.apiKey} value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
disabled={disableControls || saving} disabled={saving || disableControls || isTesting}
/> />
<Input <Input
label={t('ai_providers.prefix_label')} label={t('ai_providers.prefix_label')}
@@ -225,19 +273,19 @@ export function AiProvidersClaudeEditPage() {
value={form.prefix ?? ''} value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')} hint={t('ai_providers.prefix_hint')}
disabled={disableControls || saving} disabled={saving || disableControls || isTesting}
/> />
<Input <Input
label={t('ai_providers.claude_add_modal_url_label')} label={t('ai_providers.claude_add_modal_url_label')}
value={form.baseUrl ?? ''} value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
disabled={disableControls || saving} disabled={saving || disableControls || isTesting}
/> />
<Input <Input
label={t('ai_providers.claude_add_modal_proxy_label')} label={t('ai_providers.claude_add_modal_proxy_label')}
value={form.proxyUrl ?? ''} value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
disabled={disableControls || saving} disabled={saving || disableControls || isTesting}
/> />
<HeaderInputList <HeaderInputList
entries={form.headers} entries={form.headers}
@@ -245,19 +293,119 @@ export function AiProvidersClaudeEditPage() {
addLabel={t('common.custom_headers_add')} addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')} keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')}
disabled={disableControls || saving} removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={saving || disableControls || isTesting}
/> />
<div className="form-group">
<label>{t('ai_providers.claude_models_label')}</label> <div className={styles.modelConfigSection}>
<div className={styles.modelConfigHeader}>
<label className={styles.modelConfigTitle}>{t('ai_providers.claude_models_label')}</label>
<div className={styles.modelConfigToolbar}>
<Button
variant="secondary"
size="sm"
onClick={() =>
setForm((prev) => ({
...prev,
modelEntries: [...prev.modelEntries, { name: '', alias: '' }],
}))
}
disabled={saving || disableControls || isTesting}
>
{t('ai_providers.claude_models_add_btn')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={openClaudeModelDiscovery}
disabled={saving || disableControls || isTesting}
>
{t('ai_providers.claude_models_fetch_button')}
</Button>
</div>
</div>
<div className={styles.sectionHint}>{t('ai_providers.claude_models_hint')}</div>
<ModelInputList <ModelInputList
entries={form.modelEntries} entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))} onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.claude_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')} namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')} aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={disableControls || saving} disabled={saving || disableControls || isTesting}
hideAddButton
className={styles.modelInputList}
rowClassName={styles.modelInputRow}
inputClassName={styles.modelInputField}
removeButtonClassName={styles.modelRowRemoveButton}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
/> />
<div className={styles.modelTestPanel}>
<div className={styles.modelTestMeta}>
<label className={styles.modelTestLabel}>{t('ai_providers.claude_test_title')}</label>
<span className={styles.modelTestHint}>{t('ai_providers.claude_test_hint')}</span>
</div>
<div className={styles.modelTestControls}>
<Select
value={testModel}
options={modelSelectOptions}
onChange={(value) => {
setTestModel(value);
setTestStatus('idle');
setTestMessage('');
}}
placeholder={
availableModels.length
? t('ai_providers.claude_test_select_placeholder')
: t('ai_providers.claude_test_select_empty')
}
className={styles.openaiTestSelect}
ariaLabel={t('ai_providers.claude_test_title')}
disabled={
saving ||
disableControls ||
isTesting ||
testStatus === 'loading' ||
availableModels.length === 0
}
/>
<Button
variant={testStatus === 'error' ? 'danger' : 'secondary'}
size="sm"
onClick={() => void runClaudeConnectivityTest()}
loading={testStatus === 'loading'}
disabled={
saving ||
disableControls ||
isTesting ||
testStatus === 'loading' ||
availableModels.length === 0
}
className={styles.modelTestAllButton}
>
{t('ai_providers.claude_test_action')}
</Button>
</div>
</div>
{testMessage && (
<div
className={`status-badge ${
testStatus === 'error'
? 'error'
: testStatus === 'success'
? 'success'
: 'muted'
}`}
>
{testMessage}
</div>
)}
</div> </div>
<div className="form-group"> <div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label> <label>{t('ai_providers.excluded_models_label')}</label>
<textarea <textarea
@@ -266,11 +414,11 @@ export function AiProvidersClaudeEditPage() {
value={form.excludedText} value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4} rows={4}
disabled={disableControls || saving} disabled={saving || disableControls || isTesting}
/> />
<div className="hint">{t('ai_providers.excluded_models_hint')}</div> <div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div> </div>
</> </div>
)} )}
</Card> </Card>
</SecondaryScreenShell> </SecondaryScreenShell>

View File

@@ -0,0 +1,248 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { modelsApi } from '@/services/api';
import type { ModelInfo } from '@/utils/models';
import { buildHeaderObject } from '@/utils/headers';
import type { ClaudeEditOutletContext } from './AiProvidersClaudeEditLayout';
import styles from './AiProvidersPage.module.scss';
import layoutStyles from './AiProvidersEditLayout.module.scss';
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
export function AiProvidersClaudeModelsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const {
disableControls,
loading: initialLoading,
saving,
form,
mergeDiscoveredModels,
} = useOutletContext<ClaudeEditOutletContext>();
const [endpoint, setEndpoint] = useState('');
const [models, setModels] = useState<ModelInfo[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const autoFetchSignatureRef = useRef<string>('');
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 fetchClaudeModelDiscovery = useCallback(async () => {
setFetching(true);
setError('');
const headerObject = buildHeaderObject(form.headers);
try {
const list = await modelsApi.fetchClaudeModelsViaApiCall(
form.baseUrl ?? '',
form.apiKey.trim() || undefined,
headerObject
);
setModels(list);
} catch (err: unknown) {
setModels([]);
const message = getErrorMessage(err);
const hasCustomXApiKey = Object.keys(headerObject).some(
(key) => key.toLowerCase() === 'x-api-key'
);
const hasAuthorization = Object.keys(headerObject).some(
(key) => key.toLowerCase() === 'authorization'
);
const shouldAttachDiag =
message.toLowerCase().includes('x-api-key') || message.includes('401');
const diag = shouldAttachDiag
? ` [diag: apiKeyField=${form.apiKey.trim() ? 'yes' : 'no'}, customXApiKey=${
hasCustomXApiKey ? 'yes' : 'no'
}, customAuthorization=${hasAuthorization ? 'yes' : 'no'}]`
: '';
setError(`${t('ai_providers.claude_models_fetch_error')}: ${message}${diag}`);
} finally {
setFetching(false);
}
}, [form.apiKey, form.baseUrl, form.headers, t]);
useEffect(() => {
if (initialLoading) return;
const nextEndpoint = modelsApi.buildClaudeModelsEndpoint(form.baseUrl ?? '');
setEndpoint(nextEndpoint);
setModels([]);
setSearch('');
setSelected(new Set());
setError('');
const headerObject = buildHeaderObject(form.headers);
const hasCustomXApiKey = Object.keys(headerObject).some(
(key) => key.toLowerCase() === 'x-api-key'
);
const hasAuthorization = Object.keys(headerObject).some(
(key) => key.toLowerCase() === 'authorization'
);
const hasApiKeyField = Boolean(form.apiKey.trim());
const canAutoFetch = hasApiKeyField || hasCustomXApiKey || hasAuthorization;
// Avoid firing a guaranteed 401 on initial render (common while the parent form is still
// initializing), and avoid duplicate auto-fetches (e.g. React StrictMode in dev).
if (!canAutoFetch) return;
const headerSignature = Object.entries(headerObject)
.sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase()))
.map(([key, value]) => `${key}:${value}`)
.join('|');
const signature = `${nextEndpoint}||${form.apiKey.trim()}||${headerSignature}`;
if (autoFetchSignatureRef.current === signature) return;
autoFetchSignatureRef.current = signature;
void fetchClaudeModelDiscovery();
}, [fetchClaudeModelDiscovery, form.apiKey, form.baseUrl, form.headers, initialLoading]);
const handleBack = useCallback(() => {
navigate(-1);
}, [navigate]);
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleBack();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleBack]);
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));
if (selectedModels.length) {
mergeDiscoveredModels(selectedModels);
}
handleBack();
};
const canApply = !disableControls && !saving && !fetching;
return (
<SecondaryScreenShell
ref={swipeRef}
contentClassName={layoutStyles.content}
title={t('ai_providers.claude_models_fetch_title')}
onBack={handleBack}
backLabel={t('common.back')}
backAriaLabel={t('common.back')}
rightAction={
<Button size="sm" onClick={handleApply} disabled={!canApply}>
{t('ai_providers.claude_models_fetch_apply')}
</Button>
}
isLoading={initialLoading}
loadingLabel={t('common.loading')}
>
<Card>
<div className={styles.openaiModelsContent}>
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_hint')}</div>
<div className={styles.openaiModelsEndpointSection}>
<label className={styles.openaiModelsEndpointLabel}>
{t('ai_providers.claude_models_fetch_url_label')}
</label>
<div className={styles.openaiModelsEndpointControls}>
<input
className={`input ${styles.openaiModelsEndpointInput}`}
readOnly
value={endpoint}
/>
<Button
variant="secondary"
size="sm"
onClick={() => void fetchClaudeModelDiscovery()}
loading={fetching}
disabled={disableControls || saving}
>
{t('ai_providers.claude_models_fetch_refresh')}
</Button>
</div>
</div>
<Input
label={t('ai_providers.claude_models_search_label')}
placeholder={t('ai_providers.claude_models_search_placeholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={fetching}
/>
{error && <div className="error-box">{error}</div>}
{fetching ? (
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_loading')}</div>
) : models.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_empty')}</div>
) : filteredModels.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.claude_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>
)}
</div>
</Card>
</SecondaryScreenShell>
);
}

View File

@@ -210,7 +210,7 @@ export function AiProvidersCodexEditPage() {
<Card> <Card>
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}
{invalidIndexParam || invalidIndex ? ( {invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div> <div className="hint">{t('common.invalid_provider_index')}</div>
) : ( ) : (
<> <>
<Input <Input
@@ -245,6 +245,8 @@ export function AiProvidersCodexEditPage() {
addLabel={t('common.custom_headers_add')} addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')} keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving} disabled={disableControls || saving}
/> />
<div className="form-group"> <div className="form-group">

View File

@@ -3,3 +3,17 @@
max-width: 960px; max-width: 960px;
margin: 0 auto; margin: 0 auto;
} }
.upstreamApiKeyRow {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.upstreamApiKeyHint {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
}

View File

@@ -21,6 +21,7 @@ const buildEmptyForm = (): GeminiFormState => ({
apiKey: '', apiKey: '',
prefix: '', prefix: '',
baseUrl: '', baseUrl: '',
proxyUrl: '',
headers: [], headers: [],
excludedModels: [], excludedModels: [],
excludedText: '', excludedText: '',
@@ -138,6 +139,7 @@ export function AiProvidersGeminiEditPage() {
apiKey: form.apiKey.trim(), apiKey: form.apiKey.trim(),
prefix: form.prefix?.trim() || undefined, prefix: form.prefix?.trim() || undefined,
baseUrl: form.baseUrl?.trim() || undefined, baseUrl: form.baseUrl?.trim() || undefined,
proxyUrl: form.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(form.headers), headers: buildHeaderObject(form.headers),
excludedModels: parseExcludedModels(form.excludedText), excludedModels: parseExcludedModels(form.excludedText),
}; };
@@ -193,7 +195,7 @@ export function AiProvidersGeminiEditPage() {
<Card> <Card>
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}
{invalidIndexParam || invalidIndex ? ( {invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div> <div className="hint">{t('common.invalid_provider_index')}</div>
) : ( ) : (
<> <>
<Input <Input
@@ -218,12 +220,21 @@ export function AiProvidersGeminiEditPage() {
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
disabled={disableControls || saving} disabled={disableControls || saving}
/> />
<Input
label={t('ai_providers.gemini_add_modal_proxy_label')}
placeholder={t('ai_providers.gemini_add_modal_proxy_placeholder')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
disabled={disableControls || saving}
/>
<HeaderInputList <HeaderInputList
entries={form.headers} entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))} onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')} addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')} keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving} disabled={disableControls || saving}
/> />
<div className="form-group"> <div className="form-group">

View File

@@ -10,6 +10,7 @@ import type { ModelInfo } from '@/utils/models';
import { buildHeaderObject, headersToEntries } from '@/utils/headers'; import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { buildApiKeyEntry } from '@/components/providers/utils'; import { buildApiKeyEntry } from '@/components/providers/utils';
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types'; import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
type LocationState = { fromAiProviders?: boolean } | null; type LocationState = { fromAiProviders?: boolean } | null;
@@ -29,6 +30,9 @@ export type OpenAIEditOutletContext = {
setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>>; setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>>;
testMessage: string; testMessage: string;
setTestMessage: Dispatch<SetStateAction<string>>; setTestMessage: Dispatch<SetStateAction<string>>;
keyTestStatuses: KeyTestStatus[];
setDraftKeyTestStatus: (keyIndex: number, status: KeyTestStatus) => void;
resetDraftKeyTestStatuses: (count: number) => void;
availableModels: string[]; availableModels: string[];
handleBack: () => void; handleBack: () => void;
handleSave: () => Promise<void>; handleSave: () => Promise<void>;
@@ -73,8 +77,6 @@ export function AiProvidersOpenAIEditLayout() {
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 updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const isCacheValid = useConfigStore((state) => state.isCacheValid); const isCacheValid = useConfigStore((state) => state.isCacheValid);
const [providers, setProviders] = useState<OpenAIProviderConfig[]>( const [providers, setProviders] = useState<OpenAIProviderConfig[]>(
@@ -99,11 +101,14 @@ export function AiProvidersOpenAIEditLayout() {
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel); const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus); const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage); const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage);
const setDraftKeyTestStatus = useOpenAIEditDraftStore((state) => state.setDraftKeyTestStatus);
const resetDraftKeyTestStatuses = useOpenAIEditDraftStore((state) => state.resetDraftKeyTestStatuses);
const form = draft?.form ?? buildEmptyForm(); const form = draft?.form ?? buildEmptyForm();
const testModel = draft?.testModel ?? ''; const testModel = draft?.testModel ?? '';
const testStatus = draft?.testStatus ?? 'idle'; const testStatus = draft?.testStatus ?? 'idle';
const testMessage = draft?.testMessage ?? ''; const testMessage = draft?.testMessage ?? '';
const keyTestStatuses = draft?.keyTestStatuses ?? [];
const setForm: Dispatch<SetStateAction<OpenAIFormState>> = useCallback( const setForm: Dispatch<SetStateAction<OpenAIFormState>> = useCallback(
(action) => { (action) => {
@@ -134,6 +139,20 @@ export function AiProvidersOpenAIEditLayout() {
[draftKey, setDraftTestMessage] [draftKey, setDraftTestMessage]
); );
const handleSetDraftKeyTestStatus = useCallback(
(keyIndex: number, status: KeyTestStatus) => {
setDraftKeyTestStatus(draftKey, keyIndex, status);
},
[draftKey, setDraftKeyTestStatus]
);
const handleResetDraftKeyTestStatuses = useCallback(
(count: number) => {
resetDraftKeyTestStatuses(draftKey, count);
},
[draftKey, resetDraftKeyTestStatuses]
);
const initialData = useMemo(() => { const initialData = useMemo(() => {
if (editIndex === null) return undefined; if (editIndex === null) return undefined;
return providers[editIndex]; return providers[editIndex];
@@ -215,6 +234,7 @@ export function AiProvidersOpenAIEditLayout() {
testModel: initialTestModel, testModel: initialTestModel,
testStatus: 'idle', testStatus: 'idle',
testMessage: '', testMessage: '',
keyTestStatuses: [],
}); });
} else { } else {
initDraft(draftKey, { initDraft(draftKey, {
@@ -222,6 +242,7 @@ export function AiProvidersOpenAIEditLayout() {
testModel: '', testModel: '',
testStatus: 'idle', testStatus: 'idle',
testMessage: '', testMessage: '',
keyTestStatuses: [],
}); });
} }
}, [draft?.initialized, draftKey, initDraft, initialData, loading]); }, [draft?.initialized, draftKey, initDraft, initialData, loading]);
@@ -243,7 +264,7 @@ export function AiProvidersOpenAIEditLayout() {
setTestStatus('idle'); setTestStatus('idle');
setTestMessage(''); setTestMessage('');
} }
}, [availableModels, loading, testModel]); }, [availableModels, loading, setTestMessage, setTestModel, setTestStatus, testModel]);
const mergeDiscoveredModels = useCallback( const mergeDiscoveredModels = useCallback(
(selectedModels: ModelInfo[]) => { (selectedModels: ModelInfo[]) => {
@@ -280,12 +301,20 @@ export function AiProvidersOpenAIEditLayout() {
); );
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
const name = form.name.trim();
const baseUrl = form.baseUrl.trim();
if (!name || !baseUrl) {
showNotification(t('notification.openai_provider_required'), 'error');
return;
}
setSaving(true); setSaving(true);
try { try {
const payload: OpenAIProviderConfig = { const payload: OpenAIProviderConfig = {
name: form.name.trim(), name,
prefix: form.prefix?.trim() || undefined, prefix: form.prefix?.trim() || undefined,
baseUrl: form.baseUrl.trim(), baseUrl,
headers: buildHeaderObject(form.headers), headers: buildHeaderObject(form.headers),
apiKeyEntries: form.apiKeyEntries.map((entry: ApiKeyEntry) => ({ apiKeyEntries: form.apiKeyEntries.map((entry: ApiKeyEntry) => ({
apiKey: entry.apiKey.trim(), apiKey: entry.apiKey.trim(),
@@ -304,9 +333,18 @@ export function AiProvidersOpenAIEditLayout() {
: [...providers, payload]; : [...providers, payload];
await providersApi.saveOpenAIProviders(nextList); await providersApi.saveOpenAIProviders(nextList);
setProviders(nextList);
updateConfigValue('openai-compatibility', nextList); let syncedProviders = nextList;
clearCache('openai-compatibility'); try {
const latest = await fetchConfig('openai-compatibility', true);
if (Array.isArray(latest)) {
syncedProviders = latest as OpenAIProviderConfig[];
}
} catch {
// 保存成功后刷新失败时,回退到本地计算结果,避免页面数据为空或回退
}
setProviders(syncedProviders);
showNotification( showNotification(
editIndex !== null editIndex !== null
? t('notification.openai_provider_updated') ? t('notification.openai_provider_updated')
@@ -320,15 +358,14 @@ export function AiProvidersOpenAIEditLayout() {
setSaving(false); setSaving(false);
} }
}, [ }, [
clearCache,
editIndex, editIndex,
fetchConfig,
form, form,
handleBack, handleBack,
providers, providers,
testModel, testModel,
showNotification, showNotification,
t, t,
updateConfigValue,
]); ]);
const resolvedLoading = !draft?.initialized; const resolvedLoading = !draft?.initialized;
@@ -351,6 +388,9 @@ export function AiProvidersOpenAIEditLayout() {
setTestStatus, setTestStatus,
testMessage, testMessage,
setTestMessage, setTestMessage,
keyTestStatuses,
setDraftKeyTestStatus: handleSetDraftKeyTestStatus,
resetDraftKeyTestStatuses: handleResetDraftKeyTestStatuses,
availableModels, availableModels,
handleBack, handleBack,
handleSave, handleSave,

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useCallback, useMemo, useRef, useState } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom'; import { useNavigate, useOutletContext } 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';
@@ -6,6 +6,7 @@ import { Card } from '@/components/ui/Card';
import { HeaderInputList } from '@/components/ui/HeaderInputList'; import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { ModelInputList } from '@/components/ui/ModelInputList'; import { ModelInputList } from '@/components/ui/ModelInputList';
import { Select } from '@/components/ui/Select';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell'; import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack'; import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useNotificationStore } from '@/stores'; import { useNotificationStore } from '@/stores';
@@ -14,6 +15,7 @@ import type { ApiKeyEntry } from '@/types';
import { buildHeaderObject } from '@/utils/headers'; import { buildHeaderObject } from '@/utils/headers';
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils'; import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils';
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout'; import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
import styles from './AiProvidersPage.module.scss'; import styles from './AiProvidersPage.module.scss';
import layoutStyles from './AiProvidersEditLayout.module.scss'; import layoutStyles from './AiProvidersEditLayout.module.scss';
@@ -25,6 +27,72 @@ const getErrorMessage = (err: unknown) => {
return ''; return '';
}; };
// Status icon components
function StatusLoadingIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className={styles.statusIconSpin}>
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
<path
d="M8 1A7 7 0 0 1 8 15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
function StatusSuccessIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="8" fill="var(--success-color, #22c55e)" />
<path
d="M4.5 8L7 10.5L11.5 6"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function StatusErrorIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="8" fill="var(--danger-color, #c65746)" />
<path
d="M5 5L11 11M11 5L5 11"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function StatusIdleIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="var(--text-tertiary, #9ca3af)" strokeWidth="2" />
</svg>
);
}
function StatusIcon({ status }: { status: KeyTestStatus['status'] }) {
switch (status) {
case 'loading':
return <StatusLoadingIcon />;
case 'success':
return <StatusSuccessIcon />;
case 'error':
return <StatusErrorIcon />;
default:
return <StatusIdleIcon />;
}
}
export function AiProvidersOpenAIEditPage() { export function AiProvidersOpenAIEditPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -44,6 +112,9 @@ export function AiProvidersOpenAIEditPage() {
setTestStatus, setTestStatus,
testMessage, testMessage,
setTestMessage, setTestMessage,
keyTestStatuses,
setDraftKeyTestStatus,
resetDraftKeyTestStatuses,
availableModels, availableModels,
handleBack, handleBack,
handleSave, handleSave,
@@ -54,6 +125,7 @@ export function AiProvidersOpenAIEditPage() {
: t('ai_providers.openai_add_modal_title'); : t('ai_providers.openai_add_modal_title');
const swipeRef = useEdgeSwipeBack({ onBack: handleBack }); const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
const [isTestingKeys, setIsTestingKeys] = useState(false);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -65,80 +137,145 @@ export function AiProvidersOpenAIEditPage() {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleBack]); }, [handleBack]);
const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex; const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex && !isTestingKeys;
const hasConfiguredModels = form.modelEntries.some((entry) => entry.name.trim());
const hasTestableKeys = form.apiKeyEntries.some((entry) => entry.apiKey?.trim());
const modelSelectOptions = useMemo(() => {
const seen = new Set<string>();
return form.modelEntries.reduce<Array<{ value: string; label: string }>>((acc, entry) => {
const name = entry.name.trim();
if (!name || seen.has(name)) return acc;
seen.add(name);
const alias = entry.alias.trim();
acc.push({
value: name,
label: alias && alias !== name ? `${name} (${alias})` : name,
});
return acc;
}, []);
}, [form.modelEntries]);
const connectivityConfigSignature = useMemo(() => {
const headersSignature = form.headers
.map((entry) => `${entry.key.trim()}:${entry.value.trim()}`)
.join('|');
const modelsSignature = form.modelEntries
.map((entry) => `${entry.name.trim()}:${entry.alias.trim()}`)
.join('|');
return [form.baseUrl.trim(), testModel.trim(), headersSignature, modelsSignature].join('||');
}, [form.baseUrl, form.headers, form.modelEntries, testModel]);
const previousConnectivityConfigRef = useRef(connectivityConfigSignature);
const renderKeyEntries = (entries: ApiKeyEntry[]) => { useEffect(() => {
const list = entries.length ? entries : [buildApiKeyEntry()]; if (previousConnectivityConfigRef.current === connectivityConfigSignature) {
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)}
disabled={saving || disableControls}
/>
<Input
label={t('common.proxy_url')}
value={entry.proxyUrl ?? ''}
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
disabled={saving || disableControls}
/>
</div>
<div className="item-actions">
<Button
variant="ghost"
size="sm"
onClick={() => removeEntry(index)}
disabled={saving || disableControls || list.length <= 1}
>
{t('common.delete')}
</Button>
</div>
</div>
))}
<Button
variant="secondary"
size="sm"
onClick={addEntry}
disabled={saving || disableControls}
>
{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; return;
} }
navigate('models'); previousConnectivityConfigRef.current = connectivityConfigSignature;
}; resetDraftKeyTestStatuses(form.apiKeyEntries.length);
setTestStatus('idle');
setTestMessage('');
}, [
connectivityConfigSignature,
form.apiKeyEntries.length,
resetDraftKeyTestStatuses,
setTestStatus,
setTestMessage,
]);
// Test a single key by index
const runSingleKeyTest = useCallback(
async (keyIndex: number): Promise<boolean> => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
showNotification(t('notification.openai_test_url_required'), 'error');
return false;
}
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
if (!endpoint) {
showNotification(t('notification.openai_test_url_required'), 'error');
return false;
}
const keyEntry = form.apiKeyEntries[keyIndex];
if (!keyEntry?.apiKey?.trim()) {
setDraftKeyTestStatus(keyIndex, { status: 'error', message: t('notification.openai_test_key_required') });
return false;
}
const modelName = testModel.trim() || availableModels[0] || '';
if (!modelName) {
showNotification(t('notification.openai_test_model_required'), 'error');
return false;
}
const customHeaders = buildHeaderObject(form.headers);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...customHeaders,
};
if (!headers.Authorization && !headers['authorization']) {
headers.Authorization = `Bearer ${keyEntry.apiKey.trim()}`;
}
// Set loading state for this key
setDraftKeyTestStatus(keyIndex, { status: 'loading', message: '' });
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));
}
setDraftKeyTestStatus(keyIndex, { status: 'success', message: '' });
return true;
} catch (err: unknown) {
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');
const errorMessage = isTimeout
? t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
: message;
setDraftKeyTestStatus(keyIndex, { status: 'error', message: errorMessage });
return false;
}
},
[form.baseUrl, form.apiKeyEntries, form.headers, testModel, availableModels, t, setDraftKeyTestStatus, showNotification]
);
const testSingleKey = useCallback(
async (keyIndex: number): Promise<boolean> => {
if (isTestingKeys) return false;
setIsTestingKeys(true);
try {
return await runSingleKeyTest(keyIndex);
} finally {
setIsTestingKeys(false);
}
},
[isTestingKeys, runSingleKeyTest]
);
// Test all keys
const testAllKeys = useCallback(async () => {
if (isTestingKeys) return;
const testOpenaiProviderConnection = async () => {
const baseUrl = form.baseUrl.trim(); const baseUrl = form.baseUrl.trim();
if (!baseUrl) { if (!baseUrl) {
const message = t('notification.openai_test_url_required'); const message = t('notification.openai_test_url_required');
@@ -157,15 +294,6 @@ export function AiProvidersOpenAIEditPage() {
return; 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] || ''; const modelName = testModel.trim() || availableModels[0] || '';
if (!modelName) { if (!modelName) {
const message = t('notification.openai_test_model_required'); const message = t('notification.openai_test_model_required');
@@ -175,56 +303,194 @@ export function AiProvidersOpenAIEditPage() {
return; return;
} }
const customHeaders = buildHeaderObject(form.headers); const validKeyIndexes = form.apiKeyEntries
const headers: Record<string, string> = { .map((entry, index) => (entry.apiKey?.trim() ? index : -1))
'Content-Type': 'application/json', .filter((index) => index >= 0);
...customHeaders, if (validKeyIndexes.length === 0) {
}; const message = t('notification.openai_test_key_required');
if (!headers.Authorization && !headers['authorization']) { setTestStatus('error');
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`; setTestMessage(message);
showNotification(message, 'error');
return;
} }
setIsTestingKeys(true);
setTestStatus('loading'); setTestStatus('loading');
setTestMessage(t('ai_providers.openai_test_running')); setTestMessage(t('ai_providers.openai_test_running'));
resetDraftKeyTestStatuses(form.apiKeyEntries.length);
try { try {
const result = await apiCallApi.request( const results = await Promise.all(validKeyIndexes.map((index) => runSingleKeyTest(index)));
{
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) { const successCount = results.filter(Boolean).length;
throw new Error(getApiCallErrorMessage(result)); const failCount = validKeyIndexes.length - successCount;
}
setTestStatus('success'); if (failCount === 0) {
setTestMessage(t('ai_providers.openai_test_success')); const message = t('ai_providers.openai_test_all_success', { count: successCount });
} catch (err: unknown) { setTestStatus('success');
setTestStatus('error'); setTestMessage(message);
const message = getErrorMessage(err); showNotification(message, 'success');
const errorCode = } else if (successCount === 0) {
typeof err === 'object' && err !== null && 'code' in err const message = t('ai_providers.openai_test_all_failed', { count: failCount });
? String((err as { code?: string }).code) setTestStatus('error');
: ''; setTestMessage(message);
const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout'); showNotification(message, 'error');
if (isTimeout) {
setTestMessage(
t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
);
} else { } else {
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`); const message = t('ai_providers.openai_test_all_partial', { success: successCount, failed: failCount });
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'warning');
} }
} finally {
setIsTestingKeys(false);
} }
}, [
isTestingKeys,
form.baseUrl,
form.apiKeyEntries,
testModel,
availableModels,
t,
setTestStatus,
setTestMessage,
resetDraftKeyTestStatuses,
runSingleKeyTest,
showNotification,
]);
const openOpenaiModelDiscovery = () => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
return;
}
navigate('models');
};
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 }));
setDraftKeyTestStatus(idx, { status: 'idle', message: '' });
setTestStatus('idle');
setTestMessage('');
};
const removeEntry = (idx: number) => {
const next = list.filter((_, i) => i !== idx);
const nextLength = next.length ? next.length : 1;
setForm((prev) => ({
...prev,
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
}));
resetDraftKeyTestStatuses(nextLength);
setTestStatus('idle');
setTestMessage('');
};
const addEntry = () => {
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
resetDraftKeyTestStatuses(list.length + 1);
setTestStatus('idle');
setTestMessage('');
};
return (
<div className={styles.keyEntriesList}>
<div className={styles.keyEntriesToolbar}>
<span className={styles.keyEntriesCount}>
{t('ai_providers.openai_keys_count')}: {list.length}
</span>
<Button
variant="secondary"
size="sm"
onClick={addEntry}
disabled={saving || disableControls || isTestingKeys}
className={styles.addKeyButton}
>
{t('ai_providers.openai_keys_add_btn')}
</Button>
</div>
<div className={styles.keyTableShell}>
{/* 表头 */}
<div className={styles.keyTableHeader}>
<div className={styles.keyTableColIndex}>#</div>
<div className={styles.keyTableColStatus}>{t('common.status')}</div>
<div className={styles.keyTableColKey}>{t('common.api_key')}</div>
<div className={styles.keyTableColProxy}>{t('common.proxy_url')}</div>
<div className={styles.keyTableColAction}>{t('common.action')}</div>
</div>
{/* 数据行 */}
{list.map((entry, index) => {
const keyStatus = keyTestStatuses[index]?.status ?? 'idle';
const canTestKey = Boolean(entry.apiKey?.trim()) && hasConfiguredModels;
return (
<div key={index} className={styles.keyTableRow}>
{/* 序号 */}
<div className={styles.keyTableColIndex}>{index + 1}</div>
{/* 状态指示灯 */}
<div
className={styles.keyTableColStatus}
title={keyTestStatuses[index]?.message || ''}
>
<StatusIcon status={keyStatus} />
</div>
{/* Key 输入框 */}
<div className={styles.keyTableColKey}>
<input
type="text"
value={entry.apiKey}
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
disabled={saving || disableControls || isTestingKeys}
className={`input ${styles.keyTableInput}`}
placeholder={t('ai_providers.openai_key_placeholder')}
/>
</div>
{/* Proxy 输入框 */}
<div className={styles.keyTableColProxy}>
<input
type="text"
value={entry.proxyUrl ?? ''}
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
disabled={saving || disableControls || isTestingKeys}
className={`input ${styles.keyTableInput}`}
placeholder={t('ai_providers.openai_proxy_placeholder')}
/>
</div>
{/* 操作按钮 */}
<div className={styles.keyTableColAction}>
<Button
variant="secondary"
size="sm"
onClick={() => void testSingleKey(index)}
disabled={saving || disableControls || isTestingKeys || !canTestKey}
loading={keyStatus === 'loading'}
>
{t('ai_providers.openai_test_single_action')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeEntry(index)}
disabled={saving || disableControls || isTestingKeys || list.length <= 1}
>
{t('common.delete')}
</Button>
</div>
</div>
);
})}
</div>
</div>
);
}; };
return ( return (
@@ -245,14 +511,14 @@ export function AiProvidersOpenAIEditPage() {
> >
<Card> <Card>
{invalidIndexParam || invalidIndex ? ( {invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div> <div className={styles.sectionHint}>{t('common.invalid_provider_index')}</div>
) : ( ) : (
<> <div className={styles.openaiEditForm}>
<Input <Input
label={t('ai_providers.openai_add_modal_name_label')} label={t('ai_providers.openai_add_modal_name_label')}
value={form.name} value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
disabled={saving || disableControls} disabled={saving || disableControls || isTestingKeys}
/> />
<Input <Input
label={t('ai_providers.prefix_label')} label={t('ai_providers.prefix_label')}
@@ -260,13 +526,13 @@ export function AiProvidersOpenAIEditPage() {
value={form.prefix ?? ''} value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')} hint={t('ai_providers.prefix_hint')}
disabled={saving || disableControls} disabled={saving || disableControls || isTestingKeys}
/> />
<Input <Input
label={t('ai_providers.openai_add_modal_url_label')} label={t('ai_providers.openai_add_modal_url_label')}
value={form.baseUrl} value={form.baseUrl}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
disabled={saving || disableControls} disabled={saving || disableControls || isTestingKeys}
/> />
<HeaderInputList <HeaderInputList
@@ -275,77 +541,98 @@ export function AiProvidersOpenAIEditPage() {
addLabel={t('common.custom_headers_add')} addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')} keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')}
disabled={saving || disableControls} removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={saving || disableControls || isTestingKeys}
/> />
<div className="form-group"> {/* 模型配置区域 - 统一布局 */}
<label> <div className={styles.modelConfigSection}>
{hasIndexParam {/* 标题行 */}
? t('ai_providers.openai_edit_modal_models_label') <div className={styles.modelConfigHeader}>
: t('ai_providers.openai_add_modal_models_label')} <label className={styles.modelConfigTitle}>
</label> {hasIndexParam
<div className="hint">{t('ai_providers.openai_models_hint')}</div> ? t('ai_providers.openai_edit_modal_models_label')
: t('ai_providers.openai_add_modal_models_label')}
</label>
<div className={styles.modelConfigToolbar}>
<Button
variant="secondary"
size="sm"
onClick={() => setForm((prev) => ({
...prev,
modelEntries: [...prev.modelEntries, { name: '', alias: '' }]
}))}
disabled={saving || disableControls || isTestingKeys}
>
{t('ai_providers.openai_models_add_btn')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={openOpenaiModelDiscovery}
disabled={saving || disableControls || isTestingKeys}
>
{t('ai_providers.openai_models_fetch_button')}
</Button>
</div>
</div>
{/* 提示文本 */}
<div className={styles.sectionHint}>{t('ai_providers.openai_models_hint')}</div>
{/* 模型列表 */}
<ModelInputList <ModelInputList
entries={form.modelEntries} entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))} onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.openai_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')} namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')} aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={saving || disableControls} disabled={saving || disableControls || isTestingKeys}
hideAddButton
className={styles.modelInputList}
rowClassName={styles.modelInputRow}
inputClassName={styles.modelInputField}
removeButtonClassName={styles.modelRowRemoveButton}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
/> />
<Button
variant="secondary"
size="sm"
onClick={openOpenaiModelDiscovery}
disabled={saving || disableControls}
>
{t('ai_providers.openai_models_fetch_button')}
</Button>
</div>
<div className="form-group"> {/* 测试区域 */}
<label>{t('ai_providers.openai_test_title')}</label> <div className={styles.modelTestPanel}>
<div className="hint">{t('ai_providers.openai_test_hint')}</div> <div className={styles.modelTestMeta}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <label className={styles.modelTestLabel}>{t('ai_providers.openai_test_title')}</label>
<select <span className={styles.modelTestHint}>{t('ai_providers.openai_test_hint')}</span>
className={`input ${styles.openaiTestSelect}`} </div>
value={testModel} <div className={styles.modelTestControls}>
onChange={(e) => { <Select
setTestModel(e.target.value); value={testModel}
setTestStatus('idle'); options={modelSelectOptions}
setTestMessage(''); onChange={(value) => {
}} setTestModel(value);
disabled={saving || disableControls || availableModels.length === 0} setTestStatus('idle');
> setTestMessage('');
<option value=""> }}
{availableModels.length placeholder={
? t('ai_providers.openai_test_select_placeholder') availableModels.length
: t('ai_providers.openai_test_select_empty')} ? t('ai_providers.openai_test_select_placeholder')
</option> : t('ai_providers.openai_test_select_empty')
{form.modelEntries }
.filter((entry) => entry.name.trim()) className={styles.openaiTestSelect}
.map((entry, idx) => { ariaLabel={t('ai_providers.openai_test_title')}
const name = entry.name.trim(); disabled={saving || disableControls || isTestingKeys || testStatus === 'loading' || availableModels.length === 0}
const alias = entry.alias.trim(); />
const label = alias && alias !== name ? `${name} (${alias})` : name; <Button
return ( variant={testStatus === 'error' ? 'danger' : 'secondary'}
<option key={`${name}-${idx}`} value={name}> size="sm"
{label} onClick={() => void testAllKeys()}
</option> loading={testStatus === 'loading'}
); disabled={saving || disableControls || isTestingKeys || testStatus === 'loading' || !hasConfiguredModels || !hasTestableKeys}
})} title={t('ai_providers.openai_test_all_hint')}
</select> className={styles.modelTestAllButton}
<Button >
variant={testStatus === 'error' ? 'danger' : 'secondary'} {t('ai_providers.openai_test_all_action')}
className={`${styles.openaiTestButton} ${ </Button>
testStatus === 'success' ? styles.openaiTestButtonSuccess : '' </div>
}`}
onClick={() => void testOpenaiProviderConnection()}
loading={testStatus === 'loading'}
disabled={saving || disableControls || availableModels.length === 0}
>
{t('ai_providers.openai_test_action')}
</Button>
</div> </div>
{testMessage && ( {testMessage && (
<div <div
@@ -362,11 +649,14 @@ export function AiProvidersOpenAIEditPage() {
)} )}
</div> </div>
<div className="form-group"> <div className={styles.keyEntriesSection}>
<label>{t('ai_providers.openai_add_modal_keys_label')}</label> <div className={styles.keyEntriesHeader}>
<label className={styles.keyEntriesTitle}>{t('ai_providers.openai_add_modal_keys_label')}</label>
<span className={styles.keyEntriesHint}>{t('ai_providers.openai_keys_hint')}</span>
</div>
{renderKeyEntries(form.apiKeyEntries)} {renderKeyEntries(form.apiKeyEntries)}
</div> </div>
</> </div>
)} )}
</Card> </Card>
</SecondaryScreenShell> </SecondaryScreenShell>

View File

@@ -153,70 +153,76 @@ export function AiProvidersOpenAIModelsPage() {
loadingLabel={t('common.loading')} loadingLabel={t('common.loading')}
> >
<Card> <Card>
<div className="hint" style={{ marginBottom: 8 }}> <div className={styles.openaiModelsContent}>
{t('ai_providers.openai_models_fetch_hint')} <div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_hint')}</div>
</div> <div className={styles.openaiModelsEndpointSection}>
<div className="form-group"> <label className={styles.openaiModelsEndpointLabel}>
<label>{t('ai_providers.openai_models_fetch_url_label')}</label> {t('ai_providers.openai_models_fetch_url_label')}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> </label>
<input className="input" readOnly value={endpoint} /> <div className={styles.openaiModelsEndpointControls}>
<Button <input
variant="secondary" className={`input ${styles.openaiModelsEndpointInput}`}
size="sm" readOnly
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })} value={endpoint}
loading={fetching} />
disabled={disableControls || saving} <Button
> variant="secondary"
{t('ai_providers.openai_models_fetch_refresh')} size="sm"
</Button> onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
loading={fetching}
disabled={disableControls || saving}
>
{t('ai_providers.openai_models_fetch_refresh')}
</Button>
</div>
</div> </div>
</div> <Input
<Input label={t('ai_providers.openai_models_search_label')}
label={t('ai_providers.openai_models_search_label')} placeholder={t('ai_providers.openai_models_search_placeholder')}
placeholder={t('ai_providers.openai_models_search_placeholder')} value={search}
value={search} onChange={(e) => setSearch(e.target.value)}
onChange={(e) => setSearch(e.target.value)} disabled={fetching}
disabled={fetching} />
/> {error && <div className="error-box">{error}</div>}
{error && <div className="error-box">{error}</div>} {fetching ? (
{fetching ? ( <div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_loading')}</div>
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div> ) : models.length === 0 ? (
) : models.length === 0 ? ( <div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_empty')}</div>
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div> ) : filteredModels.length === 0 ? (
) : filteredModels.length === 0 ? ( <div className={styles.sectionHint}>{t('ai_providers.openai_models_search_empty')}</div>
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div> ) : (
) : ( <div className={styles.modelDiscoveryList}>
<div className={styles.modelDiscoveryList}> {filteredModels.map((model) => {
{filteredModels.map((model) => { const checked = selected.has(model.name);
const checked = selected.has(model.name); return (
return ( <label
<label key={model.name}
key={model.name} className={`${styles.modelDiscoveryRow} ${
className={`${styles.modelDiscoveryRow} ${ checked ? styles.modelDiscoveryRowSelected : ''
checked ? styles.modelDiscoveryRowSelected : '' }`}
}`} >
> <input
<input type="checkbox"
type="checkbox" checked={checked}
checked={checked} onChange={() => toggleSelection(model.name)}
onChange={() => toggleSelection(model.name)} />
/> <div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryMeta}> <div className={styles.modelDiscoveryName}>
<div className={styles.modelDiscoveryName}> {model.name}
{model.name} {model.alias && (
{model.alias && ( <span className={styles.modelDiscoveryAlias}>{model.alias}</span>
<span className={styles.modelDiscoveryAlias}>{model.alias}</span> )}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)} )}
</div> </div>
{model.description && ( </label>
<div className={styles.modelDiscoveryDesc}>{model.description}</div> );
)} })}
</div> </div>
</label> )}
); </div>
})}
</div>
)}
</Card> </Card>
</SecondaryScreenShell> </SecondaryScreenShell>
); );

View File

@@ -93,9 +93,9 @@
} }
.statFailure { .statFailure {
background-color: var(--failure-badge-bg, #fee2e2); background-color: var(--failure-badge-bg);
color: var(--failure-badge-text, #991b1b); color: var(--failure-badge-text);
border-color: var(--failure-badge-border, #fca5a5); border-color: var(--failure-badge-border);
} }
// 字段行样式:标签 + 值 // 字段行样式:标签 + 值
@@ -311,8 +311,8 @@
} }
.apiKeyEntryStatFailure { .apiKeyEntryStatFailure {
background: var(--failure-badge-bg, #fee2e2); background: var(--failure-badge-bg);
color: var(--failure-badge-text, #991b1b); color: var(--failure-badge-text);
} }
// OpenAI 模型发现(二级界面) // OpenAI 模型发现(二级界面)
@@ -322,7 +322,7 @@
gap: 6px; gap: 6px;
max-height: 360px; max-height: 360px;
overflow-y: auto; overflow-y: auto;
margin-top: 8px; margin-top: 0;
padding-right: 4px; padding-right: 4px;
} }
@@ -387,19 +387,6 @@
} }
} }
// 连通性测试按钮高度对齐
.openaiTestSelect {
flex: 1 1 0;
min-width: 0;
}
.openaiTestButton {
flex: 1 1 0;
padding: 8px 12px;
font-size: 14px;
line-height: 1.5;
}
// 状态监测栏 // 状态监测栏
.statusBar { .statusBar {
display: flex; display: flex;
@@ -415,37 +402,123 @@
gap: 2px; gap: 2px;
flex: 1; flex: 1;
min-width: 180px; min-width: 180px;
position: relative;
}
.statusBlockWrapper {
flex: 1;
min-width: 6px;
position: relative;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
} }
.statusBlock { .statusBlock {
flex: 1; width: 100%;
height: 8px; height: 8px;
border-radius: 2px; border-radius: 2px;
min-width: 6px;
transition: transform 0.15s ease, opacity 0.15s ease; transition: transform 0.15s ease, opacity 0.15s ease;
&:hover { .statusBlockWrapper:hover &,
transform: scaleY(1.5); .statusBlockWrapper.statusBlockActive & {
opacity: 0.85; transform: scaleY(1.8);
opacity: 0.9;
} }
} }
.statusBlockSuccess {
background-color: var(--success-color, #22c55e);
}
.statusBlockFailure {
background-color: var(--danger-color, #ef4444);
}
.statusBlockMixed {
background-color: var(--warning-color, #f59e0b);
}
.statusBlockIdle { .statusBlockIdle {
background-color: var(--border-secondary, #e5e7eb); background-color: var(--border-secondary, #e5e7eb);
} }
.statusTooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-primary, #fff);
border: 1px solid var(--border-secondary, #e5e7eb);
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
line-height: 1.5;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: $z-dropdown;
pointer-events: none;
color: var(--text-primary);
// 小箭头
&::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--bg-primary, #fff);
}
&::before {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border-secondary, #e5e7eb);
}
}
// 防止左右溢出
.statusTooltipLeft {
left: 0;
transform: translateX(0);
&::after,
&::before {
left: 8px;
transform: none;
}
}
.statusTooltipRight {
left: auto;
right: 0;
transform: translateX(0);
&::after,
&::before {
left: auto;
right: 8px;
transform: none;
}
}
.tooltipTime {
color: var(--text-secondary);
display: block;
margin-bottom: 2px;
}
.tooltipStats {
display: flex;
align-items: center;
gap: 8px;
}
.tooltipSuccess {
color: var(--success-color, #22c55e);
}
.tooltipFailure {
color: var(--danger-color, #ef4444);
}
.tooltipRate {
color: var(--text-secondary);
margin-left: 2px;
}
.statusRate { .statusRate {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -469,15 +542,407 @@
} }
.statusRateLow { .statusRateLow {
color: var(--failure-badge-text, #991b1b); color: var(--failure-badge-text);
background: var(--failure-badge-bg, #fee2e2); background: var(--failure-badge-bg);
}
@include mobile {
.statusTooltip {
font-size: 12px;
padding: 8px 12px;
}
.statusBlocks {
min-width: 140px;
}
}
// ============================================
// Model Config Section - Unified Layout
// ============================================
.openaiEditForm {
display: flex;
flex-direction: column;
gap: $spacing-md;
:global(.form-group) {
margin-bottom: 0;
}
:global(.status-badge) {
margin-bottom: 0;
align-self: flex-start;
}
}
.sectionHint {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
}
.openaiModelsContent {
display: flex;
flex-direction: column;
gap: $spacing-md;
:global(.form-group) {
margin-bottom: 0;
}
}
.openaiModelsEndpointSection {
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
.openaiModelsEndpointLabel {
display: block;
margin: 0;
font-weight: 600;
color: var(--text-primary);
}
.openaiModelsEndpointControls {
display: flex;
align-items: center;
gap: $spacing-sm;
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.openaiModelsEndpointInput {
flex: 1;
min-width: 0;
}
.modelConfigSection {
margin-bottom: 0;
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.modelConfigHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
flex-wrap: wrap;
@include mobile {
align-items: flex-start;
}
}
.modelConfigTitle {
margin: 0;
font-weight: 600;
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
}
.modelConfigToolbar {
display: flex;
align-items: center;
gap: $spacing-xs;
flex-wrap: wrap;
justify-content: flex-end;
@include mobile {
width: 100%;
justify-content: flex-start;
}
:global(.btn) {
white-space: nowrap;
}
}
.modelInputList {
gap: $spacing-xs;
}
.modelInputRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
gap: $spacing-sm;
align-items: center;
@include mobile {
grid-template-columns: minmax(0, 1fr) auto;
row-gap: $spacing-xs;
> :nth-child(2) {
display: none;
}
> :nth-child(3) {
grid-column: 1 / 3;
}
> :nth-child(4) {
grid-column: 2 / 3;
grid-row: 1 / 2;
}
}
}
.modelInputField {
min-width: 0;
}
.modelRowRemoveButton {
justify-self: center;
}
.modelTestPanel {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $spacing-md;
margin-top: 0;
padding: $spacing-sm $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.modelTestMeta {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.modelTestLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
line-height: 1.5;
}
.modelTestHint {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
}
.modelTestControls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: $spacing-xs;
flex: 1;
min-width: 0;
@include mobile {
justify-content: flex-start;
}
}
// ============================================
// Key Entry Styles - Table Design
// ============================================
.keyEntriesSection {
margin-bottom: 0;
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.keyEntriesHeader {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 0;
}
.keyEntriesTitle {
display: block;
margin: 0;
font-weight: 600;
color: var(--text-primary);
line-height: 1.5;
}
.keyEntriesHint {
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
}
.keyEntriesList {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.keyEntriesToolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
flex-wrap: wrap;
}
.keyEntriesCount {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.keyTableShell {
overflow-x: auto;
border-radius: $radius-md;
}
// 表头
.keyTableHeader {
display: grid;
grid-template-columns: 46px 56px minmax(220px, 1.4fr) minmax(200px, 1.1fr) 180px;
gap: $spacing-sm;
min-width: 760px;
padding: 10px $spacing-md;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-bottom: none;
border-radius: $radius-md $radius-md 0 0;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: none;
align-items: center;
text-align: center;
}
// 数据行
.keyTableRow {
display: grid;
grid-template-columns: 46px 56px minmax(220px, 1.4fr) minmax(200px, 1.1fr) 180px;
gap: $spacing-sm;
min-width: 760px;
padding: 10px $spacing-md;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-top: none;
align-items: center;
&:last-child {
border-radius: 0 0 $radius-md $radius-md;
}
&:hover {
background: var(--bg-tertiary);
}
}
// 列定义
.keyTableColIndex {
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--text-tertiary);
}
.keyTableColStatus {
display: flex;
align-items: center;
justify-content: center;
svg {
display: block;
}
}
.keyTableColKey,
.keyTableColProxy {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
}
.keyTableColAction {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-xs;
flex-shrink: 0;
white-space: nowrap;
}
.keyTableInput {
width: 100%;
padding: 8px 10px;
font-size: 14px;
min-height: 38px;
text-align: center;
}
.addKeyButton {
align-self: auto;
margin-top: 0;
}
.openaiTestSelect {
flex: 1 1 260px;
min-width: 180px;
max-width: 380px;
@include mobile {
min-width: 0;
max-width: none;
}
}
.modelTestAllButton {
white-space: nowrap;
flex-shrink: 0;
}
.statusIconWrapper {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--text-secondary);
flex-shrink: 0;
}
.statusIconSpin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }
// 暗色主题适配 // 暗色主题适配
:global([data-theme='dark']) { :global([data-theme='dark']) {
.headerBadge { .headerBadge {
background: rgba(59, 130, 246, 0.15); background: rgba($primary-color, 0.14);
border-color: rgba(59, 130, 246, 0.3); border-color: rgba($primary-color, 0.35);
color: var(--text-secondary); color: var(--text-secondary);
strong { strong {
@@ -486,22 +951,22 @@
} }
.modelTag { .modelTag {
background: rgba(59, 130, 246, 0.1); background: rgba($primary-color, 0.1);
border-color: var(--border-secondary); border-color: var(--border-secondary);
} }
.excludedModelTag { .excludedModelTag {
background: rgba(251, 191, 36, 0.22); background: rgba($warning-color, 0.22);
border-color: rgba(251, 191, 36, 0.55); border-color: rgba($warning-color, 0.55);
color: #fde68a; color: var(--warning-color);
.modelName { .modelName {
color: #fde68a; color: var(--warning-color);
} }
} }
.excludedModelsLabel { .excludedModelsLabel {
color: #fde68a; color: var(--warning-color);
} }
.apiKeyEntryCard { .apiKeyEntryCard {
@@ -517,6 +982,20 @@
background-color: var(--border-primary, #374151); background-color: var(--border-primary, #374151);
} }
.statusTooltip {
background: var(--bg-secondary, #1f2937);
border-color: var(--border-primary, #374151);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
&::after {
border-top-color: var(--bg-secondary, #1f2937);
}
&::before {
border-top-color: var(--border-primary, #374151);
}
}
.statusRateHigh { .statusRateHigh {
background: rgba(34, 197, 94, 0.2); background: rgba(34, 197, 94, 0.2);
color: #86efac; color: #86efac;
@@ -528,7 +1007,7 @@
} }
.statusRateLow { .statusRateLow {
background: rgba(239, 68, 68, 0.2); background: rgba($error-color, 0.24);
color: #fca5a5; color: #f1b0a6;
} }
} }

View File

@@ -218,7 +218,7 @@ export function AiProvidersVertexEditPage() {
<Card> <Card>
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}
{invalidIndexParam || invalidIndex ? ( {invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div> <div className="hint">{t('common.invalid_provider_index')}</div>
) : ( ) : (
<> <>
<Input <Input
@@ -256,6 +256,8 @@ export function AiProvidersVertexEditPage() {
addLabel={t('common.custom_headers_add')} addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')} keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving} disabled={disableControls || saving}
/> />
<div className="form-group"> <div className="form-group">
@@ -266,6 +268,8 @@ export function AiProvidersVertexEditPage() {
addLabel={t('ai_providers.vertex_models_add_btn')} addLabel={t('ai_providers.vertex_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')} namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')} aliasPlaceholder={t('common.model_alias_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving} disabled={disableControls || saving}
/> />
<div className="hint">{t('ai_providers.vertex_models_hint')}</div> <div className="hint">{t('ai_providers.vertex_models_hint')}</div>

View File

@@ -26,6 +26,7 @@ const OAUTH_PROVIDER_PRESETS = [
'claude', 'claude',
'codex', 'codex',
'qwen', 'qwen',
'kimi',
'iflow', 'iflow',
]; ];

View File

@@ -29,6 +29,7 @@ const OAUTH_PROVIDER_PRESETS = [
'claude', 'claude',
'codex', 'codex',
'qwen', 'qwen',
'kimi',
'iflow', 'iflow',
]; ];

View File

@@ -5,6 +5,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-lg; gap: $spacing-lg;
padding-bottom: calc(var(--auth-files-action-bar-height, 0px) + 16px + env(safe-area-inset-bottom));
} }
.pageHeader { .pageHeader {
@@ -56,7 +57,7 @@
.errorBox { .errorBox {
padding: $spacing-md; padding: $spacing-md;
background-color: rgba(239, 68, 68, 0.1); background-color: rgba($error-color, 0.1);
border: 1px solid var(--danger-color); border: 1px solid var(--danger-color);
border-radius: $radius-md; border-radius: $radius-md;
color: var(--danger-color); color: var(--danger-color);
@@ -80,12 +81,13 @@
.filterTag { .filterTag {
display: inline-flex; display: inline-flex;
align-items: center; align-items: baseline;
gap: 8px; gap: 8px;
padding: 6px 14px; padding: 6px 14px;
border-radius: 20px; border-radius: 20px;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
line-height: 1;
border: 1px solid transparent; border: 1px solid transparent;
cursor: pointer; cursor: pointer;
transition: all $transition-fast; transition: all $transition-fast;
@@ -101,12 +103,19 @@
} }
.filterTagLabel { .filterTagLabel {
display: inline-flex;
align-items: baseline;
white-space: nowrap; white-space: nowrap;
} }
.filterTagCount { .filterTagCount {
display: inline-flex;
align-items: baseline;
justify-content: flex-end;
min-width: 2ch;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
font-variant-numeric: tabular-nums;
opacity: 0.85; opacity: 0.85;
} }
@@ -185,10 +194,10 @@
} }
.fileGridQuotaManaged { .fileGridQuotaManaged {
grid-template-columns: repeat(auto-fill, minmax(520px, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@include tablet { @include tablet {
grid-template-columns: 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@include mobile { @include mobile {
@@ -374,11 +383,11 @@
} }
.quotaBarFillMedium { .quotaBarFillMedium {
background-color: var(--warning-color, #f59e0b); background-color: var(--warning-color);
} }
.quotaBarFillLow { .quotaBarFillLow {
background-color: var(--danger-color, #ef4444); background-color: var(--danger-color);
} }
.quotaMeta { .quotaMeta {
@@ -414,10 +423,28 @@
padding: $spacing-sm 0; padding: $spacing-sm 0;
} }
.quotaMessageAction {
width: 100%;
border: none;
background: none;
cursor: pointer;
text-decoration: underline;
&:hover:not(:disabled) {
color: var(--text-primary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
text-decoration: none;
}
}
.quotaError { .quotaError {
font-size: 12px; font-size: 12px;
color: var(--danger-color); color: var(--danger-color);
background-color: rgba(239, 68, 68, 0.08); background-color: rgba($error-color, 0.08);
border: 1px solid var(--danger-color); border: 1px solid var(--danger-color);
border-radius: $radius-sm; border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm; padding: $spacing-xs $spacing-sm;
@@ -425,9 +452,9 @@
.quotaWarning { .quotaWarning {
font-size: 12px; font-size: 12px;
color: var(--warning-color, #f59e0b); color: var(--warning-text);
background-color: rgba(245, 158, 11, 0.12); background-color: var(--warning-bg);
border: 1px solid var(--warning-color, #f59e0b); border: 1px solid var(--warning-border);
border-radius: $radius-sm; border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm; padding: $spacing-xs $spacing-sm;
} }
@@ -471,6 +498,15 @@
} }
} }
.fileCardSelected {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary-color) 70%, transparent);
&:hover {
border-color: var(--primary-color);
}
}
.fileCardDisabled { .fileCardDisabled {
opacity: 0.6; opacity: 0.6;
@@ -487,17 +523,6 @@
gap: $spacing-md; gap: $spacing-md;
} }
.fileCardLayoutQuota {
display: grid;
grid-template-columns: 1fr 156px;
gap: $spacing-md;
align-items: stretch;
@include mobile {
grid-template-columns: 1fr;
}
}
.fileCardMain { .fileCardMain {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -506,41 +531,6 @@
min-width: 0; min-width: 0;
} }
.fileCardSidebar {
display: flex;
flex-direction: column;
gap: $spacing-sm;
padding-left: $spacing-md;
border-left: 1px dashed var(--border-color);
@include mobile {
border-left: none;
border-top: 1px dashed var(--border-color);
padding-left: 0;
padding-top: $spacing-md;
}
}
.fileCardSidebarHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-xs;
}
.fileCardSidebarTitle {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
}
.fileCardSidebarHint {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.cardHeader { .cardHeader {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -548,6 +538,45 @@
min-height: 28px; min-height: 28px;
} }
.selectionToggle {
width: 22px;
height: 22px;
margin: 0;
flex-shrink: 0;
border-radius: 7px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
color: var(--primary-contrast, #fff);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
border-color $transition-fast,
background-color $transition-fast,
box-shadow $transition-fast,
transform $transition-fast;
&:hover {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent);
}
&:active {
transform: scale(0.95);
}
}
.selectionToggleActive {
border-color: var(--primary-color);
background: var(--primary-color);
}
.selectionToggleActive svg {
display: block;
stroke-width: 2.4;
}
.typeBadge { .typeBadge {
padding: 4px 10px; padding: 4px 10px;
border-radius: 12px; border-radius: 12px;
@@ -606,9 +635,9 @@
} }
.statFailure { .statFailure {
background-color: var(--failure-badge-bg, #fee2e2); background-color: var(--failure-badge-bg);
color: var(--failure-badge-text, #991b1b); color: var(--failure-badge-text);
border-color: var(--failure-badge-border, #fca5a5); border-color: var(--failure-badge-border);
} }
// 状态监测栏 // 状态监测栏
@@ -625,39 +654,121 @@
gap: 2px; gap: 2px;
flex: 1; flex: 1;
min-width: 180px; min-width: 180px;
position: relative;
}
.statusBlockWrapper {
flex: 1;
min-width: 6px;
position: relative;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
} }
.statusBlock { .statusBlock {
flex: 1; width: 100%;
height: 8px; height: 8px;
border-radius: 2px; border-radius: 2px;
min-width: 6px; transition: transform 0.15s ease, opacity 0.15s ease;
transition:
transform 0.15s ease,
opacity 0.15s ease;
&:hover { .statusBlockWrapper:hover &,
transform: scaleY(1.5); .statusBlockWrapper.statusBlockActive & {
opacity: 0.85; transform: scaleY(1.8);
opacity: 0.9;
} }
} }
.statusBlockSuccess {
background-color: var(--success-color, #22c55e);
}
.statusBlockFailure {
background-color: var(--danger-color, #ef4444);
}
.statusBlockMixed {
background-color: var(--warning-color, #f59e0b);
}
.statusBlockIdle { .statusBlockIdle {
background-color: var(--border-secondary, #e5e7eb); background-color: var(--border-secondary, #e5e7eb);
} }
.statusTooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-primary, #fff);
border: 1px solid var(--border-secondary, #e5e7eb);
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
line-height: 1.5;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: $z-dropdown;
pointer-events: none;
color: var(--text-primary);
&::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--bg-primary, #fff);
}
&::before {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border-secondary, #e5e7eb);
}
}
.statusTooltipLeft {
left: 0;
transform: translateX(0);
&::after,
&::before {
left: 8px;
transform: none;
}
}
.statusTooltipRight {
left: auto;
right: 0;
transform: translateX(0);
&::after,
&::before {
left: auto;
right: 8px;
transform: none;
}
}
.tooltipTime {
color: var(--text-secondary);
display: block;
margin-bottom: 2px;
}
.tooltipStats {
display: flex;
align-items: center;
gap: 8px;
}
.tooltipSuccess {
color: var(--success-color, #22c55e);
}
.tooltipFailure {
color: var(--danger-color, #ef4444);
}
.tooltipRate {
color: var(--text-secondary);
margin-left: 2px;
}
.statusRate { .statusRate {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -681,8 +792,19 @@
} }
.statusRateLow { .statusRateLow {
color: var(--failure-badge-text, #991b1b); color: var(--failure-badge-text);
background: var(--failure-badge-bg, #fee2e2); background: var(--failure-badge-bg);
}
@include mobile {
.statusTooltip {
font-size: 12px;
padding: 8px 12px;
}
.statusBlocks {
min-width: 140px;
}
} }
.prefixProxyEditor { .prefixProxyEditor {
@@ -707,7 +829,7 @@
padding: $spacing-sm $spacing-md; padding: $spacing-sm $spacing-md;
border-radius: $radius-md; border-radius: $radius-md;
border: 1px solid var(--danger-color); border: 1px solid var(--danger-color);
background-color: rgba(239, 68, 68, 0.1); background-color: rgba($error-color, 0.1);
color: var(--danger-color); color: var(--danger-color);
font-size: 12px; font-size: 12px;
} }
@@ -805,6 +927,66 @@
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
} }
.batchActionContainer {
position: fixed;
left: var(--content-center-x, 50%);
bottom: calc(16px + env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 50;
width: min(960px, calc(100vw - 24px));
will-change: transform, opacity;
}
.batchActionBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
padding: 10px 12px;
border-radius: $radius-lg;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
background: color-mix(in srgb, var(--bg-primary) 84%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: var(--shadow-lg);
}
.batchActionLeft,
.batchActionRight {
display: flex;
align-items: center;
gap: $spacing-xs;
flex-wrap: wrap;
}
.batchActionRight {
justify-content: flex-end;
}
.batchSelectionText {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-right: 2px;
}
@include mobile {
.batchActionContainer {
width: calc(100vw - 16px);
bottom: calc(12px + env(safe-area-inset-bottom));
}
.batchActionBar {
flex-direction: column;
align-items: stretch;
}
.batchActionLeft,
.batchActionRight {
justify-content: center;
}
}
.pageInfo { .pageInfo {
font-size: 13px; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
@@ -813,7 +995,7 @@
border-radius: $radius-md; border-radius: $radius-md;
} }
// OAuth 排除列表 // OAuth 模型禁用
.excludedList { .excludedList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -861,7 +1043,7 @@
flex-shrink: 0; flex-shrink: 0;
} }
// OAuth 排除列表表单:提供商快捷标签 // OAuth 模型禁用表单:提供商快捷标签
.providerField { .providerField {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1117,7 +1299,7 @@
.modelExcludedBadge { .modelExcludedBadge {
font-size: 10px; font-size: 10px;
color: var(--danger-color); color: var(--danger-color);
background-color: rgba(239, 68, 68, 0.1); background-color: rgba($error-color, 0.1);
padding: 2px 6px; padding: 2px 6px;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--danger-color); border: 1px solid var(--danger-color);

File diff suppressed because it is too large Load Diff

View File

@@ -192,15 +192,15 @@
color: var(--text-secondary); color: var(--text-secondary);
&.modified { &.modified {
color: #f59e0b; color: var(--warning-color);
} }
&.saved { &.saved {
color: #16a34a; color: var(--success-color);
} }
&.error { &.error {
color: #dc2626; color: var(--danger-color);
} }
} }
@@ -331,12 +331,12 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 10px; padding: 8px 10px;
background: rgba(255, 255, 255, 0.7); background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
border-radius: 999px; border-radius: 999px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-lg);
max-width: inherit; max-width: inherit;
overflow-x: auto; overflow-x: auto;
scrollbar-width: none; scrollbar-width: none;
@@ -351,7 +351,7 @@
font-weight: 600; font-weight: 600;
padding: 5px 8px; padding: 5px 8px;
border-radius: 999px; border-radius: 999px;
background: rgba(0, 0, 0, 0.06); background: color-mix(in srgb, var(--text-primary) 6%, transparent);
text-align: center; text-align: center;
max-width: min(280px, 46vw); max-width: min(280px, 46vw);
white-space: nowrap; white-space: nowrap;
@@ -373,7 +373,7 @@
transition: background-color 0.2s ease, transform 0.15s ease; transition: background-color 0.2s ease, transform 0.15s ease;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.06); background: color-mix(in srgb, var(--text-primary) 10%, transparent);
transform: scale(1.08); transform: scale(1.08);
} }
@@ -395,26 +395,8 @@
width: 7px; width: 7px;
height: 7px; height: 7px;
border-radius: 999px; border-radius: 999px;
background: #f59e0b; background: var(--warning-color);
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25); box-shadow: 0 0 0 2px rgba($warning-color, 0.25);
}
:global([data-theme='dark']) {
.floatingActionList {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
.floatingStatus {
background: rgba(255, 255, 255, 0.08);
}
.floatingActionButton {
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
}
}
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {

View File

@@ -5,11 +5,13 @@ import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml'; import { yaml } from '@codemirror/lang-yaml';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search'; import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { keymap } from '@codemirror/view'; import { keymap } from '@codemirror/view';
import { parse as parseYaml } from 'yaml';
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 { IconCheck, IconChevronDown, IconChevronUp, IconRefreshCw, IconSearch } from '@/components/ui/icons'; import { IconCheck, IconChevronDown, IconChevronUp, IconRefreshCw, IconSearch } from '@/components/ui/icons';
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor'; import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
import { DiffModal } from '@/components/config/DiffModal';
import { useVisualConfig } from '@/hooks/useVisualConfig'; import { useVisualConfig } from '@/hooks/useVisualConfig';
import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores'; import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores';
import { configFileApi } from '@/services/api/configFile'; import { configFileApi } from '@/services/api/configFile';
@@ -17,6 +19,16 @@ import styles from './ConfigPage.module.scss';
type ConfigEditorTab = 'visual' | 'source'; type ConfigEditorTab = 'visual' | 'source';
function readCommercialModeFromYaml(yamlContent: string): boolean {
try {
const parsed = parseYaml(yamlContent);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
return Boolean((parsed as Record<string, unknown>)['commercial-mode']);
} catch {
return false;
}
}
export function ConfigPage() { export function ConfigPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
@@ -42,6 +54,9 @@ export function ConfigPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [diffModalOpen, setDiffModalOpen] = useState(false);
const [serverYaml, setServerYaml] = useState('');
const [mergedYaml, setMergedYaml] = useState('');
// Search state // Search state
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -62,6 +77,9 @@ export function ConfigPage() {
const data = await configFileApi.fetchConfigYaml(); const data = await configFileApi.fetchConfigYaml();
setContent(data); setContent(data);
setDirty(false); setDirty(false);
setDiffModalOpen(false);
setServerYaml(data);
setMergedYaml(data);
loadVisualValuesFromYaml(data); loadVisualValuesFromYaml(data);
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : t('notification.refresh_failed'); const message = err instanceof Error ? err.message : t('notification.refresh_failed');
@@ -75,16 +93,52 @@ export function ConfigPage() {
loadConfig(); loadConfig();
}, [loadConfig]); }, [loadConfig]);
const handleConfirmSave = async () => {
setSaving(true);
try {
const previousCommercialMode = readCommercialModeFromYaml(serverYaml);
const nextCommercialMode = readCommercialModeFromYaml(mergedYaml);
const commercialModeChanged = previousCommercialMode !== nextCommercialMode;
await configFileApi.saveConfigYaml(mergedYaml);
const latestContent = await configFileApi.fetchConfigYaml();
setDirty(false);
setDiffModalOpen(false);
setContent(latestContent);
setServerYaml(latestContent);
setMergedYaml(latestContent);
loadVisualValuesFromYaml(latestContent);
showNotification(t('config_management.save_success'), 'success');
if (commercialModeChanged) {
showNotification(t('notification.commercial_mode_restart_required'), 'warning');
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(`${t('notification.save_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
try { try {
const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content; const nextMergedYaml = applyVisualChangesToYaml(content);
await configFileApi.saveConfigYaml(nextContent); const latestServerYaml = await configFileApi.fetchConfigYaml();
const latestContent = await configFileApi.fetchConfigYaml();
setDirty(false); if (latestServerYaml === nextMergedYaml) {
setContent(latestContent); setDirty(false);
loadVisualValuesFromYaml(latestContent); setContent(latestServerYaml);
showNotification(t('config_management.save_success'), 'success'); setServerYaml(latestServerYaml);
setMergedYaml(nextMergedYaml);
loadVisualValuesFromYaml(latestServerYaml);
showNotification(t('config_management.diff.no_changes'), 'info');
return;
}
setServerYaml(latestServerYaml);
setMergedYaml(nextMergedYaml);
setDiffModalOpen(true);
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : ''; const message = err instanceof Error ? err.message : '';
showNotification(`${t('notification.save_failed')}: ${message}`, 'error'); showNotification(`${t('notification.save_failed')}: ${message}`, 'error');
@@ -309,7 +363,7 @@ export function ConfigPage() {
type="button" type="button"
className={styles.floatingActionButton} className={styles.floatingActionButton}
onClick={handleSave} onClick={handleSave}
disabled={disableControls || loading || saving || !isDirty} disabled={disableControls || loading || saving || !isDirty || diffModalOpen}
title={t('config_management.save')} title={t('config_management.save')}
aria-label={t('config_management.save')} aria-label={t('config_management.save')}
> >
@@ -457,6 +511,14 @@ export function ConfigPage() {
</Card> </Card>
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null} {typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
<DiffModal
open={diffModalOpen}
original={serverYaml}
modified={mergedYaml}
onConfirm={handleConfirmSave}
onCancel={() => setDiffModalOpen(false)}
loading={saving}
/>
</div> </div>
); );
} }

View File

@@ -62,14 +62,23 @@ export function DashboardPage() {
apiKeysCache.current = []; apiKeysCache.current = [];
}, [apiBase, config?.apiKeys]); }, [apiBase, config?.apiKeys]);
const normalizeApiKeyList = (input: any): string[] => { const normalizeApiKeyList = (input: unknown): string[] => {
if (!Array.isArray(input)) return []; if (!Array.isArray(input)) return [];
const seen = new Set<string>(); const seen = new Set<string>();
const keys: string[] = []; const keys: string[] = [];
input.forEach((item) => { input.forEach((item) => {
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? ''; const record =
const trimmed = String(value || '').trim(); item !== null && typeof item === 'object' && !Array.isArray(item)
? (item as Record<string, unknown>)
: null;
const value =
typeof item === 'string'
? item
: record
? (record['api-key'] ?? record['apiKey'] ?? record.key ?? record.Key)
: '';
const trimmed = String(value ?? '').trim();
if (!trimmed || seen.has(trimmed)) return; if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed); seen.add(trimmed);
keys.push(trimmed); keys.push(trimmed);

View File

@@ -4,7 +4,7 @@
.container { .container {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
background: var(--bg-primary); background: var(--bg-secondary);
} }
// 左侧品牌展示区 // 左侧品牌展示区
@@ -88,7 +88,7 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: $spacing-2xl; padding: $spacing-2xl;
background: var(--bg-primary); background: var(--bg-secondary);
position: relative; position: relative;
@media (max-width: $breakpoint-mobile) { @media (max-width: $breakpoint-mobile) {
@@ -167,9 +167,24 @@
font-size: 14px; font-size: 14px;
} }
// 语言切换按钮 // 语言下拉选择
.languageBtn { .languageSelect {
white-space: nowrap; white-space: nowrap;
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: 10px 12px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
height: 40px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba($primary-color, 0.18);
}
} }
// 连接信息框 // 连接信息框
@@ -220,8 +235,8 @@
// 错误提示框 // 错误提示框
.errorBox { .errorBox {
background: rgba(239, 68, 68, 0.1); background: rgba($error-color, 0.1);
border: 1px solid rgba(239, 68, 68, 0.4); border: 1px solid rgba($error-color, 0.4);
border-radius: $radius-md; border-radius: $radius-md;
padding: $spacing-sm $spacing-md; padding: $spacing-sm $spacing-md;
color: $error-color; color: $error-color;

View File

@@ -6,6 +6,8 @@ import { Input } from '@/components/ui/Input';
import { IconEye, IconEyeOff } from '@/components/ui/icons'; import { IconEye, IconEyeOff } from '@/components/ui/icons';
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores'; import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection'; import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
import { isSupportedLanguage } from '@/utils/language';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import type { ApiError } from '@/types'; import type { ApiError } from '@/types';
import styles from './LoginPage.module.scss'; import styles from './LoginPage.module.scss';
@@ -13,11 +15,20 @@ import styles from './LoginPage.module.scss';
/** /**
* 将 API 错误转换为本地化的用户友好消息 * 将 API 错误转换为本地化的用户友好消息
*/ */
function getLocalizedErrorMessage(error: any, t: (key: string) => string): string { type RedirectState = { from?: { pathname?: string } };
const apiError = error as ApiError;
const status = apiError?.status; function getLocalizedErrorMessage(error: unknown, t: (key: string) => string): string {
const code = apiError?.code; const apiError = error as Partial<ApiError>;
const message = apiError?.message || ''; const status = typeof apiError.status === 'number' ? apiError.status : undefined;
const code = typeof apiError.code === 'string' ? apiError.code : undefined;
const message =
error instanceof Error
? error.message
: typeof apiError.message === 'string'
? apiError.message
: typeof error === 'string'
? error
: '';
// 根据 HTTP 状态码判断 // 根据 HTTP 状态码判断
if (status === 401) { if (status === 401) {
@@ -59,7 +70,7 @@ export function LoginPage() {
const location = useLocation(); const location = useLocation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const language = useLanguageStore((state) => state.language); const language = useLanguageStore((state) => state.language);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage); const setLanguage = useLanguageStore((state) => state.setLanguage);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const login = useAuthStore((state) => state.login); const login = useAuthStore((state) => state.login);
const restoreSession = useAuthStore((state) => state.restoreSession); const restoreSession = useAuthStore((state) => state.restoreSession);
@@ -78,7 +89,16 @@ export function LoginPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []); const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese'); const handleLanguageChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
const selectedLanguage = event.target.value;
if (!isSupportedLanguage(selectedLanguage)) {
return;
}
setLanguage(selectedLanguage);
},
[setLanguage]
);
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@@ -88,7 +108,7 @@ export function LoginPage() {
setAutoLoginSuccess(true); setAutoLoginSuccess(true);
// 延迟跳转,让用户看到成功动画 // 延迟跳转,让用户看到成功动画
setTimeout(() => { setTimeout(() => {
const redirect = (location.state as any)?.from?.pathname || '/'; const redirect = (location.state as RedirectState | null)?.from?.pathname || '/';
navigate(redirect, { replace: true }); navigate(redirect, { replace: true });
}, 1500); }, 1500);
} else { } else {
@@ -124,7 +144,7 @@ export function LoginPage() {
}); });
showNotification(t('common.connected_status'), 'success'); showNotification(t('common.connected_status'), 'success');
navigate('/', { replace: true }); navigate('/', { replace: true });
} catch (err: any) { } catch (err: unknown) {
const message = getLocalizedErrorMessage(err, t); const message = getLocalizedErrorMessage(err, t);
setError(message); setError(message);
showNotification(`${t('notification.login_failed')}: ${message}`, 'error'); showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
@@ -144,7 +164,7 @@ export function LoginPage() {
); );
if (isAuthenticated && !autoLoading && !autoLoginSuccess) { if (isAuthenticated && !autoLoading && !autoLoginSuccess) {
const redirect = (location.state as any)?.from?.pathname || '/'; const redirect = (location.state as RedirectState | null)?.from?.pathname || '/';
return <Navigate to={redirect} replace />; return <Navigate to={redirect} replace />;
} }
@@ -168,8 +188,8 @@ export function LoginPage() {
/* 启动动画 */ /* 启动动画 */
<div className={styles.splashContent}> <div className={styles.splashContent}>
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.splashLogo} /> <img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.splashLogo} />
<h1 className={styles.splashTitle}>CLI Proxy API</h1> <h1 className={styles.splashTitle}>{t('splash.title')}</h1>
<p className={styles.splashSubtitle}>Management Center</p> <p className={styles.splashSubtitle}>{t('splash.subtitle')}</p>
<div className={styles.splashLoader}> <div className={styles.splashLoader}>
<div className={styles.splashLoaderBar} /> <div className={styles.splashLoaderBar} />
</div> </div>
@@ -185,17 +205,19 @@ export function LoginPage() {
<div className={styles.loginHeader}> <div className={styles.loginHeader}>
<div className={styles.titleRow}> <div className={styles.titleRow}>
<div className={styles.title}>{t('title.login')}</div> <div className={styles.title}>{t('title.login')}</div>
<Button <select
type="button" className={styles.languageSelect}
variant="ghost" value={language}
size="sm" onChange={handleLanguageChange}
className={styles.languageBtn}
onClick={toggleLanguage}
title={t('language.switch')} title={t('language.switch')}
aria-label={t('language.switch')} aria-label={t('language.switch')}
> >
{nextLanguageLabel} {LANGUAGE_ORDER.map((lang) => (
</Button> <option key={lang} value={lang}>
{t(LANGUAGE_LABEL_KEYS[lang])}
</option>
))}
</select>
</div> </div>
<div className={styles.subtitle}>{t('login.subtitle')}</div> <div className={styles.subtitle}>{t('login.subtitle')}</div>
</div> </div>

View File

@@ -308,7 +308,7 @@
color: var(--text-primary); color: var(--text-primary);
&:hover { &:hover {
background: rgba(59, 130, 246, 0.06); background: rgba($primary-color, 0.06);
} }
@include tablet { @include tablet {
@@ -409,14 +409,14 @@
.statusInfo { .statusInfo {
color: var(--info-color); color: var(--info-color);
background: rgba(59, 130, 246, 0.12); background: rgba($primary-color, 0.12);
border-color: rgba(59, 130, 246, 0.25); border-color: rgba($primary-color, 0.25);
} }
.statusWarn { .statusWarn {
color: var(--warning-color); color: var(--warning-text);
background: rgba(245, 158, 11, 0.14); background: var(--warning-bg);
border-color: rgba(245, 158, 11, 0.25); border-color: var(--warning-border);
} }
.statusError { .statusError {
@@ -427,20 +427,20 @@
.levelInfo { .levelInfo {
color: var(--info-color); color: var(--info-color);
background: rgba(59, 130, 246, 0.12); background: rgba($primary-color, 0.12);
border-color: rgba(59, 130, 246, 0.25); border-color: rgba($primary-color, 0.25);
} }
.levelWarn { .levelWarn {
color: var(--warning-color); color: var(--warning-text);
background: rgba(245, 158, 11, 0.14); background: var(--warning-bg);
border-color: rgba(245, 158, 11, 0.25); border-color: var(--warning-border);
} }
.levelError { .levelError {
color: var(--error-color); color: var(--error-color);
background: rgba(239, 68, 68, 0.12); background: rgba($error-color, 0.12);
border-color: rgba(239, 68, 68, 0.25); border-color: rgba($error-color, 0.25);
} }
.levelDebug, .levelDebug,
@@ -452,8 +452,8 @@
.methodBadge { .methodBadge {
color: var(--text-primary); color: var(--text-primary);
background: rgba(59, 130, 246, 0.08); background: rgba($primary-color, 0.08);
border-color: rgba(59, 130, 246, 0.22); border-color: rgba($primary-color, 0.22);
} }
.path { .path {

View File

@@ -20,6 +20,7 @@ import {
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { logsApi } from '@/services/api/logs'; import { logsApi } from '@/services/api/logs';
import { copyToClipboard } from '@/utils/clipboard';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants'; import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
import { formatUnixTimestamp } from '@/utils/format'; import { formatUnixTimestamp } from '@/utils/format';
import styles from './LogsPage.module.scss'; import styles from './LogsPage.module.scss';
@@ -344,30 +345,6 @@ const getErrorMessage = (err: unknown): string => {
return typeof message === 'string' ? message : ''; return typeof message === 'string' ? message : '';
}; };
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
};
type TabType = 'logs' | 'errors'; type TabType = 'logs' | 'errors';
export function LogsPage() { export function LogsPage() {
@@ -400,6 +377,8 @@ export function LogsPage() {
startY: number; startY: number;
fired: boolean; fired: boolean;
} | null>(null); } | null>(null);
const logRequestInFlightRef = useRef(false);
const pendingFullReloadRef = useRef(false);
// 保存最新时间戳用于增量获取 // 保存最新时间戳用于增量获取
const latestTimestampRef = useRef<number>(0); const latestTimestampRef = useRef<number>(0);
@@ -424,6 +403,15 @@ export function LogsPage() {
return; return;
} }
if (logRequestInFlightRef.current) {
if (!incremental) {
pendingFullReloadRef.current = true;
}
return;
}
logRequestInFlightRef.current = true;
if (!incremental) { if (!incremental) {
setLoading(true); setLoading(true);
} }
@@ -474,6 +462,11 @@ export function LogsPage() {
if (!incremental) { if (!incremental) {
setLoading(false); setLoading(false);
} }
logRequestInFlightRef.current = false;
if (pendingFullReloadRef.current) {
pendingFullReloadRef.current = false;
void loadLogs(false);
}
} }
}; };

View File

@@ -28,6 +28,35 @@
gap: $spacing-xl; gap: $spacing-xl;
} }
.cardContent {
display: flex;
flex-direction: column;
gap: $spacing-md;
:global(.form-group) {
margin-bottom: 0;
}
:global(.status-badge) {
margin-bottom: 0;
align-self: flex-start;
}
}
.cardHint {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
}
.cardHintSecondary {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-tertiary);
}
.oauthSection { .oauthSection {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -61,21 +90,21 @@
} }
&.error { &.error {
background-color: rgba(239, 68, 68, 0.1); background-color: rgba($error-color, 0.12);
color: #dc2626; color: $error-color;
} }
&.waiting { &.waiting {
background-color: rgba(59, 130, 246, 0.1); background-color: rgba($primary-color, 0.12);
color: #3b82f6; color: var(--primary-color);
} }
} }
.callbackSection { .callbackSection {
margin-top: $spacing-md; margin-top: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-xs; gap: $spacing-sm;
} }
.callbackActions { .callbackActions {
@@ -117,11 +146,24 @@
.geminiProjectField { .geminiProjectField {
:global(.form-group) { :global(.form-group) {
margin-top: $spacing-sm; margin-top: 0;
margin-bottom: 0;
gap: $spacing-sm; gap: $spacing-sm;
} }
} }
.formItem {
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
.formItemLabel {
margin: 0;
font-weight: 600;
color: var(--text-primary);
}
.filePicker { .filePicker {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -143,3 +185,49 @@
.fileNamePlaceholder { .fileNamePlaceholder {
color: var(--text-secondary); color: var(--text-secondary);
} }
.connectionBox {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.connectionLabel {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.keyValueList {
display: flex;
flex-direction: column;
gap: 6px;
}
.keyValueItem {
display: grid;
grid-template-columns: 140px 1fr;
gap: 10px;
align-items: start;
@include mobile {
grid-template-columns: 1fr;
gap: 2px;
}
}
.keyValueKey {
color: var(--text-secondary);
font-size: 13px;
}
.keyValueValue {
color: var(--text-primary);
word-break: break-all;
overflow-wrap: anywhere;
}

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