Compare commits

...

162 Commits

Author SHA1 Message Date
kongkongyo
8911e325e1 chore: bump version to 1.1.5
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 14:34:45 +08:00
kongkongyo
131da6c718 Merge pull request #15 from Lany798/feat/auth-index-column
Feat/auth index column
2026-02-06 14:32:54 +08:00
kongkongyo
b6d0a1e0f6 chore: bump version to 1.1.4
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 14:27:01 +08:00
kongkongyo
5f2566074f Merge upstream/main: 同步上游更新,保留本地功能 2026-02-06 14:22:19 +08:00
kongkongyo
e2afc17f22 feat: 同步上游更新 - 可视化配置编辑器与导航重构
- feat(config): 添加可视化配置编辑器和 YAML 处理
- feat(i18n): 可视化配置编辑器国际化支持
- feat(logs): 优化日志加载,支持自动预加载
- feat(ui): API 使用统计显示成功/失败状态
- feat(ui): Payload 规则编辑器空状态显示
- refactor(nav): Config Panel 导航重构,移除 Settings/API Keys 页面
- fix(ui): Config Panel 操作栏居中,ProviderNav 移至底部

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 14:20:16 +08:00
hkfires
26fa1ea98e feat(logs): optimize log loading with auto-prepend functionality 2026-02-06 12:09:25 +08:00
hkfires
e568e4a2b5 feat(ui): show empty state for payload rules editor 2026-02-06 11:29:21 +08:00
hkfires
4a0386472d feat(ui): show success/failure in API usage stats 2026-02-06 11:23:50 +08:00
LTbinglingfeng
b9001c27c5 fix 2026-02-06 03:56:57 +08:00
LTbinglingfeng
e6e62e2992 feat(i18n): add internationalization support for visual config editor 2026-02-06 03:34:38 +08:00
LTbinglingfeng
f53d333198 fix(ui): center Config Panel action bar and move ProviderNav to bottom 2026-02-06 03:13:13 +08:00
LTbinglingfeng
adcf0b6582 refactor(nav): move Config Panel and remove Settings/API Keys pages 2026-02-06 02:47:37 +08:00
LTbinglingfeng
11c2498be6 feat: add visual configuration editor and YAML handling
- Implemented a new hook `useVisualConfig` for managing visual configuration state and YAML parsing.
- Added types for visual configuration in `visualConfig.ts`.
- Enhanced `ConfigPage` to support switching between visual and source editors.
- Introduced floating action buttons for save and reload actions.
- Updated translations for tab labels in English and Chinese.
- Styled the configuration page with new tab and floating action button styles.
2026-02-06 02:15:40 +08:00
kongkongyo
43ec39040d chore: bump version to 1.1.3 2026-02-05 21:38:30 +08:00
kongkongyo
d981506942 Merge upstream/main: 合并上游更新,保留本地 Kiro 功能 2026-02-05 21:35:00 +08:00
LTbinglingfeng
7d41afb5f1 feat(auth-files): add quota management features and enhance UI layout 2026-02-05 02:22:23 +08:00
LTbinglingfeng
d4bc0bc622 fix(model-alias): restore diagram drag-and-drop and add touch tap-to-link fallback 2026-02-05 01:26:01 +08:00
LTbinglingfeng
5241d52b14 fix(model-alias): improve diagram mobile layout and refresh reliability 2026-02-05 00:55:03 +08:00
LTbinglingfeng
9887a78889 fix(layout): add scroll container for improved responsiveness and adjust container styles 2026-02-05 00:23:18 +08:00
LTbinglingfeng
759e369d42 fix(drag-and-drop): add data transfer for source and alias during drag events
fix(i18n): update view mode labels in Chinese localization
fix(auth-files): set fork to true in empty mapping entry and improve error handling in save/delete operations
2026-02-05 00:02:05 +08:00
Supra4E8C
db487dc49d Merge pull request #81 from thanhtunguet/main
Add diagram-based view mode for model alias mapping
2026-02-04 23:08:05 +08:00
LTbinglingfeng
a94a9791bc fix(ui): scope ToggleSwitch styles with CSS Modules to prevent label text collapsing into a vertical column 2026-02-04 22:00:57 +08:00
Lany798
ee60be6f0a fix: use auth_index field from API response for auth file mapping
- API returns 'auth_index' not 'authIndex'
- Support both field names for compatibility
- Properly map hash-based auth_index to file name
2026-02-04 21:28:53 +08:00
Lany798
259bf88f81 feat: display auth file name instead of index in request logs
- Load auth files to build authIndex -> fileName mapping
- Display actual auth file name in the Auth Index column
- Fallback to index number if mapping not found
2026-02-04 21:28:53 +08:00
Lany798
149d89a3d8 feat: add auth_index column to monitor request logs
- Add authIndex field to LogEntry interface
- Display auth_index in request logs table
- Shows which account/credential was used for each request
2026-02-04 21:28:52 +08:00
hkfires
473cece09e fix(quota): classify codex windows by 5-hour and weekly limits 2026-02-04 09:31:09 +08:00
Phạm Thanh Tùng
aebe95d221 Merge branch 'router-for-me:main' into main 2026-02-04 01:50:23 +07:00
LTbinglingfeng
08e8fe2edd fix(ProviderNav): remove unused provider icons and clean up code 2026-02-04 01:42:32 +08:00
kongkongyo
20d93142d6 fix: fix right navigation overlapping buttons on AI providers page
Changes:
- Add right padding (80px) to content area to prevent ProviderNav from overlapping buttons
- Mobile responsive: reset right padding to 0 (navigation displays at bottom)

Modified files:
- src/pages/AiProvidersPage.module.scss (modified)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 14:08:40 +08:00
kongkongyo
9ee120feb8 chore: bump version to 1.1.1
Changes:
- Upgrade project version from 1.0.9 to 1.1.1

Modified files:
- package.json (modified)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 13:35:11 +08:00
kongkongyo
dd21ec2c72 feat: add Kiro OAuth login support
- Add Kiro icon (kiro.svg)
- Add Kiro OAuth with AWS Builder ID and Identity Center (IDC) support
- Add Kiro refreshToken import functionality
- Add i18n translations for Kiro OAuth (en/zh-CN)

Merged from PR #13 with upstream ProviderNav changes preserved
2026-02-03 12:12:03 +08:00
kongkongyo
f54172e2df Merge remote-tracking branch 'upstream/main' 2026-02-03 12:03:57 +08:00
LTbinglingfeng
bca7082bb0 feat(ProviderNav): enhance indicator functionality and improve mobile navigation experience 2026-02-03 00:06:21 +08:00
Lany798
a61893d102 feat: add Kiro OAuth login support
- Add Kiro OAuth card to OAuthPage with AWS Builder ID and IDC login
- Support refreshToken import from Kiro IDE
- Add kiro.svg icon
- Add i18n translations for zh-CN and en
2026-02-02 22:43:26 +08:00
kongkongyo
8513bd4186 chore: bump version to 1.0.9
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 21:45:40 +08:00
kongkongyo
d996b95f0a Merge pull request #11: feat: add Kiro quota display support
新增 Kiro (AWS CodeWhisperer) 额度查看功能:
- 配额页面新增 Kiro 额度区块
- 显示基础额度、赠送额度、合计额度
- 显示订阅类型和重置时间
- 支持单个刷新和批量获取

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 21:43:04 +08:00
kongkongyo
c4642444ef Merge pull request #12 from router-for-me/main
sync: merge upstream router-for-me/Cli-Proxy-API-Management-Center

主要更新:
- 登录页面重新设计(分屏布局 + 自动登录 UI)
- AI 提供商编辑改为独立页面(替代弹窗模式)
- 新增浮动导航侧边栏,支持快速跳转
- OAuth 模型别名和排除模型编辑页面
- 新增多个模型图标(Codex、DeepSeek、GLM、Grok、Kimi、MiniMax)
- 页面过渡动画优化,防止空白闪烁
- 日志页面新增原始日志显示开关
- 移动端响应式布局优化
- 配额管理新增认证类型统计

冲突解决:
- src/stores/index.ts: 保留本地 useDisabledModelsStore 和 PR 的 useOpenAIEditDraftStore
- src/pages/AiProvidersPage.tsx: 采用 PR 版本(页面导航重构)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 21:38:04 +08:00
Phạm Thanh Tùng
d9272d6d0e Merge branch 'router-for-me:main' into main 2026-02-01 17:20:32 +07:00
LTbinglingfeng
f8c4a434ed feat(ProviderNav): update mobile layout to use bottom floating navigation and improve scroll handling 2026-02-01 02:24:05 +08:00
LTbinglingfeng
237cca5680 feat(PageTransition): enhance layer management to prevent blank flashes during transitions 2026-02-01 02:18:14 +08:00
LTbinglingfeng
f0735dbc1e feat(store): add OpenAI edit draft state management 2026-02-01 00:48:28 +08:00
Lany798
f77cbad98e feat: add Kiro (AWS CodeWhisperer) quota display support
- Add Kiro quota types (KiroFreeTrialInfo, KiroUsageBreakdown, KiroQuotaPayload, KiroQuotaState)
- Add Kiro API constants and request headers
- Add isKiroFile validator and parseKiroQuotaPayload parser
- Add KIRO_CONFIG with fetchKiroQuota and renderKiroItems
- Add kiroQuota state to useQuotaStore
- Add Kiro QuotaSection to QuotaPage
- Add Kiro styles (.kiroGrid, .kiroControls, .kiroControl, .kiroCard)
- Add i18n translations for kiro_quota (zh-CN and en)

Features:
- Separate display for base credits and bonus credits (freeTrialInfo)
- Total credits summary
- Correct parsing of seconds timestamp (scientific notation like 1.769904E9)
- Reset time display
- Subscription type display
2026-01-31 23:47:46 +08:00
thanhtunguet
0d40eecbe7 refactor(ModelMappingDiagram): restructure component to utilize new modular columns and improve type definitions 2026-01-31 21:47:49 +07:00
thanhtunguet
ce47d6d985 style(ModelMappingDiagram): remove unused aliasItem class from SCSS file 2026-01-31 21:34:00 +07:00
thanhtunguet
01a69ff32b refactor(AuthFilesPage): optimize provider list generation using useMemo for better performance 2026-01-31 21:33:26 +07:00
thanhtunguet
fd1174e010 fix: update event type handling in ModelMappingDiagram for improved type safety 2026-01-31 21:07:06 +07:00
thanhtunguet
3e55d601a1 feat: enhance OAuth model alias management with new UI components and localization updates 2026-01-31 21:04:34 +07:00
Supra4E8C
c6fabcb6bc fix(ui): sync provider quick switch highlight with scroll target 2026-01-31 17:12:59 +08:00
Supra4E8C
460519ed00 feat: add Codex icons and update references in components 2026-01-31 17:06:27 +08:00
Supra4E8C
1053e91fe4 refactor: move modelsToEntries and entriesToModels to modelInputListUtils for better organization 2026-01-31 16:43:46 +08:00
Supra4E8C
b4d08dd0d7 style(auth-files): add centered padding for OAuth edit pages 2026-01-31 16:12:46 +08:00
Supra4E8C
1502e14ca7 feat: add auth type counts and hide disabled quotas 2026-01-31 16:05:48 +08:00
Supra4E8C
7b77520526 fix 2026-01-31 16:00:40 +08:00
Supra4E8C
525541ea0d feat(ui): add model icons and categories, tweak login redirect delay 2026-01-31 15:53:03 +08:00
Supra4E8C
e7a33f8852 feat(login): enhance error handling with localized messages for various connection issues 2026-01-31 15:24:56 +08:00
Supra4E8C
70968bbc4c feat(login): add auto-login splash UI and simplify app startup 2026-01-31 15:21:13 +08:00
Supra4E8C
c93030370e feat(login): redesign login page with split layout 2026-01-31 14:59:00 +08:00
LTbinglingfeng
96307873c5 fix(ui): remove focus outline on logs tabs 2026-01-31 13:18:35 +08:00
LTbinglingfeng
b4eb2d790c fix: remove unused variables and clean up PageTransition component 2026-01-31 12:40:41 +08:00
LTbinglingfeng
3d33958d9e fix 2026-01-31 02:03:17 +08:00
LTbinglingfeng
e4c5f80b02 feat: add configuration loading and error handling to AiProvidersPage 2026-01-31 01:26:49 +08:00
LTbinglingfeng
291f67e2b9 feat: add floating provider navigation sidebar to AI providers page 2026-01-31 01:09:55 +08:00
LTbinglingfeng
3cdcb7a2a3 feat: enhance scroll position management during page transitions 2026-01-31 00:36:13 +08:00
LTbinglingfeng
3d83d0bfe2 style: add shared content width constraint to AI provider edit pages 2026-01-30 23:35:41 +08:00
LTbinglingfeng
129d89cf67 feat: improve error handling and manage component mount state in AiProvidersAmpcodeEditPage 2026-01-30 02:00:35 +08:00
LTbinglingfeng
5c85df486e feat: replace AI provider modals with dedicated edit pages 2026-01-30 01:30:36 +08:00
LTbinglingfeng
34b6d114d3 feat: add toggle for showing raw logs and update log display logic 2026-01-30 00:01:12 +08:00
LTbinglingfeng
94f0038f19 style: update settings card and header for mobile responsiveness 2026-01-29 23:42:07 +08:00
Supra4E8C
aa9c7d89f9 Merge pull request #74 from router-for-me/codex/remove-blue-box-from-log-view
Remove blue focus ring from Logs page tabs
2026-01-29 20:12:26 +08:00
Supra4E8C
9bbf61e1b6 Remove focus outline from logs tabs 2026-01-29 20:09:49 +08:00
Supra4E8C
73198d6929 Merge pull request #73 from router-for-me/codex/fix-base-url-handling-for-openai-interface
Fix OpenAI test endpoint to preserve /v1 base path
2026-01-29 19:58:55 +08:00
Supra4E8C
ab86fcf674 Fix OpenAI test endpoint base URL 2026-01-29 19:57:09 +08:00
LTbinglingfeng
a88078e171 refactor(PageTransition): optimize layer management and transition handling 2026-01-29 03:10:04 +08:00
LTbinglingfeng
8148851a06 feat: add OAuth model alias editing page and routing 2026-01-29 02:21:04 +08:00
kongkongyo
dcdb20159b chore: bump version to 1.0.8
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 10:29:14 +08:00
kongkongyo
54e6121234 Merge pull request #8 from router-for-me/main
Merge branch 'router-for-me:main' into feature
2026-01-28 10:25:53 +08:00
LTbinglingfeng
8b3c4189f1 fix(providers): use /chat/completions for OpenAI test requests 2026-01-28 00:17:31 +08:00
hkfires
db5fb0d125 refactor(i18n): rename model alias translation keys 2026-01-27 15:38:07 +08:00
hkfires
9515d88e3c feat(ui): add model checklist for oauth exclusions 2026-01-27 14:56:23 +08:00
hkfires
2bf721974b feat(auth): load model lists via /model-definitions/{channel} instead of per-file
model sources.
2026-01-27 14:27:26 +08:00
hkfires
0c53dcfa80 docs(i18n): rename model mappings to aliases in ui strings 2026-01-27 12:00:42 +08:00
kongkongyo
6096ffc94e docs: 更新 README 中的 GitHub 仓库链接为正确的地址 2026-01-25 23:14:41 +08:00
kongkongyo
77fe9905b1 Merge branch 'main' of https://github.com/kongkongyo/Cli-Proxy-API-Management-Center 2026-01-25 15:54:44 +08:00
kongkongyo
eaaa8f6e80 chore: bump version to 1.0.7 2026-01-25 15:53:49 +08:00
kongkongyo
4932374bdd Merge pull request #5 from router-for-me/main
Merge branch 'router-for-me:main' into feature
2026-01-25 15:53:04 +08:00
kongkongyo
82cb521b2e feat: 同步上游仓库更新并增强功能
主要更新:
- 新增使用统计功能,支持按模型显示成功/失败计数
- 大幅增强认证文件页面功能
  - 新增每个文件的启用/禁用切换
  - 新增前缀/代理 URL 模态编辑器
  - 新增 OAuth 映射的模型建议功能
  - 优化模型映射 UI,使用自动完成输入
  - 新增禁用状态样式
- UI/UX 改进
  - 实现自定义 AutocompleteInput 组件
  - 优化页面过渡动画,使用交叉淡入淡出效果
  - 改进 GSAP 页面过渡流畅度
- 配额管理优化
  - 统一 Gemini CLI 配额组(Flash/Pro 系列)
- 系统监控增强
  - 扩展健康监控窗口到 200 分钟
- 修复多个 bug 和改进代码质量

涉及文件:21 个文件修改,新增 1484 行,删除 461 行
2026-01-25 15:49:20 +08:00
hkfires
034c086e31 feat(usage): show per-model success/failure counts 2026-01-25 11:29:34 +08:00
LTbinglingfeng
76e9eb4aa0 feat(auth-files): add disabled state styling for file cards 2026-01-25 00:01:15 +08:00
LTbinglingfeng
f22d392b21 fix 2026-01-24 18:04:59 +08:00
LTbinglingfeng
2539710075 fix(status-bar): extend health monitor window to 200 minutes 2026-01-24 17:17:29 +08:00
LTbinglingfeng
6bdc87aed6 fix(quota): unify Gemini CLI quota groups (Flash/Pro series) 2026-01-24 16:35:59 +08:00
LTbinglingfeng
268b92c59b feat(ui): implement custom AutocompleteInput and refactor model mapping UI 2026-01-24 15:55:31 +08:00
LTbinglingfeng
c89bbd5098 feat(auth-files): add auth-file model suggestions for OAuth mappings 2026-01-24 15:30:45 +08:00
LTbinglingfeng
2715f44a5e fix(ui): use crossfade animation with subtle movement for page transitions 2026-01-24 14:16:58 +08:00
LTbinglingfeng
305ddef900 fix(ui): improve GSAP page transition smoothness 2026-01-24 14:03:15 +08:00
LTbinglingfeng
7e56d33bf0 feat(auth-files): add prefix/proxy_url modal editor 2026-01-24 01:24:05 +08:00
LTbinglingfeng
80daf03fa6 feat(auth-files): add per-file enable/disable toggle 2026-01-24 00:10:04 +08:00
kongkongyo
8c3ac0d50a Merge branch 'router-for-me:main' into feature 2026-01-21 08:29:49 +08:00
LTbinglingfeng
883059b031 fix(auth-files): fix deleting OAuth model mappings providers 2026-01-19 23:29:11 +08:00
kongkongyo
e4850656a5 feat: 增强文档和监控功能
主要更新:
- 完善 README 文档,新增中文详细使用说明与监控中心介绍
- 优化 README.md 文档内容和格式,增加英文和中文文档切换链接
- 新增监控中心模块,支持请求日志、统计分析和模型管理
- 增强 AI 提供商配置页面,添加配置搜索功能
- 更新 .gitignore,移除无效注释和调整条目名称
- 删除 README_CN.md 文件,统一文档结构

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 00:58:48 +08:00
LTbinglingfeng
d077b5dd26 fix(ui): use fixed-length key masking and fingerprint usage sources 2026-01-19 00:41:11 +08:00
Supra4E8C
d79ccc480d fix: prevent focus loss in OAuth model mappings input 2026-01-17 15:41:56 +08:00
Supra4E8C
7b0d6dc7e9 fix: prevent async confirmation races in API key deletion 2026-01-17 15:31:35 +08:00
Supra4E8C
b8d7b8997c feat(ui): implement global ConfirmationModal to replace native window.confirm 2026-01-17 14:59:46 +08:00
Supra4E8C
0bb34ca74b fix(auth-files): send aliases for oauth model alias patch 2026-01-17 14:34:57 +08:00
hkfires
99c4fbc30d fix(api): use oauth model alias endpoints 2026-01-16 09:13:38 +08:00
Supra4E8C
a44257edda fix(antigravity): enhance error handling and support multiple request bodies 2026-01-14 17:13:07 +08:00
Supra4E8C
ebb80df24a fix(quota): include project_id in antigravity quota requests 2026-01-14 16:44:36 +08:00
LTbinglingfeng
5165715d37 fix: 调整登录页面的重定向逻辑和键盘事件处理顺序 2026-01-10 23:10:30 +08:00
Supra4E8C
73ee6eb2f3 fix(ai-providers): keep custom header editing stable in modals 2026-01-10 14:00:50 +08:00
Supra4E8C
161d5d1e7f Merge pull request #49 from sunday-ma/feature/fix-login-enter-key
fix: 添加登录表单 Enter 键提交功能
2026-01-08 19:16:48 +08:00
Sunny
3cbd04b296 Update src/pages/LoginPage.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-08 14:27:33 +08:00
Sunny
859f7f120c Update src/pages/LoginPage.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-08 14:27:18 +08:00
sunday-ma
fea29f7318 fix: 添加登录表单 Enter 键提交功能 2026-01-08 14:16:38 +08:00
Supra4E8C
f663b83ac8 feat(auth-files): normalize OAuth excluded models handling and update related API methods 2026-01-07 12:26:33 +08:00
LTbinglingfeng
ee99836285 Revert "feat(auth-files): add external migration modal for antigravity credentials"
This reverts commit 2086c348a9.
2026-01-07 00:02:45 +08:00
Supra4E8C
2086c348a9 feat(auth-files): add external migration modal for antigravity credentials 2026-01-06 18:21:34 +08:00
LTbinglingfeng
a8abf71bfe fix(settings): align log size and routing update controls 2026-01-06 00:30:06 +08:00
Supra4E8C
8dca670358 feat: add vertex provider, oauth model mappings, and routing/log settings 2026-01-05 19:03:05 +08:00
Supra4E8C
71556a51c5 fix(usage): prevent gaps in request trend fill by matching point colors 2026-01-05 17:32:01 +08:00
LTbinglingfeng
2a92ea8862 feat(AuthFilesPage): add title section with file count badge 2026-01-05 00:18:35 +08:00
LTbinglingfeng
681fc3cee5 fix(quota): cap per-page credentials to 14 2026-01-05 00:00:22 +08:00
Supra4E8C
916dd3ec26 Merge pull request #44 from moxi000/dev
feat: 优化配额管理页面 UI 与交互
2026-01-04 23:38:44 +08:00
moxi
692f7f3cde fix(quota): allow refresh without creds 2026-01-04 18:48:27 +08:00
Supra4E8C
bf20f3d86e fix(PageTransition): prevent unnecessary execution in useEffect when pathname matches 2026-01-04 18:25:54 +08:00
Supra4E8C
b7e720133d feat(auth-files): add file size validation for uploads 2026-01-04 18:14:18 +08:00
moxi
e914337e57 feat(button): enhance button component to conditionally render children
- Added a check to determine if children are present before rendering them in the button.
- Improved button rendering logic for better handling of empty or false children values.
2026-01-04 01:12:48 +08:00
moxi
6364bac1f2 feat(quota): improve refresh button functionality and update translations
- Added a new `isRefreshing` state to streamline loading logic for the refresh button.
- Updated the refresh button's disabled and loading states for better user experience.
- Simplified the refresh button content display.
- Revised translations for the refresh action in both English and Chinese locales.
- Enhanced styles for button alignment and SVG display.
2026-01-04 01:05:58 +08:00
moxi
38a3e20427 feat(quota): enhance QuotaSection with improved view mode handling and refresh functionality
- Introduced effective view mode logic to manage 'paged' and 'all' views based on file count.
- Added a warning for too many files when in 'all' view, prompting users to switch to 'paged'.
- Updated refresh button to handle loading states more effectively and provide clearer user feedback.
- Enhanced UI with new translations for view modes and refresh actions.
- Adjusted styles for better alignment and spacing in the view mode toggle and refresh button.
2026-01-04 00:45:34 +08:00
moxi
334d75f2dd fix: lint error 2026-01-04 00:04:36 +08:00
moxi
42eb783395 feat: 优化配额管理页面 UI 与交互
- 卡片布局改为 CSS Grid 自适应,最小宽度 380px,支持 1080p 下显示 4 列
- 分页控件重构:移除数字输入框,改为 [按页显示] / [显示全部] 切换按钮
- 动态计算每页数量:按页模式固定显示 3 行(行数 * 动态列数)
- Header 布局优化:凭证计数移至标题旁(淡蓝色气泡),刷新按钮合并为图标
- 安全限制:凭证数超过 30 个时禁用显示全部功能并弹窗提示
2026-01-03 22:43:58 +08:00
Supra4E8C
84b219957e Revert "style(config): allow editor wrapper to grow flexibly with min-height"
This reverts commit 1d8729ec53.
2026-01-03 15:54:48 +08:00
Supra4E8C
f5c1ef36ce fix(api-keys): validate api key charset 2026-01-03 15:51:32 +08:00
Supra4E8C
fae4fb0fed refactor(utils): simplify maskApiKey to show only 2 chars at each end 2026-01-03 15:42:34 +08:00
Supra4E8C
1d8729ec53 style(config): allow editor wrapper to grow flexibly with min-height 2026-01-03 15:30:40 +08:00
Supra4E8C
c6ef8a259f Merge branch 'dev' of https://github.com/router-for-me/Cli-Proxy-API-Management-Center into dev 2026-01-03 15:05:54 +08:00
Supra4E8C
0efef5a789 style(config): improve editor wrapper responsive height with clamp and dvh 2026-01-03 14:52:56 +08:00
LTbinglingfeng
db376c7504 fix(layout): wire header refresh to page loaders and quota config refresh 2026-01-03 01:40:54 +08:00
LTbinglingfeng
8232812ac2 feat(ui): show AIStudio models for virtual auth files and adjust Gemini OAuth spacing 2026-01-02 22:42:20 +08:00
LTbinglingfeng
2ae06a8860 perf(ui): smooth gsap page transitions 2026-01-02 20:26:41 +08:00
LTbinglingfeng
dc58a0701f fix(logs): parse latency durations with minutes 2026-01-02 20:11:16 +08:00
Supra4E8C
3446280987 refactor(quota,auth): change page size selector to number input with range 3-30 2026-01-02 17:27:35 +08:00
Supra4E8C
82bf1806ed refactor(quota): consolidate quota sections into config-driven components 2026-01-02 17:14:40 +08:00
Supra4E8C
47f0042bf0 refactor(usage): modularize UsagePage into separate section components 2026-01-02 16:19:04 +08:00
Supra4E8C
58154063ed refactor(quota): modularize QuotaPage into separate section components 2026-01-02 15:55:17 +08:00
Supra4E8C
cc467889d0 refactor(providers): modularize AiProvidersPage into separate provider components 2026-01-02 15:29:16 +08:00
Supra4E8C
469e5d2ed4 Merge branch 'dev' 2026-01-02 14:18:56 +08:00
LTbinglingfeng
6ce301d7e0 fix(transition): avoid HiDPI text blur from page transitions 2026-01-02 11:17:57 +08:00
LTbinglingfeng
8461de124f Merge branch 'dev' 2026-01-02 02:04:17 +08:00
Supra4E8C
276f416ec9 Merge pull request #41 from yanyuhualb/feature/make-project-id-optional
feat: make Gemini CLI project ID optional
2026-01-02 02:03:04 +08:00
LTbinglingfeng
583a844771 Merge branch 'dev' 2026-01-02 01:58:06 +08:00
LTbinglingfeng
62fa437285 fix(ui): use resolvedTheme for OpenAI icon switching in providers page 2026-01-02 01:54:22 +08:00
LTbinglingfeng
daab589c49 fix(ui): remove redundant add button from empty state in providers list 2026-01-02 01:42:43 +08:00
LTbinglingfeng
e18e9b25ce fix(transition): allow page container to shrink for proper layout 2026-01-02 01:37:17 +08:00
LTbinglingfeng
4cfb77dd44 fix(ui): center modals in viewport and lock background scroll 2026-01-02 01:15:04 +08:00
LTbinglingfeng
7cab1e8782 fix(oauth): remove iFlow provider from OAuth flow 2026-01-02 00:58:24 +08:00
LTbinglingfeng
079f37ec93 fix(quota): translate Codex window labels at render time 2026-01-02 00:41:35 +08:00
LTbinglingfeng
7ce97a616f fix(transition): preserve scroll position during page animations 2026-01-02 00:29:42 +08:00
LTbinglingfeng
946ed36af0 feat(router): add GSAP page transition animations 2026-01-02 00:01:25 +08:00
yanyuhualb
f139598526 feat: make Gemini CLI project ID optional
- Remove required validation for project ID field
- Update translations to indicate field is optional (zh-CN, en)
- Auto-select first available project when left empty
- Backend already supports empty project ID by fetching project list

This improves user experience by eliminating the need to manually enter project ID for users with only one or a preferred default project.
2025-12-31 23:06:44 +08:00
Supra4E8C
40ddd3c066 Merge pull request #40 from router-for-me/dev
feat(auth): add remember-password login and clear local auth data card
2025-12-31 20:48:25 +08:00
Supra4E8C
3a66dc225d feat(auth): add remember-password login and clear local auth data card 2025-12-31 20:04:32 +08:00
181 changed files with 27686 additions and 7812 deletions

4
.gitignore vendored
View File

@@ -12,6 +12,7 @@ CLAUDE.md
AGENTS.md
antigravity_usage.json
codex_usage.json
style.md
node_modules
dist
@@ -29,3 +30,6 @@ settings.local.json
*.njsproj
*.sln
*.sw?
tmpclaude*
.claude
CLIProxyAPI

363
README.md
View File

@@ -1,130 +1,305 @@
# CLI Proxy API Management Center
# CLI Proxy API 管理中心 (CPAMC)
A single-file WebUI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
> 一个基于官方仓库二次创作的 Web 管理界面
[中文文档](README_CN.md)
**[English](README_EN.md) | [中文](README.md)**
**Main Project**: https://github.com/router-for-me/CLIProxyAPI
**Example URL**: https://remote.router-for.me/
**Minimum Required Version**: ≥ 6.3.0 (recommended ≥ 6.5.0)
---
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
## 关于本项目
## What this is (and isnt)
本项目是基于官方 [CLI Proxy API WebUI](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) 进行开发的日志监控和数据可视化管理界面
- This repository is the WebUI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage.
- It is **not** a proxy and does not forward traffic.
### 与官方版本的区别
## Quick start
本版本与官方版本其他功能保持一致,主要差异在于**新增监控中心**,对日志分析和查看的增强
### Option A: Use the WebUI bundled in CLIProxyAPI (recommended)
### 界面预览
1. Start your CLI Proxy API service.
2. Open: `http://<host>:<api_port>/management.html`
3. Enter your **management key** and connect.
管理界面展示
The address is auto-detected from the current page URL; manual override is supported.
![Dashboard Preview](dashboard-preview.png)
### Option B: Run the dev server
---
```bash
npm install
npm run dev
## 快速开始
### 使用本管理界面
在你的 `config.yaml` 中修改以下配置:
```yaml
remote-management:
panel-github-repository: "https://github.com/kongkongyo/Cli-Proxy-API-Management-Center"
```
Open `http://localhost:5173`, then connect to your CLI Proxy API instance.
配置完成后,重启 CLI Proxy API 服务,访问 `http://<host>:<api_port>/management.html` 即可查看管理界面
### Option C: Build a single HTML file
详细配置说明请参考官方文档https://help.router-for.me/cn/management/webui.html
```bash
npm install
npm run build
---
## 主要功能
### 监控中心 - 核心新增功能
这是本管理界面相对于官方版本的唯一新增功能,提供了全方位的数据可视化和监控能力
> 注意CLI Proxy API 主程序目前没有数据持久化功能,重启程序后统计数据会丢失。需要先通过 API 使用相关服务产生数据后,才能在监控中心看到统计信息。
#### KPI 指标仪表盘
实时展示核心运营指标,支持按时间范围筛选:
- **请求数**:总请求数、成功/失败统计、成功率百分比
- **Token 数**:总 Token 数、输入 Token、输出 Token
- **平均 TPM**:每分钟 Token 使用量
- **平均 RPM**:每分钟请求数
- **日均 RPD**:日均请求数
所有指标都会根据选择的时间范围(今天/7天/14天/30天动态计算实时更新
#### 模型用量分布
直观的饼图展示不同模型的使用占比:
- 按请求数分布
- 按 Token 数分布
- 可切换查看请求占比或 Token 占比
#### 每日趋势分析
详细的时间序列图表,展示每日用量变化趋势:
- 请求数趋势曲线
- 输入 Token 趋势
- 输出 Token 趋势
- 思考 Token 趋势(如支持)
- 缓存 Token 趋势
#### 每小时分析
两个详细的小时级图表,帮助定位高峰时段:
**每小时模型请求分布**
- 柱状图展示不同模型在各小时的请求数
- 支持最近 6 小时/12 小时/24 小时/全部视图切换
**每小时 Token 用量**
- 堆叠柱状图展示 Token 使用构成
- 区分输入 Token、输出 Token、思考 Token、缓存 Token
#### 渠道统计
详细表格展示各渠道API Key/模型)的使用情况:
- 可按全部渠道/特定渠道筛选
- 可按全部模型/特定模型筛选
- 可按全部状态/仅成功/仅失败筛选
- 显示渠道名称、请求数、成功率
- 点击展开查看该渠道下各模型的详细统计
- 显示最近请求状态(最近 10 次请求的迷你状态条)
- 最近请求时间
#### 失败来源分析
帮助定位问题渠道和模型:
- 按渠道统计失败次数
- 显示最近失败时间
- 列出主要失败的模型
- 点击展开查看该渠道下所有失败的请求详情
#### 请求日志 - 高级功能
功能强大的请求日志表格,支持海量数据流畅浏览
**多维度筛选**
- 按 API Key 筛选
- 按提供商类型筛选OpenAI/Gemini/Claude 等)
- 按模型名称筛选
- 按来源渠道筛选
- 按请求状态筛选(全部/成功/失败)
**独立时间范围**
- 支持今天/7天/14天/30天/自定义日期范围
- 与主页面时间范围独立控制
**虚拟滚动**
- 支持 10 万+ 条日志流畅浏览
- 显示当前可见范围统计
- 性能优化,只渲染可见行
**智能信息展示**
- 自动匹配 API Key 到提供商名称(基于配置信息)
- 完整的渠道信息(提供商名称 + 掩码后的密钥)
- 请求类型/模型名称/请求状态
- 最近 10 次请求的状态可视化(绿点=成功,红点=失败)
- 成功率百分比
- 总请求数/输入 Token/输出 Token/总 Token
- 请求时间(完整时间戳)
**自动刷新**
- 支持手动刷新 / 5秒 / 10秒 / 15秒 / 30秒 / 60秒 自动刷新
- 倒计时显示下次刷新时间
- 独立数据加载,不阻塞主页面
**一键禁用模型**
- 支持直接在日志中禁用某渠道的某个模型
- 只对支持该操作的渠道类型生效
- 不支持时显示提示和手动操作指南
---
## 官方版本功能
以下功能与官方版本一致,通过改进的界面提供更好的使用体验
### 仪表盘
- 连接状态实时监控
- 服务器版本和构建信息一目了然
- 使用数据快速概览,掌握全局
- 可用模型统计
### API 密钥管理
- 添加、编辑、删除 API 密钥
- 管理代理服务认证
### AI 提供商配置
- **Gemini**API 密钥管理、排除模型、模型前缀
- **Claude**API 密钥和配置、自定义模型列表
- **Codex**完整配置管理API 密钥、Base URL、代理
- **Vertex**:模型映射配置
- **OpenAI 兼容**:多密钥管理、模型别名导入、连通性测试
- **Ampcode**:上游集成和模型映射
### 认证文件管理
- 上传、下载、删除 JSON 认证文件
- 支持多种提供商Qwen、Gemini、Claude 等)
- 搜索、筛选、分页浏览
- 查看每个凭证支持的模型
### OAuth 登录
- 一键启动 OAuth 授权流程
- 支持 Codex、Anthropic、Gemini CLI、Qwen、iFlow 等
- 自动保存认证文件
- 支持远程浏览器回调提交
### 配额管理
- Antigravity 额度查询
- Codex 额度查询5 小时、周限额、代码审查)
- Gemini CLI 额度查询
- 一键刷新所有额度
### 使用统计
- 请求/Token 趋势图表
- 按模型和 API 的详细统计
- RPM/TPM 实时速率
- 缓存和推理 Token 分解
- 成本估算(支持自定义价格)
### 配置管理
- 在线编辑 `config.yaml`
- YAML 语法高亮
- 搜索和导航
- 保存和重载配置
### 日志查看
- 实时日志流
- 搜索和过滤
- 自动刷新
- 下载错误日志
- 屏蔽管理端流量
### 中心信息
- 连接状态检查
- 版本更新检查
- 可用模型列表展示
- 快捷链接入口
---
## 连接说明
### API 地址格式
以下格式都可以,系统会自动识别
```
localhost:8317
http://192.168.1.10:8317
https://example.com:8317
```
- Output: `dist/index.html` (all assets are inlined).
- For CLIProxyAPI bundling, the release workflow renames it to `management.html`.
- To preview locally: `npm run preview`
### 管理密钥
Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
管理密钥是验证管理操作的钥匙,和客户端使用的 API 密钥不一样
## Connecting to the server
### 远程管理
### API address
从非本地浏览器访问的时候,需要在服务器启用远程管理(`allow-remote-management: true`
You can enter any of the following; the UI will normalize it:
---
- `localhost:8317`
- `http://192.168.1.10:8317`
- `https://example.com:8317`
- `http://example.com:8317/v0/management` (also accepted; the suffix is removed internally)
## 界面特性
### Management key (not the same as API keys)
### 主题切换
- 亮色模式
- 暗色模式
- 跟随系统
The management key is sent with every request as:
### 语言支持
- 简体中文
- English
- `Authorization: Bearer <MANAGEMENT_KEY>` (default)
### 响应式设计
- 桌面端完整功能
- 移动端适配体验
- 侧边栏可折叠
This is different from the proxy `api-keys` you manage inside the UI (those are for client requests to the proxy endpoints).
---
### Remote management
## 常见问题
If you connect from a non-localhost browser, the server must allow remote management (e.g. `allow-remote-management: true`).
See `api.md` for the full authentication rules, server-side limits, and edge cases.
**Q: 如何使用这个自定义 UI**
## What you can manage (mapped to the UI pages)
- **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot.
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project/preview models), usage statistics, request logging, file logging, WebSocket auth.
- **API Keys**: manage proxy `api-keys` (add/edit/delete).
- **AI Providers**:
- Gemini/Codex/Claude key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side “chat/completions” test).
- Ampcode integration (upstream URL/key, force mappings, model mapping table).
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards).
- **OAuth**: start OAuth/device flows for supported providers, poll status, optionally submit callback `redirect_url`; includes iFlow cookie import.
- **Usage**: requests/tokens charts (hour/day), per-API & per-model breakdown, cached/reasoning token breakdown, RPM/TPM window, optional cost estimation with locally-saved model pricing.
- **Config**: edit `/config.yaml` in-browser with YAML highlighting + search, then save/reload.
- **Logs**: tail logs with incremental polling, auto-refresh, search, hide management traffic, clear logs; download request error log files.
- **System**: quick links + fetch `/v1/models` (grouped view). Requires at least one proxy API key to query models.
## Build & release notes
- Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`).
- Tagging `vX.Y.Z` triggers `.github/workflows/release.yml` to publish `dist/management.html`.
- The UI version shown in the footer is injected at build time (env `VERSION`, git tag, or `package.json` fallback).
## Security notes
- The management key is stored in browser `localStorage` using a lightweight obfuscation format (`enc::v1::...`) to avoid plaintext storage; treat it as sensitive.
- Use a dedicated browser profile/device for management. Be cautious when enabling remote management and evaluate its exposure surface.
## Troubleshooting
- **Cant connect / 401**: confirm the API address and management key; remote access may require enabling remote management in the server config.
- **Repeated auth failures**: the server may temporarily block remote IPs.
- **Logs page missing**: enable “Logging to file” in Basic Settings; the navigation item is shown only when file logging is enabled.
- **Some features show “unsupported”**: the backend may be too old or the endpoint is disabled/absent (common for model lists per auth file, excluded models, logs).
- **OpenAI provider test fails**: the test runs in the browser and depends on network/CORS of the provider endpoint; a failure here does not always mean the server cannot reach it.
## Development
```bash
npm run dev # Vite dev server
npm run build # tsc + Vite build
npm run preview # serve dist locally
npm run lint # ESLint (fails on warnings)
npm run format # Prettier
npm run type-check # tsc --noEmit
A: 在 CLI Proxy API 的配置文件中添加以下配置即可
```yaml
remote-management:
panel-github-repository: "https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard"
```
## Contributing
**Q: 无法连接到服务器?**
Issues and PRs are welcome. Please include:
A: 请检查以下内容
- API 地址是否正确
- 管理密钥是否正确
- 服务器是否启动
- 远程访问是否启用
- Reproduction steps (server version + UI version)
- Screenshots for UI changes
- Verification notes (`npm run lint`, `npm run type-check`)
**Q: 日志页面不显示?**
## License
A: 需要去"基础设置"里开启"日志记录到文件"功能
MIT
**Q: 某些功能显示"不支持"**
A: 可能是服务器版本太旧,升级到最新版本的 CLI Proxy API
**Q: OpenAI 提供商测试失败?**
A: 测试是在浏览器端执行的,可能会受到 CORS 限制,失败不一定代表服务器端不能用
**Q: 这个版本和官方版本有什么区别?**
A: 主要区别有两个:
1. **界面风格**全新的视觉设计UI 细节更精致
2. **监控中心**:这是唯一新增的功能模块,提供了强大的数据可视化和监控能力,包括 KPI 仪表盘、模型用量分布、趋势分析、小时级图表、渠道统计、失败分析和高级请求日志等功能
其他所有功能与官方版本保持一致
---
## 相关链接
- **官方主程序**: https://github.com/router-for-me/CLIProxyAPI
- **官方 WebUI**: https://github.com/router-for-me/Cli-Proxy-API-Management-Center
- **本仓库**: https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard
## 许可证
MIT License

View File

@@ -1,130 +0,0 @@
# CLI Proxy API 管理中心
用于管理与排障 **CLI Proxy API** 的单文件 WebUIReact + TypeScript通过 **Management API** 完成配置、凭据、日志与统计等运维工作。
[English](README.md)
**主项目**: https://github.com/router-for-me/CLIProxyAPI
**示例地址**: https://remote.router-for.me/
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
## 这是什么(以及不是什么)
- 本仓库只包含 Web 管理界面本身,通过 CLI Proxy API 的 **Management API**`/v0/management`)读取/修改配置、上传凭据、查看日志与使用统计。
-**不是** 代理本体,不参与流量转发。
## 快速开始
### 方式 A使用 CLIProxyAPI 自带的 WebUI推荐
1. 启动 CLI Proxy API 服务。
2. 打开:`http://<host>:<api_port>/management.html`
3. 输入 **管理密钥** 并连接。
页面会根据当前地址自动推断 API 地址,也支持手动修改。
### 方式 B开发调试
```bash
npm install
npm run dev
```
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
### 方式 C构建单文件 HTML
```bash
npm install
npm run build
```
- 构建产物:`dist/index.html`(资源已全部内联)。
- 在 CLIProxyAPI 的发布流程里会重命名为 `management.html`
- 本地预览:`npm run preview`
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
## 连接说明
### API 地址怎么填
以下格式均可WebUI 会自动归一化:
- `localhost:8317`
- `http://192.168.1.10:8317`
- `https://example.com:8317`
- `http://example.com:8317/v0/management`(也可填写,后缀会被自动去除)
### 管理密钥(注意:不是 API Keys
管理密钥会以如下方式随请求发送:
- `Authorization: Bearer <MANAGEMENT_KEY>`(默认)
这与 WebUI 中“API Keys”页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
### 远程管理
当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。
完整鉴权规则、限制与边界情况请查看 `api.md`
## 功能一览(按页面对应)
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
- **基础设置**:调试开关、代理 URL、请求重试、配额回退切项目/切预览模型、使用统计、请求日志、文件日志、WebSocket 鉴权。
- **API Keys**:管理代理 `api-keys`(增/改/删)。
- **AI 提供商**
- Gemini/Codex/Claude 配置Base URL、Headers、代理、模型别名、排除模型、Prefix
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only查看单个凭据可用模型依赖后端支持管理 OAuth 排除模型(支持 `*` 通配符)。
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
- **配置文件**:浏览器内编辑 `/config.yaml`YAML 高亮 + 搜索),保存/重载。
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
## 构建与发布说明
- 使用 Vite 输出 **单文件 HTML**`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。
-`vX.Y.Z` 标签会触发 `.github/workflows/release.yml`,发布 `dist/management.html`
- 页脚显示的 UI 版本在构建期注入(优先使用环境变量 `VERSION`,否则使用 git tag / `package.json`)。
## 安全提示
- 管理密钥会存入浏览器 `localStorage`,并使用轻量混淆格式(`enc::v1::...`)避免明文;仍应视为敏感信息。
- 建议使用独立浏览器配置/设备进行管理;开启远程管理时请谨慎评估暴露面。
## 常见问题
- **无法连接 / 401**:确认 API 地址与管理密钥;远程访问可能需要服务端开启远程管理。
- **反复输错密钥**:服务端可能对远程 IP 进行临时封禁。
- **日志页面不显示**:需要在“基础设置”里开启“写入日志文件”,导航项才会出现。
- **功能提示不支持**:多为后端版本较旧或接口未启用/不存在(如:认证文件模型列表、排除模型、日志相关接口)。
- **OpenAI 提供商测试失败**:测试在浏览器侧执行,会受网络与 CORS 影响;这里失败不一定代表服务端不可用。
## 开发命令
```bash
npm run dev # 启动开发服务器
npm run build # tsc + Vite 构建
npm run preview # 本地预览 dist
npm run lint # ESLintwarnings 视为失败)
npm run format # Prettier
npm run type-check # tsc --noEmit
```
## 贡献
欢迎提 Issue 与 PR。建议附上
- 复现步骤(服务端版本 + UI 版本)
- UI 改动截图
- 验证记录(`npm run lint``npm run type-check`
## 许可证
MIT

305
README_EN.md Normal file
View File

@@ -0,0 +1,305 @@
# CLI Proxy API Management Center (CPAMC)
> A Web management interface based on the official repository with custom modifications
**[English](README_EN.md) | [中文](README.md)**
---
## About This Project
This project is a log monitoring and data visualization management interface developed based on the official [CLI Proxy API WebUI](https://github.com/router-for-me/Cli-Proxy-API-Management-Center)
### Differences from Official Version
This version is consistent with the official version in other functions, with the main difference being the **new monitoring center**, which enhances log analysis and viewing
### Interface Preview
Management interface display
![Dashboard Preview](dashboard-preview.png)
---
## Quick Start
### Using This Management Interface
Modify following configuration in your `config.yaml`:
```yaml
remote-management:
panel-github-repository: "https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard"
```
After configuration, restart the CLI Proxy API service and visit `http://<host>:<api_port>/management.html` to view the management interface
For detailed configuration instructions, please refer to the official documentation: https://help.router-for.me/cn/management/webui.html
---
## Main Features
### Monitoring Center - Core New Feature
This is the only new feature of this management interface compared to the official version, providing comprehensive data visualization and monitoring capabilities
> Note: The CLI Proxy API main program currently does not have data persistence functionality. Statistical data will be lost after restarting the program. You need to use related services through the API first to generate data before you can see statistical information in the monitoring center.
#### KPI Dashboard
Real-time display of core operational metrics, supports filtering by time range:
- **Request Count**: Total requests, success/failure statistics, success rate percentage
- **Token Count**: Total tokens, input tokens, output tokens
- **Average TPM**: Tokens per minute
- **Average RPM**: Requests per minute
- **Average RPD**: Daily average requests
All metrics are dynamically calculated and updated in real-time based on the selected time range (today/7 days/14 days/30 days)
#### Model Usage Distribution
Intuitive pie chart showing the usage distribution of different models:
- Distribution by request count
- Distribution by token count
- Switchable between request percentage and token percentage
#### Daily Trend Analysis
Detailed time series charts showing daily usage trends:
- Request count trend curve
- Input token trend
- Output token trend
- Thinking token trend (if supported)
- Cache token trend
#### Hourly Analysis
Two detailed hourly charts to help identify peak periods:
**Hourly Model Request Distribution**
- Bar chart showing requests for different models in each hour
- Supports switching between recent 6 hours/12 hours/24 hours/all views
**Hourly Token Usage**
- Stacked bar chart showing token usage composition
- Distinguishes between input tokens, output tokens, thinking tokens, cache tokens
#### Channel Statistics
Detailed table showing usage of each channel (API Key/model):
- Filter by all channels/specific channel
- Filter by all models/specific model
- Filter by all status/success only/failure only
- Display channel name, request count, success rate
- Click to expand and view detailed statistics of each model under that channel
- Display recent request status (mini status bar of recent 10 requests)
- Most recent request time
#### Failure Source Analysis
Help locate problematic channels and models:
- Statistics of failure counts by channel
- Display most recent failure time
- List of main failed models
- Click to expand and view all failed request details under that channel
#### Request Logs - Advanced Feature
Powerful request log table, supports smooth browsing of massive data
**Multi-dimensional Filtering**
- Filter by API Key
- Filter by provider type (OpenAI/Gemini/Claude, etc.)
- Filter by model name
- Filter by source channel
- Filter by request status (all/success/failure)
**Independent Time Range**
- Supports today/7 days/14 days/30 days/custom date range
- Independent control from main page time range
**Virtual Scrolling**
- Supports smooth browsing of 100,000+ logs
- Display current visible range statistics
- Performance optimized, only renders visible rows
**Smart Information Display**
- Automatically match API Key to provider name (based on configuration)
- Complete channel information (provider name + masked key)
- Request type/model name/request status
- Status visualization of recent 10 requests (green dot=success, red dot=failure)
- Success rate percentage
- Total requests/input tokens/output tokens/total tokens
- Request time (complete timestamp)
**Auto Refresh**
- Supports manual refresh / 5s / 10s / 15s / 30s / 60s auto refresh
- Countdown display for next refresh time
- Independent data loading, does not block main page
**One-click Disable Model**
- Supports directly disabling a specific model of a channel in logs
- Only effective for channel types that support this operation
- Shows prompt and manual operation guide when not supported
---
## Official Version Features
The following features are consistent with the official version, providing a better user experience through an improved interface
### Dashboard
- Real-time connection status monitoring
- Server version and build information at a glance
- Quick overview of usage data
- Available model statistics
### API Key Management
- Add, edit, delete API keys
- Manage proxy service authentication
### AI Provider Configuration
- **Gemini**: API key management, model exclusion, model prefix
- **Claude**: API key and configuration, custom model list
- **Codex**: Complete configuration management (API key, Base URL, proxy)
- **Vertex**: Model mapping configuration
- **OpenAI Compatible**: Multi-key management, model alias import, connectivity testing
- **Ampcode**: Upstream integration and model mapping
### Authentication File Management
- Upload, download, delete JSON authentication files
- Supports multiple providers (Qwen, Gemini, Claude, etc.)
- Search, filter, paginated browsing
- View models supported by each credential
### OAuth Login
- One-click start OAuth authorization flow
- Supports Codex, Anthropic, Gemini CLI, Qwen, iFlow, etc.
- Automatically save authentication files
- Supports remote browser callback submission
### Quota Management
- Antigravity quota query
- Codex quota query (5 hours, weekly limit, code review)
- Gemini CLI quota query
- One-click refresh all quotas
### Usage Statistics
- Request/Token trend charts
- Detailed statistics by model and API
- RPM/TPM real-time rates
- Cache and reasoning token breakdown
- Cost estimation (supports custom prices)
### Configuration Management
- Online editing of `config.yaml`
- YAML syntax highlighting
- Search and navigation
- Save and reload configuration
### Log Viewing
- Real-time log stream
- Search and filtering
- Auto refresh
- Download error logs
- Mask management traffic
### Center Information
- Connection status check
- Version update check
- Available model list display
- Quick link entry
---
## Connection Instructions
### API Address Format
The following formats are all supported, and the system will automatically recognize them
```
localhost:8317
http://192.168.1.10:8317
https://example.com:8317
```
### Management Key
The management key is the key for verifying management operations and is different from the API key used by clients
### Remote Management
When accessing from a non-local browser, you need to enable remote management on the server (`allow-remote-management: true`)
---
## Interface Features
### Theme Switching
- Light mode
- Dark mode
- Follow system
### Language Support
- Simplified Chinese
- English
### Responsive Design
- Full functionality on desktop
- Mobile-adapted experience
- Collapsible sidebar
---
## FAQ
**Q: How to use this custom UI?**
A: Add the following configuration to your CLI Proxy API configuration file
```yaml
remote-management:
panel-github-repository: "https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard"
```
**Q: Cannot connect to the server?**
A: Please check the following
- Is the API address correct?
- Is the management key correct?
- Is the server started?
- Is remote access enabled?
**Q: Log page not displaying?**
A: You need to enable the "Log to file" function in "Basic Settings"
**Q: Some functions show "not supported"?**
A: The server version may be too old. Upgrade to the latest version of CLI Proxy API
**Q: OpenAI provider test failed?**
A: Tests are executed in the browser and may be subject to CORS restrictions. Failure does not necessarily mean it won't work on the server side
**Q: What is the difference between this version and the official version?**
A: There are two main differences:
1. **Interface Style**: Completely new visual design with more refined UI details
2. **Monitoring Center**: This is the only newly added feature module, providing powerful data visualization and monitoring capabilities, including KPI dashboard, model usage distribution, trend analysis, hourly charts, channel statistics, failure analysis, and advanced request logs
All other features remain consistent with the official version
---
## Related Links
- **Official Main Program**: https://github.com/router-for-me/CLIProxyAPI
- **Official WebUI**: https://github.com/router-for-me/Cli-Proxy-API-Management-Center
- **This Repository**: https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard
## License
MIT License

BIN
dashboard-preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

85
package-lock.json generated
View File

@@ -1,23 +1,26 @@
{
"name": "cli-proxy-webui-react",
"version": "0.0.0",
"version": "1.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cli-proxy-webui-react",
"version": "0.0.0",
"version": "1.1.3",
"dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@tanstack/react-virtual": "^3.13.18",
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
"gsap": "^3.14.2",
"i18next": "^25.7.1",
"react": "^19.2.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.1",
"react-i18next": "^16.4.0",
"react-router-dom": "^7.10.1",
"yaml": "^2.8.2",
"zustand": "^5.0.9"
},
"devDependencies": {
@@ -70,6 +73,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -464,6 +468,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -1864,6 +1869,33 @@
"win32"
]
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
"integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1929,6 +1961,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2016,6 +2049,7 @@
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.1",
"@typescript-eslint/types": "8.48.1",
@@ -2333,6 +2367,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2544,6 +2579,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -2808,6 +2844,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3194,6 +3231,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gsap": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz",
"integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/has-flag": {
"version": "4.0.0",
"dev": true,
@@ -3278,6 +3321,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -3607,6 +3651,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3713,6 +3758,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3730,6 +3776,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3773,9 +3820,9 @@
}
},
"node_modules/react-router": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz",
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -3795,12 +3842,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz",
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
"license": "MIT",
"dependencies": {
"react-router": "7.10.1"
"react-router": "7.12.0"
},
"engines": {
"node": ">=20.0.0"
@@ -3838,6 +3885,7 @@
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -4020,6 +4068,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4096,6 +4145,7 @@
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -4220,6 +4270,22 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"dev": true,
@@ -4237,6 +4303,7 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1,27 +1,30 @@
{
"name": "cli-proxy-webui-react",
"private": true,
"version": "0.0.0",
"version": "1.1.5",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@tanstack/react-virtual": "^3.13.18",
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
"gsap": "^3.14.2",
"i18next": "^25.7.1",
"react": "^19.2.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.1",
"react-i18next": "^16.4.0",
"react-router-dom": "^7.10.1",
"yaml": "^2.8.2",
"zustand": "^5.0.9"
},
"devDependencies": {

View File

@@ -1,43 +1,21 @@
import { useCallback, useEffect, useState } from 'react';
import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
import { useEffect } from 'react';
import { HashRouter, Route, Routes } from 'react-router-dom';
import { LoginPage } from '@/pages/LoginPage';
import { DashboardPage } from '@/pages/DashboardPage';
import { SettingsPage } from '@/pages/SettingsPage';
import { ApiKeysPage } from '@/pages/ApiKeysPage';
import { AiProvidersPage } from '@/pages/AiProvidersPage';
import { AuthFilesPage } from '@/pages/AuthFilesPage';
import { OAuthPage } from '@/pages/OAuthPage';
import { QuotaPage } from '@/pages/QuotaPage';
import { UsagePage } from '@/pages/UsagePage';
import { ConfigPage } from '@/pages/ConfigPage';
import { LogsPage } from '@/pages/LogsPage';
import { SystemPage } from '@/pages/SystemPage';
import { NotificationContainer } from '@/components/common/NotificationContainer';
import { SplashScreen } from '@/components/common/SplashScreen';
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
import { MainLayout } from '@/components/layout/MainLayout';
import { ProtectedRoute } from '@/router/ProtectedRoute';
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
const SPLASH_DURATION = 1500;
const SPLASH_FADE_DURATION = 400;
import { useLanguageStore, useThemeStore } from '@/stores';
function App() {
const initializeTheme = useThemeStore((state) => state.initializeTheme);
const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage);
const restoreSession = useAuthStore((state) => state.restoreSession);
const [splashReadyToFade, setSplashReadyToFade] = useState(false);
const [showSplash, setShowSplash] = useState(true);
const [authReady, setAuthReady] = useState(false);
useEffect(() => {
const cleanupTheme = initializeTheme();
void restoreSession().finally(() => {
setAuthReady(true);
});
return cleanupTheme;
}, [initializeTheme, restoreSession]);
}, [initializeTheme]);
useEffect(() => {
setLanguage(language);
@@ -48,54 +26,20 @@ function App() {
document.documentElement.lang = language;
}, [language]);
useEffect(() => {
const timer = setTimeout(() => {
setSplashReadyToFade(true);
}, SPLASH_DURATION - SPLASH_FADE_DURATION);
return () => clearTimeout(timer);
}, []);
const handleSplashFinish = useCallback(() => {
setShowSplash(false);
}, []);
if (showSplash) {
return (
<SplashScreen
fadeOut={splashReadyToFade && authReady}
onFinish={handleSplashFinish}
/>
);
}
return (
<HashRouter>
<NotificationContainer />
<ConfirmationModal />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
path="/*"
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="api-keys" element={<ApiKeysPage />} />
<Route path="ai-providers" element={<AiProvidersPage />} />
<Route path="auth-files" element={<AuthFilesPage />} />
<Route path="oauth" element={<OAuthPage />} />
<Route path="quota" element={<QuotaPage />} />
<Route path="usage" element={<UsagePage />} />
<Route path="config" element={<ConfigPage />} />
<Route path="logs" element={<LogsPage />} />
<Route path="system" element={<SystemPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
/>
</Routes>
</HashRouter>
);

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)"
fill="#FFFFFF" stroke="none">
<path d="M1107 2290 c-316 -57 -615 -283 -748 -565 -68 -144 -91 -241 -96
-406 -6 -156 7 -249 49 -374 87 -254 291 -478 542 -596 146 -68 226 -84 426
-84 152 0 186 3 260 23 182 50 327 136 465 277 147 150 245 334 282 529 23
123 14 344 -20 456 -35 116 -69 190 -134 290 -131 200 -340 354 -578 426 -78
23 -111 27 -245 30 -85 1 -177 -1 -203 -6z m362 -216 c91 -21 224 -86 310
-152 133 -101 249 -275 293 -439 16 -60 21 -108 21 -203 0 -152 -21 -240 -88
-368 -130 -253 -350 -407 -634 -443 -393 -50 -777 214 -882 607 -30 110 -30
296 0 408 72 270 282 489 552 576 130 41 287 47 428 14z"/>
<path d="M849 1637 c-31 -24 -52 -67 -46 -95 3 -15 35 -78 71 -139 36 -61 66
-115 66 -119 0 -5 -30 -58 -66 -119 -36 -60 -68 -123 -70 -140 -7 -42 26 -90
70 -105 31 -10 42 -9 72 7 31 15 51 43 125 173 93 162 101 188 73 243 -50 97
-169 289 -185 297 -25 14 -91 12 -110 -3z"/>
<path d="M1353 1139 c-42 -12 -73 -53 -73 -96 0 -27 8 -43 35 -70 l34 -34 216
3 217 3 30 34 c26 29 29 40 25 73 -7 49 -29 75 -76 88 -45 12 -364 12 -408 -1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1107 2290 c-316 -57 -615 -283 -748 -565 -68 -144 -91 -241 -96
-406 -6 -156 7 -249 49 -374 87 -254 291 -478 542 -596 146 -68 226 -84 426
-84 152 0 186 3 260 23 182 50 327 136 465 277 147 150 245 334 282 529 23
123 14 344 -20 456 -35 116 -69 190 -134 290 -131 200 -340 354 -578 426 -78
23 -111 27 -245 30 -85 1 -177 -1 -203 -6z m362 -216 c91 -21 224 -86 310
-152 133 -101 249 -275 293 -439 16 -60 21 -108 21 -203 0 -152 -21 -240 -88
-368 -130 -253 -350 -407 -634 -443 -393 -50 -777 214 -882 607 -30 110 -30
296 0 408 72 270 282 489 552 576 130 41 287 47 428 14z"/>
<path d="M849 1637 c-31 -24 -52 -67 -46 -95 3 -15 35 -78 71 -139 36 -61 66
-115 66 -119 0 -5 -30 -58 -66 -119 -36 -60 -68 -123 -70 -140 -7 -42 26 -90
70 -105 31 -10 42 -9 72 7 31 15 51 43 125 173 93 162 101 188 73 243 -50 97
-169 289 -185 297 -25 14 -91 12 -110 -3z"/>
<path d="M1353 1139 c-42 -12 -73 -53 -73 -96 0 -27 8 -43 35 -70 l34 -34 216
3 217 3 30 34 c26 29 29 40 25 73 -7 49 -29 75 -76 88 -45 12 -364 12 -408 -1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

1
src/assets/icons/glm.svg Normal file
View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

After

Width:  |  Height:  |  Size: 756 B

View File

@@ -0,0 +1 @@
<svg fill="#FFFFFF" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#FFFFFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>

After

Width:  |  Height:  |  Size: 706 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#027AFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>

After

Width:  |  Height:  |  Size: 711 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#FF9900"/>
<path d="M12 6L8 10h3v4H8l4 4 4-4h-3v-4h3l-4-4z" fill="#232F3E"/>
</svg>

After

Width:  |  Height:  |  Size: 244 B

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-fill" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,65 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { useNotificationStore } from '@/stores';
export function ConfirmationModal() {
const { t } = useTranslation();
const confirmation = useNotificationStore((state) => state.confirmation);
const hideConfirmation = useNotificationStore((state) => state.hideConfirmation);
const setConfirmationLoading = useNotificationStore((state) => state.setConfirmationLoading);
const { isOpen, isLoading, options } = confirmation;
if (!isOpen || !options) {
return null;
}
const { title, message, onConfirm, onCancel, confirmText, cancelText, variant = 'primary' } = options;
const handleConfirm = async () => {
try {
setConfirmationLoading(true);
await onConfirm();
hideConfirmation();
} catch (error) {
console.error('Confirmation action failed:', error);
// Optional: show error notification here if needed,
// but usually the calling component handles specific errors.
} finally {
setConfirmationLoading(false);
}
};
const handleCancel = () => {
if (isLoading) {
return;
}
if (onCancel) {
onCancel();
}
hideConfirmation();
};
return (
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
{typeof message === 'string' ? (
<p style={{ margin: '1rem 0' }}>{message}</p>
) : (
<div style={{ margin: '1rem 0' }}>{message}</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
{cancelText || t('common.cancel')}
</Button>
<Button
variant={variant}
onClick={handleConfirm}
loading={isLoading}
>
{confirmText || t('common.confirm')}
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,54 @@
@use '@/styles/variables.scss' as *;
.page-transition {
position: relative;
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
&__layer {
display: flex;
flex-direction: column;
gap: $spacing-lg;
min-height: 0;
flex: 1;
background: var(--bg-secondary);
backface-visibility: hidden;
transform: translateZ(0);
// During animation, exit layer uses absolute positioning
&--exit {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
will-change: transform, opacity;
}
&--stacked {
display: none;
// Keep the previous layer rendered (but invisible) to avoid a blank flash when popping back.
// Older stacked layers remain `display: none` for performance.
&.page-transition__layer--stacked-keep {
display: flex;
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
opacity: 0;
will-change: transform, opacity;
}
}
}
&--animating &__layer {
will-change: transform, opacity;
}
&--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) {
position: relative;
}
}

View File

@@ -0,0 +1,393 @@
import {
ReactNode,
createContext,
useCallback,
useContext,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useLocation, type Location } from 'react-router-dom';
import gsap from 'gsap';
import './PageTransition.scss';
interface PageTransitionProps {
render: (location: Location) => ReactNode;
getRouteOrder?: (pathname: string) => number | null;
getTransitionVariant?: (fromPathname: string, toPathname: string) => TransitionVariant;
scrollContainerRef?: React.RefObject<HTMLElement | null>;
}
const VERTICAL_TRANSITION_DURATION = 0.35;
const VERTICAL_TRAVEL_DISTANCE = 60;
const IOS_TRANSITION_DURATION = 0.42;
const IOS_ENTER_FROM_X_PERCENT = 100;
const IOS_EXIT_TO_X_PERCENT_FORWARD = -30;
const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
const IOS_EXIT_DIM_OPACITY = 0.72;
type LayerStatus = 'current' | 'exiting' | 'stacked';
type Layer = {
key: string;
location: Location;
status: LayerStatus;
};
type TransitionDirection = 'forward' | 'backward';
type TransitionVariant = 'vertical' | 'ios';
type PageTransitionLayerContextValue = {
status: LayerStatus;
};
const PageTransitionLayerContext = createContext<PageTransitionLayerContextValue | null>(null);
export function usePageTransitionLayer() {
return useContext(PageTransitionLayerContext);
}
export function PageTransition({
render,
getRouteOrder,
getTransitionVariant,
scrollContainerRef,
}: PageTransitionProps) {
const location = useLocation();
const currentLayerRef = useRef<HTMLDivElement>(null);
const exitingLayerRef = useRef<HTMLDivElement>(null);
const transitionDirectionRef = useRef<TransitionDirection>('forward');
const transitionVariantRef = useRef<TransitionVariant>('vertical');
const exitScrollOffsetRef = useRef(0);
const enterScrollOffsetRef = useRef(0);
const scrollPositionsRef = useRef(new Map<string, number>());
const nextLayersRef = useRef<Layer[] | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [layers, setLayers] = useState<Layer[]>(() => [
{
key: location.key,
location,
status: 'current',
},
]);
const currentLayer =
layers.find((layer) => layer.status === 'current') ?? layers[layers.length - 1];
const currentLayerKey = currentLayer?.key ?? location.key;
const currentLayerPathname = currentLayer?.location.pathname;
const resolveScrollContainer = useCallback(() => {
if (scrollContainerRef?.current) return scrollContainerRef.current;
if (typeof document === 'undefined') return null;
return document.scrollingElement as HTMLElement | null;
}, [scrollContainerRef]);
useLayoutEffect(() => {
if (isAnimating) return;
if (location.key === currentLayerKey) return;
if (currentLayerPathname === location.pathname) return;
const scrollContainer = resolveScrollContainer();
const exitScrollOffset = scrollContainer?.scrollTop ?? 0;
exitScrollOffsetRef.current = exitScrollOffset;
scrollPositionsRef.current.set(currentLayerKey, exitScrollOffset);
enterScrollOffsetRef.current = scrollPositionsRef.current.get(location.key) ?? 0;
const resolveOrderIndex = (pathname?: string) => {
if (!getRouteOrder || !pathname) return null;
const index = getRouteOrder(pathname);
return typeof index === 'number' && index >= 0 ? index : null;
};
const fromIndex = resolveOrderIndex(currentLayerPathname);
const toIndex = resolveOrderIndex(location.pathname);
const nextVariant: TransitionVariant = getTransitionVariant
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
: 'vertical';
let nextDirection: TransitionDirection =
fromIndex === null || toIndex === null || fromIndex === toIndex
? 'forward'
: toIndex > fromIndex
? 'forward'
: 'backward';
// When using iOS-style stacking, history POP within the same "section" can have equal route order.
// In that case, prefer treating navigation to an existing layer as a backward (pop) transition.
if (nextVariant === 'ios' && layers.some((layer) => layer.key === location.key)) {
nextDirection = 'backward';
}
transitionDirectionRef.current = nextDirection;
transitionVariantRef.current = nextVariant;
const shouldSkipExitLayer = (() => {
if (nextVariant !== 'ios' || nextDirection !== 'backward') return false;
const normalizeSegments = (pathname: string) =>
pathname
.split('/')
.filter(Boolean)
.filter((segment) => segment.length > 0);
const fromSegments = normalizeSegments(currentLayerPathname ?? '');
const toSegments = normalizeSegments(location.pathname);
if (!fromSegments.length || !toSegments.length) return false;
return fromSegments[0] === toSegments[0] && toSegments.length === 1;
})();
setLayers((prev) => {
const variant = transitionVariantRef.current;
const direction = transitionDirectionRef.current;
const previousCurrentIndex = prev.findIndex((layer) => layer.status === 'current');
const resolvedCurrentIndex =
previousCurrentIndex >= 0 ? previousCurrentIndex : prev.length - 1;
const previousCurrent = prev[resolvedCurrentIndex];
const previousStack: Layer[] = prev
.filter((_, idx) => idx !== resolvedCurrentIndex)
.map((layer): Layer => ({ ...layer, status: 'stacked' }));
const nextCurrent: Layer = { key: location.key, location, status: 'current' };
if (!previousCurrent) {
nextLayersRef.current = [nextCurrent];
return [nextCurrent];
}
if (variant === 'ios') {
if (direction === 'forward') {
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
const stackedLayer: Layer = { ...previousCurrent, status: 'stacked' };
nextLayersRef.current = [...previousStack, stackedLayer, nextCurrent];
return [...previousStack, exitingLayer, nextCurrent];
}
const targetIndex = prev.findIndex((layer) => layer.key === location.key);
if (targetIndex !== -1) {
const targetStack: Layer[] = prev
.slice(0, targetIndex + 1)
.map((layer, idx): Layer => {
const isTarget = idx === targetIndex;
return {
...layer,
location: isTarget ? location : layer.location,
status: isTarget ? 'current' : 'stacked',
};
});
if (shouldSkipExitLayer) {
nextLayersRef.current = targetStack;
return targetStack;
}
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
nextLayersRef.current = targetStack;
return [...targetStack, exitingLayer];
}
}
if (shouldSkipExitLayer) {
nextLayersRef.current = [nextCurrent];
return [nextCurrent];
}
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
nextLayersRef.current = [nextCurrent];
return [exitingLayer, nextCurrent];
});
setIsAnimating(true);
}, [
isAnimating,
location,
currentLayerKey,
currentLayerPathname,
getRouteOrder,
getTransitionVariant,
resolveScrollContainer,
layers,
]);
// Run GSAP animation when animating starts
useLayoutEffect(() => {
if (!isAnimating) return;
if (!currentLayerRef.current) return;
const currentLayerEl = currentLayerRef.current;
const exitingLayerEl = exitingLayerRef.current;
const transitionVariant = transitionVariantRef.current;
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
const scrollContainer = resolveScrollContainer();
const exitScrollOffset = exitScrollOffsetRef.current;
const enterScrollOffset = enterScrollOffsetRef.current;
if (scrollContainer && exitScrollOffset !== enterScrollOffset) {
scrollContainer.scrollTo({ top: enterScrollOffset, left: 0, behavior: 'auto' });
}
const transitionDirection = transitionDirectionRef.current;
const isForward = transitionDirection === 'forward';
const enterFromY = isForward ? VERTICAL_TRAVEL_DISTANCE : -VERTICAL_TRAVEL_DISTANCE;
const exitToY = isForward ? -VERTICAL_TRAVEL_DISTANCE : VERTICAL_TRAVEL_DISTANCE;
const exitBaseY = enterScrollOffset - exitScrollOffset;
const tl = gsap.timeline({
onComplete: () => {
const nextLayers = nextLayersRef.current;
nextLayersRef.current = null;
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
setIsAnimating(false);
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
},
});
if (transitionVariant === 'ios') {
const exitToXPercent = isForward
? IOS_EXIT_TO_X_PERCENT_FORWARD
: IOS_EXIT_TO_X_PERCENT_BACKWARD;
const enterFromXPercent = isForward
? IOS_ENTER_FROM_X_PERCENT
: IOS_ENTER_FROM_X_PERCENT_BACKWARD;
if (exitingLayerEl) {
gsap.set(exitingLayerEl, {
y: exitBaseY,
xPercent: 0,
opacity: 1,
});
}
gsap.set(currentLayerEl, {
xPercent: enterFromXPercent,
opacity: 1,
});
const shadowValue = '-14px 0 24px rgba(0, 0, 0, 0.16)';
const topLayerEl = isForward ? currentLayerEl : exitingLayerEl;
if (topLayerEl) {
gsap.set(topLayerEl, { boxShadow: shadowValue });
}
if (exitingLayerEl) {
tl.to(
exitingLayerEl,
{
xPercent: exitToXPercent,
opacity: isForward ? IOS_EXIT_DIM_OPACITY : 1,
duration: IOS_TRANSITION_DURATION,
ease: 'power2.out',
force3D: true,
},
0
);
}
tl.to(
currentLayerEl,
{
xPercent: 0,
opacity: 1,
duration: IOS_TRANSITION_DURATION,
ease: 'power2.out',
force3D: true,
},
0
);
} else {
// Exit animation: fade out with slight movement (runs simultaneously)
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { y: exitBaseY });
tl.to(
exitingLayerEl,
{
y: exitBaseY + exitToY,
opacity: 0,
duration: VERTICAL_TRANSITION_DURATION,
ease: 'circ.out',
force3D: true,
},
0
);
}
// Enter animation: fade in with slight movement (runs simultaneously)
tl.fromTo(
currentLayerEl,
{ y: enterFromY, opacity: 0 },
{
y: 0,
opacity: 1,
duration: VERTICAL_TRANSITION_DURATION,
ease: 'circ.out',
force3D: true,
onComplete: () => {
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
}
},
},
0
);
}
return () => {
tl.kill();
gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
};
}, [isAnimating, resolveScrollContainer]);
return (
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
{(() => {
const currentIndex = layers.findIndex((layer) => layer.status === 'current');
const resolvedCurrentIndex = currentIndex === -1 ? layers.length - 1 : currentIndex;
const keepStackedIndex = layers
.slice(0, resolvedCurrentIndex)
.map((layer, index) => ({ layer, index }))
.reverse()
.find(({ layer }) => layer.status === 'stacked')?.index;
return layers.map((layer, index) => {
const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex;
return (
<div
key={layer.key}
className={[
'page-transition__layer',
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
]
.filter(Boolean)
.join(' ')}
aria-hidden={layer.status !== 'current'}
inert={layer.status !== 'current'}
ref={
layer.status === 'exiting'
? exitingLayerRef
: layer.status === 'current'
? currentLayerRef
: undefined
}
>
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
{render(layer.location)}
</PageTransitionLayerContext.Provider>
</div>
);
});
})()}
</div>
);
}

View File

@@ -0,0 +1,84 @@
@use '../../styles/variables' as *;
.container {
display: flex;
flex-direction: column;
gap: $spacing-lg;
min-height: 0;
}
.topBar {
position: sticky;
top: 0;
z-index: 5;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: $spacing-md;
padding: $spacing-sm $spacing-md;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
min-height: 44px;
}
.topBarTitle {
min-width: 0;
text-align: center;
font-size: 16px;
font-weight: 650;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
justify-self: center;
}
.backButton {
padding-left: 6px;
padding-right: 10px;
justify-self: start;
gap: 0;
}
.backButton > span:last-child {
display: inline-flex;
align-items: center;
gap: 6px;
}
.backIcon {
display: inline-flex;
align-items: center;
justify-content: center;
svg {
display: block;
}
}
.backText {
font-weight: 600;
line-height: 18px;
}
.rightSlot {
justify-self: end;
display: flex;
justify-content: flex-end;
}
.loadingState {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
padding: $spacing-2xl 0;
color: var(--text-secondary);
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}

View File

@@ -0,0 +1,78 @@
import { forwardRef, type ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconChevronLeft } from '@/components/ui/icons';
import styles from './SecondaryScreenShell.module.scss';
export type SecondaryScreenShellProps = {
title: ReactNode;
onBack?: () => void;
backLabel?: string;
backAriaLabel?: string;
rightAction?: ReactNode;
isLoading?: boolean;
loadingLabel?: ReactNode;
className?: string;
contentClassName?: string;
children?: ReactNode;
};
export const SecondaryScreenShell = forwardRef<HTMLDivElement, SecondaryScreenShellProps>(
function SecondaryScreenShell(
{
title,
onBack,
backLabel = 'Back',
backAriaLabel,
rightAction,
isLoading = false,
loadingLabel = 'Loading...',
className = '',
contentClassName = '',
children,
},
ref
) {
const containerClassName = [styles.container, className].filter(Boolean).join(' ');
const contentClasses = [styles.content, contentClassName].filter(Boolean).join(' ');
const titleTooltip = typeof title === 'string' ? title : undefined;
const resolvedBackAriaLabel = backAriaLabel ?? backLabel;
return (
<div className={containerClassName} ref={ref}>
<div className={styles.topBar}>
{onBack ? (
<Button
variant="ghost"
size="sm"
onClick={onBack}
className={styles.backButton}
aria-label={resolvedBackAriaLabel}
>
<span className={styles.backIcon}>
<IconChevronLeft size={18} />
</span>
<span className={styles.backText}>{backLabel}</span>
</Button>
) : (
<div />
)}
<div className={styles.topBarTitle} title={titleTooltip}>
{title}
</div>
<div className={styles.rightSlot}>{rightAction}</div>
</div>
{isLoading ? (
<div className={styles.loadingState}>
<LoadingSpinner size={16} />
<span>{loadingLabel}</span>
</div>
) : (
<div className={contentClasses}>{children}</div>
)}
</div>
);
}
);

View File

@@ -0,0 +1,22 @@
import type { PropsWithChildren, ReactNode } from 'react';
import { Card } from '@/components/ui/Card';
interface ConfigSectionProps {
title: ReactNode;
description?: ReactNode;
className?: string;
}
export function ConfigSection({ title, description, className, children }: PropsWithChildren<ConfigSectionProps>) {
return (
<Card title={title} className={className}>
{description && (
<p style={{ margin: '-4px 0 16px 0', color: 'var(--text-secondary)', fontSize: 13 }}>
{description}
</p>
)}
{children}
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,23 +7,24 @@ import {
useRef,
useState,
} from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { PageTransition } from '@/components/common/PageTransition';
import { MainRoutes } from '@/router/MainRoutes';
import {
IconBot,
IconChartLine,
IconFileText,
IconInfo,
IconKey,
IconLayoutDashboard,
IconScrollText,
IconSettings,
IconShield,
IconSlidersHorizontal,
IconTimer,
IconActivity,
} from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import {
@@ -34,11 +35,10 @@ import {
useThemeStore,
} from '@/stores';
import { configApi, versionApi } from '@/services/api';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />,
settings: <IconSlidersHorizontal size={18} />,
apiKeys: <IconKey size={18} />,
aiProviders: <IconBot size={18} />,
authFiles: <IconFileText size={18} />,
oauth: <IconShield size={18} />,
@@ -47,6 +47,7 @@ const sidebarIcons: Record<string, ReactNode> = {
config: <IconSettings size={18} />,
logs: <IconScrollText size={18} />,
system: <IconInfo size={18} />,
monitor: <IconActivity size={18} />,
};
// Header action icons - smaller size for header buttons
@@ -200,6 +201,7 @@ export function MainLayout() {
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null);
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRef = useRef<HTMLElement | null>(null);
const versionTapCount = useRef(0);
@@ -241,6 +243,37 @@ export function MainLayout() {
};
}, []);
// 将主内容区的中心点写入 CSS 变量,供底部浮层(如配置面板操作栏)对齐到内容区而非整窗
useLayoutEffect(() => {
const updateContentCenter = () => {
const el = contentRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
document.documentElement.style.setProperty('--content-center-x', `${centerX}px`);
};
updateContentCenter();
const resizeObserver =
typeof ResizeObserver !== 'undefined' && contentRef.current
? new ResizeObserver(updateContentCenter)
: null;
if (resizeObserver && contentRef.current) {
resizeObserver.observe(contentRef.current);
}
window.addEventListener('resize', updateContentCenter);
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
window.removeEventListener('resize', updateContentCenter);
};
}, []);
// 5秒后自动收起品牌名称
useEffect(() => {
brandCollapseTimer.current = setTimeout(() => {
@@ -341,6 +374,7 @@ export function MainLayout() {
});
}, [fetchConfig]);
const statusClass =
connectionStatus === 'connected'
? 'success'
@@ -352,28 +386,92 @@ export function MainLayout() {
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
...(config?.loggingToFile
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
: []),
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
{ path: '/monitor', label: t('nav.monitor'), icon: sidebarIcons.monitor },
];
const navOrder = navItems.map((item) => item.path);
const getRouteOrder = (pathname: string) => {
const trimmedPath =
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
const aiProvidersIndex = navOrder.indexOf('/ai-providers');
if (aiProvidersIndex !== -1) {
if (normalizedPath === '/ai-providers') return aiProvidersIndex;
if (normalizedPath.startsWith('/ai-providers/')) {
if (normalizedPath.startsWith('/ai-providers/gemini')) return aiProvidersIndex + 0.1;
if (normalizedPath.startsWith('/ai-providers/codex')) return aiProvidersIndex + 0.2;
if (normalizedPath.startsWith('/ai-providers/claude')) return aiProvidersIndex + 0.3;
if (normalizedPath.startsWith('/ai-providers/vertex')) return aiProvidersIndex + 0.4;
if (normalizedPath.startsWith('/ai-providers/ampcode')) return aiProvidersIndex + 0.5;
if (normalizedPath.startsWith('/ai-providers/openai')) return aiProvidersIndex + 0.6;
return aiProvidersIndex + 0.05;
}
}
const authFilesIndex = navOrder.indexOf('/auth-files');
if (authFilesIndex !== -1) {
if (normalizedPath === '/auth-files') return authFilesIndex;
if (normalizedPath.startsWith('/auth-files/')) {
if (normalizedPath.startsWith('/auth-files/oauth-excluded')) return authFilesIndex + 0.1;
if (normalizedPath.startsWith('/auth-files/oauth-model-alias')) return authFilesIndex + 0.2;
return authFilesIndex + 0.05;
}
}
const exactIndex = navOrder.indexOf(normalizedPath);
if (exactIndex !== -1) return exactIndex;
const nestedIndex = navOrder.findIndex(
(path) => path !== '/' && normalizedPath.startsWith(`${path}/`)
);
return nestedIndex === -1 ? null : nestedIndex;
};
const getTransitionVariant = useCallback((fromPathname: string, toPathname: string) => {
const normalize = (pathname: string) => {
const trimmed =
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
return trimmed === '/dashboard' ? '/' : trimmed;
};
const from = normalize(fromPathname);
const to = normalize(toPathname);
const isAuthFiles = (pathname: string) =>
pathname === '/auth-files' || pathname.startsWith('/auth-files/');
const isAiProviders = (pathname: string) =>
pathname === '/ai-providers' || pathname.startsWith('/ai-providers/');
if (isAuthFiles(from) && isAuthFiles(to)) return 'ios';
if (isAiProviders(from) && isAiProviders(to)) return 'ios';
return 'vertical';
}, []);
const handleRefreshAll = async () => {
clearCache();
try {
await fetchConfig(undefined, true);
showNotification(t('notification.data_refreshed'), 'success');
} catch (error: any) {
showNotification(`${t('notification.refresh_failed')}: ${error?.message || ''}`, 'error');
const results = await Promise.allSettled([
fetchConfig(undefined, true),
triggerHeaderRefresh()
]);
const rejected = results.find((result) => result.status === 'rejected');
if (rejected && rejected.status === 'rejected') {
const reason = rejected.reason;
const message =
typeof reason === 'string' ? reason : reason instanceof Error ? reason.message : '';
showNotification(
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
'error'
);
return;
}
showNotification(t('notification.data_refreshed'), 'success');
};
const handleVersionCheck = async () => {
@@ -508,9 +606,14 @@ export function MainLayout() {
</div>
</aside>
<div className={`content${isLogsPage ? ' content-logs' : ''}`}>
<div className={`content${isLogsPage ? ' content-logs' : ''}`} ref={contentRef}>
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
<Outlet />
<PageTransition
render={(location) => <MainRoutes location={location} />}
getRouteOrder={getRouteOrder}
getTransitionVariant={getTransitionVariant}
scrollContainerRef={contentRef}
/>
</main>
<footer className="footer">

View File

@@ -0,0 +1,359 @@
@use '../../styles/variables' as *;
.scrollContainer {
width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
.tapHint {
position: sticky;
left: 0;
z-index: 3;
font-size: 12px;
color: var(--text-secondary);
padding: 0 4px;
margin-bottom: 8px;
}
.container {
display: inline-flex;
position: relative;
min-width: 100%;
min-height: 300px;
justify-content: space-between;
padding: 20px 0;
user-select: none;
@media (max-width: 768px) {
// Give mobile extra horizontal room to reduce line overlap; users can swipe to scroll.
min-width: max(100%, 960px);
padding: 12px 0;
}
}
// SVG layer for connection lines (behind columns so links are visible)
.connections {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
overflow: visible;
path {
fill: none;
stroke-width: 2;
}
}
.column {
display: flex;
flex-direction: column;
gap: 12px;
z-index: 2;
flex: 0 0 auto;
&.providers {
align-items: flex-end;
min-width: 140px;
}
&.sources {
align-items: flex-start;
min-width: 200px;
}
&.aliases {
align-items: flex-start;
min-width: 200px;
}
}
.columnHeader {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
margin-bottom: 8px;
padding: 0 4px;
}
.item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 280px;
position: relative;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
&:hover {
border-color: var(--primary-color);
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
z-index: 10;
}
&.dropTarget {
background-color: var(--bg-secondary);
border-color: var(--primary-color);
border-width: 2px;
}
&.selected {
border-color: var(--primary-color);
background-color: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
}
// Mindmap-style provider branch (root node)
.providerItem {
border-left: 3px solid transparent;
padding-left: 8px;
display: flex;
align-items: center;
gap: 8px;
.providerLabel {
font-weight: 600;
font-size: 13px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collapseBtn {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: var(--bg-secondary);
border-radius: 4px;
cursor: pointer;
color: var(--text-secondary);
transition: background-color 0.15s, color 0.15s;
&:hover {
background: var(--border-color);
color: var(--text-primary);
}
}
.chevronDown,
.chevronRight {
display: inline-block;
width: 0;
height: 0;
border-style: solid;
}
.chevronDown {
border-width: 5px 4px 0 4px;
border-color: currentColor transparent transparent transparent;
}
.chevronRight {
border-width: 4px 0 4px 5px;
border-color: transparent transparent transparent currentColor;
}
}
.providerGroup {
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
}
.sourceItem,
.aliasItem {
cursor: grab;
&:active {
cursor: grabbing;
}
&.dragging {
opacity: 0.5;
border-style: dashed;
}
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
position: absolute;
top: 50%;
margin-top: -3px;
flex-shrink: 0;
&.dotLeft {
left: -3px;
background: var(--text-tertiary);
}
}
.sourceItem .dot {
right: -3px;
}
.providerBadge {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-secondary);
margin-right: 8px;
font-weight: 500;
}
.itemName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemCount {
font-size: 11px;
color: var(--text-tertiary);
margin-left: 8px;
background: var(--bg-secondary);
padding: 1px 6px;
border-radius: 10px;
}
.contextMenu {
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9999;
min-width: 120px;
overflow: hidden;
padding: 4px 0;
.menuItem {
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
transition: background-color 0.1s;
display: flex;
align-items: center;
gap: 8px;
&:hover {
background-color: var(--bg-secondary);
}
&.danger {
color: var(--error-color);
&:hover {
background-color: var(--bg-error-light);
}
}
}
.menuDivider {
height: 1px;
margin: 4px 0;
background: var(--border-color);
padding: 0;
cursor: default;
pointer-events: none;
}
}
.settingsEmpty {
color: var(--text-tertiary);
font-size: 13px;
text-align: center;
padding: $spacing-lg 0;
}
.settingsList {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.settingsRow {
display: grid;
grid-template-columns: minmax(200px, 1fr) auto;
gap: $spacing-md;
align-items: center;
padding: $spacing-sm $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
@media (max-width: 768px) {
grid-template-columns: 1fr;
align-items: flex-start;
}
}
.settingsNames {
display: flex;
align-items: center;
gap: $spacing-xs;
font-size: 13px;
color: var(--text-primary);
min-width: 0;
}
.settingsSource,
.settingsAlias {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.settingsArrow {
color: var(--text-tertiary);
}
.settingsActions {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.settingsLabel {
font-size: 12px;
color: var(--text-secondary);
}
.settingsDelete {
border: 0;
background: transparent;
color: var(--error-color);
padding: 6px;
border-radius: 6px;
cursor: pointer;
&:hover {
background: var(--bg-error-light);
}
}

View File

@@ -0,0 +1,659 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { OAuthModelAliasEntry } from '@/types';
import { useThemeStore } from '@/stores';
import { AliasColumn, ProviderColumn, SourceColumn } from './ModelMappingDiagramColumns';
import { DiagramContextMenu } from './ModelMappingDiagramContextMenu';
import {
AddAliasModal,
RenameAliasModal,
SettingsAliasModal,
SettingsSourceModal
} from './ModelMappingDiagramModals';
import type {
AliasNode,
AuthFileModelItem,
ContextMenuState,
DiagramLine,
SourceNode
} from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
export interface ModelMappingDiagramProps {
modelAlias: Record<string, OAuthModelAliasEntry[]>;
allProviderModels?: Record<string, AuthFileModelItem[]>;
onUpdate?: (provider: string, sourceModel: string, newAlias: string) => void;
onDeleteLink?: (provider: string, sourceModel: string, alias: string) => void;
onToggleFork?: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
onRenameAlias?: (oldAlias: string, newAlias: string) => void;
onDeleteAlias?: (alias: string) => void;
onEditProvider?: (provider: string) => void;
onDeleteProvider?: (provider: string) => void;
className?: string;
}
const PROVIDER_COLORS = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'
];
function getProviderColor(provider: string): string {
const hash = provider.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
return PROVIDER_COLORS[hash % PROVIDER_COLORS.length];
}
export interface ModelMappingDiagramRef {
collapseAll: () => void;
refreshLayout: () => void;
}
export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappingDiagramProps>(function ModelMappingDiagram({
modelAlias,
allProviderModels = {},
onUpdate,
onDeleteLink,
onToggleFork,
onRenameAlias,
onDeleteAlias,
onEditProvider,
onDeleteProvider,
className
}, ref) {
const { t } = useTranslation();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
const enableTapLinking = useMemo(() => {
if (typeof window === 'undefined' || typeof window.matchMedia === 'undefined') return false;
return (
window.matchMedia('(any-pointer: coarse)').matches &&
!window.matchMedia('(any-pointer: fine)').matches
);
}, []);
const containerRef = useRef<HTMLDivElement>(null);
const [lines, setLines] = useState<DiagramLine[]>([]);
const [draggedSource, setDraggedSource] = useState<SourceNode | null>(null);
const [draggedAlias, setDraggedAlias] = useState<string | null>(null);
const [dropTargetAlias, setDropTargetAlias] = useState<string | null>(null);
const [dropTargetSource, setDropTargetSource] = useState<string | null>(null);
const [tapSourceId, setTapSourceId] = useState<string | null>(null);
const [tapAlias, setTapAlias] = useState<string | null>(null);
const [extraAliases, setExtraAliases] = useState<string[]>([]);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [collapsedProviders, setCollapsedProviders] = useState<Set<string>>(new Set());
const [providerGroupHeights, setProviderGroupHeights] = useState<Record<string, number>>({});
const [renameState, setRenameState] = useState<{ oldAlias: string } | null>(null);
const [renameValue, setRenameValue] = useState('');
const [renameError, setRenameError] = useState('');
const [addAliasOpen, setAddAliasOpen] = useState(false);
const [addAliasValue, setAddAliasValue] = useState('');
const [addAliasError, setAddAliasError] = useState('');
const [settingsAlias, setSettingsAlias] = useState<string | null>(null);
const [settingsSourceId, setSettingsSourceId] = useState<string | null>(null);
// Parse data: each source model (provider+name) and each alias is distinct by id; 1 source -> many aliases.
const { aliasNodes, providerNodes } = useMemo(() => {
const sourceMap = new Map<
string,
{ provider: string; name: string; aliases: Map<string, boolean> }
>();
const aliasSet = new Set<string>();
// 1. Existing mappings: group by (provider, name), each source has a set of aliases
Object.entries(modelAlias).forEach(([provider, mappings]) => {
(mappings ?? []).forEach((m) => {
const name = (m?.name || '').trim();
const alias = (m?.alias || '').trim();
if (!name || !alias) return;
const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
if (!sourceMap.has(pk)) {
sourceMap.set(pk, { provider, name, aliases: new Map() });
}
sourceMap.get(pk)!.aliases.set(alias, m?.fork === true);
aliasSet.add(alias);
});
});
// 2. Unmapped models from allProviderModels (no mapping yet)
Object.entries(allProviderModels).forEach(([provider, models]) => {
(models ?? []).forEach((m) => {
const name = (m.id || '').trim();
if (!name) return;
const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
if (sourceMap.has(pk)) {
// Already in sourceMap from mappings; keep provider from mapping for correct grouping.
return;
}
sourceMap.set(pk, { provider, name, aliases: new Map() });
});
});
// 3. Source nodes: distinct by id = provider::name
const sources: SourceNode[] = Array.from(sourceMap.entries())
.map(([id, v]) => ({
id,
provider: v.provider,
name: v.name,
aliases: Array.from(v.aliases.entries()).map(([alias, fork]) => ({ alias, fork }))
}))
.sort((a, b) => {
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
return a.name.localeCompare(b.name);
});
// 4. Extra aliases (no mapping yet)
extraAliases.forEach((alias) => aliasSet.add(alias));
// 5. Alias nodes: distinct by id = alias; sources = SourceNodes that have this alias in their aliases
const aliasNodesList: AliasNode[] = Array.from(aliasSet)
.map((alias) => ({
id: alias,
alias,
sources: sources.filter((s) => s.aliases.some((entry) => entry.alias === alias))
}))
.sort((a, b) => {
if (b.sources.length !== a.sources.length) return b.sources.length - a.sources.length;
return a.alias.localeCompare(b.alias);
});
// 6. Group sources by provider
const providerMap = new Map<string, SourceNode[]>();
sources.forEach((s) => {
if (!providerMap.has(s.provider)) providerMap.set(s.provider, []);
providerMap.get(s.provider)!.push(s);
});
const providerNodesList = Array.from(providerMap.entries())
.map(([provider, providerSources]) => ({ provider, sources: providerSources }))
.sort((a, b) => a.provider.localeCompare(b.provider));
return { aliasNodes: aliasNodesList, providerNodes: providerNodesList };
}, [modelAlias, allProviderModels, extraAliases]);
// Track element positions
const providerRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const sourceRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const aliasRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const toggleProviderCollapse = (provider: string) => {
setCollapsedProviders((prev) => {
const next = new Set(prev);
if (next.has(provider)) next.delete(provider);
else next.add(provider);
return next;
});
};
// Calculate lines: provider→source, source→alias (when expanded); midpoint + linkData for source→alias
const updateLines = useCallback(() => {
if (!containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const newLines: { path: string; color: string; id: string }[] = [];
const nextProviderGroupHeights: Record<string, number> = {};
const bezier = (
x1: number, y1: number,
x2: number, y2: number
) => {
const cpx1 = x1 + (x2 - x1) * 0.5;
const cpx2 = x2 - (x2 - x1) * 0.5;
return `M ${x1} ${y1} C ${cpx1} ${y1}, ${cpx2} ${y2}, ${x2} ${y2}`;
};
providerNodes.forEach(({ provider, sources }) => {
const collapsed = collapsedProviders.has(provider);
if (collapsed) return;
if (sources.length > 0) {
const firstEl = sourceRefs.current.get(sources[0].id);
const lastEl = sourceRefs.current.get(sources[sources.length - 1].id);
if (firstEl && lastEl) {
const height = Math.max(0, Math.round(lastEl.getBoundingClientRect().bottom - firstEl.getBoundingClientRect().top));
if (height > 0) nextProviderGroupHeights[provider] = height;
}
}
const providerEl = providerRefs.current.get(provider);
if (!providerEl) return;
const providerRect = providerEl.getBoundingClientRect();
const px = providerRect.right - containerRect.left;
const py = providerRect.top + providerRect.height / 2 - containerRect.top;
const color = getProviderColor(provider);
// Provider → Source (branch link, no dot)
sources.forEach((source) => {
const sourceEl = sourceRefs.current.get(source.id);
if (!sourceEl) return;
const sourceRect = sourceEl.getBoundingClientRect();
const sx = sourceRect.left - containerRect.left;
const sy = sourceRect.top + sourceRect.height / 2 - containerRect.top;
newLines.push({
id: `provider-${provider}-source-${source.id}`,
path: bezier(px, py, sx, sy),
color
});
});
// Source → Alias: one line per alias
sources.forEach((source) => {
if (!source.aliases || source.aliases.length === 0) return;
source.aliases.forEach((aliasEntry) => {
const sourceEl = sourceRefs.current.get(source.id);
const aliasEl = aliasRefs.current.get(aliasEntry.alias);
if (!sourceEl || !aliasEl) return;
const sourceRect = sourceEl.getBoundingClientRect();
const aliasRect = aliasEl.getBoundingClientRect();
// Calculate coordinates relative to the container
const x1 = sourceRect.right - containerRect.left;
const y1 = sourceRect.top + sourceRect.height / 2 - containerRect.top;
const x2 = aliasRect.left - containerRect.left;
const y2 = aliasRect.top + aliasRect.height / 2 - containerRect.top;
newLines.push({
id: `${source.id}-${aliasEntry.alias}`,
path: bezier(x1, y1, x2, y2),
color
});
});
});
});
setLines(newLines);
setProviderGroupHeights((prev) => {
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(nextProviderGroupHeights);
if (prevKeys.length !== nextKeys.length) return nextProviderGroupHeights;
for (const key of nextKeys) {
if (!(key in prev) || prev[key] !== nextProviderGroupHeights[key]) {
return nextProviderGroupHeights;
}
}
return prev;
});
}, [providerNodes, collapsedProviders]);
useImperativeHandle(
ref,
() => ({
collapseAll: () => setCollapsedProviders(new Set(providerNodes.map((p) => p.provider))),
refreshLayout: () => updateLines()
}),
[providerNodes, updateLines]
);
useLayoutEffect(() => {
// updateLines is called after layout is calculated, ensuring elements are in place.
updateLines();
const raf = requestAnimationFrame(updateLines);
window.addEventListener('resize', updateLines);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', updateLines);
};
}, [updateLines, aliasNodes]);
useLayoutEffect(() => {
updateLines();
const raf = requestAnimationFrame(updateLines);
return () => cancelAnimationFrame(raf);
}, [providerGroupHeights, updateLines]);
useEffect(() => {
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => updateLines());
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [updateLines]);
// Drag and Drop handlers
// 1. Source -> Alias
const handleDragStart = (e: DragEvent, source: SourceNode) => {
setTapSourceId(null);
setTapAlias(null);
setDraggedSource(source);
e.dataTransfer.setData('text/plain', source.id);
e.dataTransfer.effectAllowed = 'link';
};
const handleDragOver = (e: DragEvent, alias: string) => {
if (!draggedSource || draggedSource.aliases.some((entry) => entry.alias === alias)) return;
e.preventDefault(); // Allow drop
e.dataTransfer.dropEffect = 'link';
setDropTargetAlias(alias);
};
const handleDragLeave = () => {
setDropTargetAlias(null);
};
const handleDrop = (e: DragEvent, alias: string) => {
e.preventDefault();
if (draggedSource && !draggedSource.aliases.some((entry) => entry.alias === alias) && onUpdate) {
onUpdate(draggedSource.provider, draggedSource.name, alias);
}
setDraggedSource(null);
setDropTargetAlias(null);
};
// 2. Alias -> Source
const handleDragStartAlias = (e: DragEvent, alias: string) => {
setTapSourceId(null);
setTapAlias(null);
setDraggedAlias(alias);
e.dataTransfer.setData('text/plain', alias);
e.dataTransfer.effectAllowed = 'link';
};
const handleDragOverSource = (e: DragEvent, source: SourceNode) => {
if (!draggedAlias || source.aliases.some((entry) => entry.alias === draggedAlias)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'link';
setDropTargetSource(source.id);
};
const handleDragLeaveSource = () => {
setDropTargetSource(null);
};
const handleDropOnSource = (e: DragEvent, source: SourceNode) => {
e.preventDefault();
if (draggedAlias && !source.aliases.some((entry) => entry.alias === draggedAlias) && onUpdate) {
onUpdate(source.provider, source.name, draggedAlias);
}
setDraggedAlias(null);
setDropTargetSource(null);
};
const handleContextMenu = (
e: ReactMouseEvent,
type: 'alias' | 'background' | 'provider' | 'source',
data?: string
) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
type,
data
});
};
const closeContextMenu = () => setContextMenu(null);
const resolveSourceById = useCallback(
(id: string | null) => {
if (!id) return null;
for (const { sources } of providerNodes) {
const found = sources.find((source) => source.id === id);
if (found) return found;
}
return null;
},
[providerNodes]
);
const handleTapSelectSource = (source: SourceNode) => {
if (!onUpdate) return;
if (tapSourceId === source.id) {
setTapSourceId(null);
return;
}
if (tapAlias) {
onUpdate(source.provider, source.name, tapAlias);
setTapSourceId(null);
setTapAlias(null);
return;
}
setTapSourceId(source.id);
setTapAlias(null);
};
const handleTapSelectAlias = (alias: string) => {
if (!onUpdate) return;
if (tapAlias === alias) {
setTapAlias(null);
return;
}
if (tapSourceId) {
const source = resolveSourceById(tapSourceId);
if (source) {
onUpdate(source.provider, source.name, alias);
}
setTapSourceId(null);
setTapAlias(null);
return;
}
setTapAlias(alias);
setTapSourceId(null);
};
const handleUnlinkSource = (provider: string, sourceModel: string, alias: string) => {
if (onDeleteLink) onDeleteLink(provider, sourceModel, alias);
};
const handleToggleFork = (
provider: string,
sourceModel: string,
alias: string,
value: boolean
) => {
if (onToggleFork) onToggleFork(provider, sourceModel, alias, value);
};
const handleAddAlias = () => {
closeContextMenu();
setAddAliasOpen(true);
setAddAliasValue('');
setAddAliasError('');
};
const handleAddAliasSubmit = () => {
const trimmed = addAliasValue.trim();
if (!trimmed) {
setAddAliasError(t('oauth_model_alias.diagram_please_enter_alias'));
return;
}
if (aliasNodes.some(a => a.alias === trimmed)) {
setAddAliasError(t('oauth_model_alias.diagram_alias_exists'));
return;
}
setExtraAliases(prev => [...prev, trimmed]);
setAddAliasOpen(false);
};
const handleRenameClick = (oldAlias: string) => {
closeContextMenu();
setRenameState({ oldAlias });
setRenameValue(oldAlias);
setRenameError('');
};
const handleRenameSubmit = () => {
const trimmed = renameValue.trim();
if (!trimmed) {
setRenameError(t('oauth_model_alias.diagram_please_enter_alias'));
return;
}
if (trimmed === renameState?.oldAlias) {
setRenameState(null);
return;
}
if (aliasNodes.some(a => a.alias === trimmed)) {
setRenameError(t('oauth_model_alias.diagram_alias_exists'));
return;
}
if (onRenameAlias && renameState) onRenameAlias(renameState.oldAlias, trimmed);
if (extraAliases.includes(renameState?.oldAlias ?? '')) {
setExtraAliases(prev => prev.map(a => a === renameState?.oldAlias ? trimmed : a));
}
setRenameState(null);
};
const handleDeleteClick = (alias: string) => {
closeContextMenu();
const node = aliasNodes.find(n => n.alias === alias);
if (!node) return;
if (node.sources.length === 0) {
setExtraAliases(prev => prev.filter(a => a !== alias));
} else {
if (onDeleteAlias) onDeleteAlias(alias);
}
};
return (
<div className={[styles.scrollContainer, className].filter(Boolean).join(' ')}>
{enableTapLinking && onUpdate && (
<div className={styles.tapHint}>{t('oauth_model_alias.diagram_tap_hint')}</div>
)}
<div
className={styles.container}
ref={containerRef}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, 'background');
}}
>
<svg className={styles.connections}>
{lines.map((line) => (
<path
key={line.id}
d={line.path}
stroke={line.color}
strokeOpacity={isDark ? 0.4 : 0.3}
/>
))}
</svg>
<ProviderColumn
providerNodes={providerNodes}
collapsedProviders={collapsedProviders}
getProviderColor={getProviderColor}
providerGroupHeights={providerGroupHeights}
providerRefs={providerRefs}
onToggleCollapse={toggleProviderCollapse}
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
label={t('oauth_model_alias.diagram_providers')}
expandLabel={t('oauth_model_alias.diagram_expand')}
collapseLabel={t('oauth_model_alias.diagram_collapse')}
/>
<SourceColumn
providerNodes={providerNodes}
collapsedProviders={collapsedProviders}
sourceRefs={sourceRefs}
getProviderColor={getProviderColor}
selectedSourceId={enableTapLinking ? tapSourceId : null}
onSelectSource={enableTapLinking ? handleTapSelectSource : undefined}
draggedSource={draggedSource}
dropTargetSource={dropTargetSource}
draggable={!!onUpdate}
onDragStart={handleDragStart}
onDragEnd={() => {
setDraggedSource(null);
setDropTargetAlias(null);
}}
onDragOver={handleDragOverSource}
onDragLeave={handleDragLeaveSource}
onDrop={handleDropOnSource}
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
label={t('oauth_model_alias.diagram_source_models')}
/>
<AliasColumn
aliasNodes={aliasNodes}
aliasRefs={aliasRefs}
dropTargetAlias={dropTargetAlias}
draggedAlias={draggedAlias}
selectedAlias={enableTapLinking ? tapAlias : null}
onSelectAlias={enableTapLinking ? handleTapSelectAlias : undefined}
draggable={!!onUpdate}
onDragStart={handleDragStartAlias}
onDragEnd={() => {
setDraggedAlias(null);
setDropTargetSource(null);
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
label={t('oauth_model_alias.diagram_aliases')}
/>
</div>
<DiagramContextMenu
contextMenu={contextMenu}
t={t}
onRequestClose={() => setContextMenu(null)}
onAddAlias={handleAddAlias}
onRenameAlias={handleRenameClick}
onOpenAliasSettings={(alias) => {
setContextMenu(null);
setSettingsAlias(alias);
}}
onDeleteAlias={handleDeleteClick}
onEditProvider={(provider) => {
setContextMenu(null);
onEditProvider?.(provider);
}}
onDeleteProvider={(provider) => {
setContextMenu(null);
onDeleteProvider?.(provider);
}}
onOpenSourceSettings={(sourceId) => {
setContextMenu(null);
setSettingsSourceId(sourceId);
}}
/>
<RenameAliasModal
open={!!renameState}
t={t}
value={renameValue}
error={renameError}
onChange={(value) => {
setRenameValue(value);
setRenameError('');
}}
onClose={() => setRenameState(null)}
onSubmit={handleRenameSubmit}
/>
<AddAliasModal
open={addAliasOpen}
t={t}
value={addAliasValue}
error={addAliasError}
onChange={(value) => {
setAddAliasValue(value);
setAddAliasError('');
}}
onClose={() => setAddAliasOpen(false)}
onSubmit={handleAddAliasSubmit}
/>
<SettingsAliasModal
open={Boolean(settingsAlias)}
t={t}
alias={settingsAlias}
aliasNodes={aliasNodes}
onClose={() => setSettingsAlias(null)}
onToggleFork={handleToggleFork}
onUnlink={handleUnlinkSource}
/>
<SettingsSourceModal
open={Boolean(settingsSourceId)}
t={t}
source={resolveSourceById(settingsSourceId)}
onClose={() => setSettingsSourceId(null)}
onToggleFork={handleToggleFork}
onUnlink={handleUnlinkSource}
/>
</div>
);
});

View File

@@ -0,0 +1,251 @@
import type { DragEvent, MouseEvent as ReactMouseEvent, RefObject } from 'react';
import type { AliasNode, ProviderNode, SourceNode } from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
interface ProviderColumnProps {
providerNodes: ProviderNode[];
collapsedProviders: Set<string>;
getProviderColor: (provider: string) => string;
providerGroupHeights?: Record<string, number>;
providerRefs: RefObject<Map<string, HTMLDivElement>>;
onToggleCollapse: (provider: string) => void;
onContextMenu: (e: ReactMouseEvent, type: 'provider' | 'background', data?: string) => void;
label: string;
expandLabel: string;
collapseLabel: string;
}
export function ProviderColumn({
providerNodes,
collapsedProviders,
getProviderColor,
providerGroupHeights = {},
providerRefs,
onToggleCollapse,
onContextMenu,
label,
expandLabel,
collapseLabel
}: ProviderColumnProps) {
return (
<div
className={`${styles.column} ${styles.providers}`}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'background');
}}
>
<div className={styles.columnHeader}>{label}</div>
{providerNodes.map(({ provider, sources }) => {
const collapsed = collapsedProviders.has(provider);
const groupHeight = collapsed ? undefined : providerGroupHeights[provider];
return (
<div
key={provider}
className={styles.providerGroup}
style={groupHeight ? { height: groupHeight } : undefined}
>
<div
ref={(el) => {
if (el) providerRefs.current?.set(provider, el);
else providerRefs.current?.delete(provider);
}}
className={`${styles.item} ${styles.providerItem}`}
style={{ borderLeftColor: getProviderColor(provider) }}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'provider', provider);
}}
>
<button
type="button"
className={styles.collapseBtn}
onClick={() => onToggleCollapse(provider)}
aria-label={collapsed ? expandLabel : collapseLabel}
title={collapsed ? expandLabel : collapseLabel}
>
<span className={collapsed ? styles.chevronRight : styles.chevronDown} />
</button>
<span className={styles.providerLabel} style={{ color: getProviderColor(provider) }}>
{provider}
</span>
<span className={styles.itemCount}>{sources.length}</span>
</div>
</div>
);
})}
</div>
);
}
interface SourceColumnProps {
providerNodes: ProviderNode[];
collapsedProviders: Set<string>;
sourceRefs: RefObject<Map<string, HTMLDivElement>>;
getProviderColor: (provider: string) => string;
selectedSourceId?: string | null;
onSelectSource?: (source: SourceNode) => void;
draggedSource: SourceNode | null;
dropTargetSource: string | null;
draggable: boolean;
onDragStart: (e: DragEvent, source: SourceNode) => void;
onDragEnd: () => void;
onDragOver: (e: DragEvent, source: SourceNode) => void;
onDragLeave: () => void;
onDrop: (e: DragEvent, source: SourceNode) => void;
onContextMenu: (e: ReactMouseEvent, type: 'source' | 'background', data?: string) => void;
label: string;
}
export function SourceColumn({
providerNodes,
collapsedProviders,
sourceRefs,
getProviderColor,
selectedSourceId,
onSelectSource,
draggedSource,
dropTargetSource,
draggable,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
onContextMenu,
label
}: SourceColumnProps) {
return (
<div
className={`${styles.column} ${styles.sources}`}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'background');
}}
>
<div className={styles.columnHeader}>{label}</div>
{providerNodes.flatMap(({ provider, sources }) => {
if (collapsedProviders.has(provider)) return [];
return sources.map((source) => (
<div
key={source.id}
ref={(el) => {
if (el) sourceRefs.current?.set(source.id, el);
else sourceRefs.current?.delete(source.id);
}}
className={`${styles.item} ${styles.sourceItem} ${
draggedSource?.id === source.id ? styles.dragging : ''
} ${dropTargetSource === source.id ? styles.dropTarget : ''} ${
selectedSourceId === source.id ? styles.selected : ''
}`}
onClick={() => onSelectSource?.(source)}
draggable={draggable}
onDragStart={(e) => onDragStart(e, source)}
onDragEnd={onDragEnd}
onDragOver={(e) => onDragOver(e, source)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, source)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'source', source.id);
}}
>
<span className={styles.itemName} title={source.name}>
{source.name}
</span>
<div
className={styles.dot}
style={{
background: getProviderColor(source.provider),
opacity: source.aliases.length > 0 ? 1 : 0.3
}}
/>
</div>
));
})}
</div>
);
}
interface AliasColumnProps {
aliasNodes: AliasNode[];
aliasRefs: RefObject<Map<string, HTMLDivElement>>;
dropTargetAlias: string | null;
draggedAlias: string | null;
selectedAlias?: string | null;
onSelectAlias?: (alias: string) => void;
draggable: boolean;
onDragStart: (e: DragEvent, alias: string) => void;
onDragEnd: () => void;
onDragOver: (e: DragEvent, alias: string) => void;
onDragLeave: () => void;
onDrop: (e: DragEvent, alias: string) => void;
onContextMenu: (e: ReactMouseEvent, type: 'alias' | 'background', data?: string) => void;
label: string;
}
export function AliasColumn({
aliasNodes,
aliasRefs,
dropTargetAlias,
draggedAlias,
selectedAlias,
onSelectAlias,
draggable,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
onContextMenu,
label
}: AliasColumnProps) {
return (
<div
className={`${styles.column} ${styles.aliases}`}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'background');
}}
>
<div className={styles.columnHeader}>{label}</div>
{aliasNodes.map((node) => (
<div
key={node.id}
ref={(el) => {
if (el) aliasRefs.current?.set(node.id, el);
else aliasRefs.current?.delete(node.id);
}}
className={`${styles.item} ${styles.aliasItem} ${
dropTargetAlias === node.alias ? styles.dropTarget : ''
} ${draggedAlias === node.alias ? styles.dragging : ''} ${
selectedAlias === node.alias ? styles.selected : ''
}`}
onClick={() => onSelectAlias?.(node.alias)}
draggable={draggable}
onDragStart={(e) => onDragStart(e, node.alias)}
onDragEnd={onDragEnd}
onDragOver={(e) => onDragOver(e, node.alias)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, node.alias)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'alias', node.alias);
}}
>
<div className={`${styles.dot} ${styles.dotLeft}`} />
<span className={styles.itemName} title={node.alias}>
{node.alias}
</span>
<span className={styles.itemCount}>{node.sources.length}</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import type { TFunction } from 'i18next';
import type { ContextMenuState } from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
interface DiagramContextMenuProps {
contextMenu: ContextMenuState | null;
t: TFunction;
onRequestClose: () => void;
onAddAlias: () => void;
onRenameAlias: (alias: string) => void;
onOpenAliasSettings: (alias: string) => void;
onDeleteAlias: (alias: string) => void;
onEditProvider: (provider: string) => void;
onDeleteProvider: (provider: string) => void;
onOpenSourceSettings: (sourceId: string) => void;
}
export function DiagramContextMenu({
contextMenu,
t,
onRequestClose,
onAddAlias,
onRenameAlias,
onOpenAliasSettings,
onDeleteAlias,
onEditProvider,
onDeleteProvider,
onOpenSourceSettings
}: DiagramContextMenuProps) {
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!contextMenu) return;
const handleClick = (event: globalThis.MouseEvent) => {
if (!menuRef.current?.contains(event.target as Node)) {
onRequestClose();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [contextMenu, onRequestClose]);
if (!contextMenu) return null;
const { type, data } = contextMenu;
const renderBackground = () => (
<div className={styles.menuItem} onClick={onAddAlias}>
<span>{t('oauth_model_alias.diagram_add_alias')}</span>
</div>
);
const renderAlias = () => {
if (!data) return null;
return (
<>
<div className={styles.menuItem} onClick={() => onRenameAlias(data)}>
<span>{t('oauth_model_alias.diagram_rename')}</span>
</div>
<div className={styles.menuItem} onClick={() => onOpenAliasSettings(data)}>
<span>{t('oauth_model_alias.diagram_settings')}</span>
</div>
<div className={styles.menuDivider} />
<div className={`${styles.menuItem} ${styles.danger}`} onClick={() => onDeleteAlias(data)}>
<span>{t('oauth_model_alias.diagram_delete_alias')}</span>
</div>
</>
);
};
const renderProvider = () => {
if (!data) return null;
return (
<>
<div className={styles.menuItem} onClick={() => onEditProvider(data)}>
<span>{t('common.edit')}</span>
</div>
<div className={styles.menuDivider} />
<div className={`${styles.menuItem} ${styles.danger}`} onClick={() => onDeleteProvider(data)}>
<span>{t('oauth_model_alias.delete')}</span>
</div>
</>
);
};
const renderSource = () => {
if (!data) return null;
return (
<div className={styles.menuItem} onClick={() => onOpenSourceSettings(data)}>
<span>{t('oauth_model_alias.diagram_settings')}</span>
</div>
);
};
return createPortal(
<div
ref={menuRef}
className={styles.contextMenu}
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{type === 'background' && renderBackground()}
{type === 'alias' && renderAlias()}
{type === 'provider' && renderProvider()}
{type === 'source' && renderSource()}
</div>,
document.body
);
}

View File

@@ -0,0 +1,267 @@
import type { KeyboardEvent } from 'react';
import type { TFunction } from 'i18next';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconTrash2 } from '@/components/ui/icons';
import type { AliasNode, SourceNode } from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
interface RenameAliasModalProps {
open: boolean;
t: TFunction;
value: string;
error: string;
onChange: (value: string) => void;
onClose: () => void;
onSubmit: () => void;
}
export function RenameAliasModal({
open,
t,
value,
error,
onChange,
onClose,
onSubmit
}: RenameAliasModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_rename_alias_title')}
width={400}
footer={
<>
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_rename_btn')}</Button>
</>
}
>
<Input
label={t('oauth_model_alias.diagram_rename_alias_label')}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') onSubmit();
}}
error={error}
placeholder={t('oauth_model_alias.diagram_rename_placeholder')}
autoFocus
/>
</Modal>
);
}
interface AddAliasModalProps {
open: boolean;
t: TFunction;
value: string;
error: string;
onChange: (value: string) => void;
onClose: () => void;
onSubmit: () => void;
}
export function AddAliasModal({
open,
t,
value,
error,
onChange,
onClose,
onSubmit
}: AddAliasModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_add_alias_title')}
width={400}
footer={
<>
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_add_btn')}</Button>
</>
}
>
<Input
label={t('oauth_model_alias.diagram_add_alias_label')}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') onSubmit();
}}
error={error}
placeholder={t('oauth_model_alias.diagram_add_placeholder')}
autoFocus
/>
</Modal>
);
}
interface SettingsAliasModalProps {
open: boolean;
t: TFunction;
alias: string | null;
aliasNodes: AliasNode[];
onClose: () => void;
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
}
export function SettingsAliasModal({
open,
t,
alias,
aliasNodes,
onClose,
onToggleFork,
onUnlink
}: SettingsAliasModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_settings_title', { alias: alias ?? '' })}
width={720}
footer={
<Button variant="secondary" onClick={onClose}>
{t('common.close')}
</Button>
}
>
{alias ? (
(() => {
const node = aliasNodes.find((n) => n.alias === alias);
if (!node || node.sources.length === 0) {
return <div className={styles.settingsEmpty}>{t('oauth_model_alias.diagram_settings_empty')}</div>;
}
return (
<div className={styles.settingsList}>
{node.sources.map((source) => {
const entry = source.aliases.find((item) => item.alias === alias);
const forkEnabled = entry?.fork === true;
return (
<div key={source.id} className={styles.settingsRow}>
<div className={styles.settingsNames}>
<span className={styles.settingsSource}>{source.name}</span>
<span className={styles.settingsArrow}></span>
<span className={styles.settingsAlias}>{alias}</span>
</div>
<div className={styles.settingsActions}>
<span className={styles.settingsLabel}>
{t('oauth_model_alias.alias_fork_label')}
</span>
<ToggleSwitch
checked={forkEnabled}
onChange={(value) => onToggleFork(source.provider, source.name, alias, value)}
ariaLabel={t('oauth_model_alias.alias_fork_label')}
/>
<button
type="button"
className={styles.settingsDelete}
onClick={() => onUnlink(source.provider, source.name, alias)}
aria-label={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name
})}
title={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name
})}
>
<IconTrash2 size={14} />
</button>
</div>
</div>
);
})}
</div>
);
})()
) : null}
</Modal>
);
}
interface SettingsSourceModalProps {
open: boolean;
t: TFunction;
source: SourceNode | null;
onClose: () => void;
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
}
export function SettingsSourceModal({
open,
t,
source,
onClose,
onToggleFork,
onUnlink
}: SettingsSourceModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_settings_source_title')}
width={720}
footer={
<Button variant="secondary" onClick={onClose}>
{t('common.close')}
</Button>
}
>
{source ? (
source.aliases.length === 0 ? (
<div className={styles.settingsEmpty}>{t('oauth_model_alias.diagram_settings_empty')}</div>
) : (
<div className={styles.settingsList}>
{source.aliases.map((entry) => (
<div key={`${source.id}-${entry.alias}`} className={styles.settingsRow}>
<div className={styles.settingsNames}>
<span className={styles.settingsSource}>{source.name}</span>
<span className={styles.settingsArrow}></span>
<span className={styles.settingsAlias}>{entry.alias}</span>
</div>
<div className={styles.settingsActions}>
<span className={styles.settingsLabel}>
{t('oauth_model_alias.alias_fork_label')}
</span>
<ToggleSwitch
checked={entry.fork === true}
onChange={(value) => onToggleFork(source.provider, source.name, entry.alias, value)}
ariaLabel={t('oauth_model_alias.alias_fork_label')}
/>
<button
type="button"
className={styles.settingsDelete}
onClick={() => onUnlink(source.provider, source.name, entry.alias)}
aria-label={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name
})}
title={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name
})}
>
<IconTrash2 size={14} />
</button>
</div>
</div>
))}
</div>
)
) : null}
</Modal>
);
}

View File

@@ -0,0 +1,33 @@
export interface AuthFileModelItem {
id: string;
display_name?: string;
type?: string;
owned_by?: string;
}
export interface SourceNode {
id: string; // unique: provider::name
provider: string;
name: string;
aliases: { alias: string; fork: boolean }[]; // all aliases this source maps to
}
export interface AliasNode {
id: string; // alias
alias: string;
sources: SourceNode[];
}
export interface ProviderNode {
provider: string;
sources: SourceNode[];
}
export interface ContextMenuState {
x: number;
y: number;
type: 'alias' | 'background' | 'provider' | 'source';
data?: string;
}
export type DiagramLine = { path: string; color: string; id: string };

View File

@@ -0,0 +1,2 @@
export { ModelMappingDiagram } from './ModelMappingDiagram';
export type { ModelMappingDiagramProps, ModelMappingDiagramRef } from './ModelMappingDiagram';

View File

@@ -0,0 +1,409 @@
import { useMemo, useState, useCallback, Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { useDisableModel } from '@/hooks';
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
import { DisableModelModal } from './DisableModelModal';
import {
formatTimestamp,
getRateClassName,
filterDataByTimeRange,
getProviderDisplayParts,
type DateRange,
} from '@/utils/monitor';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface ChannelStatsProps {
data: UsageData | null;
loading: boolean;
providerMap: Record<string, string>;
providerModels: Record<string, Set<string>>;
}
interface ModelStat {
requests: number;
success: number;
failed: number;
successRate: number;
recentRequests: { failed: boolean; timestamp: number }[];
lastTimestamp: number;
}
interface ChannelStat {
source: string;
displayName: string;
providerName: string | null;
maskedKey: string;
totalRequests: number;
successRequests: number;
failedRequests: number;
successRate: number;
lastRequestTime: number;
recentRequests: { failed: boolean; timestamp: number }[];
models: Record<string, ModelStat>;
}
export function ChannelStats({ data, loading, providerMap, providerModels }: ChannelStatsProps) {
const { t } = useTranslation();
const [expandedChannel, setExpandedChannel] = useState<string | null>(null);
const [filterChannel, setFilterChannel] = useState('');
const [filterModel, setFilterModel] = useState('');
const [filterStatus, setFilterStatus] = useState<'' | 'success' | 'failed'>('');
// 时间范围状态
const [timeRange, setTimeRange] = useState<TimeRange>(7);
const [customRange, setCustomRange] = useState<DateRange | undefined>();
// 使用禁用模型 Hook
const {
disableState,
disabling,
isModelDisabled,
handleDisableClick: onDisableClick,
handleConfirmDisable,
handleCancelDisable,
} = useDisableModel({ providerMap, providerModels });
// 处理时间范围变化
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
setTimeRange(range);
if (custom) {
setCustomRange(custom);
}
}, []);
// 根据时间范围过滤数据
const timeFilteredData = useMemo(() => {
return filterDataByTimeRange(data, timeRange, customRange);
}, [data, timeRange, customRange]);
// 计算渠道统计数据
const channelStats = useMemo(() => {
if (!timeFilteredData?.apis) return [];
const stats: Record<string, ChannelStat> = {};
Object.values(timeFilteredData.apis).forEach((apiData) => {
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
modelData.details.forEach((detail) => {
const source = detail.source || 'unknown';
// 获取渠道显示信息
const { provider, masked } = getProviderDisplayParts(source, providerMap);
// 只统计在 providerMap 中存在的渠道
if (!provider) return;
const displayName = `${provider} (${masked})`;
const timestamp = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
if (!stats[displayName]) {
stats[displayName] = {
source,
displayName,
providerName: provider,
maskedKey: masked,
totalRequests: 0,
successRequests: 0,
failedRequests: 0,
successRate: 0,
lastRequestTime: 0,
recentRequests: [],
models: {},
};
}
stats[displayName].totalRequests++;
if (detail.failed) {
stats[displayName].failedRequests++;
} else {
stats[displayName].successRequests++;
}
// 更新最近请求时间
if (timestamp > stats[displayName].lastRequestTime) {
stats[displayName].lastRequestTime = timestamp;
}
// 收集请求状态
stats[displayName].recentRequests.push({ failed: detail.failed, timestamp });
// 模型统计
if (!stats[displayName].models[modelName]) {
stats[displayName].models[modelName] = {
requests: 0,
success: 0,
failed: 0,
successRate: 0,
recentRequests: [],
lastTimestamp: 0,
};
}
stats[displayName].models[modelName].requests++;
if (detail.failed) {
stats[displayName].models[modelName].failed++;
} else {
stats[displayName].models[modelName].success++;
}
stats[displayName].models[modelName].recentRequests.push({ failed: detail.failed, timestamp });
if (timestamp > stats[displayName].models[modelName].lastTimestamp) {
stats[displayName].models[modelName].lastTimestamp = timestamp;
}
});
});
});
// 计算成功率并排序请求
Object.values(stats).forEach((stat) => {
stat.successRate = stat.totalRequests > 0
? (stat.successRequests / stat.totalRequests) * 100
: 0;
// 按时间排序取最近12个
stat.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
stat.recentRequests = stat.recentRequests.slice(-12);
Object.values(stat.models).forEach((model) => {
model.successRate = model.requests > 0
? (model.success / model.requests) * 100
: 0;
model.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
model.recentRequests = model.recentRequests.slice(-12);
});
});
return Object.values(stats)
.filter((stat) => stat.totalRequests > 0)
.sort((a, b) => b.totalRequests - a.totalRequests)
.slice(0, 10);
}, [timeFilteredData, providerMap]);
// 获取所有渠道和模型列表
const { channels, models } = useMemo(() => {
const channelSet = new Set<string>();
const modelSet = new Set<string>();
channelStats.forEach((stat) => {
channelSet.add(stat.displayName);
Object.keys(stat.models).forEach((model) => modelSet.add(model));
});
return {
channels: Array.from(channelSet).sort(),
models: Array.from(modelSet).sort(),
};
}, [channelStats]);
// 过滤后的数据
const filteredStats = useMemo(() => {
return channelStats.filter((stat) => {
if (filterChannel && stat.displayName !== filterChannel) return false;
if (filterModel && !stat.models[filterModel]) return false;
if (filterStatus === 'success' && stat.failedRequests > 0) return false;
if (filterStatus === 'failed' && stat.failedRequests === 0) return false;
return true;
});
}, [channelStats, filterChannel, filterModel, filterStatus]);
// 切换展开状态
const toggleExpand = (displayName: string) => {
setExpandedChannel(expandedChannel === displayName ? null : displayName);
};
// 开始禁用流程(阻止事件冒泡)
const handleDisableClick = (source: string, model: string, e: React.MouseEvent) => {
e.stopPropagation();
onDisableClick(source, model);
};
return (
<>
<Card
title={t('monitor.channel.title')}
subtitle={
<span>
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.channel.subtitle')}
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.channel.click_hint')}</span>
</span>
}
extra={
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
customRange={customRange}
/>
}
>
{/* 筛选器 */}
<div className={styles.logFilters}>
<select
className={styles.logSelect}
value={filterChannel}
onChange={(e) => setFilterChannel(e.target.value)}
>
<option value="">{t('monitor.channel.all_channels')}</option>
{channels.map((channel) => (
<option key={channel} value={channel}>{channel}</option>
))}
</select>
<select
className={styles.logSelect}
value={filterModel}
onChange={(e) => setFilterModel(e.target.value)}
>
<option value="">{t('monitor.channel.all_models')}</option>
{models.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<select
className={styles.logSelect}
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as '' | 'success' | 'failed')}
>
<option value="">{t('monitor.channel.all_status')}</option>
<option value="success">{t('monitor.channel.only_success')}</option>
<option value="failed">{t('monitor.channel.only_failed')}</option>
</select>
</div>
{/* 表格 */}
<div className={styles.tableWrapper}>
{loading ? (
<div className={styles.emptyState}>{t('common.loading')}</div>
) : filteredStats.length === 0 ? (
<div className={styles.emptyState}>{t('monitor.no_data')}</div>
) : (
<table className={styles.table}>
<thead>
<tr>
<th>{t('monitor.channel.header_name')}</th>
<th>{t('monitor.channel.header_count')}</th>
<th>{t('monitor.channel.header_rate')}</th>
<th>{t('monitor.channel.header_recent')}</th>
<th>{t('monitor.channel.header_time')}</th>
</tr>
</thead>
<tbody>
{filteredStats.map((stat) => (
<Fragment key={stat.displayName}>
<tr
className={styles.expandable}
onClick={() => toggleExpand(stat.displayName)}
>
<td>
{stat.providerName ? (
<>
<span className={styles.channelName}>{stat.providerName}</span>
<span className={styles.channelSecret}> ({stat.maskedKey})</span>
</>
) : (
stat.maskedKey
)}
</td>
<td>{stat.totalRequests.toLocaleString()}</td>
<td className={getRateClassName(stat.successRate, styles)}>
{stat.successRate.toFixed(1)}%
</td>
<td>
<div className={styles.statusBars}>
{stat.recentRequests.map((req, i) => (
<div
key={i}
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
/>
))}
</div>
</td>
<td>{formatTimestamp(stat.lastRequestTime)}</td>
</tr>
{expandedChannel === stat.displayName && (
<tr key={`${stat.displayName}-detail`}>
<td colSpan={5} className={styles.expandDetail}>
<div className={styles.expandTableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('monitor.channel.model')}</th>
<th>{t('monitor.channel.header_count')}</th>
<th>{t('monitor.channel.header_rate')}</th>
<th>{t('monitor.channel.success')}/{t('monitor.channel.failed')}</th>
<th>{t('monitor.channel.header_recent')}</th>
<th>{t('monitor.channel.header_time')}</th>
<th>{t('monitor.logs.header_actions')}</th>
</tr>
</thead>
<tbody>
{Object.entries(stat.models)
.sort((a, b) => {
const aDisabled = isModelDisabled(stat.source, a[0]);
const bDisabled = isModelDisabled(stat.source, b[0]);
// 已禁用的排在后面
if (aDisabled !== bDisabled) {
return aDisabled ? 1 : -1;
}
// 然后按请求数降序
return b[1].requests - a[1].requests;
})
.map(([modelName, modelStat]) => {
const disabled = isModelDisabled(stat.source, modelName);
return (
<tr key={modelName} className={disabled ? styles.modelDisabled : ''}>
<td>{modelName}</td>
<td>{modelStat.requests.toLocaleString()}</td>
<td className={getRateClassName(modelStat.successRate, styles)}>
{modelStat.successRate.toFixed(1)}%
</td>
<td>
<span className={styles.kpiSuccess}>{modelStat.success}</span>
{' / '}
<span className={styles.kpiFailure}>{modelStat.failed}</span>
</td>
<td>
<div className={styles.statusBars}>
{modelStat.recentRequests.map((req, i) => (
<div
key={i}
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
/>
))}
</div>
</td>
<td>{formatTimestamp(modelStat.lastTimestamp)}</td>
<td>
{disabled ? (
<span className={styles.disabledLabel}>{t('monitor.logs.removed')}</span>
) : stat.source && stat.source !== '-' && stat.source !== 'unknown' ? (
<button
className={styles.disableBtn}
onClick={(e) => handleDisableClick(stat.source, modelName, e)}
>
{t('monitor.logs.disable')}
</button>
) : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
)}
</div>
</Card>
{/* 禁用确认弹窗 */}
<DisableModelModal
disableState={disableState}
disabling={disabling}
onConfirm={handleConfirmDisable}
onCancel={handleCancelDisable}
/>
</>
);
}

View File

@@ -0,0 +1,279 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Chart } from 'react-chartjs-2';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface DailyTrendChartProps {
data: UsageData | null;
loading: boolean;
isDark: boolean;
timeRange: number;
}
interface DailyStat {
date: string;
requests: number;
successRequests: number;
failedRequests: number;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cachedTokens: number;
}
export function DailyTrendChart({ data, loading, isDark, timeRange }: DailyTrendChartProps) {
const { t } = useTranslation();
// 按日期聚合数据
const dailyData = useMemo((): DailyStat[] => {
if (!data?.apis) return [];
const dailyStats: Record<string, {
requests: number;
successRequests: number;
failedRequests: number;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cachedTokens: number;
}> = {};
// 辅助函数:获取本地日期字符串 YYYY-MM-DD
const getLocalDateString = (timestamp: string): string => {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
Object.values(data.apis).forEach((apiData) => {
Object.values(apiData.models).forEach((modelData) => {
modelData.details.forEach((detail) => {
// 使用本地日期而非 UTC 日期
const date = getLocalDateString(detail.timestamp);
if (!dailyStats[date]) {
dailyStats[date] = {
requests: 0,
successRequests: 0,
failedRequests: 0,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
cachedTokens: 0,
};
}
dailyStats[date].requests++;
if (detail.failed) {
dailyStats[date].failedRequests++;
} else {
dailyStats[date].successRequests++;
// 只统计成功请求的 Token
dailyStats[date].inputTokens += detail.tokens.input_tokens || 0;
dailyStats[date].outputTokens += detail.tokens.output_tokens || 0;
dailyStats[date].reasoningTokens += detail.tokens.reasoning_tokens || 0;
dailyStats[date].cachedTokens += detail.tokens.cached_tokens || 0;
}
});
});
});
// 转换为数组并按日期排序
return Object.entries(dailyStats)
.map(([date, stats]) => ({ date, ...stats }))
.sort((a, b) => a.date.localeCompare(b.date));
}, [data]);
// 图表数据
const chartData = useMemo(() => {
const labels = dailyData.map((item) => {
const date = new Date(item.date);
return `${date.getMonth() + 1}/${date.getDate()}`;
});
return {
labels,
datasets: [
{
type: 'line' as const,
label: t('monitor.trend.requests'),
data: dailyData.map((item) => item.requests),
borderColor: '#3b82f6',
backgroundColor: '#3b82f6',
borderWidth: 3,
fill: false,
tension: 0.35,
yAxisID: 'y1',
order: 0,
pointRadius: 3,
pointBackgroundColor: '#3b82f6',
},
{
type: 'bar' as const,
label: t('monitor.trend.input_tokens'),
data: dailyData.map((item) => item.inputTokens / 1000),
backgroundColor: 'rgba(34, 197, 94, 0.7)',
borderColor: 'rgba(34, 197, 94, 0.7)',
borderWidth: 1,
borderRadius: 0,
yAxisID: 'y',
order: 1,
stack: 'tokens',
},
{
type: 'bar' as const,
label: t('monitor.trend.output_tokens'),
data: dailyData.map((item) => item.outputTokens / 1000),
backgroundColor: 'rgba(249, 115, 22, 0.7)',
borderColor: 'rgba(249, 115, 22, 0.7)',
borderWidth: 1,
borderRadius: 4,
yAxisID: 'y',
order: 1,
stack: 'tokens',
},
],
};
}, [dailyData, t]);
// 图表配置
const chartOptions = useMemo(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index' as const,
intersect: false,
},
plugins: {
legend: {
display: true,
position: 'bottom' as const,
labels: {
color: isDark ? '#9ca3af' : '#6b7280',
usePointStyle: true,
padding: 16,
font: {
size: 11,
},
generateLabels: (chart: any) => {
return chart.data.datasets.map((dataset: any, i: number) => {
const isLine = dataset.type === 'line';
return {
text: dataset.label,
fillStyle: dataset.backgroundColor,
strokeStyle: dataset.borderColor,
lineWidth: 0,
hidden: !chart.isDatasetVisible(i),
datasetIndex: i,
pointStyle: isLine ? 'circle' : 'rect',
};
});
},
},
},
tooltip: {
backgroundColor: isDark ? '#374151' : '#ffffff',
titleColor: isDark ? '#f3f4f6' : '#111827',
bodyColor: isDark ? '#d1d5db' : '#4b5563',
borderColor: isDark ? '#4b5563' : '#e5e7eb',
borderWidth: 1,
padding: 12,
callbacks: {
label: (context: any) => {
const label = context.dataset.label || '';
const value = context.raw;
if (context.dataset.yAxisID === 'y1') {
return `${label}: ${value.toLocaleString()}`;
}
return `${label}: ${value.toFixed(1)}K`;
},
},
},
},
scales: {
x: {
grid: {
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
y: {
type: 'linear' as const,
position: 'left' as const,
stacked: true,
grid: {
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
callback: (value: string | number) => `${value}K`,
},
title: {
display: true,
text: 'Tokens (K)',
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
y1: {
type: 'linear' as const,
position: 'right' as const,
grid: {
drawOnChartArea: false,
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
title: {
display: true,
text: t('monitor.trend.requests'),
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
},
}), [isDark, t]);
const timeRangeLabel = timeRange === 1
? t('monitor.today')
: t('monitor.last_n_days', { n: timeRange });
return (
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div>
<h3 className={styles.chartTitle}>{t('monitor.trend.title')}</h3>
<p className={styles.chartSubtitle}>
{timeRangeLabel} · {t('monitor.trend.subtitle')}
</p>
</div>
</div>
<div className={styles.chartContent}>
{loading || dailyData.length === 0 ? (
<div className={styles.chartEmpty}>
{loading ? t('common.loading') : t('monitor.no_data')}
</div>
) : (
<Chart type="bar" data={chartData} options={chartOptions} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
/**
* 禁用模型确认弹窗组件
* 封装三次确认的 UI 逻辑
*/
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import type { DisableState } from '@/utils/monitor';
interface DisableModelModalProps {
/** 禁用状态 */
disableState: DisableState | null;
/** 是否正在禁用中 */
disabling: boolean;
/** 确认回调 */
onConfirm: () => void;
/** 取消回调 */
onCancel: () => void;
}
export function DisableModelModal({
disableState,
disabling,
onConfirm,
onCancel,
}: DisableModelModalProps) {
const { t, i18n } = useTranslation();
const isZh = i18n.language === 'zh-CN' || i18n.language === 'zh';
// 获取警告内容
const getWarningContent = () => {
if (!disableState) return null;
if (disableState.step === 1) {
return (
<p style={{ marginBottom: 16, lineHeight: 1.6 }}>
{isZh ? '确定要禁用 ' : 'Are you sure you want to disable '}
<strong>{disableState.displayName}</strong>
{isZh ? ' 吗?' : '?'}
</p>
);
}
if (disableState.step === 2) {
return (
<p style={{ marginBottom: 16, lineHeight: 1.6, color: 'var(--warning-color, #f59e0b)' }}>
{isZh
? '⚠️ 警告:此操作将从配置中移除该模型映射!'
: '⚠️ Warning: this removes the model mapping from config!'}
</p>
);
}
return (
<p style={{ marginBottom: 16, lineHeight: 1.6, color: 'var(--danger-color, #ef4444)' }}>
{isZh
? '🚨 最后确认:禁用后需要手动重新添加才能恢复!'
: "🚨 Final confirmation: you'll need to add it back manually later!"}
</p>
);
};
// 获取确认按钮文本
const getConfirmButtonText = () => {
if (!disableState) return '';
const btnTexts = isZh
? ['确认禁用 (3)', '我确定 (2)', '立即禁用 (1)']
: ['Confirm (3)', "I'm sure (2)", 'Disable now (1)'];
return btnTexts[disableState.step - 1] || btnTexts[0];
};
return (
<Modal
open={!!disableState}
onClose={onCancel}
title={t('monitor.logs.disable_confirm_title')}
width={400}
>
<div style={{ padding: '16px 0' }}>
{getWarningContent()}
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<Button
variant="secondary"
onClick={onCancel}
disabled={disabling}
>
{t('common.cancel')}
</Button>
<Button
variant="danger"
onClick={onConfirm}
disabled={disabling}
>
{disabling ? t('monitor.logs.disabling') : getConfirmButtonText()}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,420 @@
import { useMemo, useState, useCallback, Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { useDisableModel } from '@/hooks';
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
import { DisableModelModal } from './DisableModelModal';
import {
formatTimestamp,
getRateClassName,
filterDataByTimeRange,
getProviderDisplayParts,
type DateRange,
} from '@/utils/monitor';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface FailureAnalysisProps {
data: UsageData | null;
loading: boolean;
providerMap: Record<string, string>;
providerModels: Record<string, Set<string>>;
}
interface ModelFailureStat {
success: number;
failure: number;
total: number;
successRate: number;
recentRequests: { failed: boolean; timestamp: number }[];
lastTimestamp: number;
}
interface FailureStat {
source: string;
displayName: string;
providerName: string | null;
maskedKey: string;
failedCount: number;
lastFailTime: number;
models: Record<string, ModelFailureStat>;
}
export function FailureAnalysis({ data, loading, providerMap, providerModels }: FailureAnalysisProps) {
const { t } = useTranslation();
const [expandedChannel, setExpandedChannel] = useState<string | null>(null);
const [filterChannel, setFilterChannel] = useState('');
const [filterModel, setFilterModel] = useState('');
// 时间范围状态
const [timeRange, setTimeRange] = useState<TimeRange>(7);
const [customRange, setCustomRange] = useState<DateRange | undefined>();
// 使用禁用模型 Hook
const {
disableState,
disabling,
isModelDisabled,
handleDisableClick: onDisableClick,
handleConfirmDisable,
handleCancelDisable,
} = useDisableModel({ providerMap, providerModels });
// 处理时间范围变化
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
setTimeRange(range);
if (custom) {
setCustomRange(custom);
}
}, []);
// 根据时间范围过滤数据
const timeFilteredData = useMemo(() => {
return filterDataByTimeRange(data, timeRange, customRange);
}, [data, timeRange, customRange]);
// 计算失败统计数据
const failureStats = useMemo(() => {
if (!timeFilteredData?.apis) return [];
// 首先收集有失败记录的渠道
const failedSources = new Set<string>();
Object.values(timeFilteredData.apis).forEach((apiData) => {
Object.values(apiData.models).forEach((modelData) => {
modelData.details.forEach((detail) => {
if (detail.failed) {
const source = detail.source || 'unknown';
const { provider } = getProviderDisplayParts(source, providerMap);
if (provider) {
failedSources.add(source);
}
}
});
});
});
// 统计这些渠道的所有请求
const stats: Record<string, FailureStat> = {};
Object.values(timeFilteredData.apis).forEach((apiData) => {
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
modelData.details.forEach((detail) => {
const source = detail.source || 'unknown';
// 只统计有失败记录的渠道
if (!failedSources.has(source)) return;
const { provider, masked } = getProviderDisplayParts(source, providerMap);
const displayName = provider ? `${provider} (${masked})` : masked;
const timestamp = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
if (!stats[displayName]) {
stats[displayName] = {
source,
displayName,
providerName: provider,
maskedKey: masked,
failedCount: 0,
lastFailTime: 0,
models: {},
};
}
if (detail.failed) {
stats[displayName].failedCount++;
if (timestamp > stats[displayName].lastFailTime) {
stats[displayName].lastFailTime = timestamp;
}
}
// 按模型统计
if (!stats[displayName].models[modelName]) {
stats[displayName].models[modelName] = {
success: 0,
failure: 0,
total: 0,
successRate: 0,
recentRequests: [],
lastTimestamp: 0,
};
}
stats[displayName].models[modelName].total++;
if (detail.failed) {
stats[displayName].models[modelName].failure++;
} else {
stats[displayName].models[modelName].success++;
}
stats[displayName].models[modelName].recentRequests.push({ failed: detail.failed, timestamp });
if (timestamp > stats[displayName].models[modelName].lastTimestamp) {
stats[displayName].models[modelName].lastTimestamp = timestamp;
}
});
});
});
// 计算成功率并排序请求
Object.values(stats).forEach((stat) => {
Object.values(stat.models).forEach((model) => {
model.successRate = model.total > 0
? (model.success / model.total) * 100
: 0;
model.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
model.recentRequests = model.recentRequests.slice(-12);
});
});
return Object.values(stats)
.filter((stat) => stat.failedCount > 0)
.sort((a, b) => b.failedCount - a.failedCount)
.slice(0, 10);
}, [timeFilteredData, providerMap]);
// 获取所有渠道和模型列表
const { channels, models } = useMemo(() => {
const channelSet = new Set<string>();
const modelSet = new Set<string>();
failureStats.forEach((stat) => {
channelSet.add(stat.displayName);
Object.keys(stat.models).forEach((model) => modelSet.add(model));
});
return {
channels: Array.from(channelSet).sort(),
models: Array.from(modelSet).sort(),
};
}, [failureStats]);
// 过滤后的数据
const filteredStats = useMemo(() => {
return failureStats.filter((stat) => {
if (filterChannel && stat.displayName !== filterChannel) return false;
if (filterModel && !stat.models[filterModel]) return false;
return true;
});
}, [failureStats, filterChannel, filterModel]);
// 切换展开状态
const toggleExpand = (displayName: string) => {
setExpandedChannel(expandedChannel === displayName ? null : displayName);
};
// 获取主要失败模型前2个已禁用的排在后面
const getTopFailedModels = (source: string, modelsMap: Record<string, ModelFailureStat>) => {
return Object.entries(modelsMap)
.filter(([, stat]) => stat.failure > 0)
.sort((a, b) => {
const aDisabled = isModelDisabled(source, a[0]);
const bDisabled = isModelDisabled(source, b[0]);
// 已禁用的排在后面
if (aDisabled !== bDisabled) {
return aDisabled ? 1 : -1;
}
// 然后按失败数降序
return b[1].failure - a[1].failure;
})
.slice(0, 2);
};
// 开始禁用流程(阻止事件冒泡)
const handleDisableClick = (source: string, model: string, e: React.MouseEvent) => {
e.stopPropagation();
onDisableClick(source, model);
};
return (
<>
<Card
title={t('monitor.failure.title')}
subtitle={
<span>
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.failure.subtitle')}
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.failure.click_hint')}</span>
</span>
}
extra={
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
customRange={customRange}
/>
}
>
{/* 筛选器 */}
<div className={styles.logFilters}>
<select
className={styles.logSelect}
value={filterChannel}
onChange={(e) => setFilterChannel(e.target.value)}
>
<option value="">{t('monitor.channel.all_channels')}</option>
{channels.map((channel) => (
<option key={channel} value={channel}>{channel}</option>
))}
</select>
<select
className={styles.logSelect}
value={filterModel}
onChange={(e) => setFilterModel(e.target.value)}
>
<option value="">{t('monitor.channel.all_models')}</option>
{models.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
</div>
{/* 表格 */}
<div className={styles.tableWrapper}>
{loading ? (
<div className={styles.emptyState}>{t('common.loading')}</div>
) : filteredStats.length === 0 ? (
<div className={styles.emptyState}>{t('monitor.failure.no_failures')}</div>
) : (
<table className={styles.table}>
<thead>
<tr>
<th>{t('monitor.failure.header_name')}</th>
<th>{t('monitor.failure.header_count')}</th>
<th>{t('monitor.failure.header_time')}</th>
<th>{t('monitor.failure.header_models')}</th>
</tr>
</thead>
<tbody>
{filteredStats.map((stat) => {
const topModels = getTopFailedModels(stat.source, stat.models);
const totalFailedModels = Object.values(stat.models).filter(m => m.failure > 0).length;
return (
<Fragment key={stat.displayName}>
<tr
className={styles.expandable}
onClick={() => toggleExpand(stat.displayName)}
>
<td>
{stat.providerName ? (
<>
<span className={styles.channelName}>{stat.providerName}</span>
<span className={styles.channelSecret}> ({stat.maskedKey})</span>
</>
) : (
stat.maskedKey
)}
</td>
<td className={styles.kpiFailure}>{stat.failedCount.toLocaleString()}</td>
<td>{formatTimestamp(stat.lastFailTime)}</td>
<td>
{topModels.map(([model, modelStat]) => {
const percent = ((modelStat.failure / stat.failedCount) * 100).toFixed(0);
const shortModel = model.length > 16 ? model.slice(0, 13) + '...' : model;
const disabled = isModelDisabled(stat.source, model);
return (
<span
key={model}
className={`${styles.failureModelTag} ${disabled ? styles.modelDisabled : ''}`}
title={`${model}: ${modelStat.failure} (${percent}%)${disabled ? ` - ${t('monitor.logs.removed')}` : ''}`}
>
{shortModel}
</span>
);
})}
{totalFailedModels > 2 && (
<span className={styles.moreModelsHint}>
+{totalFailedModels - 2}
</span>
)}
</td>
</tr>
{expandedChannel === stat.displayName && (
<tr key={`${stat.displayName}-detail`}>
<td colSpan={4} className={styles.expandDetail}>
<div className={styles.expandTableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('monitor.channel.model')}</th>
<th>{t('monitor.channel.header_count')}</th>
<th>{t('monitor.channel.header_rate')}</th>
<th>{t('monitor.channel.success')}/{t('monitor.channel.failed')}</th>
<th>{t('monitor.channel.header_recent')}</th>
<th>{t('monitor.channel.header_time')}</th>
<th>{t('monitor.logs.header_actions')}</th>
</tr>
</thead>
<tbody>
{Object.entries(stat.models)
.filter(([, m]) => m.failure > 0)
.sort((a, b) => {
const aDisabled = isModelDisabled(stat.source, a[0]);
const bDisabled = isModelDisabled(stat.source, b[0]);
// 已禁用的排在后面
if (aDisabled !== bDisabled) {
return aDisabled ? 1 : -1;
}
// 然后按失败数降序
return b[1].failure - a[1].failure;
})
.map(([modelName, modelStat]) => {
const disabled = isModelDisabled(stat.source, modelName);
return (
<tr key={modelName} className={disabled ? styles.modelDisabled : ''}>
<td>{modelName}</td>
<td>{modelStat.total.toLocaleString()}</td>
<td className={getRateClassName(modelStat.successRate, styles)}>
{modelStat.successRate.toFixed(1)}%
</td>
<td>
<span className={styles.kpiSuccess}>{modelStat.success}</span>
{' / '}
<span className={styles.kpiFailure}>{modelStat.failure}</span>
</td>
<td>
<div className={styles.statusBars}>
{modelStat.recentRequests.map((req, i) => (
<div
key={i}
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
/>
))}
</div>
</td>
<td>{formatTimestamp(modelStat.lastTimestamp)}</td>
<td>
{disabled ? (
<span className={styles.disabledLabel}>{t('monitor.logs.removed')}</span>
) : stat.source && stat.source !== '-' && stat.source !== 'unknown' ? (
<button
className={styles.disableBtn}
onClick={(e) => handleDisableClick(stat.source, modelName, e)}
>
{t('monitor.logs.disable')}
</button>
) : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
)}
</div>
</Card>
{/* 禁用确认弹窗 */}
<DisableModelModal
disableState={disableState}
disabling={disabling}
onConfirm={handleConfirmDisable}
onCancel={handleCancelDisable}
/>
</>
);
}

View File

@@ -0,0 +1,314 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Chart } from 'react-chartjs-2';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface HourlyModelChartProps {
data: UsageData | null;
loading: boolean;
isDark: boolean;
}
// 颜色调色板
const COLORS = [
'rgba(59, 130, 246, 0.7)', // 蓝色
'rgba(34, 197, 94, 0.7)', // 绿色
'rgba(249, 115, 22, 0.7)', // 橙色
'rgba(139, 92, 246, 0.7)', // 紫色
'rgba(236, 72, 153, 0.7)', // 粉色
'rgba(6, 182, 212, 0.7)', // 青色
];
type HourRange = 6 | 12 | 24;
export function HourlyModelChart({ data, loading, isDark }: HourlyModelChartProps) {
const { t } = useTranslation();
const [hourRange, setHourRange] = useState<HourRange>(12);
// 按小时聚合数据
const hourlyData = useMemo(() => {
if (!data?.apis) return { hours: [], models: [], modelData: {} as Record<string, number[]>, successRates: [] };
const now = new Date();
let cutoffTime: Date;
let hoursCount: number;
cutoffTime = new Date(now.getTime() - hourRange * 60 * 60 * 1000);
cutoffTime.setMinutes(0, 0, 0);
hoursCount = hourRange + 1;
// 生成所有小时的时间点
const allHours: string[] = [];
for (let i = 0; i < hoursCount; i++) {
const hourTime = new Date(cutoffTime.getTime() + i * 60 * 60 * 1000);
const hourKey = hourTime.toISOString().slice(0, 13); // YYYY-MM-DDTHH
allHours.push(hourKey);
}
// 收集每小时每个模型的请求数
const hourlyStats: Record<string, Record<string, { success: number; failed: number }>> = {};
const modelSet = new Set<string>();
// 初始化所有小时
allHours.forEach((hour) => {
hourlyStats[hour] = {};
});
Object.values(data.apis).forEach((apiData) => {
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
modelSet.add(modelName);
modelData.details.forEach((detail) => {
const timestamp = new Date(detail.timestamp);
if (timestamp < cutoffTime) return;
const hourKey = timestamp.toISOString().slice(0, 13); // YYYY-MM-DDTHH
if (!hourlyStats[hourKey]) {
hourlyStats[hourKey] = {};
}
if (!hourlyStats[hourKey][modelName]) {
hourlyStats[hourKey][modelName] = { success: 0, failed: 0 };
}
if (detail.failed) {
hourlyStats[hourKey][modelName].failed++;
} else {
hourlyStats[hourKey][modelName].success++;
}
});
});
});
// 获取排序后的小时列表
const hours = allHours.sort();
// 计算每个模型的总请求数,取 Top 6
const modelTotals: Record<string, number> = {};
hours.forEach((hour) => {
Object.entries(hourlyStats[hour]).forEach(([model, stats]) => {
modelTotals[model] = (modelTotals[model] || 0) + stats.success + stats.failed;
});
});
const topModels = Object.entries(modelTotals)
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([name]) => name);
// 构建每个模型的数据数组
const modelData: Record<string, number[]> = {};
topModels.forEach((model) => {
modelData[model] = hours.map((hour) => {
const stats = hourlyStats[hour][model];
return stats ? stats.success + stats.failed : 0;
});
});
// 计算每小时的成功率
const successRates = hours.map((hour) => {
let totalSuccess = 0;
let totalRequests = 0;
Object.values(hourlyStats[hour]).forEach((stats) => {
totalSuccess += stats.success;
totalRequests += stats.success + stats.failed;
});
return totalRequests > 0 ? (totalSuccess / totalRequests) * 100 : 0;
});
return { hours, models: topModels, modelData, successRates };
}, [data, hourRange]);
// 获取时间范围标签
const hourRangeLabel = useMemo(() => {
if (hourRange === 6) return t('monitor.hourly.last_6h');
if (hourRange === 12) return t('monitor.hourly.last_12h');
return t('monitor.hourly.last_24h');
}, [hourRange, t]);
// 图表数据
const chartData = useMemo(() => {
const labels = hourlyData.hours.map((hour) => {
const date = new Date(hour + ':00:00Z'); // 添加 Z 表示 UTC 时间,确保正确转换为本地时间显示
return `${date.getHours()}:00`;
});
// 成功率折线放在最前面
const datasets: any[] = [{
type: 'line' as const,
label: t('monitor.hourly.success_rate'),
data: hourlyData.successRates,
borderColor: '#4ef0c3',
backgroundColor: '#4ef0c3',
borderWidth: 2.5,
tension: 0.4,
yAxisID: 'y1',
stack: '',
pointRadius: 3,
pointBackgroundColor: '#4ef0c3',
pointBorderColor: '#4ef0c3',
}];
// 添加模型柱状图
hourlyData.models.forEach((model, index) => {
datasets.push({
type: 'bar' as const,
label: model,
data: hourlyData.modelData[model],
backgroundColor: COLORS[index % COLORS.length],
borderColor: COLORS[index % COLORS.length],
borderWidth: 1,
borderRadius: 4,
stack: 'models',
yAxisID: 'y',
});
});
return { labels, datasets };
}, [hourlyData, t]);
// 图表配置
const chartOptions = useMemo(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index' as const,
intersect: false,
},
plugins: {
legend: {
display: true,
position: 'bottom' as const,
labels: {
color: isDark ? '#9ca3af' : '#6b7280',
usePointStyle: true,
padding: 12,
font: {
size: 11,
},
generateLabels: (chart: any) => {
return chart.data.datasets.map((dataset: any, i: number) => {
const isLine = dataset.type === 'line';
return {
text: dataset.label,
fillStyle: dataset.backgroundColor,
strokeStyle: dataset.borderColor,
lineWidth: 0,
hidden: !chart.isDatasetVisible(i),
datasetIndex: i,
pointStyle: isLine ? 'circle' : 'rect',
};
});
},
},
},
tooltip: {
backgroundColor: isDark ? '#374151' : '#ffffff',
titleColor: isDark ? '#f3f4f6' : '#111827',
bodyColor: isDark ? '#d1d5db' : '#4b5563',
borderColor: isDark ? '#4b5563' : '#e5e7eb',
borderWidth: 1,
padding: 12,
},
},
scales: {
x: {
stacked: true,
grid: {
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
y: {
stacked: true,
position: 'left' as const,
grid: {
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
title: {
display: true,
text: t('monitor.hourly.requests'),
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
y1: {
position: 'right' as const,
min: 0,
max: 100,
grid: {
drawOnChartArea: false,
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
callback: (value: string | number) => `${value}%`,
},
title: {
display: true,
text: t('monitor.hourly.success_rate'),
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
},
}), [isDark, t]);
return (
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div>
<h3 className={styles.chartTitle}>{t('monitor.hourly_model.title')}</h3>
<p className={styles.chartSubtitle}>
{hourRangeLabel}
</p>
</div>
<div className={styles.chartControls}>
<button
className={`${styles.chartControlBtn} ${hourRange === 6 ? styles.active : ''}`}
onClick={() => setHourRange(6)}
>
{t('monitor.hourly.last_6h')}
</button>
<button
className={`${styles.chartControlBtn} ${hourRange === 12 ? styles.active : ''}`}
onClick={() => setHourRange(12)}
>
{t('monitor.hourly.last_12h')}
</button>
<button
className={`${styles.chartControlBtn} ${hourRange === 24 ? styles.active : ''}`}
onClick={() => setHourRange(24)}
>
{t('monitor.hourly.last_24h')}
</button>
</div>
</div>
<div className={styles.chartContent}>
{loading || hourlyData.hours.length === 0 ? (
<div className={styles.chartEmpty}>
{loading ? t('common.loading') : t('monitor.no_data')}
</div>
) : (
<Chart type="bar" data={chartData} options={chartOptions} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Chart } from 'react-chartjs-2';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface HourlyTokenChartProps {
data: UsageData | null;
loading: boolean;
isDark: boolean;
}
type HourRange = 6 | 12 | 24;
export function HourlyTokenChart({ data, loading, isDark }: HourlyTokenChartProps) {
const { t } = useTranslation();
const [hourRange, setHourRange] = useState<HourRange>(12);
// 按小时聚合 Token 数据
const hourlyData = useMemo(() => {
if (!data?.apis) return { hours: [], totalTokens: [], inputTokens: [], outputTokens: [], reasoningTokens: [], cachedTokens: [] };
const now = new Date();
let cutoffTime: Date;
let hoursCount: number;
cutoffTime = new Date(now.getTime() - hourRange * 60 * 60 * 1000);
cutoffTime.setMinutes(0, 0, 0);
hoursCount = hourRange + 1;
// 生成所有小时的时间点
const allHours: string[] = [];
for (let i = 0; i < hoursCount; i++) {
const hourTime = new Date(cutoffTime.getTime() + i * 60 * 60 * 1000);
const hourKey = hourTime.toISOString().slice(0, 13); // YYYY-MM-DDTHH
allHours.push(hourKey);
}
// 初始化所有小时的数据为0
const hourlyStats: Record<string, {
total: number;
input: number;
output: number;
reasoning: number;
cached: number;
}> = {};
allHours.forEach((hour) => {
hourlyStats[hour] = { total: 0, input: 0, output: 0, reasoning: 0, cached: 0 };
});
// 收集每小时的 Token 数据(只统计成功请求)
Object.values(data.apis).forEach((apiData) => {
Object.values(apiData.models).forEach((modelData) => {
modelData.details.forEach((detail) => {
// 跳过失败请求,失败请求的 Token 数据不准确
if (detail.failed) return;
const timestamp = new Date(detail.timestamp);
if (timestamp < cutoffTime) return;
const hourKey = timestamp.toISOString().slice(0, 13); // YYYY-MM-DDTHH
if (!hourlyStats[hourKey]) {
hourlyStats[hourKey] = { total: 0, input: 0, output: 0, reasoning: 0, cached: 0 };
}
hourlyStats[hourKey].total += detail.tokens.total_tokens || 0;
hourlyStats[hourKey].input += detail.tokens.input_tokens || 0;
hourlyStats[hourKey].output += detail.tokens.output_tokens || 0;
hourlyStats[hourKey].reasoning += detail.tokens.reasoning_tokens || 0;
hourlyStats[hourKey].cached += detail.tokens.cached_tokens || 0;
});
});
});
// 获取排序后的小时列表
const hours = allHours.sort();
return {
hours,
totalTokens: hours.map((h) => (hourlyStats[h]?.total || 0) / 1000),
inputTokens: hours.map((h) => (hourlyStats[h]?.input || 0) / 1000),
outputTokens: hours.map((h) => (hourlyStats[h]?.output || 0) / 1000),
reasoningTokens: hours.map((h) => (hourlyStats[h]?.reasoning || 0) / 1000),
cachedTokens: hours.map((h) => (hourlyStats[h]?.cached || 0) / 1000),
};
}, [data, hourRange]);
// 获取时间范围标签
const hourRangeLabel = useMemo(() => {
if (hourRange === 6) return t('monitor.hourly.last_6h');
if (hourRange === 12) return t('monitor.hourly.last_12h');
return t('monitor.hourly.last_24h');
}, [hourRange, t]);
// 图表数据
const chartData = useMemo(() => {
const labels = hourlyData.hours.map((hour) => {
const date = new Date(hour + ':00:00Z'); // 添加 Z 表示 UTC 时间,确保正确转换为本地时间显示
return `${date.getHours()}:00`;
});
return {
labels,
datasets: [
{
type: 'line' as const,
label: t('monitor.hourly_token.input'),
data: hourlyData.inputTokens,
borderColor: '#22c55e',
backgroundColor: '#22c55e',
borderWidth: 2,
tension: 0.4,
yAxisID: 'y',
order: 0,
pointRadius: 3,
pointBackgroundColor: '#22c55e',
},
{
type: 'line' as const,
label: t('monitor.hourly_token.output'),
data: hourlyData.outputTokens,
borderColor: '#f97316',
backgroundColor: '#f97316',
borderWidth: 2,
tension: 0.4,
yAxisID: 'y',
order: 0,
pointRadius: 3,
pointBackgroundColor: '#f97316',
},
{
type: 'bar' as const,
label: t('monitor.hourly_token.total'),
data: hourlyData.totalTokens,
backgroundColor: 'rgba(59, 130, 246, 0.6)',
borderColor: 'rgba(59, 130, 246, 0.6)',
borderWidth: 1,
borderRadius: 4,
yAxisID: 'y',
order: 1,
},
],
};
}, [hourlyData, t]);
// 图表配置
const chartOptions = useMemo(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index' as const,
intersect: false,
},
plugins: {
legend: {
display: true,
position: 'bottom' as const,
labels: {
color: isDark ? '#9ca3af' : '#6b7280',
usePointStyle: true,
padding: 12,
font: {
size: 11,
},
generateLabels: (chart: any) => {
return chart.data.datasets.map((dataset: any, i: number) => {
const isLine = dataset.type === 'line';
return {
text: dataset.label,
fillStyle: dataset.backgroundColor,
strokeStyle: dataset.borderColor,
lineWidth: 0,
hidden: !chart.isDatasetVisible(i),
datasetIndex: i,
pointStyle: isLine ? 'circle' : 'rect',
};
});
},
},
},
tooltip: {
backgroundColor: isDark ? '#374151' : '#ffffff',
titleColor: isDark ? '#f3f4f6' : '#111827',
bodyColor: isDark ? '#d1d5db' : '#4b5563',
borderColor: isDark ? '#4b5563' : '#e5e7eb',
borderWidth: 1,
padding: 12,
callbacks: {
label: (context: any) => {
const label = context.dataset.label || '';
const value = context.raw;
return `${label}: ${value.toFixed(1)}K`;
},
},
},
},
scales: {
x: {
grid: {
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
y: {
position: 'left' as const,
grid: {
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
callback: (value: string | number) => `${value}K`,
},
title: {
display: true,
text: 'Tokens (K)',
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
},
}), [isDark]);
return (
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div>
<h3 className={styles.chartTitle}>{t('monitor.hourly_token.title')}</h3>
<p className={styles.chartSubtitle}>
{hourRangeLabel}
</p>
</div>
<div className={styles.chartControls}>
<button
className={`${styles.chartControlBtn} ${hourRange === 6 ? styles.active : ''}`}
onClick={() => setHourRange(6)}
>
{t('monitor.hourly.last_6h')}
</button>
<button
className={`${styles.chartControlBtn} ${hourRange === 12 ? styles.active : ''}`}
onClick={() => setHourRange(12)}
>
{t('monitor.hourly.last_12h')}
</button>
<button
className={`${styles.chartControlBtn} ${hourRange === 24 ? styles.active : ''}`}
onClick={() => setHourRange(24)}
>
{t('monitor.hourly.last_24h')}
</button>
</div>
</div>
<div className={styles.chartContent}>
{loading || hourlyData.hours.length === 0 ? (
<div className={styles.chartEmpty}>
{loading ? t('common.loading') : t('monitor.no_data')}
</div>
) : (
<Chart type="bar" data={chartData} options={chartOptions} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,201 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface KpiCardsProps {
data: UsageData | null;
loading: boolean;
timeRange: number;
}
// 格式化数字
function formatNumber(num: number): string {
if (num >= 1000000000) {
return (num / 1000000000).toFixed(2) + 'B';
}
if (num >= 1000000) {
return (num / 1000000).toFixed(2) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(2) + 'K';
}
return num.toLocaleString();
}
export function KpiCards({ data, loading, timeRange }: KpiCardsProps) {
const { t } = useTranslation();
// 计算统计数据
const stats = useMemo(() => {
if (!data?.apis) {
return {
totalRequests: 0,
successRequests: 0,
failedRequests: 0,
successRate: 0,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
cachedTokens: 0,
avgTpm: 0,
avgRpm: 0,
avgRpd: 0,
};
}
let totalRequests = 0;
let successRequests = 0;
let failedRequests = 0;
let totalTokens = 0;
let inputTokens = 0;
let outputTokens = 0;
let reasoningTokens = 0;
let cachedTokens = 0;
// 收集所有时间戳用于计算 TPM/RPM
const timestamps: number[] = [];
Object.values(data.apis).forEach((apiData) => {
Object.values(apiData.models).forEach((modelData) => {
modelData.details.forEach((detail) => {
totalRequests++;
if (detail.failed) {
failedRequests++;
} else {
successRequests++;
}
totalTokens += detail.tokens.total_tokens || 0;
inputTokens += detail.tokens.input_tokens || 0;
outputTokens += detail.tokens.output_tokens || 0;
reasoningTokens += detail.tokens.reasoning_tokens || 0;
cachedTokens += detail.tokens.cached_tokens || 0;
timestamps.push(new Date(detail.timestamp).getTime());
});
});
});
const successRate = totalRequests > 0 ? (successRequests / totalRequests) * 100 : 0;
// 计算 TPM 和 RPM基于实际时间跨度
let avgTpm = 0;
let avgRpm = 0;
let avgRpd = 0;
if (timestamps.length > 0) {
const minTime = Math.min(...timestamps);
const maxTime = Math.max(...timestamps);
const timeSpanMinutes = Math.max((maxTime - minTime) / (1000 * 60), 1);
const timeSpanDays = Math.max(timeSpanMinutes / (60 * 24), 1);
avgTpm = Math.round(totalTokens / timeSpanMinutes);
avgRpm = Math.round(totalRequests / timeSpanMinutes * 10) / 10;
avgRpd = Math.round(totalRequests / timeSpanDays);
}
return {
totalRequests,
successRequests,
failedRequests,
successRate,
totalTokens,
inputTokens,
outputTokens,
reasoningTokens,
cachedTokens,
avgTpm,
avgRpm,
avgRpd,
};
}, [data]);
const timeRangeLabel = timeRange === 1
? t('monitor.today')
: t('monitor.last_n_days', { n: timeRange });
return (
<div className={styles.kpiGrid}>
{/* 请求数 */}
<div className={styles.kpiCard}>
<div className={styles.kpiTitle}>
<span className={styles.kpiLabel}>{t('monitor.kpi.requests')}</span>
<span className={styles.kpiTag}>{timeRangeLabel}</span>
</div>
<div className={styles.kpiValue}>
{loading ? '--' : formatNumber(stats.totalRequests)}
</div>
<div className={styles.kpiMeta}>
<span className={styles.kpiSuccess}>
{t('monitor.kpi.success')}: {loading ? '--' : stats.successRequests.toLocaleString()}
</span>
<span className={styles.kpiFailure}>
{t('monitor.kpi.failed')}: {loading ? '--' : stats.failedRequests.toLocaleString()}
</span>
<span>
{t('monitor.kpi.rate')}: {loading ? '--' : stats.successRate.toFixed(1)}%
</span>
</div>
</div>
{/* Tokens */}
<div className={`${styles.kpiCard} ${styles.green}`}>
<div className={styles.kpiTitle}>
<span className={styles.kpiLabel}>{t('monitor.kpi.tokens')}</span>
<span className={styles.kpiTag}>{timeRangeLabel}</span>
</div>
<div className={styles.kpiValue}>
{loading ? '--' : formatNumber(stats.totalTokens)}
</div>
<div className={styles.kpiMeta}>
<span>{t('monitor.kpi.input')}: {loading ? '--' : formatNumber(stats.inputTokens)}</span>
<span>{t('monitor.kpi.output')}: {loading ? '--' : formatNumber(stats.outputTokens)}</span>
</div>
</div>
{/* 平均 TPM */}
<div className={`${styles.kpiCard} ${styles.purple}`}>
<div className={styles.kpiTitle}>
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_tpm')}</span>
<span className={styles.kpiTag}>{timeRangeLabel}</span>
</div>
<div className={styles.kpiValue}>
{loading ? '--' : formatNumber(stats.avgTpm)}
</div>
<div className={styles.kpiMeta}>
<span>{t('monitor.kpi.tokens_per_minute')}</span>
</div>
</div>
{/* 平均 RPM */}
<div className={`${styles.kpiCard} ${styles.orange}`}>
<div className={styles.kpiTitle}>
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_rpm')}</span>
<span className={styles.kpiTag}>{timeRangeLabel}</span>
</div>
<div className={styles.kpiValue}>
{loading ? '--' : stats.avgRpm.toFixed(1)}
</div>
<div className={styles.kpiMeta}>
<span>{t('monitor.kpi.requests_per_minute')}</span>
</div>
</div>
{/* 日均 RPD */}
<div className={`${styles.kpiCard} ${styles.cyan}`}>
<div className={styles.kpiTitle}>
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_rpd')}</span>
<span className={styles.kpiTag}>{timeRangeLabel}</span>
</div>
<div className={styles.kpiValue}>
{loading ? '--' : formatNumber(stats.avgRpd)}
</div>
<div className={styles.kpiMeta}>
<span>{t('monitor.kpi.requests_per_day')}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,205 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Doughnut } from 'react-chartjs-2';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface ModelDistributionChartProps {
data: UsageData | null;
loading: boolean;
isDark: boolean;
timeRange: number;
}
// 颜色调色板
const COLORS = [
'#3b82f6', // 蓝色
'#22c55e', // 绿色
'#f97316', // 橙色
'#8b5cf6', // 紫色
'#ec4899', // 粉色
'#06b6d4', // 青色
'#eab308', // 黄色
'#ef4444', // 红色
'#14b8a6', // 青绿
'#6366f1', // 靛蓝
];
type ViewMode = 'request' | 'token';
export function ModelDistributionChart({ data, loading, isDark, timeRange }: ModelDistributionChartProps) {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>('request');
const timeRangeLabel = timeRange === 1
? t('monitor.today')
: t('monitor.last_n_days', { n: timeRange });
// 计算模型分布数据
const distributionData = useMemo(() => {
if (!data?.apis) return [];
const modelStats: Record<string, { requests: number; tokens: number }> = {};
Object.values(data.apis).forEach((apiData) => {
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
if (!modelStats[modelName]) {
modelStats[modelName] = { requests: 0, tokens: 0 };
}
modelData.details.forEach((detail) => {
modelStats[modelName].requests++;
modelStats[modelName].tokens += detail.tokens.total_tokens || 0;
});
});
});
// 转换为数组并排序
const sorted = Object.entries(modelStats)
.map(([name, stats]) => ({
name,
requests: stats.requests,
tokens: stats.tokens,
}))
.sort((a, b) => {
if (viewMode === 'request') {
return b.requests - a.requests;
}
return b.tokens - a.tokens;
});
// 取 Top 10
return sorted.slice(0, 10);
}, [data, viewMode]);
// 计算总数
const total = useMemo(() => {
return distributionData.reduce((sum, item) => {
return sum + (viewMode === 'request' ? item.requests : item.tokens);
}, 0);
}, [distributionData, viewMode]);
// 图表数据
const chartData = useMemo(() => {
return {
labels: distributionData.map((item) => item.name),
datasets: [
{
data: distributionData.map((item) =>
viewMode === 'request' ? item.requests : item.tokens
),
backgroundColor: COLORS.slice(0, distributionData.length),
borderColor: isDark ? '#1f2937' : '#ffffff',
borderWidth: 2,
},
],
};
}, [distributionData, viewMode, isDark]);
// 图表配置
const chartOptions = useMemo(() => ({
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: isDark ? '#374151' : '#ffffff',
titleColor: isDark ? '#f3f4f6' : '#111827',
bodyColor: isDark ? '#d1d5db' : '#4b5563',
borderColor: isDark ? '#4b5563' : '#e5e7eb',
borderWidth: 1,
padding: 12,
callbacks: {
label: (context: any) => {
const value = context.raw;
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
if (viewMode === 'request') {
return `${value.toLocaleString()} ${t('monitor.requests')} (${percentage}%)`;
}
return `${value.toLocaleString()} tokens (${percentage}%)`;
},
},
},
},
}), [isDark, total, viewMode, t]);
// 格式化数值
const formatValue = (value: number) => {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M';
}
if (value >= 1000) {
return (value / 1000).toFixed(1) + 'K';
}
return value.toString();
};
return (
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div>
<h3 className={styles.chartTitle}>{t('monitor.distribution.title')}</h3>
<p className={styles.chartSubtitle}>
{timeRangeLabel} · {viewMode === 'request' ? t('monitor.distribution.by_requests') : t('monitor.distribution.by_tokens')}
{' · Top 10'}
</p>
</div>
<div className={styles.chartControls}>
<button
className={`${styles.chartControlBtn} ${viewMode === 'request' ? styles.active : ''}`}
onClick={() => setViewMode('request')}
>
{t('monitor.distribution.requests')}
</button>
<button
className={`${styles.chartControlBtn} ${viewMode === 'token' ? styles.active : ''}`}
onClick={() => setViewMode('token')}
>
{t('monitor.distribution.tokens')}
</button>
</div>
</div>
{loading || distributionData.length === 0 ? (
<div className={styles.chartContent}>
<div className={styles.chartEmpty}>
{loading ? t('common.loading') : t('monitor.no_data')}
</div>
</div>
) : (
<div className={styles.distributionContent}>
<div className={styles.donutWrapper}>
<Doughnut data={chartData} options={chartOptions} />
<div className={styles.donutCenter}>
<div className={styles.donutLabel}>
{viewMode === 'request' ? t('monitor.distribution.request_share') : t('monitor.distribution.token_share')}
</div>
</div>
</div>
<div className={styles.legendList}>
{distributionData.map((item, index) => {
const value = viewMode === 'request' ? item.requests : item.tokens;
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0';
return (
<div key={item.name} className={styles.legendItem}>
<span
className={styles.legendDot}
style={{ backgroundColor: COLORS[index] }}
/>
<span className={styles.legendName} title={item.name}>
{item.name}
</span>
<span className={styles.legendValue}>
{formatValue(value)} ({percentage}%)
</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,710 @@
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Card } from '@/components/ui/Card';
import { usageApi, authFilesApi } from '@/services/api';
import { useDisableModel } from '@/hooks';
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
import { DisableModelModal } from './DisableModelModal';
import { UnsupportedDisableModal } from './UnsupportedDisableModal';
import {
maskSecret,
formatProviderDisplay,
formatTimestamp,
getRateClassName,
getProviderDisplayParts,
type DateRange,
} from '@/utils/monitor';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface RequestLogsProps {
data: UsageData | null;
loading: boolean;
providerMap: Record<string, string>;
providerTypeMap: Record<string, string>;
apiFilter: string;
}
interface LogEntry {
id: string;
timestamp: string;
timestampMs: number;
apiKey: string;
model: string;
source: string;
displayName: string;
providerName: string | null;
providerType: string;
maskedKey: string;
failed: boolean;
inputTokens: number;
outputTokens: number;
totalTokens: number;
authIndex: string;
}
interface ChannelModelRequest {
failed: boolean;
timestamp: number;
}
// 预计算的统计数据缓存
interface PrecomputedStats {
recentRequests: ChannelModelRequest[];
successRate: string;
totalCount: number;
}
// 虚拟滚动行高
const ROW_HEIGHT = 40;
export function RequestLogs({ data, loading: parentLoading, providerMap, providerTypeMap, apiFilter }: RequestLogsProps) {
const { t } = useTranslation();
const [filterApi, setFilterApi] = useState('');
const [filterModel, setFilterModel] = useState('');
const [filterSource, setFilterSource] = useState('');
const [filterStatus, setFilterStatus] = useState<'' | 'success' | 'failed'>('');
const [filterProviderType, setFilterProviderType] = useState('');
const [autoRefresh, setAutoRefresh] = useState(10);
const [countdown, setCountdown] = useState(0);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 用 ref 存储 fetchLogData避免作为定时器 useEffect 的依赖
const fetchLogDataRef = useRef<() => Promise<void>>(() => Promise.resolve());
// 虚拟滚动容器 ref
const tableContainerRef = useRef<HTMLDivElement>(null);
// 固定表头容器 ref
const headerRef = useRef<HTMLDivElement>(null);
// 同步表头和内容的水平滚动
const handleScroll = useCallback(() => {
if (tableContainerRef.current && headerRef.current) {
headerRef.current.scrollLeft = tableContainerRef.current.scrollLeft;
}
}, []);
// 时间范围状态
const [timeRange, setTimeRange] = useState<TimeRange>(7);
const [customRange, setCustomRange] = useState<DateRange | undefined>();
// 日志独立数据状态
const [logData, setLogData] = useState<UsageData | null>(null);
const [logLoading, setLogLoading] = useState(false);
const [isFirstLoad, setIsFirstLoad] = useState(true);
// 认证文件索引到名称的映射
const [authIndexMap, setAuthIndexMap] = useState<Record<string, string>>({});
// 使用禁用模型 Hook
const {
disableState,
unsupportedState,
disabling,
isModelDisabled,
handleDisableClick,
handleConfirmDisable,
handleCancelDisable,
handleCloseUnsupported,
} = useDisableModel({ providerMap, providerTypeMap });
// 处理时间范围变化
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
setTimeRange(range);
if (custom) {
setCustomRange(custom);
}
}, []);
// 使用日志独立数据或父组件数据
const effectiveData = logData || data;
// 只在首次加载且没有数据时显示 loading 状态
const showLoading = (parentLoading && isFirstLoad && !effectiveData) || (logLoading && !effectiveData);
// 当父组件数据加载完成时,标记首次加载完成
useEffect(() => {
if (!parentLoading && data) {
setIsFirstLoad(false);
}
}, [parentLoading, data]);
// 加载认证文件映射authIndex -> 文件名)
const loadAuthIndexMap = useCallback(async () => {
try {
const response = await authFilesApi.list();
const files = response?.files || [];
const map: Record<string, string> = {};
files.forEach((file) => {
// 兼容 auth_index 和 authIndex 两种字段名API 返回的是 auth_index
const rawAuthIndex = (file as Record<string, unknown>)['auth_index'] ?? file.authIndex;
if (rawAuthIndex !== undefined && rawAuthIndex !== null) {
const authIndexKey = String(rawAuthIndex).trim();
if (authIndexKey) {
map[authIndexKey] = file.name;
}
}
});
setAuthIndexMap(map);
} catch (err) {
console.warn('Failed to load auth files for index mapping:', err);
}
}, []);
// 初始加载认证文件映射
useEffect(() => {
loadAuthIndexMap();
}, [loadAuthIndexMap]);
// 独立获取日志数据
const fetchLogData = useCallback(async () => {
setLogLoading(true);
try {
const response = await usageApi.getUsage();
const usageData = response?.usage ?? response;
// 应用时间范围过滤
if (usageData?.apis) {
const apis = usageData.apis as UsageData['apis'];
const now = new Date();
let cutoffStart: Date;
let cutoffEnd: Date = new Date(now.getTime());
cutoffEnd.setHours(23, 59, 59, 999);
if (timeRange === 'custom' && customRange) {
cutoffStart = customRange.start;
cutoffEnd = customRange.end;
} else if (typeof timeRange === 'number') {
cutoffStart = new Date(now.getTime() - timeRange * 24 * 60 * 60 * 1000);
cutoffStart.setHours(0, 0, 0, 0);
} else {
cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
cutoffStart.setHours(0, 0, 0, 0);
}
const filtered: UsageData = { apis: {} };
Object.entries(apis).forEach(([apiKey, apiData]) => {
// 如果有 API 过滤器,检查是否匹配
if (apiFilter && !apiKey.toLowerCase().includes(apiFilter.toLowerCase())) {
return;
}
if (!apiData?.models) return;
const filteredModels: Record<string, { details: UsageData['apis'][string]['models'][string]['details'] }> = {};
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
if (!modelData?.details || !Array.isArray(modelData.details)) return;
const filteredDetails = modelData.details.filter((detail) => {
const timestamp = new Date(detail.timestamp);
return timestamp >= cutoffStart && timestamp <= cutoffEnd;
});
if (filteredDetails.length > 0) {
filteredModels[modelName] = { details: filteredDetails };
}
});
if (Object.keys(filteredModels).length > 0) {
filtered.apis[apiKey] = { models: filteredModels };
}
});
setLogData(filtered);
}
} catch (err) {
console.error('日志刷新失败:', err);
} finally {
setLogLoading(false);
}
}, [timeRange, customRange, apiFilter]);
// 同步 fetchLogData 到 ref确保定时器始终调用最新版本
useEffect(() => {
fetchLogDataRef.current = fetchLogData;
}, [fetchLogData]);
// 统一的自动刷新定时器管理
useEffect(() => {
// 清理旧定时器
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
// 禁用自动刷新时
if (autoRefresh <= 0) {
setCountdown(0);
return;
}
// 设置初始倒计时
setCountdown(autoRefresh);
// 创建新定时器
countdownRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
// 倒计时结束,触发刷新并重置倒计时
fetchLogDataRef.current();
return autoRefresh;
}
return prev - 1;
});
}, 1000);
// 组件卸载或 autoRefresh 变化时清理
return () => {
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
};
}, [autoRefresh]);
// 时间范围变化时立即刷新数据
useEffect(() => {
fetchLogData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeRange, customRange]);
// 获取倒计时显示文本
const getCountdownText = () => {
if (logLoading) {
return t('monitor.logs.refreshing');
}
if (autoRefresh === 0) {
return t('monitor.logs.manual_refresh');
}
if (countdown > 0) {
return t('monitor.logs.refresh_in_seconds', { seconds: countdown });
}
return t('monitor.logs.refreshing');
};
// 将数据转换为日志条目
const logEntries = useMemo(() => {
if (!effectiveData?.apis) return [];
const entries: LogEntry[] = [];
let idCounter = 0;
Object.entries(effectiveData.apis).forEach(([apiKey, apiData]) => {
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
modelData.details.forEach((detail) => {
const source = detail.source || 'unknown';
const { provider, masked } = getProviderDisplayParts(source, providerMap);
const displayName = provider ? `${provider} (${masked})` : masked;
const timestampMs = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
// 获取提供商类型
const providerType = providerTypeMap[source] || '--';
entries.push({
id: `${idCounter++}`,
timestamp: detail.timestamp,
timestampMs,
apiKey,
model: modelName,
source,
displayName,
providerName: provider,
providerType,
maskedKey: masked,
failed: detail.failed,
inputTokens: detail.tokens.input_tokens || 0,
outputTokens: detail.tokens.output_tokens || 0,
totalTokens: detail.tokens.total_tokens || 0,
authIndex: detail.auth_index || '',
});
});
});
});
// 按时间倒序排序
return entries.sort((a, b) => b.timestampMs - a.timestampMs);
}, [effectiveData, providerMap, providerTypeMap]);
// 预计算所有条目的统计数据(一次性计算,避免渲染时重复计算)
const precomputedStats = useMemo(() => {
const statsMap = new Map<string, PrecomputedStats>();
// 首先按渠道+模型分组,并按时间排序
const channelModelGroups: Record<string, { entry: LogEntry; index: number }[]> = {};
logEntries.forEach((entry, index) => {
const key = `${entry.source}|||${entry.model}`;
if (!channelModelGroups[key]) {
channelModelGroups[key] = [];
}
channelModelGroups[key].push({ entry, index });
});
// 对每个分组按时间正序排序(用于计算累计统计)
Object.values(channelModelGroups).forEach((group) => {
group.sort((a, b) => a.entry.timestampMs - b.entry.timestampMs);
});
// 计算每个条目的统计数据
Object.entries(channelModelGroups).forEach(([, group]) => {
let successCount = 0;
let totalCount = 0;
const recentRequests: ChannelModelRequest[] = [];
group.forEach(({ entry }) => {
totalCount++;
if (!entry.failed) {
successCount++;
}
// 维护最近 10 次请求
recentRequests.push({ failed: entry.failed, timestamp: entry.timestampMs });
if (recentRequests.length > 10) {
recentRequests.shift();
}
// 计算成功率
const successRate = totalCount > 0 ? ((successCount / totalCount) * 100).toFixed(1) : '0.0';
// 存储该条目的统计数据
statsMap.set(entry.id, {
recentRequests: [...recentRequests],
successRate,
totalCount,
});
});
});
return statsMap;
}, [logEntries]);
// 获取筛选选项
const { apis, models, sources, providerTypes } = useMemo(() => {
const apiSet = new Set<string>();
const modelSet = new Set<string>();
const sourceSet = new Set<string>();
const providerTypeSet = new Set<string>();
logEntries.forEach((entry) => {
apiSet.add(entry.apiKey);
modelSet.add(entry.model);
sourceSet.add(entry.source);
if (entry.providerType && entry.providerType !== '--') {
providerTypeSet.add(entry.providerType);
}
});
return {
apis: Array.from(apiSet).sort(),
models: Array.from(modelSet).sort(),
sources: Array.from(sourceSet).sort(),
providerTypes: Array.from(providerTypeSet).sort(),
};
}, [logEntries]);
// 过滤后的数据
const filteredEntries = useMemo(() => {
return logEntries.filter((entry) => {
if (filterApi && entry.apiKey !== filterApi) return false;
if (filterModel && entry.model !== filterModel) return false;
if (filterSource && entry.source !== filterSource) return false;
if (filterStatus === 'success' && entry.failed) return false;
if (filterStatus === 'failed' && !entry.failed) return false;
if (filterProviderType && entry.providerType !== filterProviderType) return false;
return true;
});
}, [logEntries, filterApi, filterModel, filterSource, filterStatus, filterProviderType]);
// 虚拟滚动配置
const rowVirtualizer = useVirtualizer({
count: filteredEntries.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 10, // 预渲染上下各 10 行
});
// 格式化数字
const formatNumber = (num: number) => {
return num.toLocaleString('zh-CN');
};
// 获取预计算的统计数据
const getStats = (entry: LogEntry): PrecomputedStats => {
return precomputedStats.get(entry.id) || {
recentRequests: [],
successRate: '0.0',
totalCount: 0,
};
};
// 渲染单行
const renderRow = (entry: LogEntry) => {
const stats = getStats(entry);
const rateValue = parseFloat(stats.successRate);
const disabled = isModelDisabled(entry.source, entry.model);
// 将 authIndex 映射为文件名
const authDisplayName = entry.authIndex ? (authIndexMap[entry.authIndex] || entry.authIndex) : '-';
return (
<>
<td title={authDisplayName}>
{authDisplayName}
</td>
<td title={entry.apiKey}>
{maskSecret(entry.apiKey)}
</td>
<td>{entry.providerType}</td>
<td title={entry.model}>
{entry.model}
</td>
<td title={entry.source}>
{entry.providerName ? (
<>
<span className={styles.channelName}>{entry.providerName}</span>
<span className={styles.channelSecret}> ({entry.maskedKey})</span>
</>
) : (
entry.maskedKey
)}
</td>
<td>
<span className={`${styles.statusPill} ${entry.failed ? styles.failed : styles.success}`}>
{entry.failed ? t('monitor.logs.failed') : t('monitor.logs.success')}
</span>
</td>
<td>
<div className={styles.statusBars}>
{stats.recentRequests.map((req, idx) => (
<div
key={idx}
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
/>
))}
</div>
</td>
<td className={getRateClassName(rateValue, styles)}>
{stats.successRate}%
</td>
<td>{formatNumber(stats.totalCount)}</td>
<td>{formatNumber(entry.inputTokens)}</td>
<td>{formatNumber(entry.outputTokens)}</td>
<td>{formatNumber(entry.totalTokens)}</td>
<td>{formatTimestamp(entry.timestamp)}</td>
<td>
{entry.source && entry.source !== '-' && entry.source !== 'unknown' ? (
disabled ? (
<span className={styles.disabledLabel}>
{t('monitor.logs.disabled')}
</span>
) : (
<button
className={styles.disableBtn}
title={t('monitor.logs.disable_model')}
onClick={() => handleDisableClick(entry.source, entry.model)}
>
{t('monitor.logs.disable')}
</button>
)
) : (
'-'
)}
</td>
</>
);
};
return (
<>
<Card
title={t('monitor.logs.title')}
subtitle={
<span>
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.logs.total_count', { count: logEntries.length })}
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.logs.scroll_hint')}</span>
</span>
}
extra={
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
customRange={customRange}
/>
}
>
{/* 筛选器 */}
<div className={styles.logFilters}>
<select
className={styles.logSelect}
value={filterApi}
onChange={(e) => setFilterApi(e.target.value)}
>
<option value="">{t('monitor.logs.all_apis')}</option>
{apis.map((api) => (
<option key={api} value={api}>
{maskSecret(api)}
</option>
))}
</select>
<select
className={styles.logSelect}
value={filterProviderType}
onChange={(e) => setFilterProviderType(e.target.value)}
>
<option value="">{t('monitor.logs.all_provider_types')}</option>
{providerTypes.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
<select
className={styles.logSelect}
value={filterModel}
onChange={(e) => setFilterModel(e.target.value)}
>
<option value="">{t('monitor.logs.all_models')}</option>
{models.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<select
className={styles.logSelect}
value={filterSource}
onChange={(e) => setFilterSource(e.target.value)}
>
<option value="">{t('monitor.logs.all_sources')}</option>
{sources.map((source) => (
<option key={source} value={source}>
{formatProviderDisplay(source, providerMap)}
</option>
))}
</select>
<select
className={styles.logSelect}
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as '' | 'success' | 'failed')}
>
<option value="">{t('monitor.logs.all_status')}</option>
<option value="success">{t('monitor.logs.success')}</option>
<option value="failed">{t('monitor.logs.failed')}</option>
</select>
<span className={styles.logLastUpdate}>
{getCountdownText()}
</span>
<select
className={styles.logSelect}
value={autoRefresh}
onChange={(e) => setAutoRefresh(Number(e.target.value))}
>
<option value="0">{t('monitor.logs.manual_refresh')}</option>
<option value="5">{t('monitor.logs.refresh_5s')}</option>
<option value="10">{t('monitor.logs.refresh_10s')}</option>
<option value="15">{t('monitor.logs.refresh_15s')}</option>
<option value="30">{t('monitor.logs.refresh_30s')}</option>
<option value="60">{t('monitor.logs.refresh_60s')}</option>
</select>
</div>
{/* 虚拟滚动表格 */}
<div className={styles.tableWrapper}>
{showLoading ? (
<div className={styles.emptyState}>{t('common.loading')}</div>
) : filteredEntries.length === 0 ? (
<div className={styles.emptyState}>{t('monitor.no_data')}</div>
) : (
<>
{/* 固定表头 */}
<div ref={headerRef} className={styles.stickyHeader}>
<table className={`${styles.table} ${styles.virtualTable}`}>
<thead>
<tr>
<th>{t('monitor.logs.header_auth')}</th>
<th>{t('monitor.logs.header_api')}</th>
<th>{t('monitor.logs.header_request_type')}</th>
<th>{t('monitor.logs.header_model')}</th>
<th>{t('monitor.logs.header_source')}</th>
<th>{t('monitor.logs.header_status')}</th>
<th>{t('monitor.logs.header_recent')}</th>
<th>{t('monitor.logs.header_rate')}</th>
<th>{t('monitor.logs.header_count')}</th>
<th>{t('monitor.logs.header_input')}</th>
<th>{t('monitor.logs.header_output')}</th>
<th>{t('monitor.logs.header_total')}</th>
<th>{t('monitor.logs.header_time')}</th>
<th>{t('monitor.logs.header_actions')}</th>
</tr>
</thead>
</table>
</div>
{/* 虚拟滚动容器 */}
<div
ref={tableContainerRef}
className={styles.virtualScrollContainer}
style={{
height: 'calc(100vh - 420px)',
minHeight: '360px',
overflow: 'auto',
}}
onScroll={handleScroll}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
<table className={`${styles.table} ${styles.virtualTable}`}>
<tbody>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const entry = filteredEntries[virtualRow.index];
return (
<tr
key={entry.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'table',
tableLayout: 'fixed',
}}
>
{renderRow(entry)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
{/* 统计信息 */}
{filteredEntries.length > 0 && (
<div style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-tertiary)', marginTop: 8 }}>
{t('monitor.logs.total_count', { count: filteredEntries.length })}
</div>
)}
</Card>
{/* 禁用确认弹窗 */}
<DisableModelModal
disableState={disableState}
disabling={disabling}
onConfirm={handleConfirmDisable}
onCancel={handleCancelDisable}
/>
{/* 不支持自动禁用提示弹窗 */}
<UnsupportedDisableModal
state={unsupportedState}
onClose={handleCloseUnsupported}
/>
</>
);
}

View File

@@ -0,0 +1,158 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import styles from '@/pages/MonitorPage.module.scss';
export type TimeRange = 1 | 7 | 14 | 30 | 'custom';
interface DateRange {
start: Date;
end: Date;
}
interface TimeRangeSelectorProps {
value: TimeRange;
onChange: (range: TimeRange, customRange?: DateRange) => void;
customRange?: DateRange;
}
export function TimeRangeSelector({ value, onChange, customRange }: TimeRangeSelectorProps) {
const { t } = useTranslation();
const [showCustom, setShowCustom] = useState(value === 'custom');
const [startDate, setStartDate] = useState(() => {
if (customRange?.start) {
return formatDateForInput(customRange.start);
}
const date = new Date();
date.setDate(date.getDate() - 7);
return formatDateForInput(date);
});
const [endDate, setEndDate] = useState(() => {
if (customRange?.end) {
return formatDateForInput(customRange.end);
}
return formatDateForInput(new Date());
});
const handleTimeClick = useCallback((range: TimeRange) => {
if (range === 'custom') {
setShowCustom(true);
onChange(range);
} else {
setShowCustom(false);
onChange(range);
}
}, [onChange]);
const handleApplyCustom = useCallback(() => {
if (startDate && endDate) {
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
if (start <= end) {
onChange('custom', { start, end });
}
}
}, [startDate, endDate, onChange]);
return (
<div className={styles.timeRangeSelector}>
<div className={styles.timeButtons}>
{([1, 7, 14, 30, 'custom'] as TimeRange[]).map((range) => (
<button
key={range}
className={`${styles.timeButton} ${value === range ? styles.active : ''}`}
onClick={() => handleTimeClick(range)}
>
{range === 1
? t('monitor.time.today')
: range === 'custom'
? t('monitor.time.custom')
: t('monitor.time.last_n_days', { n: range })}
</button>
))}
</div>
{showCustom && (
<div className={styles.customDatePicker}>
<input
type="date"
className={styles.dateInput}
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<span className={styles.dateSeparator}>{t('monitor.time.to')}</span>
<input
type="date"
className={styles.dateInput}
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
<button className={styles.dateApplyBtn} onClick={handleApplyCustom}>
{t('monitor.time.apply')}
</button>
</div>
)}
</div>
);
}
function formatDateForInput(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 根据时间范围过滤数据的工具函数
export function filterByTimeRange<T extends { timestamp?: string }>(
items: T[],
range: TimeRange,
customRange?: DateRange
): T[] {
const now = new Date();
let cutoffStart: Date;
let cutoffEnd: Date = new Date(now.getTime());
cutoffEnd.setHours(23, 59, 59, 999);
if (range === 'custom' && customRange) {
cutoffStart = customRange.start;
cutoffEnd = customRange.end;
} else if (typeof range === 'number') {
cutoffStart = new Date(now.getTime() - range * 24 * 60 * 60 * 1000);
cutoffStart.setHours(0, 0, 0, 0);
} else {
// 默认7天
cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
cutoffStart.setHours(0, 0, 0, 0);
}
return items.filter((item) => {
if (!item.timestamp) return false;
const timestamp = new Date(item.timestamp);
return timestamp >= cutoffStart && timestamp <= cutoffEnd;
});
}
// 格式化时间范围显示
export function formatTimeRangeCaption(
range: TimeRange,
customRange?: DateRange,
t?: (key: string, options?: any) => string
): string {
if (range === 'custom' && customRange) {
const startStr = formatDateForDisplay(customRange.start);
const endStr = formatDateForDisplay(customRange.end);
return `${startStr} - ${endStr}`;
}
if (range === 1) {
return t ? t('monitor.time.today') : '今天';
}
return t ? t('monitor.time.last_n_days', { n: range }) : `最近 ${range}`;
}
function formatDateForDisplay(date: Date): string {
const month = date.getMonth() + 1;
const day = date.getDate();
return `${month}/${day}`;
}

View File

@@ -0,0 +1,82 @@
/**
* 不支持自动禁用提示弹窗组件
* 显示手动操作指南
*/
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import type { UnsupportedDisableState } from '@/hooks/useDisableModel';
interface UnsupportedDisableModalProps {
/** 不支持禁用的状态 */
state: UnsupportedDisableState | null;
/** 关闭回调 */
onClose: () => void;
}
export function UnsupportedDisableModal({
state,
onClose,
}: UnsupportedDisableModalProps) {
const { t } = useTranslation();
if (!state) return null;
return (
<Modal
open={!!state}
onClose={onClose}
title={t('monitor.logs.disable_unsupported_title')}
width={450}
>
<div style={{ padding: '16px 0' }}>
{/* 提示信息 */}
<p style={{
marginBottom: 16,
lineHeight: 1.6,
color: 'var(--warning-color, #f59e0b)',
fontWeight: 500,
}}>
{t('monitor.logs.disable_unsupported_desc', { providerType: state.providerType })}
</p>
{/* 手动操作指南 */}
<div style={{
padding: '12px 16px',
background: 'var(--bg-tertiary)',
borderRadius: '8px',
marginBottom: 16,
}}>
<p style={{
fontWeight: 600,
marginBottom: 8,
color: 'var(--text-primary)',
}}>
{t('monitor.logs.disable_unsupported_guide_title')}
</p>
<ul style={{
margin: 0,
padding: 0,
listStyle: 'none',
fontSize: 13,
lineHeight: 1.8,
color: 'var(--text-secondary)',
}}>
<li>{t('monitor.logs.disable_unsupported_guide_step1')}</li>
<li>{t('monitor.logs.disable_unsupported_guide_step2', { providerType: state.providerType })}</li>
<li>{t('monitor.logs.disable_unsupported_guide_step3', { model: state.model })}</li>
<li>{t('monitor.logs.disable_unsupported_guide_step4')}</li>
</ul>
</div>
{/* 关闭按钮 */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="primary" onClick={onClose}>
{t('monitor.logs.disable_unsupported_close')}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,8 @@
export { KpiCards } from './KpiCards';
export { ModelDistributionChart } from './ModelDistributionChart';
export { DailyTrendChart } from './DailyTrendChart';
export { HourlyModelChart } from './HourlyModelChart';
export { HourlyTokenChart } from './HourlyTokenChart';
export { ChannelStats } from './ChannelStats';
export { FailureAnalysis } from './FailureAnalysis';
export { RequestLogs } from './RequestLogs';

View File

@@ -0,0 +1,281 @@
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

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

View File

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

View File

@@ -0,0 +1,129 @@
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

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

View File

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

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import type { ProviderKeyConfig } from '@/types';
import { 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

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

View File

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

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import type { GeminiKeyConfig } from '@/types';
import { 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

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

View File

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

View File

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

View File

@@ -0,0 +1,433 @@
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

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

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
@use '../../../styles/variables' as *;
.navContainer {
position: fixed;
left: var(--content-center-x, 50%);
bottom: calc(12px + env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 50;
pointer-events: auto;
width: fit-content;
max-width: calc(100vw - 24px);
}
.navList {
position: relative;
display: inline-flex;
flex-direction: row;
gap: 6px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 999px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
overflow-x: auto;
scrollbar-width: none;
max-width: inherit;
&::-webkit-scrollbar {
display: none;
}
}
.indicator {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
opacity: 0;
border-radius: 999px;
background: rgba(59, 130, 246, 0.15);
box-shadow: inset 0 0 0 2px var(--primary-color);
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
width 220ms cubic-bezier(0.22, 1, 0.36, 1),
height 220ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 120ms ease;
will-change: transform, width, height;
}
.indicatorVisible {
opacity: 1;
}
.indicatorNoTransition {
transition: none;
}
.navItem {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
background: transparent;
border-radius: 999px;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.15s ease;
flex: 0 0 auto;
&:hover {
background: rgba(0, 0, 0, 0.06);
transform: scale(1.08);
}
&:active {
transform: scale(0.95);
}
}
.navItem.active {
&:hover {
background: transparent;
transform: none;
}
}
.icon {
width: 24px;
height: 24px;
object-fit: contain;
}
.active {
// Active highlight is rendered by the sliding indicator.
background: transparent;
box-shadow: none;
}
// 暗色主题适配
:global([data-theme='dark']) {
.navList {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
.navItem {
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
.indicator {
background: rgba(59, 130, 246, 0.25);
}
}
// 小屏幕进一步收紧尺寸
@media (max-width: 1200px) {
.navContainer {
max-width: calc(100vw - 16px);
}
.navList {
gap: 6px;
padding: 8px 10px;
}
.navItem {
width: 36px;
height: 36px;
}
.icon {
width: 22px;
height: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.indicator {
transition: none;
}
.navItem {
transition: background-color 0.2s ease;
}
}

View File

@@ -0,0 +1,285 @@
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useLocation } from 'react-router-dom';
import { usePageTransitionLayer } from '@/components/common/PageTransition';
import { useThemeStore } from '@/stores';
import iconGemini from '@/assets/icons/gemini.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import iconCodexLight from '@/assets/icons/codex_light.svg';
import iconCodexDark from '@/assets/icons/codex_drak.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconVertex from '@/assets/icons/vertex.svg';
import iconAmp from '@/assets/icons/amp.svg';
import styles from './ProviderNav.module.scss';
export type ProviderId = 'gemini' | 'codex' | 'claude' | 'vertex' | 'ampcode' | 'openai';
interface ProviderNavItem {
id: ProviderId;
label: string;
getIcon: (theme: string) => string;
}
const PROVIDERS: ProviderNavItem[] = [
{ id: 'gemini', label: 'Gemini', getIcon: () => iconGemini },
{ id: 'codex', label: 'Codex', getIcon: (theme) => (theme === 'dark' ? iconCodexDark : iconCodexLight) },
{ id: 'claude', label: 'Claude', getIcon: () => iconClaude },
{ id: 'vertex', label: 'Vertex', getIcon: () => iconVertex },
{ id: 'ampcode', label: 'Ampcode', getIcon: () => iconAmp },
{ id: 'openai', label: 'OpenAI', getIcon: (theme) => (theme === 'dark' ? iconOpenaiDark : iconOpenaiLight) },
];
const HEADER_OFFSET = 24;
type ScrollContainer = HTMLElement | (Window & typeof globalThis);
export function ProviderNav() {
const location = useLocation();
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
const contentScrollerRef = useRef<HTMLElement | null>(null);
const navListRef = useRef<HTMLDivElement | null>(null);
const navContainerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<ProviderId, HTMLButtonElement | null>>({
gemini: null,
codex: null,
claude: null,
vertex: null,
ampcode: null,
openai: null,
});
const [indicatorRect, setIndicatorRect] = useState<{
x: number;
y: number;
width: number;
height: number;
} | null>(null);
const [indicatorTransitionsEnabled, setIndicatorTransitionsEnabled] = useState(false);
const indicatorHasEnabledTransitionsRef = useRef(false);
// Only show this quick-switch overlay on the AI Providers list page.
// Note: The app uses iOS-style stacked page transitions inside `/ai-providers/*`,
// so this component can stay mounted while the user is on an edit route.
const normalizedPathname =
location.pathname.length > 1 && location.pathname.endsWith('/')
? location.pathname.slice(0, -1)
: location.pathname;
const shouldShow = isCurrentLayer && normalizedPathname === '/ai-providers';
const getHeaderHeight = useCallback(() => {
const header = document.querySelector('.main-header') as HTMLElement | null;
if (header) return header.getBoundingClientRect().height;
const raw = getComputedStyle(document.documentElement).getPropertyValue('--header-height');
const value = Number.parseFloat(raw);
return Number.isFinite(value) ? value : 0;
}, []);
const getContentScroller = useCallback(() => {
if (contentScrollerRef.current && document.contains(contentScrollerRef.current)) {
return contentScrollerRef.current;
}
const container = document.querySelector('.content') as HTMLElement | null;
contentScrollerRef.current = container;
return container;
}, []);
const getScrollContainer = useCallback((): ScrollContainer => {
// Mobile layout uses document scroll (layout switches at 768px); desktop uses the `.content` scroller.
const isMobile = window.matchMedia('(max-width: 768px)').matches;
if (isMobile) return window;
return getContentScroller() ?? window;
}, [getContentScroller]);
const handleScroll = useCallback(() => {
const container = getScrollContainer();
if (!container) return;
const isElementScroller = container instanceof HTMLElement;
const headerHeight = isElementScroller ? 0 : getHeaderHeight();
const containerTop = isElementScroller ? container.getBoundingClientRect().top : 0;
const activationLine = containerTop + headerHeight + HEADER_OFFSET + 1;
let currentActive: ProviderId | null = null;
for (const provider of PROVIDERS) {
const element = document.getElementById(`provider-${provider.id}`);
if (!element) continue;
const rect = element.getBoundingClientRect();
if (rect.top <= activationLine) {
currentActive = provider.id;
continue;
}
if (currentActive) break;
}
if (!currentActive) {
const firstVisible = PROVIDERS.find((provider) =>
document.getElementById(`provider-${provider.id}`)
);
currentActive = firstVisible?.id ?? null;
}
setActiveProvider(currentActive);
}, [getHeaderHeight, getScrollContainer]);
useEffect(() => {
if (!shouldShow) return;
const contentScroller = getContentScroller();
// Listen to both: desktop scroll happens on `.content`; mobile uses `window`.
window.addEventListener('scroll', handleScroll, { passive: true });
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll);
handleScroll();
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
contentScroller?.removeEventListener('scroll', handleScroll);
};
}, [getContentScroller, handleScroll, shouldShow]);
const updateIndicator = useCallback((providerId: ProviderId | null) => {
if (!providerId) {
setIndicatorRect(null);
return;
}
const itemEl = itemRefs.current[providerId];
if (!itemEl) return;
setIndicatorRect({
x: itemEl.offsetLeft,
y: itemEl.offsetTop,
width: itemEl.offsetWidth,
height: itemEl.offsetHeight,
});
// Avoid animating from an initial (0,0) state on first paint.
if (!indicatorHasEnabledTransitionsRef.current) {
indicatorHasEnabledTransitionsRef.current = true;
requestAnimationFrame(() => setIndicatorTransitionsEnabled(true));
}
}, []);
useLayoutEffect(() => {
if (!shouldShow) return;
updateIndicator(activeProvider);
}, [activeProvider, shouldShow, updateIndicator]);
// Expose overlay height to the page, so it can reserve bottom padding and avoid being covered.
useLayoutEffect(() => {
if (!shouldShow) return;
const el = navContainerRef.current;
if (!el) return;
const updateHeight = () => {
const height = el.getBoundingClientRect().height;
document.documentElement.style.setProperty('--provider-nav-height', `${height}px`);
};
updateHeight();
window.addEventListener('resize', updateHeight);
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateHeight);
ro?.observe(el);
return () => {
ro?.disconnect();
window.removeEventListener('resize', updateHeight);
document.documentElement.style.removeProperty('--provider-nav-height');
};
}, [shouldShow]);
const scrollToProvider = (providerId: ProviderId) => {
const container = getScrollContainer();
const element = document.getElementById(`provider-${providerId}`);
if (!element || !container) return;
setActiveProvider(providerId);
updateIndicator(providerId);
// Mobile: scroll the document (header is fixed, so offset by header height).
if (!(container instanceof HTMLElement)) {
const headerHeight = getHeaderHeight();
const elementTop = element.getBoundingClientRect().top + window.scrollY;
const target = Math.max(0, elementTop - headerHeight - HEADER_OFFSET);
window.scrollTo({ top: target, behavior: 'smooth' });
return;
}
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - HEADER_OFFSET;
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
};
useEffect(() => {
if (!shouldShow) return;
const handleResize = () => updateIndicator(activeProvider);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [activeProvider, shouldShow, updateIndicator]);
const navContent = (
<div className={styles.navContainer} ref={navContainerRef}>
<div className={styles.navList} ref={navListRef}>
<div
className={[
styles.indicator,
indicatorRect ? styles.indicatorVisible : '',
indicatorTransitionsEnabled ? '' : styles.indicatorNoTransition,
]
.filter(Boolean)
.join(' ')}
style={
(indicatorRect
? ({
transform: `translate3d(${indicatorRect.x}px, ${indicatorRect.y}px, 0)`,
width: indicatorRect.width,
height: indicatorRect.height,
} satisfies CSSProperties)
: undefined) as CSSProperties | undefined
}
/>
{PROVIDERS.map((provider) => {
const isActive = activeProvider === provider.id;
return (
<button
key={provider.id}
className={`${styles.navItem} ${isActive ? styles.active : ''}`}
ref={(node) => {
itemRefs.current[provider.id] = node;
}}
onClick={() => scrollToProvider(provider.id)}
title={provider.label}
type="button"
aria-label={provider.label}
aria-pressed={isActive}
>
<img
src={provider.getIcon(resolvedTheme)}
alt={provider.label}
className={styles.icon}
/>
</button>
);
})}
</div>
</div>
);
if (typeof document === 'undefined') return null;
if (!shouldShow) return null;
return createPortal(navContent, document.body);
}

View File

@@ -0,0 +1,2 @@
export { ProviderNav } from './ProviderNav';
export type { ProviderId } from './ProviderNav';

View File

@@ -0,0 +1,38 @@
import { calculateStatusBarData } from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
interface ProviderStatusBarProps {
statusData: ReturnType<typeof calculateStatusBarData>;
}
export function ProviderStatusBar({ statusData }: ProviderStatusBarProps) {
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
const rateClass = !hasData
? ''
: statusData.successRate >= 90
? styles.statusRateHigh
: statusData.successRate >= 50
? styles.statusRateMedium
: styles.statusRateLow;
return (
<div className={styles.statusBar}>
<div className={styles.statusBlocks}>
{statusData.blocks.map((state, idx) => {
const blockClass =
state === 'success'
? styles.statusBlockSuccess
: state === 'failure'
? styles.statusBlockFailure
: state === 'mixed'
? styles.statusBlockMixed
: styles.statusBlockIdle;
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
})}
</div>
<span className={`${styles.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
);
}

View File

@@ -0,0 +1,118 @@
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

@@ -0,0 +1,159 @@
import { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import iconVertex from '@/assets/icons/vertex.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource } from '../utils';
interface VertexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
}
export function VertexSection({
configs,
keyStats,
usageDetails,
loading,
disableControls,
isSwitching,
onAdd,
onEdit,
onDelete,
}: VertexSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((config) => {
if (!config.apiKey) return;
const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
});
return cache;
}, [configs, usageDetails]);
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.vertex_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.vertex_add_button')}
</Button>
}
>
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
emptyTitle={t('ai_providers.vertex_empty_title')}
emptyDescription={t('ai_providers.vertex_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {});
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
return (
<Fragment>
<div className="item-title">
{t('ai_providers.vertex_item_title')} #{index + 1}
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{item.baseUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
<span className={styles.fieldValue}>{item.baseUrl}</span>
</div>
)}
{item.proxyUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
<span className={styles.fieldValue}>{item.proxyUrl}</span>
</div>
)}
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
<span key={key} className={styles.headerBadge}>
<strong>{key}:</strong> {value}
</span>
))}
</div>
)}
{item.models?.length ? (
<div className={styles.modelTagList}>
<span className={styles.modelCountLabel}>
{t('ai_providers.vertex_models_count')}: {item.models.length}
</span>
{item.models.map((model) => (
<span key={`${model.name}-${model.alias || 'default'}`} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {stats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} />
</Fragment>
);
}}
/>
</Card>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,321 @@
/**
* Generic quota section component.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useQuotaStore, useThemeStore } from '@/stores';
import type { AuthFileItem, ResolvedTheme } from '@/types';
import { QuotaCard } from './QuotaCard';
import type { QuotaStatusState } from './QuotaCard';
import { useQuotaLoader } from './useQuotaLoader';
import type { QuotaConfig } from './quotaConfigs';
import { useGridColumns } from './useGridColumns';
import { IconRefreshCw } from '@/components/ui/icons';
import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
type ViewMode = 'paged' | 'all';
const MAX_ITEMS_PER_PAGE = 14;
const MAX_SHOW_ALL_THRESHOLD = 30;
interface QuotaPaginationState<T> {
pageSize: number;
totalPages: number;
currentPage: number;
pageItems: T[];
setPageSize: (size: number) => void;
goToPrev: () => void;
goToNext: () => void;
loading: boolean;
loadingScope: 'page' | 'all' | null;
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void;
}
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
const [page, setPage] = useState(1);
const [pageSize, setPageSizeState] = useState(defaultPageSize);
const [loading, setLoadingState] = useState(false);
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(items.length / pageSize)),
[items.length, pageSize]
);
const currentPage = useMemo(() => Math.min(page, totalPages), [page, totalPages]);
const pageItems = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return items.slice(start, start + pageSize);
}, [items, currentPage, pageSize]);
const setPageSize = useCallback((size: number) => {
setPageSizeState(size);
setPage(1);
}, []);
const goToPrev = useCallback(() => {
setPage((prev) => Math.max(1, prev - 1));
}, []);
const goToNext = useCallback(() => {
setPage((prev) => Math.min(totalPages, prev + 1));
}, [totalPages]);
const setLoading = useCallback((isLoading: boolean, scope?: 'page' | 'all' | null) => {
setLoadingState(isLoading);
setLoadingScope(isLoading ? (scope ?? null) : null);
}, []);
return {
pageSize,
totalPages,
currentPage,
pageItems,
setPageSize,
goToPrev,
goToNext,
loading,
loadingScope,
setLoading
};
};
interface QuotaSectionProps<TState extends QuotaStatusState, TData> {
config: QuotaConfig<TState, TData>;
files: AuthFileItem[];
loading: boolean;
disabled: boolean;
}
export function QuotaSection<TState extends QuotaStatusState, TData>({
config,
files,
loading,
disabled
}: QuotaSectionProps<TState, TData>) {
const { t } = useTranslation();
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
Record<string, TState>
>;
/* Removed useRef */
const [columns, gridRef] = useGridColumns(380); // Min card width 380px matches SCSS
const [viewMode, setViewMode] = useState<ViewMode>('paged');
const [showTooManyWarning, setShowTooManyWarning] = useState(false);
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
files,
config
]);
const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD;
const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode;
const {
pageSize,
totalPages,
currentPage,
pageItems,
setPageSize,
goToPrev,
goToNext,
loading: sectionLoading,
setLoading
} = useQuotaPagination(filteredFiles);
useEffect(() => {
if (showAllAllowed) return;
if (viewMode !== 'all') return;
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
setViewMode('paged');
setShowTooManyWarning(true);
});
return () => {
cancelled = true;
};
}, [showAllAllowed, viewMode]);
// Update page size based on view mode and columns
useEffect(() => {
if (effectiveViewMode === 'all') {
setPageSize(Math.max(1, filteredFiles.length));
} else {
// Paged mode: 3 rows * columns, capped to avoid oversized pages.
setPageSize(Math.min(columns * 3, MAX_ITEMS_PER_PAGE));
}
}, [effectiveViewMode, columns, filteredFiles.length, setPageSize]);
const { quota, loadQuota } = useQuotaLoader(config);
const pendingQuotaRefreshRef = useRef(false);
const prevFilesLoadingRef = useRef(loading);
const handleRefresh = useCallback(() => {
pendingQuotaRefreshRef.current = true;
void triggerHeaderRefresh();
}, []);
useEffect(() => {
const wasLoading = prevFilesLoadingRef.current;
prevFilesLoadingRef.current = loading;
if (!pendingQuotaRefreshRef.current) return;
if (loading) return;
if (!wasLoading) return;
pendingQuotaRefreshRef.current = false;
const scope = effectiveViewMode === 'all' ? 'all' : 'page';
const targets = effectiveViewMode === 'all' ? filteredFiles : pageItems;
if (targets.length === 0) return;
loadQuota(targets, scope, setLoading);
}, [loading, effectiveViewMode, filteredFiles, pageItems, loadQuota, setLoading]);
useEffect(() => {
if (loading) return;
if (filteredFiles.length === 0) {
setQuota({});
return;
}
setQuota((prev) => {
const nextState: Record<string, TState> = {};
filteredFiles.forEach((file) => {
const cached = prev[file.name];
if (cached) {
nextState[file.name] = cached;
}
});
return nextState;
});
}, [filteredFiles, loading, setQuota]);
const titleNode = (
<div className={styles.titleWrapper}>
<span>{t(`${config.i18nPrefix}.title`)}</span>
{filteredFiles.length > 0 && (
<span className={styles.countBadge}>
{filteredFiles.length}
</span>
)}
</div>
);
const isRefreshing = sectionLoading || loading;
return (
<Card
title={titleNode}
extra={
<div className={styles.headerActions}>
<div className={styles.viewModeToggle}>
<Button
variant={effectiveViewMode === 'paged' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setViewMode('paged')}
>
{t('auth_files.view_mode_paged')}
</Button>
<Button
variant={effectiveViewMode === 'all' ? 'primary' : 'secondary'}
size="sm"
onClick={() => {
if (filteredFiles.length > MAX_SHOW_ALL_THRESHOLD) {
setShowTooManyWarning(true);
} else {
setViewMode('all');
}
}}
>
{t('auth_files.view_mode_all')}
</Button>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleRefresh}
disabled={disabled || isRefreshing}
loading={isRefreshing}
title={t('quota_management.refresh_files_and_quota')}
aria-label={t('quota_management.refresh_files_and_quota')}
>
{!isRefreshing && <IconRefreshCw size={16} />}
</Button>
</div>
}
>
{filteredFiles.length === 0 ? (
<EmptyState
title={t(`${config.i18nPrefix}.empty_title`)}
description={t(`${config.i18nPrefix}.empty_desc`)}
/>
) : (
<>
<div ref={gridRef} className={config.gridClassName}>
{pageItems.map((item) => (
<QuotaCard
key={item.name}
item={item}
quota={quota[item.name]}
resolvedTheme={resolvedTheme}
i18nPrefix={config.i18nPrefix}
cardClassName={config.cardClassName}
defaultType={config.type}
renderQuotaItems={config.renderQuotaItems}
/>
))}
</div>
{filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={goToPrev}
disabled={currentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: filteredFiles.length
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={goToNext}
disabled={currentPage >= totalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</>
)}
{showTooManyWarning && (
<div className={styles.warningOverlay} onClick={() => setShowTooManyWarning(false)}>
<div className={styles.warningModal} onClick={(e) => e.stopPropagation()}>
<p>{t('auth_files.too_many_files_warning')}</p>
<Button variant="primary" size="sm" onClick={() => setShowTooManyWarning(false)}>
{t('common.confirm')}
</Button>
</div>
</div>
)}
</Card>
);
}

View File

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

View File

@@ -0,0 +1,927 @@
/**
* Quota configuration definitions.
*/
import React from 'react';
import type { ReactNode } from 'react';
import type { TFunction } from 'i18next';
import type {
AntigravityQuotaGroup,
AntigravityModelsPayload,
AntigravityQuotaState,
AuthFileItem,
CodexRateLimitInfo,
CodexQuotaState,
CodexUsageWindow,
CodexQuotaWindow,
CodexUsagePayload,
GeminiCliParsedBucket,
GeminiCliQuotaBucketState,
GeminiCliQuotaState,
KiroQuotaState,
} from '@/types';
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import {
ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS,
CODEX_USAGE_URL,
CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL,
GEMINI_CLI_REQUEST_HEADERS,
KIRO_QUOTA_URL,
KIRO_REQUEST_HEADERS,
normalizeAuthIndexValue,
normalizeNumberValue,
normalizePlanType,
normalizeQuotaFraction,
normalizeStringValue,
parseAntigravityPayload,
parseCodexUsagePayload,
parseGeminiCliQuotaPayload,
parseKiroQuotaPayload,
resolveCodexChatgptAccountId,
resolveCodexPlanType,
resolveGeminiCliProjectId,
formatCodexResetLabel,
formatQuotaResetTime,
buildAntigravityQuotaGroups,
buildGeminiCliQuotaBuckets,
createStatusError,
getStatusFromError,
isAntigravityFile,
isCodexFile,
isDisabledAuthFile,
isGeminiCliFile,
isKiroFile,
isRuntimeOnlyAuthFile,
} from '@/utils/quota';
import type { QuotaRenderHelpers } from './QuotaCard';
import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli' | 'kiro';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
kiroQuota: Record<string, KiroQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
setKiroQuota: (updater: QuotaUpdater<Record<string, KiroQuotaState>>) => void;
clearQuotaCache: () => void;
}
export interface QuotaConfig<TState, TData> {
type: QuotaType;
i18nPrefix: string;
filterFn: (file: AuthFileItem) => boolean;
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<TData>;
storeSelector: (state: QuotaStore) => Record<string, TState>;
storeSetter: keyof QuotaStore;
buildLoadingState: () => TState;
buildSuccessState: (data: TData) => TState;
buildErrorState: (message: string, status?: number) => TState;
cardClassName: string;
controlsClassName: string;
controlClassName: string;
gridClassName: string;
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
}
const resolveAntigravityProjectId = async (file: AuthFileItem): Promise<string> => {
try {
const text = await authFilesApi.downloadText(file.name);
const trimmed = text.trim();
if (!trimmed) return DEFAULT_ANTIGRAVITY_PROJECT_ID;
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const topLevel = normalizeStringValue(parsed.project_id ?? parsed.projectId);
if (topLevel) return topLevel;
const installed =
parsed.installed && typeof parsed.installed === 'object' && parsed.installed !== null
? (parsed.installed as Record<string, unknown>)
: null;
const installedProjectId = installed
? normalizeStringValue(installed.project_id ?? installed.projectId)
: null;
if (installedProjectId) return installedProjectId;
const web =
parsed.web && typeof parsed.web === 'object' && parsed.web !== null
? (parsed.web as Record<string, unknown>)
: null;
const webProjectId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null;
if (webProjectId) return webProjectId;
} catch {
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
}
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
};
const fetchAntigravityQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<AntigravityQuotaGroup[]> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('antigravity_quota.missing_auth_index'));
}
const projectId = await resolveAntigravityProjectId(file);
const requestBody = JSON.stringify({ project: projectId });
let lastError = '';
let lastStatus: number | undefined;
let priorityStatus: number | undefined;
let hadSuccess = false;
for (const url of ANTIGRAVITY_QUOTA_URLS) {
try {
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: requestBody,
});
if (result.statusCode < 200 || result.statusCode >= 300) {
lastError = getApiCallErrorMessage(result);
lastStatus = result.statusCode;
if (result.statusCode === 403 || result.statusCode === 404) {
priorityStatus ??= result.statusCode;
}
continue;
}
hadSuccess = true;
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
const models = payload?.models;
if (!models || typeof models !== 'object' || Array.isArray(models)) {
lastError = t('antigravity_quota.empty_models');
continue;
}
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
if (groups.length === 0) {
lastError = t('antigravity_quota.empty_models');
continue;
}
return groups;
} catch (err: unknown) {
lastError = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
if (status) {
lastStatus = status;
if (status === 403 || status === 404) {
priorityStatus ??= status;
}
}
}
}
if (hadSuccess) {
return [];
}
throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus);
};
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
const FIVE_HOUR_SECONDS = 18000;
const WEEK_SECONDS = 604800;
const WINDOW_META = {
codeFiveHour: { id: 'five-hour', labelKey: 'codex_quota.primary_window' },
codeWeekly: { id: 'weekly', labelKey: 'codex_quota.secondary_window' },
codeReviewFiveHour: { id: 'code-review-five-hour', labelKey: 'codex_quota.code_review_primary_window' },
codeReviewWeekly: { id: 'code-review-weekly', labelKey: 'codex_quota.code_review_secondary_window' },
} as const;
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
const windows: CodexQuotaWindow[] = [];
const addWindow = (
id: string,
labelKey: string,
window?: CodexUsageWindow | null,
limitReached?: boolean,
allowed?: boolean
) => {
if (!window) return;
const resetLabel = formatCodexResetLabel(window);
const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent);
const isLimitReached = Boolean(limitReached) || allowed === false;
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
windows.push({
id,
label: t(labelKey),
labelKey,
usedPercent,
resetLabel,
});
};
const getWindowSeconds = (window?: CodexUsageWindow | null): number | null => {
if (!window) return null;
return normalizeNumberValue(window.limit_window_seconds ?? window.limitWindowSeconds);
};
const rawLimitReached = rateLimit?.limit_reached ?? rateLimit?.limitReached;
const rawAllowed = rateLimit?.allowed;
const pickClassifiedWindows = (
limitInfo?: CodexRateLimitInfo | null
): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => {
const rawWindows = [
limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null,
limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null,
];
let fiveHourWindow: CodexUsageWindow | null = null;
let weeklyWindow: CodexUsageWindow | null = null;
for (const window of rawWindows) {
if (!window) continue;
const seconds = getWindowSeconds(window);
if (seconds === FIVE_HOUR_SECONDS && !fiveHourWindow) {
fiveHourWindow = window;
} else if (seconds === WEEK_SECONDS && !weeklyWindow) {
weeklyWindow = window;
}
}
return { fiveHourWindow, weeklyWindow };
};
const rateWindows = pickClassifiedWindows(rateLimit);
addWindow(
WINDOW_META.codeFiveHour.id,
WINDOW_META.codeFiveHour.labelKey,
rateWindows.fiveHourWindow,
rawLimitReached,
rawAllowed
);
addWindow(
WINDOW_META.codeWeekly.id,
WINDOW_META.codeWeekly.labelKey,
rateWindows.weeklyWindow,
rawLimitReached,
rawAllowed
);
const codeReviewWindows = pickClassifiedWindows(codeReviewLimit);
const codeReviewLimitReached = codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached;
const codeReviewAllowed = codeReviewLimit?.allowed;
addWindow(
WINDOW_META.codeReviewFiveHour.id,
WINDOW_META.codeReviewFiveHour.labelKey,
codeReviewWindows.fiveHourWindow,
codeReviewLimitReached,
codeReviewAllowed
);
addWindow(
WINDOW_META.codeReviewWeekly.id,
WINDOW_META.codeReviewWeekly.labelKey,
codeReviewWindows.weeklyWindow,
codeReviewLimitReached,
codeReviewAllowed
);
return windows;
};
const fetchCodexQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('codex_quota.missing_auth_index'));
}
const planTypeFromFile = resolveCodexPlanType(file);
const accountId = resolveCodexChatgptAccountId(file);
if (!accountId) {
throw new Error(t('codex_quota.missing_account_id'));
}
const requestHeader: Record<string, string> = {
...CODEX_REQUEST_HEADERS,
'Chatgpt-Account-Id': accountId,
};
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: CODEX_USAGE_URL,
header: requestHeader,
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseCodexUsagePayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('codex_quota.empty_windows'));
}
const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType);
const windows = buildCodexQuotaWindows(payload, t);
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
};
const fetchGeminiCliQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<GeminiCliQuotaBucketState[]> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('gemini_cli_quota.missing_auth_index'));
}
const projectId = resolveGeminiCliProjectId(file);
if (!projectId) {
throw new Error(t('gemini_cli_quota.missing_project_id'));
}
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url: GEMINI_CLI_QUOTA_URL,
header: { ...GEMINI_CLI_REQUEST_HEADERS },
data: JSON.stringify({ project: projectId }),
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
if (buckets.length === 0) return [];
const parsedBuckets = buckets
.map((bucket) => {
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
if (!modelId) return null;
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
const remainingFractionRaw = normalizeQuotaFraction(
bucket.remainingFraction ?? bucket.remaining_fraction
);
const remainingAmount = normalizeNumberValue(
bucket.remainingAmount ?? bucket.remaining_amount
);
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
let fallbackFraction: number | null = null;
if (remainingAmount !== null) {
fallbackFraction = remainingAmount <= 0 ? 0 : null;
} else if (resetTime) {
fallbackFraction = 0;
}
const remainingFraction = remainingFractionRaw ?? fallbackFraction;
return {
modelId,
tokenType,
remainingFraction,
remainingAmount,
resetTime,
};
})
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
return buildGeminiCliQuotaBuckets(parsedBuckets);
};
const renderAntigravityItems = (
quota: AntigravityQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h } = React;
const groups = quota.groups ?? [];
if (groups.length === 0) {
return h('div', { className: styleMap.quotaMessage }, t('antigravity_quota.empty_models'));
}
return groups.map((group) => {
const clamped = Math.max(0, Math.min(1, group.remainingFraction));
const percent = Math.round(clamped * 100);
const resetLabel = formatQuotaResetTime(group.resetTime);
return h(
'div',
{ key: group.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel, title: group.models.join(', ') }, group.label),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, `${percent}%`),
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
);
});
};
const renderCodexItems = (
quota: CodexQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h, Fragment } = React;
const windows = quota.windows ?? [];
const planType = quota.planType ?? null;
const getPlanLabel = (pt?: string | null): string | null => {
const normalized = normalizePlanType(pt);
if (!normalized) return null;
if (normalized === 'plus') return t('codex_quota.plan_plus');
if (normalized === 'team') return t('codex_quota.plan_team');
if (normalized === 'free') return t('codex_quota.plan_free');
return pt || normalized;
};
const planLabel = getPlanLabel(planType);
const nodes: ReactNode[] = [];
if (planLabel) {
nodes.push(
h(
'div',
{ key: 'plan', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('codex_quota.plan_label')),
h('span', { className: styleMap.codexPlanValue }, planLabel)
)
);
}
if (windows.length === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
);
return h(Fragment, null, ...nodes);
}
nodes.push(
...windows.map((window) => {
const used = window.usedPercent;
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
return h(
'div',
{ key: window.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, windowLabel),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
h('span', { className: styleMap.quotaReset }, window.resetLabel)
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
);
})
);
return h(Fragment, null, ...nodes);
};
const renderGeminiCliItems = (
quota: GeminiCliQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h } = React;
const buckets = quota.buckets ?? [];
if (buckets.length === 0) {
return h('div', { className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'));
}
return buckets.map((bucket) => {
const fraction = bucket.remainingFraction;
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
const percent = clamped === null ? null : Math.round(clamped * 100);
const percentLabel = percent === null ? '--' : `${percent}%`;
const remainingAmountLabel =
bucket.remainingAmount === null || bucket.remainingAmount === undefined
? null
: t('gemini_cli_quota.remaining_amount', {
count: bucket.remainingAmount,
});
const titleBase =
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
const title = bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase;
const resetLabel = formatQuotaResetTime(bucket.resetTime);
return h(
'div',
{ key: bucket.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel, title }, bucket.label),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
remainingAmountLabel
? h('span', { className: styleMap.quotaAmount }, remainingAmountLabel)
: null,
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
);
});
};
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
type: 'antigravity',
i18nPrefix: 'antigravity_quota',
filterFn: (file) => isAntigravityFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchAntigravityQuota,
storeSelector: (state) => state.antigravityQuota,
storeSetter: 'setAntigravityQuota',
buildLoadingState: () => ({ status: 'loading', groups: [] }),
buildSuccessState: (groups) => ({ status: 'success', groups }),
buildErrorState: (message, status) => ({
status: 'error',
groups: [],
error: message,
errorStatus: status,
}),
cardClassName: styles.antigravityCard,
controlsClassName: styles.antigravityControls,
controlClassName: styles.antigravityControl,
gridClassName: styles.antigravityGrid,
renderQuotaItems: renderAntigravityItems,
};
export const CODEX_CONFIG: QuotaConfig<
CodexQuotaState,
{ planType: string | null; windows: CodexQuotaWindow[] }
> = {
type: 'codex',
i18nPrefix: 'codex_quota',
filterFn: (file) => isCodexFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchCodexQuota,
storeSelector: (state) => state.codexQuota,
storeSetter: 'setCodexQuota',
buildLoadingState: () => ({ status: 'loading', windows: [] }),
buildSuccessState: (data) => ({
status: 'success',
windows: data.windows,
planType: data.planType,
}),
buildErrorState: (message, status) => ({
status: 'error',
windows: [],
error: message,
errorStatus: status,
}),
cardClassName: styles.codexCard,
controlsClassName: styles.codexControls,
controlClassName: styles.codexControl,
gridClassName: styles.codexGrid,
renderQuotaItems: renderCodexItems,
};
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
type: 'gemini-cli',
i18nPrefix: 'gemini_cli_quota',
filterFn: (file) =>
isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchGeminiCliQuota,
storeSelector: (state) => state.geminiCliQuota,
storeSetter: 'setGeminiCliQuota',
buildLoadingState: () => ({ status: 'loading', buckets: [] }),
buildSuccessState: (buckets) => ({ status: 'success', buckets }),
buildErrorState: (message, status) => ({
status: 'error',
buckets: [],
error: message,
errorStatus: status,
}),
cardClassName: styles.geminiCliCard,
controlsClassName: styles.geminiCliControls,
controlClassName: styles.geminiCliControl,
gridClassName: styles.geminiCliGrid,
renderQuotaItems: renderGeminiCliItems,
};
// Kiro quota data structure from API
interface KiroQuotaData {
// Base quota (原本额度)
baseUsage: number | null;
baseLimit: number | null;
baseRemaining: number | null;
// Free trial/bonus quota (赠送额度)
bonusUsage: number | null;
bonusLimit: number | null;
bonusRemaining: number | null;
bonusStatus?: string;
// Total (合计)
currentUsage: number | null;
usageLimit: number | null;
remainingCredits: number | null;
nextReset?: string;
subscriptionType?: string;
}
const fetchKiroQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<KiroQuotaData> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('kiro_quota.missing_auth_index'));
}
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: KIRO_QUOTA_URL,
header: { ...KIRO_REQUEST_HEADERS }
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseKiroQuotaPayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('kiro_quota.empty_data'));
}
// Extract usage data from usageBreakdownList (separating base and bonus)
const breakdownList = payload.usageBreakdownList ?? [];
let baseLimit = 0;
let baseUsage = 0;
let bonusLimit = 0;
let bonusUsage = 0;
let bonusStatus: string | undefined;
for (const breakdown of breakdownList) {
// Add base quota
const limit = normalizeNumberValue(breakdown.usageLimitWithPrecision ?? breakdown.usageLimit);
const usage = normalizeNumberValue(breakdown.currentUsageWithPrecision ?? breakdown.currentUsage);
if (limit !== null) baseLimit += limit;
if (usage !== null) baseUsage += usage;
// Add free trial quota if available (e.g., 500 bonus credits)
const freeTrialInfo = breakdown.freeTrialInfo;
if (freeTrialInfo) {
const freeLimit = normalizeNumberValue(freeTrialInfo.usageLimitWithPrecision ?? freeTrialInfo.usageLimit);
const freeUsage = normalizeNumberValue(freeTrialInfo.currentUsageWithPrecision ?? freeTrialInfo.currentUsage);
if (freeLimit !== null) bonusLimit += freeLimit;
if (freeUsage !== null) bonusUsage += freeUsage;
if (freeTrialInfo.freeTrialStatus) {
bonusStatus = freeTrialInfo.freeTrialStatus;
}
}
}
const totalLimit = baseLimit + bonusLimit;
const totalUsage = baseUsage + bonusUsage;
// Calculate next reset time
// Note: nextDateReset from Kiro API is in SECONDS (e.g., 1.769904E9 = 1769904000)
// JavaScript Date() requires milliseconds, so multiply by 1000
let nextReset: string | undefined;
if (payload.nextDateReset) {
// API returns seconds timestamp (scientific notation like 1.769904E9)
const timestampSeconds = payload.nextDateReset;
const resetDate = new Date(timestampSeconds * 1000);
if (!isNaN(resetDate.getTime())) {
nextReset = resetDate.toISOString();
}
}
// Get subscription type
const subscriptionType = payload.subscriptionInfo?.subscriptionTitle ?? payload.subscriptionInfo?.type;
return {
baseUsage,
baseLimit,
baseRemaining: baseLimit > 0 ? Math.max(0, baseLimit - baseUsage) : null,
bonusUsage,
bonusLimit,
bonusRemaining: bonusLimit > 0 ? Math.max(0, bonusLimit - bonusUsage) : null,
bonusStatus,
currentUsage: totalUsage,
usageLimit: totalLimit,
remainingCredits: totalLimit > 0 ? Math.max(0, totalLimit - totalUsage) : null,
nextReset,
subscriptionType
};
};
const renderKiroItems = (
quota: KiroQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h, Fragment } = React;
const nodes: ReactNode[] = [];
// Show subscription type if available
if (quota.subscriptionType) {
nodes.push(
h(
'div',
{ key: 'subscription', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('kiro_quota.subscription_label')),
h('span', { className: styleMap.codexPlanValue }, quota.subscriptionType)
)
);
}
const usageLimit = quota.usageLimit;
if (usageLimit === null || usageLimit === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('kiro_quota.empty_data'))
);
return h(Fragment, null, ...nodes);
}
const resetLabel = formatQuotaResetTime(quota.nextReset);
// Base quota display (原本额度)
const baseLimit = quota.baseLimit;
const baseRemaining = quota.baseRemaining;
if (baseLimit !== null && baseLimit > 0) {
const baseRemainingPercent = baseRemaining !== null && baseLimit > 0
? Math.round((baseRemaining / baseLimit) * 100)
: 0;
nodes.push(
h(
'div',
{ key: 'base-credits', className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, t('kiro_quota.base_credits_label')),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, `${baseRemainingPercent}%`),
baseRemaining !== null
? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(baseRemaining) }))
: null,
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, { percent: baseRemainingPercent, highThreshold: 60, mediumThreshold: 20 })
)
);
}
// Bonus quota display (赠送额度)
const bonusLimit = quota.bonusLimit;
const bonusRemaining = quota.bonusRemaining;
if (bonusLimit !== null && bonusLimit > 0) {
const bonusRemainingPercent = bonusRemaining !== null && bonusLimit > 0
? Math.round((bonusRemaining / bonusLimit) * 100)
: 0;
nodes.push(
h(
'div',
{ key: 'bonus-credits', className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, t('kiro_quota.bonus_credits_label')),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, `${bonusRemainingPercent}%`),
bonusRemaining !== null
? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(bonusRemaining) }))
: null
)
),
h(QuotaProgressBar, { percent: bonusRemainingPercent, highThreshold: 60, mediumThreshold: 20 })
)
);
}
// Total credits display (合计)
const currentUsage = quota.currentUsage;
const remainingCredits = quota.remainingCredits;
const totalRemainingPercent = currentUsage !== null && usageLimit > 0
? Math.max(0, 100 - Math.round((currentUsage / usageLimit) * 100))
: 0;
nodes.push(
h(
'div',
{ key: 'total-credits', className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, t('kiro_quota.total_credits_label')),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, `${totalRemainingPercent}%`),
remainingCredits !== null
? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(remainingCredits) }))
: null
)
),
h(QuotaProgressBar, { percent: totalRemainingPercent, highThreshold: 60, mediumThreshold: 20 })
)
);
return h(Fragment, null, ...nodes);
};
export const KIRO_CONFIG: QuotaConfig<KiroQuotaState, KiroQuotaData> = {
type: 'kiro',
i18nPrefix: 'kiro_quota',
filterFn: (file) => isKiroFile(file),
fetchQuota: fetchKiroQuota,
storeSelector: (state) => state.kiroQuota,
storeSetter: 'setKiroQuota',
buildLoadingState: () => ({
status: 'loading',
baseUsage: null,
baseLimit: null,
baseRemaining: null,
bonusUsage: null,
bonusLimit: null,
bonusRemaining: null,
currentUsage: null,
usageLimit: null,
remainingCredits: null
}),
buildSuccessState: (data) => ({
status: 'success',
baseUsage: data.baseUsage,
baseLimit: data.baseLimit,
baseRemaining: data.baseRemaining,
bonusUsage: data.bonusUsage,
bonusLimit: data.bonusLimit,
bonusRemaining: data.bonusRemaining,
bonusStatus: data.bonusStatus,
currentUsage: data.currentUsage,
usageLimit: data.usageLimit,
remainingCredits: data.remainingCredits,
nextReset: data.nextReset,
subscriptionType: data.subscriptionType
}),
buildErrorState: (message, status) => ({
status: 'error',
baseUsage: null,
baseLimit: null,
baseRemaining: null,
bonusUsage: null,
bonusLimit: null,
bonusRemaining: null,
currentUsage: null,
usageLimit: null,
remainingCredits: null,
error: message,
errorStatus: status
}),
cardClassName: styles.kiroCard,
controlsClassName: styles.kiroControls,
controlClassName: styles.kiroControl,
gridClassName: styles.kiroGrid,
renderQuotaItems: renderKiroItems
};

View File

@@ -0,0 +1,40 @@
import { useState, useEffect, useCallback } from 'react';
/**
* Hook to calculate the number of grid columns based on container width and item min-width.
* Returns [columns, refCallback].
*/
export function useGridColumns(
itemMinWidth: number,
gap: number = 16
): [number, (node: HTMLDivElement | null) => void] {
const [columns, setColumns] = useState(1);
const [element, setElement] = useState<HTMLDivElement | null>(null);
const refCallback = useCallback((node: HTMLDivElement | null) => {
setElement(node);
}, []);
useEffect(() => {
if (!element) return;
const updateColumns = () => {
const containerWidth = element.clientWidth;
const effectiveItemWidth = itemMinWidth + gap;
const count = Math.floor((containerWidth + gap) / effectiveItemWidth);
setColumns(Math.max(1, count));
};
updateColumns();
const observer = new ResizeObserver(() => {
updateColumns();
});
observer.observe(element);
return () => observer.disconnect();
}, [element, itemMinWidth, gap]);
return [columns, refCallback];
}

View File

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

View File

@@ -0,0 +1,175 @@
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
import { IconChevronDown } from './icons';
interface AutocompleteInputProps {
label?: string;
value: string;
onChange: (value: string) => void;
options: string[] | { value: string; label?: string }[];
placeholder?: string;
disabled?: boolean;
hint?: string;
error?: string;
className?: string;
wrapperClassName?: string;
wrapperStyle?: React.CSSProperties;
id?: string;
rightElement?: ReactNode;
}
export function AutocompleteInput({
label,
value,
onChange,
options,
placeholder,
disabled,
hint,
error,
className = '',
wrapperClassName = '',
wrapperStyle,
id,
rightElement
}: AutocompleteInputProps) {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const normalizedOptions = options.map(opt =>
typeof opt === 'string' ? { value: opt, label: opt } : { value: opt.value, label: opt.label || opt.value }
);
const filteredOptions = normalizedOptions.filter(opt => {
const v = value.toLowerCase();
return opt.value.toLowerCase().includes(v) || (opt.label && opt.label.toLowerCase().includes(v));
});
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
setIsOpen(true);
setHighlightedIndex(-1);
};
const handleSelect = (selectedValue: string) => {
onChange(selectedValue);
setIsOpen(false);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
return;
}
setHighlightedIndex(prev =>
prev < filteredOptions.length - 1 ? prev + 1 : prev
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0);
} else if (e.key === 'Enter') {
if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
e.preventDefault();
handleSelect(filteredOptions[highlightedIndex].value);
} else if (isOpen) {
e.preventDefault();
setIsOpen(false);
}
} else if (e.key === 'Escape') {
setIsOpen(false);
} else if (e.key === 'Tab') {
setIsOpen(false);
}
};
return (
<div className={`form-group ${wrapperClassName}`} ref={containerRef} style={wrapperStyle}>
{label && <label htmlFor={id}>{label}</label>}
<div style={{ position: 'relative' }}>
<input
id={id}
className={`input ${className}`.trim()}
value={value}
onChange={handleInputChange}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
style={{ paddingRight: 32 }}
/>
<div
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
alignItems: 'center',
pointerEvents: disabled ? 'none' : 'auto',
cursor: 'pointer',
height: '100%'
}}
onClick={() => !disabled && setIsOpen(!isOpen)}
>
{rightElement}
<IconChevronDown size={16} style={{ opacity: 0.5, marginLeft: 4 }} />
</div>
{isOpen && filteredOptions.length > 0 && !disabled && (
<div className="autocomplete-dropdown" style={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: 0,
right: 0,
zIndex: 1000,
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-md)',
maxHeight: 200,
overflowY: 'auto',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
}}>
{filteredOptions.map((opt, index) => (
<div
key={`${opt.value}-${index}`}
onClick={() => handleSelect(opt.value)}
style={{
padding: '8px 12px',
cursor: 'pointer',
backgroundColor: index === highlightedIndex ? 'var(--bg-tertiary)' : 'transparent',
color: 'var(--text-primary)',
display: 'flex',
flexDirection: 'column',
fontSize: '0.9rem'
}}
onMouseEnter={() => setHighlightedIndex(index)}
>
<span style={{ fontWeight: 500 }}>{opt.value}</span>
{opt.label && opt.label !== opt.value && (
<span style={{ fontSize: '0.85em', color: 'var(--text-secondary)' }}>{opt.label}</span>
)}
</div>
))}
</div>
)}
</div>
{hint && <div className="hint">{hint}</div>}
{error && <div className="error-box">{error}</div>}
</div>
);
}

View File

@@ -20,6 +20,7 @@ export function Button({
disabled,
...rest
}: PropsWithChildren<ButtonProps>) {
const hasChildren = children !== null && children !== undefined && children !== false;
const classes = [
'btn',
`btn-${variant}`,
@@ -33,7 +34,7 @@ export function Button({
return (
<button className={classes} disabled={disabled || loading} {...rest}>
{loading && <span className="loading-spinner" aria-hidden="true" />}
<span>{children}</span>
{hasChildren && <span>{children}</span>}
</button>
);
}

View File

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

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { IconX } from './icons';
interface ModalProps {
@@ -7,11 +8,42 @@ interface ModalProps {
onClose: () => void;
footer?: ReactNode;
width?: number | string;
className?: string;
closeDisabled?: boolean;
}
const CLOSE_ANIMATION_DURATION = 350;
const MODAL_LOCK_CLASS = 'modal-open';
let activeModalCount = 0;
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
const lockScroll = () => {
if (typeof document === 'undefined') return;
if (activeModalCount === 0) {
document.body?.classList.add(MODAL_LOCK_CLASS);
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
}
activeModalCount += 1;
};
const unlockScroll = () => {
if (typeof document === 'undefined') return;
activeModalCount = Math.max(0, activeModalCount - 1);
if (activeModalCount === 0) {
document.body?.classList.remove(MODAL_LOCK_CLASS);
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
}
};
export function Modal({
open,
title,
onClose,
footer,
width = 520,
className,
closeDisabled = false,
children
}: PropsWithChildren<ModalProps>) {
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -33,19 +65,28 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
);
useEffect(() => {
let cancelled = false;
if (open) {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsVisible(true);
setIsClosing(false);
return;
queueMicrotask(() => {
if (cancelled) return;
setIsVisible(true);
setIsClosing(false);
});
} else if (isVisible) {
queueMicrotask(() => {
if (cancelled) return;
startClose(false);
});
}
if (isVisible) {
startClose(false);
}
return () => {
cancelled = true;
};
}, [open, isVisible, startClose]);
const handleClose = useCallback(() => {
@@ -60,15 +101,29 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
};
}, []);
const shouldLockScroll = open || isVisible;
useEffect(() => {
if (!shouldLockScroll) return;
lockScroll();
return () => unlockScroll();
}, [shouldLockScroll]);
if (!open && !isVisible) return null;
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}${className ? ` ${className}` : ''}`;
return (
const modalContent = (
<div className={overlayClass}>
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
<button
type="button"
className="modal-close-floating"
onClick={closeDisabled ? undefined : handleClose}
aria-label="Close"
disabled={closeDisabled}
>
<IconX size={20} />
</button>
<div className="modal-header">
@@ -79,4 +134,10 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
</div>
</div>
);
if (typeof document === 'undefined') {
return modalContent;
}
return createPortal(modalContent, document.body);
}

View File

@@ -1,12 +1,7 @@
import { Fragment } from 'react';
import { Button } from './Button';
import { IconX } from './icons';
import type { ModelAlias } from '@/types';
interface ModelEntry {
name: string;
alias: string;
}
import type { ModelEntry } from './modelInputListUtils';
interface ModelInputListProps {
entries: ModelEntry[];
@@ -17,29 +12,6 @@ interface ModelInputListProps {
aliasPlaceholder?: string;
}
export const modelsToEntries = (models?: ModelAlias[]): ModelEntry[] => {
if (!Array.isArray(models) || models.length === 0) {
return [{ name: '', alias: '' }];
}
return models.map((m) => ({
name: m.name || '',
alias: m.alias || ''
}));
};
export const entriesToModels = (entries: ModelEntry[]): ModelAlias[] => {
return entries
.filter((entry) => entry.name.trim())
.map((entry) => {
const model: ModelAlias = { name: entry.name.trim() };
const alias = entry.alias.trim();
if (alias && alias !== model.name) {
model.alias = alias;
}
return model;
});
};
export function ModelInputList({
entries,
onChange,

View File

@@ -0,0 +1,58 @@
.root {
position: relative;
display: inline-flex;
align-items: center;
gap: $spacing-sm;
cursor: pointer;
}
.labelLeft {
.label {
order: -1;
}
}
.disabled {
cursor: not-allowed;
}
.root input {
width: 0;
height: 0;
opacity: 0;
position: absolute;
}
.track {
width: 44px;
height: 24px;
background: var(--border-color);
border-radius: $radius-full;
position: relative;
transition: background $transition-fast;
}
.thumb {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
background: #fff;
border-radius: $radius-full;
box-shadow: $shadow-sm;
transition: transform $transition-fast;
}
.root input:checked + .track {
background: var(--primary-color);
}
.root input:checked + .track .thumb {
transform: translateX(20px);
}
.label {
color: var(--text-primary);
font-weight: 600;
}

View File

@@ -1,9 +1,11 @@
import type { ChangeEvent, ReactNode } from 'react';
import styles from './ToggleSwitch.module.scss';
interface ToggleSwitchProps {
checked: boolean;
onChange: (value: boolean) => void;
label?: ReactNode;
ariaLabel?: string;
disabled?: boolean;
labelPosition?: 'left' | 'right';
}
@@ -12,6 +14,7 @@ export function ToggleSwitch({
checked,
onChange,
label,
ariaLabel,
disabled = false,
labelPosition = 'right'
}: ToggleSwitchProps) {
@@ -19,17 +22,27 @@ export function ToggleSwitch({
onChange(event.target.checked);
};
const className = ['switch', labelPosition === 'left' ? 'switch-label-left' : '']
const className = [
styles.root,
labelPosition === 'left' ? styles.labelLeft : '',
disabled ? styles.disabled : '',
]
.filter(Boolean)
.join(' ');
return (
<label className={className}>
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
<span className="track">
<span className="thumb" />
<input
type="checkbox"
checked={checked}
onChange={handleChange}
disabled={disabled}
aria-label={ariaLabel}
/>
<span className={styles.track}>
<span className={styles.thumb} />
</span>
{label && <span className="label">{label}</span>}
{label && <span className={styles.label}>{label}</span>}
</label>
);
}

View File

@@ -164,6 +164,14 @@ export function IconChevronDown({ size = 20, ...props }: IconProps) {
);
}
export function IconChevronLeft({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="m15 18-6-6 6-6" />
</svg>
);
}
export function IconSearch({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
@@ -314,3 +322,11 @@ export function IconLayoutDashboard({ size = 20, ...props }: IconProps) {
</svg>
);
}
export function IconActivity({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
</svg>
);
}

View File

@@ -0,0 +1,29 @@
import type { ModelAlias } from '@/types';
export interface ModelEntry {
name: string;
alias: string;
}
export const modelsToEntries = (models?: ModelAlias[]): ModelEntry[] => {
if (!Array.isArray(models) || models.length === 0) {
return [{ name: '', alias: '' }];
}
return models.map((model) => ({
name: model.name || '',
alias: model.alias || ''
}));
};
export const entriesToModels = (entries: ModelEntry[]): ModelAlias[] => {
return entries
.filter((entry) => entry.name.trim())
.map((entry) => {
const model: ModelAlias = { name: entry.name.trim() };
const alias = entry.alias.trim();
if (alias && alias !== model.name) {
model.alias = alias;
}
return model;
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useNotificationStore } from '@/stores';
import { usageApi } from '@/services/api/usage';
import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage';
export interface UsagePayload {
total_requests?: number;
success_count?: number;
failure_count?: number;
total_tokens?: number;
apis?: Record<string, unknown>;
[key: string]: unknown;
}
export interface UseUsageDataReturn {
usage: UsagePayload | null;
loading: boolean;
error: string;
modelPrices: Record<string, ModelPrice>;
setModelPrices: (prices: Record<string, ModelPrice>) => void;
loadUsage: () => Promise<void>;
handleExport: () => Promise<void>;
handleImport: () => void;
handleImportChange: (event: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
importInputRef: React.RefObject<HTMLInputElement | null>;
exporting: boolean;
importing: boolean;
}
export function useUsageData(): UseUsageDataReturn {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const [usage, setUsage] = useState<UsagePayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const importInputRef = useRef<HTMLInputElement | null>(null);
const loadUsage = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await usageApi.getUsage();
const payload = data?.usage ?? data;
setUsage(payload);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
setError(message);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
loadUsage();
setModelPrices(loadModelPrices());
}, [loadUsage]);
const handleExport = async () => {
setExporting(true);
try {
const data = await usageApi.exportUsage();
const exportedAt =
typeof data?.exported_at === 'string' ? new Date(data.exported_at) : new Date();
const safeTimestamp = Number.isNaN(exportedAt.getTime())
? new Date().toISOString()
: exportedAt.toISOString();
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
showNotification(t('usage_stats.export_success'), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setExporting(false);
}
};
const handleImport = () => {
importInputRef.current?.click();
};
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setImporting(true);
try {
const text = await file.text();
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
showNotification(t('usage_stats.import_invalid'), 'error');
return;
}
const result = await usageApi.importUsage(payload);
showNotification(
t('usage_stats.import_success', {
added: result?.added ?? 0,
skipped: result?.skipped ?? 0,
total: result?.total_requests ?? 0,
failed: result?.failed_requests ?? 0
}),
'success'
);
await loadUsage();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
`${t('notification.upload_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setImporting(false);
}
};
const handleSetModelPrices = useCallback((prices: Record<string, ModelPrice>) => {
setModelPrices(prices);
saveModelPrices(prices);
}, []);
return {
usage,
loading,
error,
modelPrices,
setModelPrices: handleSetModelPrices,
loadUsage,
handleExport,
handleImport,
handleImportChange,
importInputRef,
exporting,
importing
};
}

View File

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

View File

@@ -8,3 +8,6 @@ export { useLocalStorage } from './useLocalStorage';
export { useInterval } from './useInterval';
export { useMediaQuery } from './useMediaQuery';
export { usePagination } from './usePagination';
export { useHeaderRefresh } from './useHeaderRefresh';
export { useDisableModel } from './useDisableModel';
export type { UseDisableModelOptions, UseDisableModelReturn } from './useDisableModel';

View File

@@ -0,0 +1,199 @@
/**
* 禁用模型 Hook
* 封装禁用模型的状态管理和业务逻辑
*/
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { providersApi } from '@/services/api';
import { useDisabledModelsStore } from '@/stores';
import {
resolveProvider,
createDisableState,
type DisableState,
} from '@/utils/monitor';
import type { OpenAIProviderConfig } from '@/types';
// 不支持禁用的渠道类型(小写)
const UNSUPPORTED_PROVIDER_TYPES = ['claude', 'gemini', 'codex', 'vertex'];
/**
* 不支持禁用的提示状态
*/
export interface UnsupportedDisableState {
providerType: string;
model: string;
displayName: string;
}
export interface UseDisableModelOptions {
providerMap: Record<string, string>;
providerTypeMap?: Record<string, string>;
providerModels?: Record<string, Set<string>>;
}
export interface UseDisableModelReturn {
/** 当前禁用状态 */
disableState: DisableState | null;
/** 不支持禁用的提示状态 */
unsupportedState: UnsupportedDisableState | null;
/** 是否正在禁用中 */
disabling: boolean;
/** 开始禁用流程 */
handleDisableClick: (source: string, model: string) => void;
/** 确认禁用需要点击3次 */
handleConfirmDisable: () => Promise<void>;
/** 取消禁用 */
handleCancelDisable: () => void;
/** 关闭不支持提示 */
handleCloseUnsupported: () => void;
/** 检查模型是否已禁用 */
isModelDisabled: (source: string, model: string) => boolean;
}
/**
* 禁用模型 Hook
* @param options 配置选项
* @returns 禁用模型相关的状态和方法
*/
export function useDisableModel(options: UseDisableModelOptions): UseDisableModelReturn {
const { providerMap, providerTypeMap, providerModels } = options;
const { t } = useTranslation();
// 使用全局 store 管理禁用状态
const { addDisabledModel, isDisabled } = useDisabledModelsStore();
const [disableState, setDisableState] = useState<DisableState | null>(null);
const [unsupportedState, setUnsupportedState] = useState<UnsupportedDisableState | null>(null);
const [disabling, setDisabling] = useState(false);
// 开始禁用流程
const handleDisableClick = useCallback((source: string, model: string) => {
// 首先检查提供商类型是否支持禁用
const providerType = providerTypeMap?.[source] || '';
const lowerType = providerType.toLowerCase();
// 如果是不支持的类型,立即显示提示
if (lowerType && UNSUPPORTED_PROVIDER_TYPES.includes(lowerType)) {
const providerName = resolveProvider(source, providerMap);
const displayName = providerName
? `${providerName} / ${model}`
: `${source.slice(0, 8)}*** / ${model}`;
setUnsupportedState({
providerType,
model,
displayName,
});
return;
}
// 支持的类型,进入正常禁用流程
setDisableState(createDisableState(source, model, providerMap));
}, [providerMap, providerTypeMap]);
// 确认禁用需要点击3次
const handleConfirmDisable = useCallback(async () => {
if (!disableState) return;
// 前两次点击只增加步骤
if (disableState.step < 3) {
setDisableState({
...disableState,
step: disableState.step + 1,
});
return;
}
// 第3次点击执行禁用
setDisabling(true);
try {
const providerName = resolveProvider(disableState.source, providerMap);
if (!providerName) {
throw new Error(t('monitor.logs.disable_error_no_provider'));
}
// 获取当前配置
const providers = await providersApi.getOpenAIProviders();
const targetProvider = providers.find(
(p) => p.name && p.name.toLowerCase() === providerName.toLowerCase()
);
if (!targetProvider) {
throw new Error(t('monitor.logs.disable_error_provider_not_found', { provider: providerName }));
}
const originalModels = targetProvider.models || [];
const modelAlias = disableState.model;
// 过滤掉匹配的模型
const filteredModels = originalModels.filter(
(m) => m.alias !== modelAlias && m.name !== modelAlias
);
// 只有当模型确实被过滤掉时才调用 API
if (filteredModels.length < originalModels.length) {
await providersApi.patchOpenAIProviderByName(targetProvider.name, {
models: filteredModels,
} as Partial<OpenAIProviderConfig>);
}
// 标记为已禁用(全局状态)
addDisabledModel(disableState.source, disableState.model);
setDisableState(null);
} catch (err) {
console.error('禁用模型失败:', err);
alert(err instanceof Error ? err.message : t('monitor.logs.disable_error'));
} finally {
setDisabling(false);
}
}, [disableState, providerMap, t, addDisabledModel]);
// 取消禁用
const handleCancelDisable = useCallback(() => {
setDisableState(null);
}, []);
// 关闭不支持提示
const handleCloseUnsupported = useCallback(() => {
setUnsupportedState(null);
}, []);
// 检查模型是否已禁用
const isModelDisabled = useCallback((source: string, model: string): boolean => {
// 首先检查全局状态中是否已禁用
if (isDisabled(source, model)) {
return true;
}
// 如果提供了 providerModels检查配置中是否已移除
if (providerModels) {
if (!source || !model) return false;
// 首先尝试完全匹配
if (providerModels[source]) {
return !providerModels[source].has(model);
}
// 然后尝试前缀匹配
const entries = Object.entries(providerModels);
for (const [key, modelSet] of entries) {
if (source.startsWith(key) || key.startsWith(source)) {
return !modelSet.has(model);
}
}
}
return false;
}, [isDisabled, providerModels]);
return {
disableState,
unsupportedState,
disabling,
handleDisableClick,
handleConfirmDisable,
handleCancelDisable,
handleCloseUnsupported,
isModelDisabled,
};
}

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