Compare commits

..

192 Commits
v1.2.13 ... dev

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

View File

@@ -1,20 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
};

View File

@@ -22,7 +22,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '18' node-version: '20'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies

2
.gitignore vendored
View File

@@ -10,6 +10,7 @@ api.md
usage.json usage.json
CLAUDE.md CLAUDE.md
AGENTS.md AGENTS.md
management-api*
antigravity_usage.json antigravity_usage.json
codex_usage.json codex_usage.json
style.md style.md
@@ -18,6 +19,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
skills
# Editor directories and files # Editor directories and files
settings.local.json settings.local.json

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Router-For.ME
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,23 +1,23 @@
# CLI Proxy API Management Center # CLI Proxy API Management Center
A single-file WebUI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage). A single-file Web UI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
[中文文档](README_CN.md) [中文文档](README_CN.md)
**Main Project**: https://github.com/router-for-me/CLIProxyAPI **Main Project**: https://github.com/router-for-me/CLIProxyAPI
**Example URL**: https://remote.router-for.me/ **Example URL**: https://remote.router-for.me/
**Minimum Required Version**: ≥ 6.3.0 (recommended ≥ 6.5.0) **Minimum Required Version**: ≥ 6.8.0 (recommended ≥ 6.8.15)
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running. Since version 6.0.19, the Web UI ships with the main program; access it via `/management.html` on the API port once the service is running.
## What this is (and isnt) ## What this is (and isnt)
- This repository is the WebUI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage. - This repository is the Web UI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage.
- It is **not** a proxy and does not forward traffic. - It is **not** a proxy and does not forward traffic.
## Quick start ## Quick start
### Option A: Use the WebUI bundled in CLIProxyAPI (recommended) ### Option A: Use the Web UI bundled in CLI Proxy API (recommended)
1. Start your CLI Proxy API service. 1. Start your CLI Proxy API service.
2. Open: `http://<host>:<api_port>/management.html` 2. Open: `http://<host>:<api_port>/management.html`
@@ -32,7 +32,7 @@ npm install
npm run dev npm run dev
``` ```
Open `http://localhost:5173`, then connect to your CLI Proxy API instance. Open `http://localhost:5173`, then connect to your CLI Proxy API backend instance.
### Option C: Build a single HTML file ### Option C: Build a single HTML file
@@ -42,7 +42,7 @@ npm run build
``` ```
- Output: `dist/index.html` (all assets are inlined). - Output: `dist/index.html` (all assets are inlined).
- For CLIProxyAPI bundling, the release workflow renames it to `management.html`. - For CLI Proxy API bundling, the release workflow renames it to `management.html`.
- To preview locally: `npm run preview` - To preview locally: `npm run preview`
Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable. Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
@@ -74,19 +74,48 @@ See `api.md` for the full authentication rules, server-side limits, and edge cas
## What you can manage (mapped to the UI pages) ## What you can manage (mapped to the UI pages)
- **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot. - **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot.
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project/preview models), usage statistics, request logging, file logging, WebSocket auth. - **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project or preview models when limits reached), usage statistics, request logging, file logging, WebSocket auth.
- **API Keys**: manage proxy `api-keys` (add/edit/delete). - **API Keys**: manage proxy `api-keys` (add/edit/delete).
- **AI Providers**: - **AI Providers**:
- Gemini/Codex/Claude key entries (base URL, headers, proxy, model aliases, excluded models, prefix). - Gemini/Codex/Claude/Vertex key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side chat/completions test). - OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side "chat/completions" test).
- Ampcode integration (upstream URL/key, force mappings, model mapping table). - Ampcode integration (upstream URL/key, force mappings, model mapping table).
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards). - **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards), configure OAuth model alias mappings.
- **OAuth**: start OAuth/device flows for supported providers, poll status, optionally submit callback `redirect_url`; includes iFlow cookie import. - **OAuth**: start OAuth/device flows for supported providers, poll status, optionally submit callback `redirect_url`; includes iFlow cookie import.
- **Quota Management**: manage quota limits and usage for Claude, Antigravity, Codex, Gemini CLI, and other providers.
- **Usage**: requests/tokens charts (hour/day), per-API & per-model breakdown, cached/reasoning token breakdown, RPM/TPM window, optional cost estimation with locally-saved model pricing. - **Usage**: requests/tokens charts (hour/day), per-API & per-model breakdown, cached/reasoning token breakdown, RPM/TPM window, optional cost estimation with locally-saved model pricing.
- **Config**: edit `/config.yaml` in-browser with YAML highlighting + search, then save/reload. - **Config**: edit `/config.yaml` in-browser with YAML highlighting + search, then save/reload.
- **Logs**: tail logs with incremental polling, auto-refresh, search, hide management traffic, clear logs; download request error log files. - **Logs**: tail logs with incremental polling, auto-refresh, search, hide management traffic, clear logs; download request error log files.
- **System**: quick links + fetch `/v1/models` (grouped view). Requires at least one proxy API key to query models. - **System**: quick links + fetch `/v1/models` (grouped view). Requires at least one proxy API key to query models.
## Tech Stack
- React 19 + TypeScript 5.9
- Vite 7 (single-file build)
- Zustand (state management)
- Axios (HTTP client)
- react-router-dom v7 (HashRouter)
- Chart.js (data visualization)
- CodeMirror 6 (YAML editor)
- SCSS Modules (styling)
- i18next (internationalization)
## Internationalization
Currently supports three languages:
- English (en)
- Simplified Chinese (zh-CN)
- Russian (ru)
The UI language is automatically detected from browser settings and can be manually switched at the bottom of the page.
## Browser Compatibility
- Build target: `ES2020`
- Supports modern browsers (Chrome, Firefox, Safari, Edge)
- Responsive layout for mobile and tablet access
## Build & release notes ## Build & release notes
- Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`). - Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`).

View File

@@ -1,14 +1,14 @@
# CLI Proxy API 管理中心 # CLI Proxy API 管理中心
用于管理与排障 **CLI Proxy API** 的单文件 WebUIReact + TypeScript通过 **Management API** 完成配置、凭据、日志与统计等运维工作。 用于管理与故障排查 **CLI Proxy API** 的单文件 Web UIReact + TypeScript通过 **Management API** 完成配置、凭据、日志与统计等管理操作。
[English](README.md) [English](README.md)
**主项目**: https://github.com/router-for-me/CLIProxyAPI **主项目**: https://github.com/router-for-me/CLIProxyAPI
**示例地址**: https://remote.router-for.me/ **示例地址**: https://remote.router-for.me/
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0 **最低版本要求**: ≥ 6.8.0(推荐 ≥ 6.8.15
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running. 从6.0.19版本开始,Web UI 随主程序一起提供;服务运行后,通过 API 端口上的"/management.html"访问它。
## 这是什么(以及不是什么) ## 这是什么(以及不是什么)
@@ -17,7 +17,7 @@ Since version 6.0.19, the WebUI ships with the main program; access it via `/man
## 快速开始 ## 快速开始
### 方式 A使用 CLIProxyAPI 自带的 WebUI推荐 ### 方式 A使用 CLI Proxy API 自带的 Web UI推荐
1. 启动 CLI Proxy API 服务。 1. 启动 CLI Proxy API 服务。
2. 打开:`http://<host>:<api_port>/management.html` 2. 打开:`http://<host>:<api_port>/management.html`
@@ -32,7 +32,7 @@ npm install
npm run dev npm run dev
``` ```
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。 打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 后端实例。
### 方式 C构建单文件 HTML ### 方式 C构建单文件 HTML
@@ -42,7 +42,7 @@ npm run build
``` ```
- 构建产物:`dist/index.html`(资源已全部内联)。 - 构建产物:`dist/index.html`(资源已全部内联)。
- 在 CLIProxyAPI 的发布流程里会重命名为 `management.html` - 在 CLI Proxy API 的发布流程里会重命名为 `management.html`
- 本地预览:`npm run preview` - 本地预览:`npm run preview`
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。 提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
@@ -51,7 +51,7 @@ npm run build
### API 地址怎么填 ### API 地址怎么填
以下格式均可WebUI 会自动归一化: 以下格式均可Web UI 会自动归一化:
- `localhost:8317` - `localhost:8317`
- `http://192.168.1.10:8317` - `http://192.168.1.10:8317`
@@ -64,29 +64,57 @@ npm run build
- `Authorization: Bearer <MANAGEMENT_KEY>`(默认) - `Authorization: Bearer <MANAGEMENT_KEY>`(默认)
这与 WebUI 中API Keys页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。 这与 Web UI 中"API Keys"页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
### 远程管理 ### 远程管理
当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。 当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。
完整鉴权规则、限制与边界情况请查看 `api.md`
## 功能一览(按页面对应) ## 功能一览(按页面对应)
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。 - **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
- **基础设置**:调试开关、代理 URL、请求重试、配额回退切项目/切预览模型、使用统计、请求日志、文件日志、WebSocket 鉴权。 - **基础设置**:调试开关、代理 URL、请求重试、配额回退达到上限时切换项目或预览模型、使用统计、请求日志、文件日志、WebSocket 鉴权。
- **API Keys**:管理代理 `api-keys`(增/改/删)。 - **API Keys**:管理代理 `api-keys`(增/改/删)。
- **AI 提供商** - **AI 提供商**
- Gemini/Codex/Claude 配置Base URL、Headers、代理、模型别名、排除模型、Prefix - Gemini/Codex/Claude/Vertex 配置Base URL、Headers、代理、模型别名、排除模型、Prefix
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。 - OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。 - Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only查看单个凭据可用模型依赖后端支持管理 OAuth 排除模型(支持 `*` 通配符)。 - **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only查看单个凭据可用模型依赖后端支持管理 OAuth 排除模型(支持 `*` 通配符);配置 OAuth 模型别名映射
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。 - **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
- **配额管理**:管理 Claude、Antigravity、Codex、Gemini CLI 等提供商的配额上限与使用情况。
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。 - **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
- **配置文件**:浏览器内编辑 `/config.yaml`YAML 高亮 + 搜索),保存/重载。 - **配置文件**:浏览器内编辑 `/config.yaml`YAML 高亮 + 搜索),保存/重载。
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。 - **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。 - **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
## 技术栈
- React 19 + TypeScript 5.9
- Vite 7单文件构建
- Zustand状态管理
- AxiosHTTP 客户端)
- react-router-dom v7HashRouter
- Chart.js数据可视化
- CodeMirror 6YAML 编辑器)
- SCSS Modules样式
- i18next国际化
## 多语言支持
目前支持三种语言:
- 英文 (en)
- 简体中文 (zh-CN)
- 俄文 (ru)
界面语言会根据浏览器设置自动切换,也可在页面底部手动切换。
## 浏览器兼容性
- 构建目标:`ES2020`
- 支持 Chrome、Firefox、Safari、Edge 等现代浏览器
- 支持移动端响应式布局,可通过手机/平板访问
## 构建与发布说明 ## 构建与发布说明
- 使用 Vite 输出 **单文件 HTML**`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。 - 使用 Vite 输出 **单文件 HTML**`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。

54
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-yaml": "^6.1.2",
"@codemirror/merge": "^6.12.0",
"@uiw/react-codemirror": "^4.25.3", "@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2", "axios": "^1.13.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
@@ -19,6 +20,7 @@
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"react-i18next": "^16.4.0", "react-i18next": "^16.4.0",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",
"yaml": "^2.8.2",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
@@ -428,6 +430,19 @@
"crelt": "^1.0.5" "crelt": "^1.0.5"
} }
}, },
"node_modules/@codemirror/merge": {
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.0.tgz",
"integrity": "sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/highlight": "^1.0.0",
"style-mod": "^4.1.0"
}
},
"node_modules/@codemirror/search": { "node_modules/@codemirror/search": {
"version": "6.5.11", "version": "6.5.11",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
@@ -2383,13 +2398,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.2", "version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.11",
"form-data": "^4.0.4", "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
@@ -3780,9 +3795,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.10.1", "version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -3802,12 +3817,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.10.1", "version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.10.1" "react-router": "7.12.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -4227,6 +4242,21 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"dev": true, "dev": true,

View File

@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-yaml": "^6.1.2",
"@codemirror/merge": "^6.12.0",
"@uiw/react-codemirror": "^4.25.3", "@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2", "axios": "^1.13.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
@@ -23,6 +24,7 @@
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"react-i18next": "^16.4.0", "react-i18next": "^16.4.0",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",
"yaml": "^2.8.2",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,32 +1,21 @@
import { useCallback, useEffect, useState } from 'react'; import { useEffect } from 'react';
import { HashRouter, Route, Routes } from 'react-router-dom'; import { HashRouter, Route, Routes } from 'react-router-dom';
import { LoginPage } from '@/pages/LoginPage'; import { LoginPage } from '@/pages/LoginPage';
import { NotificationContainer } from '@/components/common/NotificationContainer'; import { NotificationContainer } from '@/components/common/NotificationContainer';
import { SplashScreen } from '@/components/common/SplashScreen'; import { ConfirmationModal } from '@/components/common/ConfirmationModal';
import { MainLayout } from '@/components/layout/MainLayout'; import { MainLayout } from '@/components/layout/MainLayout';
import { ProtectedRoute } from '@/router/ProtectedRoute'; import { ProtectedRoute } from '@/router/ProtectedRoute';
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores'; import { useLanguageStore, useThemeStore } from '@/stores';
const SPLASH_DURATION = 1500;
const SPLASH_FADE_DURATION = 400;
function App() { function App() {
const initializeTheme = useThemeStore((state) => state.initializeTheme); const initializeTheme = useThemeStore((state) => state.initializeTheme);
const language = useLanguageStore((state) => state.language); const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage); const setLanguage = useLanguageStore((state) => state.setLanguage);
const restoreSession = useAuthStore((state) => state.restoreSession);
const [splashReadyToFade, setSplashReadyToFade] = useState(false);
const [showSplash, setShowSplash] = useState(true);
const [authReady, setAuthReady] = useState(false);
useEffect(() => { useEffect(() => {
const cleanupTheme = initializeTheme(); const cleanupTheme = initializeTheme();
void restoreSession().finally(() => {
setAuthReady(true);
});
return cleanupTheme; return cleanupTheme;
}, [initializeTheme, restoreSession]); }, [initializeTheme]);
useEffect(() => { useEffect(() => {
setLanguage(language); setLanguage(language);
@@ -37,30 +26,10 @@ function App() {
document.documentElement.lang = language; document.documentElement.lang = language;
}, [language]); }, [language]);
useEffect(() => {
const timer = setTimeout(() => {
setSplashReadyToFade(true);
}, SPLASH_DURATION - SPLASH_FADE_DURATION);
return () => clearTimeout(timer);
}, []);
const handleSplashFinish = useCallback(() => {
setShowSplash(false);
}, []);
if (showSplash) {
return (
<SplashScreen
fadeOut={splashReadyToFade && authReady}
onFinish={handleSplashFinish}
/>
);
}
return ( return (
<HashRouter> <HashRouter>
<NotificationContainer /> <NotificationContainer />
<ConfirmationModal />
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route

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

@@ -14,26 +14,41 @@
gap: $spacing-lg; gap: $spacing-lg;
min-height: 0; min-height: 0;
flex: 1; flex: 1;
background: var(--bg-secondary);
backface-visibility: hidden;
transform: translateZ(0);
// During animation, exit layer uses absolute positioning // During animation, exit layer uses absolute positioning
&--exit { &--exit {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 1;
overflow: hidden; overflow: hidden;
pointer-events: none; pointer-events: none;
will-change: transform, opacity;
}
&--stacked {
display: none;
// Keep the previous layer rendered (but invisible) to avoid a blank flash when popping back.
// Older stacked layers remain `display: none` for performance.
&.page-transition__layer--stacked-keep {
display: flex;
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
opacity: 0;
will-change: transform, opacity;
}
} }
} }
&--animating &__layer { &--animating &__layer {
will-change: transform, opacity; will-change: transform, opacity;
backface-visibility: hidden;
transform-style: preserve-3d;
} }
// When both layers exist, current layer also needs positioning &--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) {
&--animating &__layer:not(&__layer--exit) {
position: relative; position: relative;
z-index: 0;
} }
} }

View File

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

View File

@@ -0,0 +1,15 @@
import { createContext, useContext } from 'react';
export type LayerStatus = 'current' | 'exiting' | 'stacked';
type PageTransitionLayerContextValue = {
status: LayerStatus;
};
export const PageTransitionLayerContext =
createContext<PageTransitionLayerContextValue | null>(null);
export function usePageTransitionLayer() {
return useContext(PageTransitionLayerContext);
}

View File

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

@@ -1,4 +1,5 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import './SplashScreen.scss'; import './SplashScreen.scss';
@@ -10,6 +11,8 @@ interface SplashScreenProps {
const FADE_OUT_DURATION = 400; const FADE_OUT_DURATION = 400;
export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) { export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
if (!fadeOut) return; if (!fadeOut) return;
const finishTimer = setTimeout(() => { const finishTimer = setTimeout(() => {
@@ -25,8 +28,8 @@ export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
<div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}> <div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}>
<div className="splash-content"> <div className="splash-content">
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className="splash-logo" /> <img src={INLINE_LOGO_JPEG} alt="CPAMC" className="splash-logo" />
<h1 className="splash-title">CLI Proxy API</h1> <h1 className="splash-title">{t('splash.title')}</h1>
<p className="splash-subtitle">Management Center</p> <p className="splash-subtitle">{t('splash.subtitle')}</p>
<div className="splash-loader"> <div className="splash-loader">
<div className="splash-loader-bar" /> <div className="splash-loader-bar" />
</div> </div>

View File

@@ -0,0 +1,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>
);
}

View File

@@ -0,0 +1,176 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.diffModal {
:global(.modal-body) {
padding: $spacing-md $spacing-lg;
max-height: none;
overflow: hidden;
}
}
.content {
display: flex;
flex-direction: column;
height: 70vh;
min-height: 420px;
}
.emptyState {
flex: 1;
border: 1px dashed var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 14px;
display: grid;
place-items: center;
}
.diffList {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: $spacing-sm;
overflow: auto;
padding-right: 2px;
}
.diffCard {
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
overflow: hidden;
}
.diffCardHeader {
padding: 8px 10px;
border-bottom: 1px dashed var(--border-color);
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
}
.diffColumns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-sm;
padding: $spacing-sm;
}
.diffColumn {
min-width: 0;
border: 1px solid var(--border-color);
border-radius: $radius-sm;
overflow: hidden;
background: var(--bg-primary);
}
.diffColumnHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $spacing-sm;
padding: 8px 10px;
border-bottom: 1px solid var(--border-color);
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-secondary);
}
.lineMeta {
display: inline-flex;
align-items: center;
gap: 8px;
}
.lineRange {
font-size: 11px;
color: var(--text-secondary);
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
}
.contextRange {
font-size: 11px;
color: var(--text-tertiary);
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
}
.codeList {
overflow: auto;
max-height: 280px;
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
}
.codeLine {
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
align-items: start;
border-top: 1px solid color-mix(in srgb, var(--border-color) 55%, transparent);
}
.codeLine:first-child {
border-top: none;
}
.codeLineChanged {
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
}
.codeLineNumber {
padding: 7px 10px 7px 8px;
text-align: right;
font-size: 11px;
color: var(--text-tertiary);
border-right: 1px solid color-mix(in srgb, var(--border-color) 55%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 90%, transparent);
font-variant-numeric: tabular-nums;
user-select: none;
box-sizing: border-box;
}
.codeLineText {
padding: 7px 10px;
font-size: 12px;
line-height: 1.45;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
display: block;
box-sizing: border-box;
}
@include mobile {
.content {
height: 65vh;
min-height: 360px;
}
.diffColumns {
grid-template-columns: 1fr;
}
.lineMeta {
flex-direction: column;
align-items: flex-end;
gap: 1px;
}
.codeLine {
grid-template-columns: 44px minmax(0, 1fr);
}
.codeLineNumber {
padding: 6px 6px 6px 4px;
font-size: 10px;
}
.codeLineText {
padding: 6px 8px;
font-size: 11px;
line-height: 1.4;
}
}

View File

@@ -0,0 +1,196 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Text } from '@codemirror/state';
import { Chunk } from '@codemirror/merge';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import styles from './DiffModal.module.scss';
type DiffModalProps = {
open: boolean;
original: string;
modified: string;
onConfirm: () => void;
onCancel: () => void;
loading?: boolean;
};
type DiffChunkCard = {
id: string;
current: DiffSide;
modified: DiffSide;
};
type LineRange = {
start: number;
end: number;
};
type DiffSideLine = {
lineNumber: number;
text: string;
changed: boolean;
};
type DiffSide = {
changedRangeLabel: string;
contextRangeLabel: string;
lines: DiffSideLine[];
};
const DIFF_CONTEXT_LINES = 2;
const clampPos = (doc: Text, pos: number) => Math.max(0, Math.min(pos, doc.length));
const getLineRangeLabel = (range: LineRange): string => {
return range.start === range.end ? String(range.start) : `${range.start}-${range.end}`;
};
const getChangedLineRange = (doc: Text, from: number, to: number): LineRange => {
const start = clampPos(doc, from);
const end = clampPos(doc, to);
if (start === end) {
const linePos = Math.min(start, doc.length);
const anchorLine = doc.lineAt(linePos).number;
return { start: anchorLine, end: anchorLine };
}
const startLine = doc.lineAt(start).number;
const endLine = doc.lineAt(Math.max(start, end - 1)).number;
return { start: startLine, end: endLine };
};
const expandContextRange = (doc: Text, range: LineRange): LineRange => ({
start: Math.max(1, range.start - DIFF_CONTEXT_LINES),
end: Math.min(doc.lines, range.end + DIFF_CONTEXT_LINES)
});
const buildSideLines = (doc: Text, contextRange: LineRange, changedRange: LineRange): DiffSideLine[] => {
const lines: DiffSideLine[] = [];
for (let lineNumber = contextRange.start; lineNumber <= contextRange.end; lineNumber += 1) {
lines.push({
lineNumber,
text: doc.line(lineNumber).text,
changed: lineNumber >= changedRange.start && lineNumber <= changedRange.end
});
}
return lines;
};
export function DiffModal({
open,
original,
modified,
onConfirm,
onCancel,
loading = false
}: DiffModalProps) {
const { t } = useTranslation();
const diffCards = useMemo<DiffChunkCard[]>(() => {
const currentDoc = Text.of(original.split('\n'));
const modifiedDoc = Text.of(modified.split('\n'));
const chunks = Chunk.build(currentDoc, modifiedDoc);
return chunks.map((chunk, index) => {
const currentChangedRange = getChangedLineRange(currentDoc, chunk.fromA, chunk.toA);
const modifiedChangedRange = getChangedLineRange(modifiedDoc, chunk.fromB, chunk.toB);
const currentContextRange = expandContextRange(currentDoc, currentChangedRange);
const modifiedContextRange = expandContextRange(modifiedDoc, modifiedChangedRange);
return {
id: `${index}-${chunk.fromA}-${chunk.toA}-${chunk.fromB}-${chunk.toB}`,
current: {
changedRangeLabel: getLineRangeLabel(currentChangedRange),
contextRangeLabel: getLineRangeLabel(currentContextRange),
lines: buildSideLines(currentDoc, currentContextRange, currentChangedRange)
},
modified: {
changedRangeLabel: getLineRangeLabel(modifiedChangedRange),
contextRangeLabel: getLineRangeLabel(modifiedContextRange),
lines: buildSideLines(modifiedDoc, modifiedContextRange, modifiedChangedRange)
}
};
});
}, [modified, original]);
return (
<Modal
open={open}
title={t('config_management.diff.title')}
onClose={onCancel}
width="min(1200px, 90vw)"
className={styles.diffModal}
closeDisabled={loading}
footer={
<>
<Button variant="secondary" onClick={onCancel} disabled={loading}>
{t('common.cancel')}
</Button>
<Button onClick={onConfirm} loading={loading} disabled={loading}>
{t('config_management.diff.confirm')}
</Button>
</>
}
>
<div className={styles.content}>
{diffCards.length === 0 ? (
<div className={styles.emptyState}>{t('config_management.diff.no_changes')}</div>
) : (
<div className={styles.diffList}>
{diffCards.map((card, index) => (
<article key={card.id} className={styles.diffCard}>
<div className={styles.diffCardHeader}>#{index + 1}</div>
<div className={styles.diffColumns}>
<section className={styles.diffColumn}>
<header className={styles.diffColumnHeader}>
<span>{t('config_management.diff.current')}</span>
<span className={styles.lineMeta}>
<span className={styles.lineRange}>L{card.current.changedRangeLabel}</span>
<span className={styles.contextRange}>
±{DIFF_CONTEXT_LINES}: L{card.current.contextRangeLabel}
</span>
</span>
</header>
<div className={styles.codeList}>
{card.current.lines.map((line) => (
<div
key={`${card.id}-a-${line.lineNumber}`}
className={`${styles.codeLine} ${line.changed ? styles.codeLineChanged : ''}`}
>
<span className={styles.codeLineNumber}>{line.lineNumber}</span>
<code className={styles.codeLineText}>{line.text || ' '}</code>
</div>
))}
</div>
</section>
<section className={styles.diffColumn}>
<header className={styles.diffColumnHeader}>
<span>{t('config_management.diff.modified')}</span>
<span className={styles.lineMeta}>
<span className={styles.lineRange}>L{card.modified.changedRangeLabel}</span>
<span className={styles.contextRange}>
±{DIFF_CONTEXT_LINES}: L{card.modified.contextRangeLabel}
</span>
</span>
</header>
<div className={styles.codeList}>
{card.modified.lines.map((line) => (
<div
key={`${card.id}-b-${line.lineNumber}`}
className={`${styles.codeLine} ${line.changed ? styles.codeLineChanged : ''}`}
>
<span className={styles.codeLineNumber}>{line.lineNumber}</span>
<code className={styles.codeLineText}>{line.text || ' '}</code>
</div>
))}
</div>
</section>
</div>
</article>
))}
</div>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,37 @@
.payloadRuleModelRow {
display: grid;
grid-template-columns: 1fr 160px auto;
gap: 8px;
align-items: center;
}
.payloadRuleModelRowProtocolFirst {
grid-template-columns: 160px 1fr auto;
}
.payloadRuleParamRow {
display: grid;
grid-template-columns: 1fr 140px 1fr auto;
gap: 8px;
align-items: center;
}
.payloadFilterModelRow {
display: grid;
grid-template-columns: 1fr 160px auto;
gap: 8px;
align-items: center;
}
@media (max-width: 900px) {
.payloadRuleModelRow,
.payloadRuleModelRowProtocolFirst,
.payloadRuleParamRow,
.payloadFilterModelRow {
grid-template-columns: minmax(0, 1fr);
}
.payloadRowActionButton {
width: 100%;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,6 @@ import {
import { NavLink, useLocation } from 'react-router-dom'; import { NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { PageTransition } from '@/components/common/PageTransition'; import { PageTransition } from '@/components/common/PageTransition';
import { MainRoutes } from '@/router/MainRoutes'; import { MainRoutes } from '@/router/MainRoutes';
import { import {
@@ -19,12 +17,10 @@ import {
IconChartLine, IconChartLine,
IconFileText, IconFileText,
IconInfo, IconInfo,
IconKey,
IconLayoutDashboard, IconLayoutDashboard,
IconScrollText, IconScrollText,
IconSettings, IconSettings,
IconShield, IconShield,
IconSlidersHorizontal,
IconTimer, IconTimer,
} from '@/components/ui/icons'; } from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
@@ -35,13 +31,13 @@ import {
useNotificationStore, useNotificationStore,
useThemeStore, useThemeStore,
} from '@/stores'; } from '@/stores';
import { configApi, versionApi } from '@/services/api'; import { versionApi } from '@/services/api';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
import { isSupportedLanguage } from '@/utils/language';
const sidebarIcons: Record<string, ReactNode> = { const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />, dashboard: <IconLayoutDashboard size={18} />,
settings: <IconSlidersHorizontal size={18} />,
apiKeys: <IconKey size={18} />,
aiProviders: <IconBot size={18} />, aiProviders: <IconBot size={18} />,
authFiles: <IconFileText size={18} />, authFiles: <IconFileText size={18} />,
oauth: <IconShield size={18} />, oauth: <IconShield size={18} />,
@@ -176,44 +172,36 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
}; };
export function MainLayout() { export function MainLayout() {
const { t, i18n } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const location = useLocation(); const location = useLocation();
const apiBase = useAuthStore((state) => state.apiBase); const apiBase = useAuthStore((state) => state.apiBase);
const serverVersion = useAuthStore((state) => state.serverVersion); const serverVersion = useAuthStore((state) => state.serverVersion);
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const logout = useAuthStore((state) => state.logout); const logout = useAuthStore((state) => state.logout);
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig); const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache); const clearCache = useConfigStore((state) => state.clearCache);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const theme = useThemeStore((state) => state.theme); const theme = useThemeStore((state) => state.theme);
const cycleTheme = useThemeStore((state) => state.cycleTheme); const cycleTheme = useThemeStore((state) => state.cycleTheme);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage); const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false); const [checkingVersion, setCheckingVersion] = useState(false);
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true); const [brandExpanded, setBrandExpanded] = useState(true);
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
const languageMenuRef = useRef<HTMLDivElement | null>(null);
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRef = useRef<HTMLElement | null>(null); const headerRef = useRef<HTMLElement | null>(null);
const versionTapCount = useRef(0);
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const fullBrandName = 'CLI Proxy API Management Center'; const fullBrandName = 'CLI Proxy API Management Center';
const abbrBrandName = t('title.abbr'); const abbrBrandName = t('title.abbr');
const requestLogEnabled = config?.requestLog ?? false;
const requestLogDirty = requestLogDraft !== requestLogEnabled;
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
const isLogsPage = location.pathname.startsWith('/logs'); const isLogsPage = location.pathname.startsWith('/logs');
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动 // 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
@@ -245,6 +233,38 @@ export function MainLayout() {
}; };
}, []); }, []);
// 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏、提供商导航)对齐到内容区
useLayoutEffect(() => {
const updateContentCenter = () => {
const el = contentRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
document.documentElement.style.setProperty('--content-center-x', `${centerX}px`);
};
updateContentCenter();
const resizeObserver =
typeof ResizeObserver !== 'undefined' && contentRef.current
? new ResizeObserver(updateContentCenter)
: null;
if (resizeObserver && contentRef.current) {
resizeObserver.observe(contentRef.current);
}
window.addEventListener('resize', updateContentCenter);
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
window.removeEventListener('resize', updateContentCenter);
document.documentElement.style.removeProperty('--content-center-x');
};
}, []);
// 5秒后自动收起品牌名称 // 5秒后自动收起品牌名称
useEffect(() => { useEffect(() => {
brandCollapseTimer.current = setTimeout(() => { brandCollapseTimer.current = setTimeout(() => {
@@ -259,18 +279,30 @@ export function MainLayout() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (requestLogModalOpen && !requestLogTouched) { if (!languageMenuOpen) {
setRequestLogDraft(requestLogEnabled); return;
} }
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
useEffect(() => { const handlePointerDown = (event: MouseEvent) => {
return () => { if (!languageMenuRef.current?.contains(event.target as Node)) {
if (versionTapTimer.current) { setLanguageMenuOpen(false);
clearTimeout(versionTapTimer.current);
} }
}; };
}, []);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setLanguageMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleEscape);
};
}, [languageMenuOpen]);
const handleBrandClick = useCallback(() => { const handleBrandClick = useCallback(() => {
if (!brandExpanded) { if (!brandExpanded) {
@@ -285,59 +317,20 @@ export function MainLayout() {
} }
}, [brandExpanded]); }, [brandExpanded]);
const openRequestLogModal = useCallback(() => { const toggleLanguageMenu = useCallback(() => {
setRequestLogTouched(false); setLanguageMenuOpen((prev) => !prev);
setRequestLogDraft(requestLogEnabled);
setRequestLogModalOpen(true);
}, [requestLogEnabled]);
const handleRequestLogClose = useCallback(() => {
setRequestLogModalOpen(false);
setRequestLogTouched(false);
}, []); }, []);
const handleVersionTap = useCallback(() => { const handleLanguageSelect = useCallback(
versionTapCount.current += 1; (nextLanguage: string) => {
if (versionTapTimer.current) { if (!isSupportedLanguage(nextLanguage)) {
clearTimeout(versionTapTimer.current); return;
}
versionTapTimer.current = setTimeout(() => {
versionTapCount.current = 0;
}, 1500);
if (versionTapCount.current >= 7) {
versionTapCount.current = 0;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
versionTapTimer.current = null;
} }
openRequestLogModal(); setLanguage(nextLanguage);
} setLanguageMenuOpen(false);
}, [openRequestLogModal]); },
[setLanguage]
const handleRequestLogSave = async () => { );
if (!canEditRequestLog) return;
if (!requestLogDirty) {
setRequestLogModalOpen(false);
return;
}
const previous = requestLogEnabled;
setRequestLogSaving(true);
updateConfigValue('request-log', requestLogDraft);
try {
await configApi.updateRequestLog(requestLogDraft);
clearCache('request-log');
showNotification(t('notification.request_log_updated'), 'success');
setRequestLogModalOpen(false);
} catch (error: any) {
updateConfigValue('request-log', previous);
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
} finally {
setRequestLogSaving(false);
}
};
useEffect(() => { useEffect(() => {
fetchConfig().catch(() => { fetchConfig().catch(() => {
@@ -357,14 +350,12 @@ export function MainLayout() {
const navItems = [ const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard }, { path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings }, { path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders }, { path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles }, { path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth }, { path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota }, { path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage }, { path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
...(config?.loggingToFile ...(config?.loggingToFile
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }] ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
: []), : []),
@@ -375,6 +366,31 @@ export function MainLayout() {
const trimmedPath = const trimmedPath =
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath; const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
const aiProvidersIndex = navOrder.indexOf('/ai-providers');
if (aiProvidersIndex !== -1) {
if (normalizedPath === '/ai-providers') return aiProvidersIndex;
if (normalizedPath.startsWith('/ai-providers/')) {
if (normalizedPath.startsWith('/ai-providers/gemini')) return aiProvidersIndex + 0.1;
if (normalizedPath.startsWith('/ai-providers/codex')) return aiProvidersIndex + 0.2;
if (normalizedPath.startsWith('/ai-providers/claude')) return aiProvidersIndex + 0.3;
if (normalizedPath.startsWith('/ai-providers/vertex')) return aiProvidersIndex + 0.4;
if (normalizedPath.startsWith('/ai-providers/ampcode')) return aiProvidersIndex + 0.5;
if (normalizedPath.startsWith('/ai-providers/openai')) return aiProvidersIndex + 0.6;
return aiProvidersIndex + 0.05;
}
}
const authFilesIndex = navOrder.indexOf('/auth-files');
if (authFilesIndex !== -1) {
if (normalizedPath === '/auth-files') return authFilesIndex;
if (normalizedPath.startsWith('/auth-files/')) {
if (normalizedPath.startsWith('/auth-files/oauth-excluded')) return authFilesIndex + 0.1;
if (normalizedPath.startsWith('/auth-files/oauth-model-alias')) return authFilesIndex + 0.2;
return authFilesIndex + 0.05;
}
}
const exactIndex = navOrder.indexOf(normalizedPath); const exactIndex = navOrder.indexOf(normalizedPath);
if (exactIndex !== -1) return exactIndex; if (exactIndex !== -1) return exactIndex;
const nestedIndex = navOrder.findIndex( const nestedIndex = navOrder.findIndex(
@@ -383,6 +399,24 @@ export function MainLayout() {
return nestedIndex === -1 ? null : nestedIndex; return nestedIndex === -1 ? null : nestedIndex;
}; };
const getTransitionVariant = useCallback((fromPathname: string, toPathname: string) => {
const normalize = (pathname: string) => {
const trimmed =
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
return trimmed === '/dashboard' ? '/' : trimmed;
};
const from = normalize(fromPathname);
const to = normalize(toPathname);
const isAuthFiles = (pathname: string) =>
pathname === '/auth-files' || pathname.startsWith('/auth-files/');
const isAiProviders = (pathname: string) =>
pathname === '/ai-providers' || pathname.startsWith('/ai-providers/');
if (isAuthFiles(from) && isAuthFiles(to)) return 'ios';
if (isAiProviders(from) && isAiProviders(to)) return 'ios';
return 'vertical';
}, []);
const handleRefreshAll = async () => { const handleRefreshAll = async () => {
clearCache(); clearCache();
const results = await Promise.allSettled([ const results = await Promise.allSettled([
@@ -407,7 +441,8 @@ export function MainLayout() {
setCheckingVersion(true); setCheckingVersion(true);
try { try {
const data = await versionApi.checkLatest(); const data = await versionApi.checkLatest();
const latest = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? ''; const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
const comparison = compareVersions(latest, serverVersion); const comparison = compareVersions(latest, serverVersion);
if (!latest) { if (!latest) {
@@ -425,8 +460,11 @@ export function MainLayout() {
} else { } else {
showNotification(t('system_info.version_is_latest'), 'success'); showNotification(t('system_info.version_is_latest'), 'success');
} }
} catch (error: any) { } catch (error: unknown) {
showNotification(`${t('system_info.version_check_error')}: ${error?.message || ''}`, 'error'); const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
const suffix = message ? `: ${message}` : '';
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
} finally { } finally {
setCheckingVersion(false); setCheckingVersion(false);
} }
@@ -498,9 +536,36 @@ export function MainLayout() {
> >
{headerIcons.update} {headerIcons.update}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}> <div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
{headerIcons.language} <Button
</Button> variant="ghost"
size="sm"
onClick={toggleLanguageMenu}
title={t('language.switch')}
aria-label={t('language.switch')}
aria-haspopup="menu"
aria-expanded={languageMenuOpen}
>
{headerIcons.language}
</Button>
{languageMenuOpen && (
<div className="notification entering language-menu-popover" role="menu" aria-label={t('language.switch')}>
{LANGUAGE_ORDER.map((lang) => (
<button
key={lang}
type="button"
className={`language-menu-option ${language === lang ? 'active' : ''}`}
onClick={() => handleLanguageSelect(lang)}
role="menuitemradio"
aria-checked={language === lang}
>
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
{language === lang ? <span className="language-menu-check"></span> : null}
</button>
))}
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}> <Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
{theme === 'auto' {theme === 'auto'
? headerIcons.autoTheme ? headerIcons.autoTheme
@@ -540,60 +605,12 @@ export function MainLayout() {
<PageTransition <PageTransition
render={(location) => <MainRoutes location={location} />} render={(location) => <MainRoutes location={location} />}
getRouteOrder={getRouteOrder} getRouteOrder={getRouteOrder}
getTransitionVariant={getTransitionVariant}
scrollContainerRef={contentRef} scrollContainerRef={contentRef}
/> />
</main> </main>
<footer className="footer">
<span>
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
</span>
<span className="footer-version" onClick={handleVersionTap}>
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
</span>
<span>
{t('footer.build_date')}:{' '}
{serverBuildDate
? new Date(serverBuildDate).toLocaleString(i18n.language)
: t('system_info.version_unknown')}
</span>
</footer>
</div> </div>
</div> </div>
<Modal
open={requestLogModalOpen}
onClose={handleRequestLogClose}
title={t('basic_settings.request_log_title')}
footer={
<>
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
{t('common.cancel')}
</Button>
<Button
onClick={handleRequestLogSave}
loading={requestLogSaving}
disabled={!canEditRequestLog || !requestLogDirty}
>
{t('common.save')}
</Button>
</>
}
>
<div className="request-log-modal">
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
<ToggleSwitch
label={t('basic_settings.request_log_enable')}
labelPosition="left"
checked={requestLogDraft}
disabled={!canEditRequestLog || requestLogSaving}
onChange={(value) => {
setRequestLogDraft(value);
setRequestLogTouched(true);
}}
/>
</div>
</Modal>
</div> </div>
); );
} }

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($primary-color, 0.18);
}
}
// Mindmap-style provider branch (root node)
.providerItem {
border-left: 3px solid transparent;
padding-left: 8px;
display: flex;
align-items: center;
gap: 8px;
.providerLabel {
font-weight: 600;
font-size: 13px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collapseBtn {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: var(--bg-secondary);
border-radius: 4px;
cursor: pointer;
color: var(--text-secondary);
transition: background-color 0.15s, color 0.15s;
&:hover {
background: var(--border-color);
color: var(--text-primary);
}
}
.chevronDown,
.chevronRight {
display: inline-block;
width: 0;
height: 0;
border-style: solid;
}
.chevronDown {
border-width: 5px 4px 0 4px;
border-color: currentColor transparent transparent transparent;
}
.chevronRight {
border-width: 4px 0 4px 5px;
border-color: transparent transparent transparent currentColor;
}
}
.providerGroup {
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
}
.sourceItem,
.aliasItem {
cursor: grab;
&:active {
cursor: grabbing;
}
&.dragging {
opacity: 0.5;
border-style: dashed;
}
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
position: absolute;
top: 50%;
margin-top: -3px;
flex-shrink: 0;
&.dotLeft {
left: -3px;
background: var(--text-tertiary);
}
}
.sourceItem .dot {
right: -3px;
}
.providerBadge {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-secondary);
margin-right: 8px;
font-weight: 500;
}
.itemName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemCount {
font-size: 11px;
color: var(--text-tertiary);
margin-left: 8px;
background: var(--bg-secondary);
padding: 1px 6px;
border-radius: 10px;
}
.contextMenu {
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9999;
min-width: 120px;
overflow: hidden;
padding: 4px 0;
.menuItem {
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
transition: background-color 0.1s;
display: flex;
align-items: center;
gap: 8px;
&:hover {
background-color: var(--bg-secondary);
}
&.danger {
color: var(--error-color);
&:hover {
background-color: var(--bg-error-light);
}
}
}
.menuDivider {
height: 1px;
margin: 4px 0;
background: var(--border-color);
padding: 0;
cursor: default;
pointer-events: none;
}
}
.settingsEmpty {
color: var(--text-tertiary);
font-size: 13px;
text-align: center;
padding: $spacing-lg 0;
}
.settingsList {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.settingsRow {
display: grid;
grid-template-columns: minmax(200px, 1fr) auto;
gap: $spacing-md;
align-items: center;
padding: $spacing-sm $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
@media (max-width: 768px) {
grid-template-columns: 1fr;
align-items: flex-start;
}
}
.settingsNames {
display: flex;
align-items: center;
gap: $spacing-xs;
font-size: 13px;
color: var(--text-primary);
min-width: 0;
}
.settingsSource,
.settingsAlias {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.settingsArrow {
color: var(--text-tertiary);
}
.settingsActions {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.settingsLabel {
font-size: 12px;
color: var(--text-secondary);
}
.settingsDelete {
border: 0;
background: transparent;
color: var(--error-color);
padding: 6px;
border-radius: 6px;
cursor: pointer;
&:hover {
background: var(--bg-error-light);
}
}

View File

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

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

@@ -1,264 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useConfigStore, useNotificationStore } from '@/stores';
import { ampcodeApi } from '@/services/api';
import type { AmpcodeConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '../utils';
import type { AmpcodeFormState } from '../types';
interface AmpcodeModalProps {
isOpen: boolean;
disableControls: boolean;
onClose: () => void;
onBusyChange?: (busy: boolean) => void;
}
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const config = useConfigStore((state) => state.config);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [mappingsDirty, setMappingsDirty] = useState(false);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const initializedRef = useRef(false);
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
useEffect(() => {
onBusyChange?.(loading || saving);
}, [loading, saving, onBusyChange]);
useEffect(() => {
if (!isOpen) {
initializedRef.current = false;
setLoading(false);
setSaving(false);
setError('');
setLoaded(false);
setMappingsDirty(false);
setForm(buildAmpcodeFormState(null));
onBusyChange?.(false);
return;
}
if (initializedRef.current) return;
initializedRef.current = true;
setLoading(true);
setLoaded(false);
setMappingsDirty(false);
setError('');
setForm(buildAmpcodeFormState(config?.ampcode ?? null));
void (async () => {
try {
const ampcode = await ampcodeApi.getAmpcode();
setLoaded(true);
updateConfigValue('ampcode', ampcode);
clearCache('ampcode');
setForm(buildAmpcodeFormState(ampcode));
} catch (err: unknown) {
setError(getErrorMessage(err) || t('notification.refresh_failed'));
} finally {
setLoading(false);
}
})();
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
const clearAmpcodeUpstreamApiKey = async () => {
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return;
setSaving(true);
setError('');
try {
await ampcodeApi.clearUpstreamApiKey();
const previous = config?.ampcode ?? {};
const next: AmpcodeConfig = { ...previous };
delete next.upstreamApiKey;
updateConfigValue('ampcode', next);
clearCache('ampcode');
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
setError(message);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
const saveAmpcode = async () => {
if (!loaded && mappingsDirty) {
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
if (!confirmed) return;
}
setSaving(true);
setError('');
try {
const upstreamUrl = form.upstreamUrl.trim();
const overrideKey = form.upstreamApiKey.trim();
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
if (upstreamUrl) {
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
} else {
await ampcodeApi.clearUpstreamUrl();
}
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
if (loaded || mappingsDirty) {
if (modelMappings.length) {
await ampcodeApi.saveModelMappings(modelMappings);
} else {
await ampcodeApi.clearModelMappings();
}
}
if (overrideKey) {
await ampcodeApi.updateUpstreamApiKey(overrideKey);
}
const previous = config?.ampcode ?? {};
const next: AmpcodeConfig = {
upstreamUrl: upstreamUrl || undefined,
forceModelMappings: form.forceModelMappings,
};
if (previous.upstreamApiKey) {
next.upstreamApiKey = previous.upstreamApiKey;
}
if (Array.isArray(previous.modelMappings)) {
next.modelMappings = previous.modelMappings;
}
if (overrideKey) {
next.upstreamApiKey = overrideKey;
}
if (loaded || mappingsDirty) {
if (modelMappings.length) {
next.modelMappings = modelMappings;
} else {
delete next.modelMappings;
}
}
updateConfigValue('ampcode', next);
clearCache('ampcode');
showNotification(t('notification.ampcode_updated'), 'success');
onClose();
} catch (err: unknown) {
const message = getErrorMessage(err);
setError(message);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
return (
<Modal
open={isOpen}
onClose={onClose}
title={t('ai_providers.ampcode_modal_title')}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={saveAmpcode} loading={saving} disabled={disableControls || loading}>
{t('common.save')}
</Button>
</>
}
>
{error && <div className="error-box">{error}</div>}
<Input
label={t('ai_providers.ampcode_upstream_url_label')}
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
value={form.upstreamUrl}
onChange={(e) => setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
disabled={loading || saving}
hint={t('ai_providers.ampcode_upstream_url_hint')}
/>
<Input
label={t('ai_providers.ampcode_upstream_api_key_label')}
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
type="password"
value={form.upstreamApiKey}
onChange={(e) => setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
disabled={loading || saving}
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
/>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
marginTop: -8,
marginBottom: 12,
flexWrap: 'wrap',
}}
>
<div className="hint" style={{ margin: 0 }}>
{t('ai_providers.ampcode_upstream_api_key_current', {
key: config?.ampcode?.upstreamApiKey
? maskApiKey(config.ampcode.upstreamApiKey)
: t('common.not_set'),
})}
</div>
<Button
variant="danger"
size="sm"
onClick={clearAmpcodeUpstreamApiKey}
disabled={loading || saving || !config?.ampcode?.upstreamApiKey}
>
{t('ai_providers.ampcode_clear_upstream_api_key')}
</Button>
</div>
<div className="form-group">
<ToggleSwitch
label={t('ai_providers.ampcode_force_model_mappings_label')}
checked={form.forceModelMappings}
onChange={(value) => setForm((prev) => ({ ...prev, forceModelMappings: value }))}
disabled={loading || saving}
/>
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
</div>
<div className="form-group">
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
<ModelInputList
entries={form.mappingEntries}
onChange={(entries) => {
setMappingsDirty(true);
setForm((prev) => ({ ...prev, mappingEntries: entries }));
}}
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
disabled={loading || saving}
/>
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -5,34 +5,24 @@ import type { AmpcodeConfig } from '@/types';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
import styles from '@/pages/AiProvidersPage.module.scss'; import styles from '@/pages/AiProvidersPage.module.scss';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AmpcodeModal } from './AmpcodeModal';
interface AmpcodeSectionProps { interface AmpcodeSectionProps {
config: AmpcodeConfig | null | undefined; config: AmpcodeConfig | null | undefined;
loading: boolean; loading: boolean;
disableControls: boolean; disableControls: boolean;
isSaving: boolean;
isSwitching: boolean; isSwitching: boolean;
isBusy: boolean; onEdit: () => void;
isModalOpen: boolean;
onOpen: () => void;
onCloseModal: () => void;
onBusyChange: (busy: boolean) => void;
} }
export function AmpcodeSection({ export function AmpcodeSection({
config, config,
loading, loading,
disableControls, disableControls,
isSaving,
isSwitching, isSwitching,
isBusy, onEdit,
isModalOpen,
onOpen,
onCloseModal,
onBusyChange,
}: AmpcodeSectionProps) { }: AmpcodeSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const showLoadingPlaceholder = loading && !config;
return ( return (
<> <>
@@ -46,14 +36,14 @@ export function AmpcodeSection({
extra={ extra={
<Button <Button
size="sm" size="sm"
onClick={onOpen} onClick={onEdit}
disabled={disableControls || isSaving || isBusy || isSwitching} disabled={disableControls || loading || isSwitching}
> >
{t('common.edit')} {t('common.edit')}
</Button> </Button>
} }
> >
{loading ? ( {showLoadingPlaceholder ? (
<div className="hint">{t('common.loading')}</div> <div className="hint">{t('common.loading')}</div>
) : ( ) : (
<> <>
@@ -99,13 +89,6 @@ export function AmpcodeSection({
</> </>
)} )}
</Card> </Card>
<AmpcodeModal
isOpen={isModalOpen}
disableControls={disableControls}
onClose={onCloseModal}
onBusyChange={onBusyChange}
/>
</> </>
); );
} }

View File

@@ -1,128 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types';
interface ClaudeModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: {},
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
excludedText: '',
});
export function ClaudeModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: ClaudeModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: initialData.headers ?? {},
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.claude_edit_modal_title')
: t('ai_providers.claude_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.claude_add_modal_key_label')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.claude_add_modal_url_label')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.claude_add_modal_proxy_label')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={headersToEntries(form.headers)}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.claude_models_label')}</label>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.claude_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={isSaving}
/>
</div>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -6,13 +6,16 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconClaude from '@/assets/icons/claude.svg'; import iconClaude from '@/assets/icons/claude.svg';
import type { ProviderKeyConfig } from '@/types'; import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage'; import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss'; import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList'; import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar'; import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import type { ProviderFormState } from '../types';
import { ClaudeModal } from './ClaudeModal';
interface ClaudeSectionProps { interface ClaudeSectionProps {
configs: ProviderKeyConfig[]; configs: ProviderKeyConfig[];
@@ -20,16 +23,11 @@ interface ClaudeSectionProps {
usageDetails: UsageDetail[]; usageDetails: UsageDetail[];
loading: boolean; loading: boolean;
disableControls: boolean; disableControls: boolean;
isSaving: boolean;
isSwitching: boolean; isSwitching: boolean;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void; onAdd: () => void;
onEdit: (index: number) => void; onEdit: (index: number) => void;
onDelete: (index: number) => void; onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void; onToggle: (index: number, enabled: boolean) => void;
onCloseModal: () => void;
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
} }
export function ClaudeSection({ export function ClaudeSection({
@@ -38,33 +36,34 @@ export function ClaudeSection({
usageDetails, usageDetails,
loading, loading,
disableControls, disableControls,
isSaving,
isSwitching, isSwitching,
isModalOpen,
modalIndex,
onAdd, onAdd,
onEdit, onEdit,
onDelete, onDelete,
onToggle, onToggle,
onCloseModal,
onSave,
}: ClaudeSectionProps) { }: ClaudeSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching; const actionsDisabled = disableControls || loading || isSwitching;
const toggleDisabled = disableControls || loading || isSaving || isSwitching; const toggleDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => { const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>(); const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey)); configs.forEach((config) => {
allApiKeys.forEach((apiKey) => { if (!config.apiKey) return;
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey)); const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
}); });
return cache; return cache;
}, [configs, usageDetails]); }, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return ( return (
<> <>
<Card <Card
@@ -99,12 +98,11 @@ export function ClaudeSection({
/> />
)} )}
renderContent={(item) => { renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels); const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? []; const excludedModels = item.excludedModels ?? [];
const statusData = const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
return ( return (
<Fragment> <Fragment>
@@ -188,15 +186,6 @@ export function ClaudeSection({
}} }}
/> />
</Card> </Card>
<ClaudeModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</> </>
); );
} }

View File

@@ -1,117 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { modelsToEntries } from '@/components/ui/ModelInputList';
import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types';
interface CodexModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: {},
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
excludedText: '',
});
export function CodexModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: CodexModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: initialData.headers ?? {},
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.codex_edit_modal_title')
: t('ai_providers.codex_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.codex_add_modal_key_label')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.codex_add_modal_url_label')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.codex_add_modal_proxy_label')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={headersToEntries(form.headers)}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -3,17 +3,20 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconOpenaiLight from '@/assets/icons/openai-light.svg'; import iconCodexLight from '@/assets/icons/codex_light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import iconCodexDark from '@/assets/icons/codex_drak.svg';
import type { ProviderKeyConfig } from '@/types'; import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage'; import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss'; import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList'; import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar'; import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import type { ProviderFormState } from '../types';
import { CodexModal } from './CodexModal';
interface CodexSectionProps { interface CodexSectionProps {
configs: ProviderKeyConfig[]; configs: ProviderKeyConfig[];
@@ -21,17 +24,12 @@ interface CodexSectionProps {
usageDetails: UsageDetail[]; usageDetails: UsageDetail[];
loading: boolean; loading: boolean;
disableControls: boolean; disableControls: boolean;
isSaving: boolean;
isSwitching: boolean; isSwitching: boolean;
resolvedTheme: string; resolvedTheme: string;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void; onAdd: () => void;
onEdit: (index: number) => void; onEdit: (index: number) => void;
onDelete: (index: number) => void; onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void; onToggle: (index: number, enabled: boolean) => void;
onCloseModal: () => void;
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
} }
export function CodexSection({ export function CodexSection({
@@ -40,41 +38,42 @@ export function CodexSection({
usageDetails, usageDetails,
loading, loading,
disableControls, disableControls,
isSaving,
isSwitching, isSwitching,
resolvedTheme, resolvedTheme,
isModalOpen,
modalIndex,
onAdd, onAdd,
onEdit, onEdit,
onDelete, onDelete,
onToggle, onToggle,
onCloseModal,
onSave,
}: CodexSectionProps) { }: CodexSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching; const actionsDisabled = disableControls || loading || isSwitching;
const toggleDisabled = disableControls || loading || isSaving || isSwitching; const toggleDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => { const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>(); const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey)); configs.forEach((config) => {
allApiKeys.forEach((apiKey) => { if (!config.apiKey) return;
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey)); const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
}); });
return cache; return cache;
}, [configs, usageDetails]); }, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return ( return (
<> <>
<Card <Card
title={ title={
<span className={styles.cardTitle}> <span className={styles.cardTitle}>
<img <img
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight} src={resolvedTheme === 'dark' ? iconCodexDark : iconCodexLight}
alt="" alt=""
className={styles.cardTitleIcon} className={styles.cardTitleIcon}
/> />
@@ -106,12 +105,11 @@ export function CodexSection({
/> />
)} )}
renderContent={(item) => { renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels); const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? []; const excludedModels = item.excludedModels ?? [];
const statusData = const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
return ( return (
<Fragment> <Fragment>
@@ -180,15 +178,6 @@ export function CodexSection({
}} }}
/> />
</Card> </Card>
<CodexModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</> </>
); );
} }

View File

@@ -1,113 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import type { GeminiKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils';
import type { GeminiFormState, ProviderModalProps } from '../types';
interface GeminiModalProps extends ProviderModalProps<GeminiKeyConfig, GeminiFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): GeminiFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
headers: {},
excludedModels: [],
excludedText: '',
});
export function GeminiModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: GeminiModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<GeminiFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: initialData.headers ?? {},
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
const handleSave = () => {
void onSave(form, editIndex);
};
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.gemini_edit_modal_title')
: t('ai_providers.gemini_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.gemini_add_modal_key_label')}
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.gemini_base_url_label')}
placeholder={t('ai_providers.gemini_base_url_placeholder')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<HeaderInputList
entries={headersToEntries(form.headers)}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -6,13 +6,16 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconGemini from '@/assets/icons/gemini.svg'; import iconGemini from '@/assets/icons/gemini.svg';
import type { GeminiKeyConfig } from '@/types'; import type { GeminiKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage'; import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss'; import styles from '@/pages/AiProvidersPage.module.scss';
import type { GeminiFormState } from '../types';
import { ProviderList } from '../ProviderList'; import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar'; import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import { GeminiModal } from './GeminiModal';
interface GeminiSectionProps { interface GeminiSectionProps {
configs: GeminiKeyConfig[]; configs: GeminiKeyConfig[];
@@ -20,16 +23,11 @@ interface GeminiSectionProps {
usageDetails: UsageDetail[]; usageDetails: UsageDetail[];
loading: boolean; loading: boolean;
disableControls: boolean; disableControls: boolean;
isSaving: boolean;
isSwitching: boolean; isSwitching: boolean;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void; onAdd: () => void;
onEdit: (index: number) => void; onEdit: (index: number) => void;
onDelete: (index: number) => void; onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void; onToggle: (index: number, enabled: boolean) => void;
onCloseModal: () => void;
onSave: (data: GeminiFormState, index: number | null) => Promise<void>;
} }
export function GeminiSection({ export function GeminiSection({
@@ -38,33 +36,34 @@ export function GeminiSection({
usageDetails, usageDetails,
loading, loading,
disableControls, disableControls,
isSaving,
isSwitching, isSwitching,
isModalOpen,
modalIndex,
onAdd, onAdd,
onEdit, onEdit,
onDelete, onDelete,
onToggle, onToggle,
onCloseModal,
onSave,
}: GeminiSectionProps) { }: GeminiSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching; const actionsDisabled = disableControls || loading || isSwitching;
const toggleDisabled = disableControls || loading || isSaving || isSwitching; const toggleDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => { const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>(); const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey)); configs.forEach((config) => {
allApiKeys.forEach((apiKey) => { if (!config.apiKey) return;
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey)); const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
}); });
return cache; return cache;
}, [configs, usageDetails]); }, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return ( return (
<> <>
<Card <Card
@@ -99,12 +98,11 @@ export function GeminiSection({
/> />
)} )}
renderContent={(item, index) => { renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels); const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? []; const excludedModels = item.excludedModels ?? [];
const statusData = const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
return ( return (
<Fragment> <Fragment>
@@ -127,6 +125,12 @@ export function GeminiSection({
<span className={styles.fieldValue}>{item.baseUrl}</span> <span className={styles.fieldValue}>{item.baseUrl}</span>
</div> </div>
)} )}
{item.proxyUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
<span className={styles.fieldValue}>{item.proxyUrl}</span>
</div>
)}
{headerEntries.length > 0 && ( {headerEntries.length > 0 && (
<div className={styles.headerBadgeList}> <div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => ( {headerEntries.map(([key, value]) => (
@@ -169,15 +173,6 @@ export function GeminiSection({
}} }}
/> />
</Card> </Card>
<GeminiModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</> </>
); );
} }

View File

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

View File

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

View File

@@ -7,13 +7,16 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import type { OpenAIProviderConfig } from '@/types'; import type { OpenAIProviderConfig } from '@/types';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage'; import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss'; import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList'; import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar'; import { ProviderStatusBar } from '../ProviderStatusBar';
import { getOpenAIProviderStats, getStatsBySource } from '../utils'; import { getOpenAIProviderStats, getStatsBySource } from '../utils';
import type { OpenAIFormState } from '../types';
import { OpenAIModal } from './OpenAIModal';
interface OpenAISectionProps { interface OpenAISectionProps {
configs: OpenAIProviderConfig[]; configs: OpenAIProviderConfig[];
@@ -21,16 +24,11 @@ interface OpenAISectionProps {
usageDetails: UsageDetail[]; usageDetails: UsageDetail[];
loading: boolean; loading: boolean;
disableControls: boolean; disableControls: boolean;
isSaving: boolean;
isSwitching: boolean; isSwitching: boolean;
resolvedTheme: string; resolvedTheme: string;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void; onAdd: () => void;
onEdit: (index: number) => void; onEdit: (index: number) => void;
onDelete: (index: number) => void; onDelete: (index: number) => void;
onCloseModal: () => void;
onSave: (data: OpenAIFormState, index: number | null) => Promise<void>;
} }
export function OpenAISection({ export function OpenAISection({
@@ -39,34 +37,34 @@ export function OpenAISection({
usageDetails, usageDetails,
loading, loading,
disableControls, disableControls,
isSaving,
isSwitching, isSwitching,
resolvedTheme, resolvedTheme,
isModalOpen,
modalIndex,
onAdd, onAdd,
onEdit, onEdit,
onDelete, onDelete,
onCloseModal,
onSave,
}: OpenAISectionProps) { }: OpenAISectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching; const actionsDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => { const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>(); const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((provider) => { configs.forEach((provider) => {
const allKeys = (provider.apiKeyEntries || []).map((entry) => entry.apiKey).filter(Boolean); const sourceIds = new Set<string>();
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source)); buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
(provider.apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
});
const filteredDetails = sourceIds.size
? usageDetails.filter((detail) => sourceIds.has(detail.source))
: [];
cache.set(provider.name, calculateStatusBarData(filteredDetails)); cache.set(provider.name, calculateStatusBarData(filteredDetails));
}); });
return cache; return cache;
}, [configs, usageDetails]); }, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return ( return (
<> <>
<Card <Card
@@ -89,14 +87,14 @@ export function OpenAISection({
<ProviderList<OpenAIProviderConfig> <ProviderList<OpenAIProviderConfig>
items={configs} items={configs}
loading={loading} loading={loading}
keyField={(item) => item.name} keyField={(_, index) => `openai-provider-${index}`}
emptyTitle={t('ai_providers.openai_empty_title')} emptyTitle={t('ai_providers.openai_empty_title')}
emptyDescription={t('ai_providers.openai_empty_desc')} emptyDescription={t('ai_providers.openai_empty_desc')}
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
actionsDisabled={actionsDisabled} actionsDisabled={actionsDisabled}
renderContent={(item) => { renderContent={(item) => {
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, maskApiKey); const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
const apiKeyEntries = item.apiKeyEntries || []; const apiKeyEntries = item.apiKeyEntries || [];
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]); const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
@@ -130,7 +128,7 @@ export function OpenAISection({
</div> </div>
<div className={styles.apiKeyEntryList}> <div className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => { {apiKeyEntries.map((entry, entryIndex) => {
const entryStats = getStatsBySource(entry.apiKey, keyStats, maskApiKey); const entryStats = getStatsBySource(entry.apiKey, keyStats);
return ( return (
<div key={entryIndex} className={styles.apiKeyEntryCard}> <div key={entryIndex} className={styles.apiKeyEntryCard}>
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span> <span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
@@ -192,15 +190,6 @@ export function OpenAISection({
}} }}
/> />
</Card> </Card>
<OpenAIModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</> </>
); );
} }

View File

@@ -6,7 +6,7 @@ import { EmptyState } from '@/components/ui/EmptyState';
interface ProviderListProps<T> { interface ProviderListProps<T> {
items: T[]; items: T[];
loading: boolean; loading: boolean;
keyField: (item: T) => string; keyField: (item: T, index: number) => string;
renderContent: (item: T, index: number) => ReactNode; renderContent: (item: T, index: number) => ReactNode;
onEdit: (index: number) => void; onEdit: (index: number) => void;
onDelete: (index: number) => void; onDelete: (index: number) => void;
@@ -34,7 +34,7 @@ export function ProviderList<T>({
}: ProviderListProps<T>) { }: ProviderListProps<T>) {
const { t } = useTranslation(); const { t } = useTranslation();
if (loading) { if (loading && items.length === 0) {
return <div className="hint">{t('common.loading')}</div>; return <div className="hint">{t('common.loading')}</div>;
} }
@@ -48,7 +48,7 @@ export function ProviderList<T>({
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false; const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
return ( return (
<div <div
key={keyField(item)} key={keyField(item, index)}
className="item-row" className="item-row"
style={rowDisabled ? { opacity: 0.6 } : undefined} style={rowDisabled ? { opacity: 0.6 } : undefined}
> >

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,143 @@
import { calculateStatusBarData } from '@/utils/usage'; import { useState, useCallback, useRef, useEffect } from 'react';
import styles from '@/pages/AiProvidersPage.module.scss'; import { useTranslation } from 'react-i18next';
import type { StatusBarData, StatusBlockDetail } from '@/utils/usage';
import defaultStyles from '@/pages/AiProvidersPage.module.scss';
interface ProviderStatusBarProps { /**
statusData: ReturnType<typeof calculateStatusBarData>; * 根据成功率 (01) 在三个色标之间做 RGB 线性插值
* 0 → 红 (#ef4444) → 0.5 → 金黄 (#facc15) → 1 → 绿 (#22c55e)
*/
const COLOR_STOPS = [
{ r: 239, g: 68, b: 68 }, // #ef4444
{ r: 250, g: 204, b: 21 }, // #facc15
{ r: 34, g: 197, b: 94 }, // #22c55e
] as const;
function rateToColor(rate: number): string {
const t = Math.max(0, Math.min(1, rate));
const segment = t < 0.5 ? 0 : 1;
const localT = segment === 0 ? t * 2 : (t - 0.5) * 2;
const from = COLOR_STOPS[segment];
const to = COLOR_STOPS[segment + 1];
const r = Math.round(from.r + (to.r - from.r) * localT);
const g = Math.round(from.g + (to.g - from.g) * localT);
const b = Math.round(from.b + (to.b - from.b) * localT);
return `rgb(${r}, ${g}, ${b})`;
} }
export function ProviderStatusBar({ statusData }: ProviderStatusBarProps) { function formatTime(timestamp: number): string {
const date = new Date(timestamp);
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
return `${h}:${m}`;
}
type StylesModule = Record<string, string>;
interface ProviderStatusBarProps {
statusData: StatusBarData;
styles?: StylesModule;
}
export function ProviderStatusBar({ statusData, styles: stylesProp }: ProviderStatusBarProps) {
const { t } = useTranslation();
const s = (stylesProp || defaultStyles) as StylesModule;
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
const blocksRef = useRef<HTMLDivElement>(null);
const hasData = statusData.totalSuccess + statusData.totalFailure > 0; const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
const rateClass = !hasData const rateClass = !hasData
? '' ? ''
: statusData.successRate >= 90 : statusData.successRate >= 90
? styles.statusRateHigh ? s.statusRateHigh
: statusData.successRate >= 50 : statusData.successRate >= 50
? styles.statusRateMedium ? s.statusRateMedium
: styles.statusRateLow; : s.statusRateLow;
// 点击外部关闭 tooltip移动端
useEffect(() => {
if (activeTooltip === null) return;
const handler = (e: PointerEvent) => {
if (blocksRef.current && !blocksRef.current.contains(e.target as Node)) {
setActiveTooltip(null);
}
};
document.addEventListener('pointerdown', handler);
return () => document.removeEventListener('pointerdown', handler);
}, [activeTooltip]);
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(idx);
}
}, []);
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(null);
}
}, []);
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'touch') {
e.preventDefault();
setActiveTooltip((prev) => (prev === idx ? null : idx));
}
}, []);
const getTooltipPositionClass = (idx: number, total: number): string => {
if (idx <= 2) return s.statusTooltipLeft;
if (idx >= total - 3) return s.statusTooltipRight;
return '';
};
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
const total = detail.success + detail.failure;
const posClass = getTooltipPositionClass(idx, statusData.blockDetails.length);
const timeRange = `${formatTime(detail.startTime)} ${formatTime(detail.endTime)}`;
return (
<div className={`${s.statusTooltip} ${posClass}`}>
<span className={s.tooltipTime}>{timeRange}</span>
{total > 0 ? (
<span className={s.tooltipStats}>
<span className={s.tooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
<span className={s.tooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
<span className={s.tooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
</span>
) : (
<span className={s.tooltipStats}>{t('status_bar.no_requests')}</span>
)}
</div>
);
};
return ( return (
<div className={styles.statusBar}> <div className={s.statusBar}>
<div className={styles.statusBlocks}> <div className={s.statusBlocks} ref={blocksRef}>
{statusData.blocks.map((state, idx) => { {statusData.blockDetails.map((detail, idx) => {
const blockClass = const isIdle = detail.rate === -1;
state === 'success' const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
? styles.statusBlockSuccess const isActive = activeTooltip === idx;
: state === 'failure'
? styles.statusBlockFailure return (
: state === 'mixed' <div
? styles.statusBlockMixed key={idx}
: styles.statusBlockIdle; className={`${s.statusBlockWrapper} ${isActive ? s.statusBlockActive : ''}`}
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />; onPointerEnter={(e) => handlePointerEnter(e, idx)}
onPointerLeave={handlePointerLeave}
onPointerDown={(e) => handlePointerDown(e, idx)}
>
<div
className={`${s.statusBlock} ${isIdle ? s.statusBlockIdle : ''}`}
style={blockStyle}
/>
{isActive && renderTooltip(detail, idx)}
</div>
);
})} })}
</div> </div>
<span className={`${styles.statusRate} ${rateClass}`}> <span className={`${s.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'} {hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span> </span>
</div> </div>

View File

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

@@ -3,8 +3,10 @@ export { ClaudeSection } from './ClaudeSection';
export { CodexSection } from './CodexSection'; export { CodexSection } from './CodexSection';
export { GeminiSection } from './GeminiSection'; export { GeminiSection } from './GeminiSection';
export { OpenAISection } from './OpenAISection'; export { OpenAISection } from './OpenAISection';
export { VertexSection } from './VertexSection';
export { ProviderList } from './ProviderList'; export { ProviderList } from './ProviderList';
export { ProviderStatusBar } from './ProviderStatusBar'; export { ProviderStatusBar } from './ProviderStatusBar';
export { ProviderNav } from './ProviderNav';
export * from './hooks/useProviderStats'; export * from './hooks/useProviderStats';
export * from './types'; export * from './types';
export * from './utils'; export * from './utils';

View File

@@ -2,13 +2,6 @@ import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
import type { HeaderEntry } from '@/utils/headers'; import type { HeaderEntry } from '@/utils/headers';
import type { KeyStats, UsageDetail } from '@/utils/usage'; import type { KeyStats, UsageDetail } from '@/utils/usage';
export type ProviderModal =
| { type: 'gemini'; index: number | null }
| { type: 'codex'; index: number | null }
| { type: 'claude'; index: number | null }
| { type: 'ampcode'; index: null }
| { type: 'openai'; index: number | null };
export interface ModelEntry { export interface ModelEntry {
name: string; name: string;
alias: string; alias: string;
@@ -31,13 +24,22 @@ export interface AmpcodeFormState {
mappingEntries: ModelEntry[]; mappingEntries: ModelEntry[];
} }
export type GeminiFormState = GeminiKeyConfig & { excludedText: string }; export type GeminiFormState = Omit<GeminiKeyConfig, 'headers'> & {
headers: HeaderEntry[];
excludedText: string;
};
export type ProviderFormState = ProviderKeyConfig & { export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
headers: HeaderEntry[];
modelEntries: ModelEntry[]; modelEntries: ModelEntry[];
excludedText: string; excludedText: string;
}; };
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
headers: HeaderEntry[];
modelEntries: ModelEntry[];
};
export interface ProviderSectionProps<TConfig> { export interface ProviderSectionProps<TConfig> {
configs: TConfig[]; configs: TConfig[];
keyStats: KeyStats; keyStats: KeyStats;
@@ -48,12 +50,3 @@ export interface ProviderSectionProps<TConfig> {
onDelete: (index: number) => void; onDelete: (index: number) => void;
onToggle?: (index: number, enabled: boolean) => void; onToggle?: (index: number, enabled: boolean) => void;
} }
export interface ProviderModalProps<TConfig, TPayload = TConfig> {
isOpen: boolean;
editIndex: number | null;
initialData?: TConfig;
onClose: () => void;
onSave: (data: TPayload, index: number | null) => Promise<void>;
disabled?: boolean;
}

View File

@@ -1,5 +1,5 @@
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types'; import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
import type { KeyStatBucket, KeyStats } from '@/utils/usage'; import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
import type { AmpcodeFormState, ModelEntry } from './types'; import type { AmpcodeFormState, ModelEntry } from './types';
export const DISABLE_ALL_MODELS_RULE = '*'; export const DISABLE_ALL_MODELS_RULE = '*';
@@ -43,10 +43,23 @@ export const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
return trimmed; return trimmed;
}; };
export const normalizeClaudeBaseUrl = (baseUrl: string): string => {
let trimmed = String(baseUrl || '').trim();
if (!trimmed) {
return 'https://api.anthropic.com';
}
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
trimmed = trimmed.replace(/\/+$/g, '');
if (!/^https?:\/\//i.test(trimmed)) {
trimmed = `http://${trimmed}`;
}
return trimmed;
};
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => { export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
const trimmed = normalizeOpenAIBaseUrl(baseUrl); const trimmed = normalizeOpenAIBaseUrl(baseUrl);
if (!trimmed) return ''; if (!trimmed) return '';
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`; return `${trimmed}/models`;
}; };
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => { export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
@@ -55,40 +68,69 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
if (trimmed.endsWith('/chat/completions')) { if (trimmed.endsWith('/chat/completions')) {
return trimmed; return trimmed;
} }
return trimmed.endsWith('/v1') ? `${trimmed}/chat/completions` : `${trimmed}/v1/chat/completions`; return `${trimmed}/chat/completions`;
};
export const buildClaudeMessagesEndpoint = (baseUrl: string): string => {
const trimmed = normalizeClaudeBaseUrl(baseUrl);
if (!trimmed) return '';
if (trimmed.endsWith('/v1/messages')) {
return trimmed;
}
if (trimmed.endsWith('/v1')) {
return `${trimmed}/messages`;
}
return `${trimmed}/v1/messages`;
}; };
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致 // 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
export const getStatsBySource = ( export const getStatsBySource = (
apiKey: string, apiKey: string,
keyStats: KeyStats, keyStats: KeyStats,
maskFn: (key: string) => string prefix?: string
): KeyStatBucket => { ): KeyStatBucket => {
const bySource = keyStats.bySource ?? {}; const bySource = keyStats.bySource ?? {};
const masked = maskFn(apiKey); const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
return bySource[apiKey] || bySource[masked] || { success: 0, failure: 0 }; if (!candidates.length) {
return { success: 0, failure: 0 };
}
let success = 0;
let failure = 0;
candidates.forEach((candidate) => {
const stats = bySource[candidate];
if (!stats) return;
success += stats.success;
failure += stats.failure;
});
return { success, failure };
}; };
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致 // 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
export const getOpenAIProviderStats = ( export const getOpenAIProviderStats = (
apiKeyEntries: ApiKeyEntry[] | undefined, apiKeyEntries: ApiKeyEntry[] | undefined,
keyStats: KeyStats, keyStats: KeyStats,
maskFn: (key: string) => string providerPrefix?: string
): KeyStatBucket => { ): KeyStatBucket => {
const bySource = keyStats.bySource ?? {}; const bySource = keyStats.bySource ?? {};
let totalSuccess = 0;
let totalFailure = 0;
const sourceIds = new Set<string>();
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
(apiKeyEntries || []).forEach((entry) => { (apiKeyEntries || []).forEach((entry) => {
const key = entry?.apiKey || ''; buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
if (!key) return;
const masked = maskFn(key);
const stats = bySource[key] || bySource[masked] || { success: 0, failure: 0 };
totalSuccess += stats.success;
totalFailure += stats.failure;
}); });
return { success: totalSuccess, failure: totalFailure }; let success = 0;
let failure = 0;
sourceIds.forEach((id) => {
const stats = bySource[id];
if (!stats) return;
success += stats.success;
failure += stats.failure;
});
return { success, failure };
}; };
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({ export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({

View File

@@ -24,7 +24,7 @@ type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
type ViewMode = 'paged' | 'all'; type ViewMode = 'paged' | 'all';
const MAX_ITEMS_PER_PAGE = 14; const MAX_ITEMS_PER_PAGE = 25;
const MAX_SHOW_ALL_THRESHOLD = 30; const MAX_SHOW_ALL_THRESHOLD = 30;
interface QuotaPaginationState<T> { interface QuotaPaginationState<T> {

View File

@@ -5,5 +5,5 @@
export { QuotaSection } from './QuotaSection'; export { QuotaSection } from './QuotaSection';
export { QuotaCard } from './QuotaCard'; export { QuotaCard } from './QuotaCard';
export { useQuotaLoader } from './useQuotaLoader'; export { useQuotaLoader } from './useQuotaLoader';
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs'; export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export type { QuotaConfig } from './quotaConfigs'; export type { QuotaConfig } from './quotaConfigs';

View File

@@ -10,28 +10,38 @@ import type {
AntigravityModelsPayload, AntigravityModelsPayload,
AntigravityQuotaState, AntigravityQuotaState,
AuthFileItem, AuthFileItem,
ClaudeExtraUsage,
ClaudeQuotaState,
ClaudeQuotaWindow,
ClaudeUsagePayload,
CodexRateLimitInfo,
CodexQuotaState, CodexQuotaState,
CodexUsageWindow, CodexUsageWindow,
CodexQuotaWindow, CodexQuotaWindow,
CodexUsagePayload, CodexUsagePayload,
GeminiCliParsedBucket, GeminiCliParsedBucket,
GeminiCliQuotaBucketState, GeminiCliQuotaBucketState,
GeminiCliQuotaState GeminiCliQuotaState,
} from '@/types'; } from '@/types';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api'; import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import { import {
ANTIGRAVITY_QUOTA_URLS, ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS, ANTIGRAVITY_REQUEST_HEADERS,
CLAUDE_USAGE_URL,
CLAUDE_REQUEST_HEADERS,
CLAUDE_USAGE_WINDOW_KEYS,
CODEX_USAGE_URL, CODEX_USAGE_URL,
CODEX_REQUEST_HEADERS, CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL, GEMINI_CLI_QUOTA_URL,
GEMINI_CLI_REQUEST_HEADERS, GEMINI_CLI_REQUEST_HEADERS,
normalizeAuthIndexValue, normalizeAuthIndexValue,
normalizeGeminiCliModelId,
normalizeNumberValue, normalizeNumberValue,
normalizePlanType, normalizePlanType,
normalizeQuotaFraction, normalizeQuotaFraction,
normalizeStringValue, normalizeStringValue,
parseAntigravityPayload, parseAntigravityPayload,
parseClaudeUsagePayload,
parseCodexUsagePayload, parseCodexUsagePayload,
parseGeminiCliQuotaPayload, parseGeminiCliQuotaPayload,
resolveCodexChatgptAccountId, resolveCodexChatgptAccountId,
@@ -44,22 +54,28 @@ import {
createStatusError, createStatusError,
getStatusFromError, getStatusFromError,
isAntigravityFile, isAntigravityFile,
isClaudeFile,
isCodexFile, isCodexFile,
isDisabledAuthFile,
isGeminiCliFile, isGeminiCliFile,
isRuntimeOnlyAuthFile isRuntimeOnlyAuthFile,
} from '@/utils/quota'; } from '@/utils/quota';
import type { QuotaRenderHelpers } from './QuotaCard'; import type { QuotaRenderHelpers } from './QuotaCard';
import styles from '@/pages/QuotaPage.module.scss'; import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T); type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli'; type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
export interface QuotaStore { export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>; antigravityQuota: Record<string, AntigravityQuotaState>;
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>; codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>; geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void; setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void; setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void; setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void; clearQuotaCache: () => void;
@@ -82,6 +98,38 @@ export interface QuotaConfig<TState, TData> {
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode; renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
} }
const resolveAntigravityProjectId = async (file: AuthFileItem): Promise<string> => {
try {
const text = await authFilesApi.downloadText(file.name);
const trimmed = text.trim();
if (!trimmed) return DEFAULT_ANTIGRAVITY_PROJECT_ID;
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const topLevel = normalizeStringValue(parsed.project_id ?? parsed.projectId);
if (topLevel) return topLevel;
const installed =
parsed.installed && typeof parsed.installed === 'object' && parsed.installed !== null
? (parsed.installed as Record<string, unknown>)
: null;
const installedProjectId = installed
? normalizeStringValue(installed.project_id ?? installed.projectId)
: null;
if (installedProjectId) return installedProjectId;
const web =
parsed.web && typeof parsed.web === 'object' && parsed.web !== null
? (parsed.web as Record<string, unknown>)
: null;
const webProjectId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null;
if (webProjectId) return webProjectId;
} catch {
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
}
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
};
const fetchAntigravityQuota = async ( const fetchAntigravityQuota = async (
file: AuthFileItem, file: AuthFileItem,
t: TFunction t: TFunction
@@ -92,6 +140,9 @@ const fetchAntigravityQuota = async (
throw new Error(t('antigravity_quota.missing_auth_index')); throw new Error(t('antigravity_quota.missing_auth_index'));
} }
const projectId = await resolveAntigravityProjectId(file);
const requestBody = JSON.stringify({ project: projectId });
let lastError = ''; let lastError = '';
let lastStatus: number | undefined; let lastStatus: number | undefined;
let priorityStatus: number | undefined; let priorityStatus: number | undefined;
@@ -104,7 +155,7 @@ const fetchAntigravityQuota = async (
method: 'POST', method: 'POST',
url, url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS }, header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: '{}' data: requestBody,
}); });
if (result.statusCode < 200 || result.statusCode >= 300) { if (result.statusCode < 200 || result.statusCode >= 300) {
@@ -151,13 +202,25 @@ const fetchAntigravityQuota = async (
}; };
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => { const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
const FIVE_HOUR_SECONDS = 18000;
const WEEK_SECONDS = 604800;
const WINDOW_META = {
codeFiveHour: { id: 'five-hour', labelKey: 'codex_quota.primary_window' },
codeWeekly: { id: 'weekly', labelKey: 'codex_quota.secondary_window' },
codeReviewFiveHour: { id: 'code-review-five-hour', labelKey: 'codex_quota.code_review_primary_window' },
codeReviewWeekly: { id: 'code-review-weekly', labelKey: 'codex_quota.code_review_secondary_window' },
} as const;
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined; const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined; const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
const additionalRateLimits = payload.additional_rate_limits ?? payload.additionalRateLimits ?? [];
const windows: CodexQuotaWindow[] = []; const windows: CodexQuotaWindow[] = [];
const addWindow = ( const addWindow = (
id: string, id: string,
labelKey: string, label: string,
labelKey: string | undefined,
labelParams: Record<string, string | number> | undefined,
window?: CodexUsageWindow | null, window?: CodexUsageWindow | null,
limitReached?: boolean, limitReached?: boolean,
allowed?: boolean allowed?: boolean
@@ -169,35 +232,143 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null); const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
windows.push({ windows.push({
id, id,
label: t(labelKey), label,
labelKey, labelKey,
labelParams,
usedPercent, usedPercent,
resetLabel resetLabel,
}); });
}; };
const getWindowSeconds = (window?: CodexUsageWindow | null): number | null => {
if (!window) return null;
return normalizeNumberValue(window.limit_window_seconds ?? window.limitWindowSeconds);
};
const rawLimitReached = rateLimit?.limit_reached ?? rateLimit?.limitReached;
const rawAllowed = rateLimit?.allowed;
const pickClassifiedWindows = (
limitInfo?: CodexRateLimitInfo | null,
options?: { allowOrderFallback?: boolean }
): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => {
const allowOrderFallback = options?.allowOrderFallback ?? true;
const primaryWindow = limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null;
const secondaryWindow = limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null;
const rawWindows = [primaryWindow, secondaryWindow];
let fiveHourWindow: CodexUsageWindow | null = null;
let weeklyWindow: CodexUsageWindow | null = null;
for (const window of rawWindows) {
if (!window) continue;
const seconds = getWindowSeconds(window);
if (seconds === FIVE_HOUR_SECONDS && !fiveHourWindow) {
fiveHourWindow = window;
} else if (seconds === WEEK_SECONDS && !weeklyWindow) {
weeklyWindow = window;
}
}
// For legacy payloads without window duration, fallback to primary/secondary ordering.
if (allowOrderFallback) {
if (!fiveHourWindow) {
fiveHourWindow = primaryWindow && primaryWindow !== weeklyWindow ? primaryWindow : null;
}
if (!weeklyWindow) {
weeklyWindow = secondaryWindow && secondaryWindow !== fiveHourWindow ? secondaryWindow : null;
}
}
return { fiveHourWindow, weeklyWindow };
};
const rateWindows = pickClassifiedWindows(rateLimit);
addWindow( addWindow(
'primary', WINDOW_META.codeFiveHour.id,
'codex_quota.primary_window', t(WINDOW_META.codeFiveHour.labelKey),
rateLimit?.primary_window ?? rateLimit?.primaryWindow, WINDOW_META.codeFiveHour.labelKey,
rateLimit?.limit_reached ?? rateLimit?.limitReached, undefined,
rateLimit?.allowed rateWindows.fiveHourWindow,
rawLimitReached,
rawAllowed
); );
addWindow( addWindow(
'secondary', WINDOW_META.codeWeekly.id,
'codex_quota.secondary_window', t(WINDOW_META.codeWeekly.labelKey),
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow, WINDOW_META.codeWeekly.labelKey,
rateLimit?.limit_reached ?? rateLimit?.limitReached, undefined,
rateLimit?.allowed rateWindows.weeklyWindow,
rawLimitReached,
rawAllowed
);
const codeReviewWindows = pickClassifiedWindows(codeReviewLimit);
const codeReviewLimitReached = codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached;
const codeReviewAllowed = codeReviewLimit?.allowed;
addWindow(
WINDOW_META.codeReviewFiveHour.id,
t(WINDOW_META.codeReviewFiveHour.labelKey),
WINDOW_META.codeReviewFiveHour.labelKey,
undefined,
codeReviewWindows.fiveHourWindow,
codeReviewLimitReached,
codeReviewAllowed
); );
addWindow( addWindow(
'code-review', WINDOW_META.codeReviewWeekly.id,
'codex_quota.code_review_window', t(WINDOW_META.codeReviewWeekly.labelKey),
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow, WINDOW_META.codeReviewWeekly.labelKey,
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached, undefined,
codeReviewLimit?.allowed codeReviewWindows.weeklyWindow,
codeReviewLimitReached,
codeReviewAllowed
); );
const normalizeWindowId = (raw: string) =>
raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
if (Array.isArray(additionalRateLimits)) {
additionalRateLimits.forEach((limitItem, index) => {
const rateInfo = limitItem?.rate_limit ?? limitItem?.rateLimit ?? null;
if (!rateInfo) return;
const limitName =
normalizeStringValue(limitItem?.limit_name ?? limitItem?.limitName) ??
normalizeStringValue(limitItem?.metered_feature ?? limitItem?.meteredFeature) ??
`additional-${index + 1}`;
const idPrefix = normalizeWindowId(limitName) || `additional-${index + 1}`;
const additionalPrimaryWindow = rateInfo.primary_window ?? rateInfo.primaryWindow ?? null;
const additionalSecondaryWindow = rateInfo.secondary_window ?? rateInfo.secondaryWindow ?? null;
const additionalLimitReached = rateInfo.limit_reached ?? rateInfo.limitReached;
const additionalAllowed = rateInfo.allowed;
addWindow(
`${idPrefix}-five-hour-${index}`,
t('codex_quota.additional_primary_window', { name: limitName }),
'codex_quota.additional_primary_window',
{ name: limitName },
additionalPrimaryWindow,
additionalLimitReached,
additionalAllowed
);
addWindow(
`${idPrefix}-weekly-${index}`,
t('codex_quota.additional_secondary_window', { name: limitName }),
'codex_quota.additional_secondary_window',
{ name: limitName },
additionalSecondaryWindow,
additionalLimitReached,
additionalAllowed
);
});
}
return windows; return windows;
}; };
@@ -219,14 +390,14 @@ const fetchCodexQuota = async (
const requestHeader: Record<string, string> = { const requestHeader: Record<string, string> = {
...CODEX_REQUEST_HEADERS, ...CODEX_REQUEST_HEADERS,
'Chatgpt-Account-Id': accountId 'Chatgpt-Account-Id': accountId,
}; };
const result = await apiCallApi.request({ const result = await apiCallApi.request({
authIndex, authIndex,
method: 'GET', method: 'GET',
url: CODEX_USAGE_URL, url: CODEX_USAGE_URL,
header: requestHeader header: requestHeader,
}); });
if (result.statusCode < 200 || result.statusCode >= 300) { if (result.statusCode < 200 || result.statusCode >= 300) {
@@ -263,7 +434,7 @@ const fetchGeminiCliQuota = async (
method: 'POST', method: 'POST',
url: GEMINI_CLI_QUOTA_URL, url: GEMINI_CLI_QUOTA_URL,
header: { ...GEMINI_CLI_REQUEST_HEADERS }, header: { ...GEMINI_CLI_REQUEST_HEADERS },
data: JSON.stringify({ project: projectId }) data: JSON.stringify({ project: projectId }),
}); });
if (result.statusCode < 200 || result.statusCode >= 300) { if (result.statusCode < 200 || result.statusCode >= 300) {
@@ -276,13 +447,15 @@ const fetchGeminiCliQuota = async (
const parsedBuckets = buckets const parsedBuckets = buckets
.map((bucket) => { .map((bucket) => {
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id); const modelId = normalizeGeminiCliModelId(bucket.modelId ?? bucket.model_id);
if (!modelId) return null; if (!modelId) return null;
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type); const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
const remainingFractionRaw = normalizeQuotaFraction( const remainingFractionRaw = normalizeQuotaFraction(
bucket.remainingFraction ?? bucket.remaining_fraction bucket.remainingFraction ?? bucket.remaining_fraction
); );
const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount); const remainingAmount = normalizeNumberValue(
bucket.remainingAmount ?? bucket.remaining_amount
);
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined; const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
let fallbackFraction: number | null = null; let fallbackFraction: number | null = null;
if (remainingAmount !== null) { if (remainingAmount !== null) {
@@ -296,7 +469,7 @@ const fetchGeminiCliQuota = async (
tokenType, tokenType,
remainingFraction, remainingFraction,
remainingAmount, remainingAmount,
resetTime resetTime,
}; };
}) })
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null); .filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
@@ -328,11 +501,7 @@ const renderAntigravityItems = (
h( h(
'div', 'div',
{ className: styleMap.quotaRowHeader }, { className: styleMap.quotaRowHeader },
h( h('span', { className: styleMap.quotaModel, title: group.models.join(', ') }, group.label),
'span',
{ className: styleMap.quotaModel, title: group.models.join(', ') },
group.label
),
h( h(
'div', 'div',
{ className: styleMap.quotaMeta }, { className: styleMap.quotaMeta },
@@ -365,7 +534,6 @@ const renderCodexItems = (
}; };
const planLabel = getPlanLabel(planType); const planLabel = getPlanLabel(planType);
const isFreePlan = normalizePlanType(planType) === 'free';
const nodes: ReactNode[] = []; const nodes: ReactNode[] = [];
if (planLabel) { if (planLabel) {
@@ -379,17 +547,6 @@ const renderCodexItems = (
); );
} }
if (isFreePlan) {
nodes.push(
h(
'div',
{ key: 'warning', className: styleMap.quotaWarning },
t('codex_quota.no_access')
)
);
return h(Fragment, null, ...nodes);
}
if (windows.length === 0) { if (windows.length === 0) {
nodes.push( nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows')) h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
@@ -403,7 +560,9 @@ const renderCodexItems = (
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used)); const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed)); const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`; const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
const windowLabel = window.labelKey ? t(window.labelKey) : window.label; const windowLabel = window.labelKey
? t(window.labelKey, window.labelParams as Record<string, string | number>)
: window.label;
return h( return h(
'div', 'div',
@@ -449,7 +608,7 @@ const renderGeminiCliItems = (
bucket.remainingAmount === null || bucket.remainingAmount === undefined bucket.remainingAmount === null || bucket.remainingAmount === undefined
? null ? null
: t('gemini_cli_quota.remaining_amount', { : t('gemini_cli_quota.remaining_amount', {
count: bucket.remainingAmount count: bucket.remainingAmount,
}); });
const titleBase = const titleBase =
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label; bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
@@ -479,10 +638,153 @@ const renderGeminiCliItems = (
}); });
}; };
const buildClaudeQuotaWindows = (
payload: ClaudeUsagePayload,
t: TFunction
): ClaudeQuotaWindow[] => {
const windows: ClaudeQuotaWindow[] = [];
for (const { key, id, labelKey } of CLAUDE_USAGE_WINDOW_KEYS) {
const window = payload[key as keyof ClaudeUsagePayload];
if (!window || typeof window !== 'object' || !('utilization' in window)) continue;
const typedWindow = window as { utilization: number; resets_at: string };
const usedPercent = normalizeNumberValue(typedWindow.utilization);
const resetLabel = formatQuotaResetTime(typedWindow.resets_at);
windows.push({
id,
label: t(labelKey),
labelKey,
usedPercent,
resetLabel,
});
}
return windows;
};
const fetchClaudeQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('claude_quota.missing_auth_index'));
}
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: CLAUDE_USAGE_URL,
header: { ...CLAUDE_REQUEST_HEADERS },
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseClaudeUsagePayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('claude_quota.empty_windows'));
}
const windows = buildClaudeQuotaWindows(payload, t);
return { windows, extraUsage: payload.extra_usage };
};
const renderClaudeItems = (
quota: ClaudeQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h, Fragment } = React;
const windows = quota.windows ?? [];
const extraUsage = quota.extraUsage ?? null;
const nodes: ReactNode[] = [];
if (extraUsage && extraUsage.is_enabled) {
const usedLabel = `$${(extraUsage.used_credits / 100).toFixed(2)} / $${(extraUsage.monthly_limit / 100).toFixed(2)}`;
nodes.push(
h(
'div',
{ key: 'extra', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('claude_quota.extra_usage_label')),
h('span', { className: styleMap.codexPlanValue }, usedLabel)
)
);
}
if (windows.length === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('claude_quota.empty_windows'))
);
return h(Fragment, null, ...nodes);
}
nodes.push(
...windows.map((window) => {
const used = window.usedPercent;
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
return h(
'div',
{ key: window.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, windowLabel),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
h('span', { className: styleMap.quotaReset }, window.resetLabel)
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
);
})
);
return h(Fragment, null, ...nodes);
};
export const CLAUDE_CONFIG: QuotaConfig<
ClaudeQuotaState,
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }
> = {
type: 'claude',
i18nPrefix: 'claude_quota',
filterFn: (file) => isClaudeFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchClaudeQuota,
storeSelector: (state) => state.claudeQuota,
storeSetter: 'setClaudeQuota',
buildLoadingState: () => ({ status: 'loading', windows: [] }),
buildSuccessState: (data) => ({
status: 'success',
windows: data.windows,
extraUsage: data.extraUsage,
}),
buildErrorState: (message, status) => ({
status: 'error',
windows: [],
error: message,
errorStatus: status,
}),
cardClassName: styles.claudeCard,
controlsClassName: styles.claudeControls,
controlClassName: styles.claudeControl,
gridClassName: styles.claudeGrid,
renderQuotaItems: renderClaudeItems,
};
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = { export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
type: 'antigravity', type: 'antigravity',
i18nPrefix: 'antigravity_quota', i18nPrefix: 'antigravity_quota',
filterFn: (file) => isAntigravityFile(file), filterFn: (file) => isAntigravityFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchAntigravityQuota, fetchQuota: fetchAntigravityQuota,
storeSelector: (state) => state.antigravityQuota, storeSelector: (state) => state.antigravityQuota,
storeSetter: 'setAntigravityQuota', storeSetter: 'setAntigravityQuota',
@@ -492,13 +794,13 @@ export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQ
status: 'error', status: 'error',
groups: [], groups: [],
error: message, error: message,
errorStatus: status errorStatus: status,
}), }),
cardClassName: styles.antigravityCard, cardClassName: styles.antigravityCard,
controlsClassName: styles.antigravityControls, controlsClassName: styles.antigravityControls,
controlClassName: styles.antigravityControl, controlClassName: styles.antigravityControl,
gridClassName: styles.antigravityGrid, gridClassName: styles.antigravityGrid,
renderQuotaItems: renderAntigravityItems renderQuotaItems: renderAntigravityItems,
}; };
export const CODEX_CONFIG: QuotaConfig< export const CODEX_CONFIG: QuotaConfig<
@@ -507,7 +809,7 @@ export const CODEX_CONFIG: QuotaConfig<
> = { > = {
type: 'codex', type: 'codex',
i18nPrefix: 'codex_quota', i18nPrefix: 'codex_quota',
filterFn: (file) => isCodexFile(file), filterFn: (file) => isCodexFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchCodexQuota, fetchQuota: fetchCodexQuota,
storeSelector: (state) => state.codexQuota, storeSelector: (state) => state.codexQuota,
storeSetter: 'setCodexQuota', storeSetter: 'setCodexQuota',
@@ -515,25 +817,26 @@ export const CODEX_CONFIG: QuotaConfig<
buildSuccessState: (data) => ({ buildSuccessState: (data) => ({
status: 'success', status: 'success',
windows: data.windows, windows: data.windows,
planType: data.planType planType: data.planType,
}), }),
buildErrorState: (message, status) => ({ buildErrorState: (message, status) => ({
status: 'error', status: 'error',
windows: [], windows: [],
error: message, error: message,
errorStatus: status errorStatus: status,
}), }),
cardClassName: styles.codexCard, cardClassName: styles.codexCard,
controlsClassName: styles.codexControls, controlsClassName: styles.codexControls,
controlClassName: styles.codexControl, controlClassName: styles.codexControl,
gridClassName: styles.codexGrid, gridClassName: styles.codexGrid,
renderQuotaItems: renderCodexItems renderQuotaItems: renderCodexItems,
}; };
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = { export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
type: 'gemini-cli', type: 'gemini-cli',
i18nPrefix: 'gemini_cli_quota', i18nPrefix: 'gemini_cli_quota',
filterFn: (file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file), filterFn: (file) =>
isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchGeminiCliQuota, fetchQuota: fetchGeminiCliQuota,
storeSelector: (state) => state.geminiCliQuota, storeSelector: (state) => state.geminiCliQuota,
storeSetter: 'setGeminiCliQuota', storeSetter: 'setGeminiCliQuota',
@@ -543,11 +846,11 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaB
status: 'error', status: 'error',
buckets: [], buckets: [],
error: message, error: message,
errorStatus: status errorStatus: status,
}), }),
cardClassName: styles.geminiCliCard, cardClassName: styles.geminiCliCard,
controlsClassName: styles.geminiCliControls, controlsClassName: styles.geminiCliControls,
controlClassName: styles.geminiCliControl, controlClassName: styles.geminiCliControl,
gridClassName: styles.geminiCliGrid, gridClassName: styles.geminiCliGrid,
renderQuotaItems: renderGeminiCliItems renderQuotaItems: renderGeminiCliItems,
}; };

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

@@ -10,6 +10,8 @@ interface HeaderInputListProps {
disabled?: boolean; disabled?: boolean;
keyPlaceholder?: string; keyPlaceholder?: string;
valuePlaceholder?: string; valuePlaceholder?: string;
removeButtonTitle?: string;
removeButtonAriaLabel?: string;
} }
export function HeaderInputList({ export function HeaderInputList({
@@ -18,7 +20,9 @@ export function HeaderInputList({
addLabel, addLabel,
disabled = false, disabled = false,
keyPlaceholder = 'X-Custom-Header', keyPlaceholder = 'X-Custom-Header',
valuePlaceholder = 'value' valuePlaceholder = 'value',
removeButtonTitle = 'Remove',
removeButtonAriaLabel = 'Remove',
}: HeaderInputListProps) { }: HeaderInputListProps) {
const currentEntries = entries.length ? entries : [{ key: '', value: '' }]; const currentEntries = entries.length ? entries : [{ key: '', value: '' }];
@@ -61,8 +65,8 @@ export function HeaderInputList({
size="sm" size="sm"
onClick={() => removeEntry(index)} onClick={() => removeEntry(index)}
disabled={disabled || currentEntries.length <= 1} disabled={disabled || currentEntries.length <= 1}
title="Remove" title={removeButtonTitle}
aria-label="Remove" aria-label={removeButtonAriaLabel}
> >
<IconX size={14} /> <IconX size={14} />
</Button> </Button>

View File

@@ -8,17 +8,61 @@ interface ModalProps {
onClose: () => void; onClose: () => void;
footer?: ReactNode; footer?: ReactNode;
width?: number | string; width?: number | string;
className?: string;
closeDisabled?: boolean;
} }
const CLOSE_ANIMATION_DURATION = 350; const CLOSE_ANIMATION_DURATION = 350;
const MODAL_LOCK_CLASS = 'modal-open'; const MODAL_LOCK_CLASS = 'modal-open';
let activeModalCount = 0; let activeModalCount = 0;
const scrollLockSnapshot = {
scrollY: 0,
contentScrollTop: 0,
contentEl: null as HTMLElement | null,
bodyPosition: '',
bodyTop: '',
bodyLeft: '',
bodyRight: '',
bodyWidth: '',
bodyOverflow: '',
htmlOverflow: '',
};
const resolveContentScrollContainer = () => {
if (typeof document === 'undefined') return null;
const contentEl = document.querySelector('.content');
return contentEl instanceof HTMLElement ? contentEl : null;
};
const lockScroll = () => { const lockScroll = () => {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
if (activeModalCount === 0) { if (activeModalCount === 0) {
document.body?.classList.add(MODAL_LOCK_CLASS); const body = document.body;
document.documentElement?.classList.add(MODAL_LOCK_CLASS); const html = document.documentElement;
const contentEl = resolveContentScrollContainer();
scrollLockSnapshot.scrollY = window.scrollY || window.pageYOffset || html.scrollTop || 0;
scrollLockSnapshot.contentEl = contentEl;
scrollLockSnapshot.contentScrollTop = contentEl?.scrollTop ?? 0;
scrollLockSnapshot.bodyPosition = body.style.position;
scrollLockSnapshot.bodyTop = body.style.top;
scrollLockSnapshot.bodyLeft = body.style.left;
scrollLockSnapshot.bodyRight = body.style.right;
scrollLockSnapshot.bodyWidth = body.style.width;
scrollLockSnapshot.bodyOverflow = body.style.overflow;
scrollLockSnapshot.htmlOverflow = html.style.overflow;
body.classList.add(MODAL_LOCK_CLASS);
html.classList.add(MODAL_LOCK_CLASS);
body.style.position = 'fixed';
body.style.top = `-${scrollLockSnapshot.scrollY}px`;
body.style.left = '0';
body.style.right = '0';
body.style.width = '100%';
body.style.overflow = 'hidden';
html.style.overflow = 'hidden';
} }
activeModalCount += 1; activeModalCount += 1;
}; };
@@ -27,12 +71,44 @@ const unlockScroll = () => {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
activeModalCount = Math.max(0, activeModalCount - 1); activeModalCount = Math.max(0, activeModalCount - 1);
if (activeModalCount === 0) { if (activeModalCount === 0) {
document.body?.classList.remove(MODAL_LOCK_CLASS); const body = document.body;
document.documentElement?.classList.remove(MODAL_LOCK_CLASS); const html = document.documentElement;
const scrollY = scrollLockSnapshot.scrollY;
const contentScrollTop = scrollLockSnapshot.contentScrollTop;
const contentEl = scrollLockSnapshot.contentEl;
body.classList.remove(MODAL_LOCK_CLASS);
html.classList.remove(MODAL_LOCK_CLASS);
body.style.position = scrollLockSnapshot.bodyPosition;
body.style.top = scrollLockSnapshot.bodyTop;
body.style.left = scrollLockSnapshot.bodyLeft;
body.style.right = scrollLockSnapshot.bodyRight;
body.style.width = scrollLockSnapshot.bodyWidth;
body.style.overflow = scrollLockSnapshot.bodyOverflow;
html.style.overflow = scrollLockSnapshot.htmlOverflow;
if (contentEl) {
contentEl.scrollTo({ top: contentScrollTop, left: 0, behavior: 'auto' });
}
window.scrollTo({ top: scrollY, left: 0, behavior: 'auto' });
scrollLockSnapshot.scrollY = 0;
scrollLockSnapshot.contentScrollTop = 0;
scrollLockSnapshot.contentEl = null;
} }
}; };
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) { export function Modal({
open,
title,
onClose,
footer,
width = 520,
className,
closeDisabled = false,
children
}: PropsWithChildren<ModalProps>) {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -101,12 +177,18 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
if (!open && !isVisible) return null; if (!open && !isVisible) return null;
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`; const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`; const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}${className ? ` ${className}` : ''}`;
const modalContent = ( const modalContent = (
<div className={overlayClass}> <div className={overlayClass}>
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true"> <div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
<button className="modal-close-floating" onClick={handleClose} aria-label="Close"> <button
type="button"
className="modal-close-floating"
onClick={closeDisabled ? undefined : handleClose}
aria-label="Close"
disabled={closeDisabled}
>
<IconX size={20} /> <IconX size={20} />
</button> </button>
<div className="modal-header"> <div className="modal-header">

View File

@@ -1,54 +1,45 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { Button } from './Button'; import { Button } from './Button';
import { IconX } from './icons'; import { IconX } from './icons';
import type { ModelAlias } from '@/types'; import type { ModelEntry } from './modelInputListUtils';
interface ModelEntry {
name: string;
alias: string;
}
interface ModelInputListProps { interface ModelInputListProps {
entries: ModelEntry[]; entries: ModelEntry[];
onChange: (entries: ModelEntry[]) => void; onChange: (entries: ModelEntry[]) => void;
addLabel: string; addLabel?: string;
disabled?: boolean; disabled?: boolean;
namePlaceholder?: string; namePlaceholder?: string;
aliasPlaceholder?: string; aliasPlaceholder?: string;
hideAddButton?: boolean;
onAdd?: () => void;
className?: string;
rowClassName?: string;
inputClassName?: string;
removeButtonClassName?: string;
removeButtonTitle?: string;
removeButtonAriaLabel?: string;
} }
export const modelsToEntries = (models?: ModelAlias[]): ModelEntry[] => {
if (!Array.isArray(models) || models.length === 0) {
return [{ name: '', alias: '' }];
}
return models.map((m) => ({
name: m.name || '',
alias: m.alias || ''
}));
};
export const entriesToModels = (entries: ModelEntry[]): ModelAlias[] => {
return entries
.filter((entry) => entry.name.trim())
.map((entry) => {
const model: ModelAlias = { name: entry.name.trim() };
const alias = entry.alias.trim();
if (alias && alias !== model.name) {
model.alias = alias;
}
return model;
});
};
export function ModelInputList({ export function ModelInputList({
entries, entries,
onChange, onChange,
addLabel, addLabel,
disabled = false, disabled = false,
namePlaceholder = 'model-name', namePlaceholder = 'model-name',
aliasPlaceholder = 'alias (optional)' aliasPlaceholder = 'alias (optional)',
hideAddButton = false,
onAdd,
className = '',
rowClassName = '',
inputClassName = '',
removeButtonClassName = '',
removeButtonTitle = 'Remove',
removeButtonAriaLabel = 'Remove',
}: ModelInputListProps) { }: ModelInputListProps) {
const currentEntries = entries.length ? entries : [{ name: '', alias: '' }]; const currentEntries = entries.length ? entries : [{ name: '', alias: '' }];
const containerClassName = ['header-input-list', className].filter(Boolean).join(' ');
const inputClassNames = ['input', inputClassName].filter(Boolean).join(' ');
const rowClassNames = ['header-input-row', rowClassName].filter(Boolean).join(' ');
const updateEntry = (index: number, field: 'name' | 'alias', value: string) => { const updateEntry = (index: number, field: 'name' | 'alias', value: string) => {
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry)); const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
@@ -56,7 +47,11 @@ export function ModelInputList({
}; };
const addEntry = () => { const addEntry = () => {
onChange([...currentEntries, { name: '', alias: '' }]); if (onAdd) {
onAdd();
} else {
onChange([...currentEntries, { name: '', alias: '' }]);
}
}; };
const removeEntry = (index: number) => { const removeEntry = (index: number) => {
@@ -65,12 +60,12 @@ export function ModelInputList({
}; };
return ( return (
<div className="header-input-list"> <div className={containerClassName}>
{currentEntries.map((entry, index) => ( {currentEntries.map((entry, index) => (
<Fragment key={index}> <Fragment key={index}>
<div className="header-input-row"> <div className={rowClassNames}>
<input <input
className="input" className={inputClassNames}
placeholder={namePlaceholder} placeholder={namePlaceholder}
value={entry.name} value={entry.name}
onChange={(e) => updateEntry(index, 'name', e.target.value)} onChange={(e) => updateEntry(index, 'name', e.target.value)}
@@ -78,7 +73,7 @@ export function ModelInputList({
/> />
<span className="header-separator"></span> <span className="header-separator"></span>
<input <input
className="input" className={inputClassNames}
placeholder={aliasPlaceholder} placeholder={aliasPlaceholder}
value={entry.alias} value={entry.alias}
onChange={(e) => updateEntry(index, 'alias', e.target.value)} onChange={(e) => updateEntry(index, 'alias', e.target.value)}
@@ -89,17 +84,20 @@ export function ModelInputList({
size="sm" size="sm"
onClick={() => removeEntry(index)} onClick={() => removeEntry(index)}
disabled={disabled || currentEntries.length <= 1} disabled={disabled || currentEntries.length <= 1}
title="Remove" className={removeButtonClassName}
aria-label="Remove" title={removeButtonTitle}
aria-label={removeButtonAriaLabel}
> >
<IconX size={14} /> <IconX size={14} />
</Button> </Button>
</div> </div>
</Fragment> </Fragment>
))} ))}
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start"> {!hideAddButton && addLabel && (
{addLabel} <Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
</Button> {addLabel}
</Button>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,110 @@
@use '../../styles/mixins' as *;
.wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.wrapFullWidth {
width: 100%;
}
.trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
box-shadow: var(--shadow);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
appearance: none;
text-align: left;
box-sizing: border-box;
&:hover {
border-color: var(--border-hover);
}
&:focus {
outline: none;
box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18);
}
&[aria-expanded='true'] {
border-color: var(--primary-color);
box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18);
}
}
.triggerText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.placeholder {
color: var(--text-tertiary);
}
.triggerIcon {
display: inline-flex;
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.2s ease;
[aria-expanded='true'] > & {
transform: rotate(180deg);
}
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1000;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: 6px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow-y: auto;
overscroll-behavior: contain;
}
.option {
padding: 8px 12px;
border-radius: $radius-md;
border: 1px solid transparent;
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 13px;
font-weight: 500;
transition: background-color 0.15s ease, border-color 0.15s ease;
flex-shrink: 0;
&:hover {
background: var(--bg-secondary);
}
}
.optionActive {
border-color: rgba($primary-color, 0.5);
background: rgba($primary-color, 0.1);
font-weight: 600;
}

View File

@@ -0,0 +1,94 @@
import { useState, useEffect, useRef } from 'react';
import { IconChevronDown } from './icons';
import styles from './Select.module.scss';
export interface SelectOption {
value: string;
label: string;
}
interface SelectProps {
value: string;
options: ReadonlyArray<SelectOption>;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
ariaLabel?: string;
fullWidth?: boolean;
}
export function Select({
value,
options,
onChange,
placeholder,
className,
disabled = false,
ariaLabel,
fullWidth = true
}: SelectProps) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open || disabled) return;
const handleClickOutside = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [disabled, open]);
const isOpen = open && !disabled;
const selected = options.find((o) => o.value === value);
const displayText = selected?.label ?? placeholder ?? '';
const isPlaceholder = !selected && placeholder;
return (
<div
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
ref={wrapRef}
>
<button
type="button"
className={styles.trigger}
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={ariaLabel}
disabled={disabled}
>
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
{displayText}
</span>
<span className={styles.triggerIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
{isOpen && (
<div className={styles.dropdown} role="listbox" aria-label={ariaLabel}>
{options.map((opt) => {
const active = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
className={`${styles.option} ${active ? styles.optionActive : ''}`}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
);
}

View File

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

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) { export function IconSearch({ size = 20, ...props }: IconProps) {
return ( return (
<svg {...baseSvgProps} width={size} height={size} {...props}> <svg {...baseSvgProps} width={size} height={size} {...props}>

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

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage'; import { formatCompactNumber, formatUsd, type ApiStats } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss'; import styles from '@/pages/UsagePage.module.scss';
export interface ApiDetailsCardProps { export interface ApiDetailsCardProps {
@@ -10,9 +10,14 @@ export interface ApiDetailsCardProps {
hasPrices: boolean; hasPrices: boolean;
} }
type ApiSortKey = 'endpoint' | 'requests' | 'tokens' | 'cost';
type SortDir = 'asc' | 'desc';
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) { export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set()); const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
const [sortKey, setSortKey] = useState<ApiSortKey>('requests');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const toggleExpand = (endpoint: string) => { const toggleExpand = (endpoint: string) => {
setExpandedApis((prev) => { setExpandedApis((prev) => {
@@ -26,51 +31,125 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
}); });
}; };
const handleSort = (key: ApiSortKey) => {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir(key === 'endpoint' ? 'asc' : 'desc');
}
};
const sorted = useMemo(() => {
const list = [...apiStats];
const dir = sortDir === 'asc' ? 1 : -1;
list.sort((a, b) => {
switch (sortKey) {
case 'endpoint': return dir * a.endpoint.localeCompare(b.endpoint);
case 'requests': return dir * (a.totalRequests - b.totalRequests);
case 'tokens': return dir * (a.totalTokens - b.totalTokens);
case 'cost': return dir * (a.totalCost - b.totalCost);
default: return 0;
}
});
return list;
}, [apiStats, sortKey, sortDir]);
const arrow = (key: ApiSortKey) =>
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
return ( return (
<Card title={t('usage_stats.api_details')}> <Card title={t('usage_stats.api_details')} className={styles.detailsFixedCard}>
{loading ? ( {loading ? (
<div className={styles.hint}>{t('common.loading')}</div> <div className={styles.hint}>{t('common.loading')}</div>
) : apiStats.length > 0 ? ( ) : sorted.length > 0 ? (
<div className={styles.apiList}> <>
{apiStats.map((api) => ( <div className={styles.apiSortBar}>
<div key={api.endpoint} className={styles.apiItem}> {([
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}> ['endpoint', 'usage_stats.api_endpoint'],
<div className={styles.apiInfo}> ['requests', 'usage_stats.requests_count'],
<span className={styles.apiEndpoint}>{api.endpoint}</span> ['tokens', 'usage_stats.tokens_count'],
<div className={styles.apiStats}> ...(hasPrices ? [['cost', 'usage_stats.total_cost']] : []),
<span className={styles.apiBadge}> ] as [ApiSortKey, string][]).map(([key, labelKey]) => (
{t('usage_stats.requests_count')}: {api.totalRequests} <button
</span> key={key}
<span className={styles.apiBadge}> type="button"
Tokens: {formatTokensInMillions(api.totalTokens)} aria-pressed={sortKey === key}
</span> className={`${styles.apiSortBtn} ${sortKey === key ? styles.apiSortBtnActive : ''}`}
{hasPrices && api.totalCost > 0 && ( onClick={() => handleSort(key)}
<span className={styles.apiBadge}> >
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)} {t(labelKey)}{arrow(key)}
</button>
))}
</div>
<div className={styles.detailsScroll}>
<div className={styles.apiList}>
{sorted.map((api, index) => {
const isExpanded = expandedApis.has(api.endpoint);
const panelId = `api-models-${index}`;
return (
<div key={api.endpoint} className={styles.apiItem}>
<button
type="button"
className={styles.apiHeader}
onClick={() => toggleExpand(api.endpoint)}
aria-expanded={isExpanded}
aria-controls={panelId}
>
<div className={styles.apiInfo}>
<span className={styles.apiEndpoint}>{api.endpoint}</span>
<div className={styles.apiStats}>
<span className={styles.apiBadge}>
<span className={styles.requestCountCell}>
<span>
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.apiBadge}>
{t('usage_stats.tokens_count')}: {formatCompactNumber(api.totalTokens)}
</span>
{hasPrices && api.totalCost > 0 && (
<span className={styles.apiBadge}>
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
</span>
)}
</div>
</div>
<span className={styles.expandIcon}>
{isExpanded ? '▼' : '▶'}
</span> </span>
</button>
{isExpanded && (
<div id={panelId} className={styles.apiModels}>
{Object.entries(api.models).map(([model, stats]) => (
<div key={model} className={styles.modelRow}>
<span className={styles.modelName}>{model}</span>
<span className={styles.modelStat}>
<span className={styles.requestCountCell}>
<span>{stats.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.modelStat}>{formatCompactNumber(stats.tokens)}</span>
</div>
))}
</div>
)} )}
</div> </div>
</div> );
<span className={styles.expandIcon}> })}
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
</span>
</div>
{expandedApis.has(api.endpoint) && (
<div className={styles.apiModels}>
{Object.entries(api.models).map(([model, stats]) => (
<div key={model} className={styles.modelRow}>
<span className={styles.modelName}>{model}</span>
<span className={styles.modelStat}>
{stats.requests} {t('usage_stats.requests_count')}
</span>
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
</div>
))}
</div>
)}
</div> </div>
))} </div>
</div> </>
) : ( ) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div> <div className={styles.hint}>{t('usage_stats.no_data')}</div>
)} )}

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import styles from '@/pages/UsagePage.module.scss'; import styles from '@/pages/UsagePage.module.scss';
export interface ChartLineSelectorProps { export interface ChartLineSelectorProps {
@@ -41,6 +43,14 @@ export function ChartLineSelector({
onChange(newLines); onChange(newLines);
}; };
const options = useMemo(
() => [
{ value: 'all', label: t('usage_stats.chart_line_all') },
...modelNames.map((name) => ({ value: name, label: name }))
],
[modelNames, t]
);
return ( return (
<Card <Card
title={t('usage_stats.chart_line_actions_label')} title={t('usage_stats.chart_line_actions_label')}
@@ -66,18 +76,11 @@ export function ChartLineSelector({
<span className={styles.chartLineLabel}> <span className={styles.chartLineLabel}>
{t(`usage_stats.chart_line_label_${index + 1}`)} {t(`usage_stats.chart_line_label_${index + 1}`)}
</span> </span>
<select <Select
value={line} value={line}
onChange={(e) => handleChange(index, e.target.value)} options={options}
className={styles.select} onChange={(value) => handleChange(index, value)}
> />
<option value="all">{t('usage_stats.chart_line_all')}</option>
{modelNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
{chartLines.length > 1 && ( {chartLines.length > 1 && (
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}> <Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
{t('usage_stats.chart_line_delete')} {t('usage_stats.chart_line_delete')}

View File

@@ -0,0 +1,144 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ScriptableContext } from 'chart.js';
import { Line } from 'react-chartjs-2';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import {
buildHourlyCostSeries,
buildDailyCostSeries,
formatUsd,
type ModelPrice
} from '@/utils/usage';
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
export interface CostTrendChartProps {
usage: UsagePayload | null;
loading: boolean;
isDark: boolean;
isMobile: boolean;
modelPrices: Record<string, ModelPrice>;
hourWindowHours?: number;
}
const COST_COLOR = '#f59e0b';
const COST_BG = 'rgba(245, 158, 11, 0.15)';
function buildGradient(ctx: ScriptableContext<'line'>) {
const chart = ctx.chart;
const area = chart.chartArea;
if (!area) return COST_BG;
const gradient = chart.ctx.createLinearGradient(0, area.top, 0, area.bottom);
gradient.addColorStop(0, 'rgba(245, 158, 11, 0.28)');
gradient.addColorStop(0.6, 'rgba(245, 158, 11, 0.12)');
gradient.addColorStop(1, 'rgba(245, 158, 11, 0.02)');
return gradient;
}
export function CostTrendChart({
usage,
loading,
isDark,
isMobile,
modelPrices,
hourWindowHours
}: CostTrendChartProps) {
const { t } = useTranslation();
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
const hasPrices = Object.keys(modelPrices).length > 0;
const { chartData, chartOptions, hasData } = useMemo(() => {
if (!hasPrices || !usage) {
return { chartData: { labels: [], datasets: [] }, chartOptions: {}, hasData: false };
}
const series =
period === 'hour'
? buildHourlyCostSeries(usage, modelPrices, hourWindowHours)
: buildDailyCostSeries(usage, modelPrices);
const data = {
labels: series.labels,
datasets: [
{
label: t('usage_stats.total_cost'),
data: series.data,
borderColor: COST_COLOR,
backgroundColor: buildGradient,
pointBackgroundColor: COST_COLOR,
pointBorderColor: COST_COLOR,
fill: true,
tension: 0.35
}
]
};
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
const options = {
...baseOptions,
scales: {
...baseOptions.scales,
y: {
...baseOptions.scales?.y,
ticks: {
...(baseOptions.scales?.y && 'ticks' in baseOptions.scales.y ? baseOptions.scales.y.ticks : {}),
callback: (value: string | number) => formatUsd(Number(value))
}
}
}
};
return { chartData: data, chartOptions: options, hasData: series.hasData };
}, [usage, period, isDark, isMobile, modelPrices, hasPrices, hourWindowHours, t]);
return (
<Card
title={t('usage_stats.cost_trend')}
extra={
<div className={styles.periodButtons}>
<Button
variant={period === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={period === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : !hasPrices ? (
<div className={styles.hint}>{t('usage_stats.cost_need_price')}</div>
) : !hasData ? (
<div className={styles.hint}>{t('usage_stats.cost_no_data')}</div>
) : (
<div className={styles.chartWrapper}>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
period === 'hour'
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
: undefined
}
>
<Line data={chartData} options={chartOptions} />
</div>
</div>
</div>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,336 @@
import { useMemo, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import {
computeKeyStats,
collectUsageDetails,
buildCandidateUsageSourceIds,
formatCompactNumber
} from '@/utils/usage';
import { authFilesApi } from '@/services/api/authFiles';
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
import type { AuthFileItem } from '@/types/authFile';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
export interface CredentialStatsCardProps {
usage: UsagePayload | null;
loading: boolean;
geminiKeys: GeminiKeyConfig[];
claudeConfigs: ProviderKeyConfig[];
codexConfigs: ProviderKeyConfig[];
vertexConfigs: ProviderKeyConfig[];
openaiProviders: OpenAIProviderConfig[];
}
interface CredentialInfo {
name: string;
type: string;
}
interface CredentialRow {
key: string;
displayName: string;
type: string;
success: number;
failure: number;
total: number;
successRate: number;
}
interface CredentialBucket {
success: number;
failure: number;
}
function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || null;
}
return null;
}
export function CredentialStatsCard({
usage,
loading,
geminiKeys,
claudeConfigs,
codexConfigs,
vertexConfigs,
openaiProviders,
}: CredentialStatsCardProps) {
const { t } = useTranslation();
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
// Fetch auth files for auth_index-based matching
useEffect(() => {
let cancelled = false;
authFilesApi
.list()
.then((res) => {
if (cancelled) return;
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
if (!Array.isArray(files)) return;
const map = new Map<string, CredentialInfo>();
files.forEach((file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const key = normalizeAuthIndexValue(rawAuthIndex);
if (key) {
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString(),
});
}
});
setAuthFileMap(map);
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
// Aggregate rows: all from bySource only (no separate byAuthIndex rows to avoid duplicates).
// Auth files are used purely for name resolution of unmatched source IDs.
const rows = useMemo((): CredentialRow[] => {
if (!usage) return [];
const { bySource } = computeKeyStats(usage);
const details = collectUsageDetails(usage);
const result: CredentialRow[] = [];
const consumedSourceIds = new Set<string>();
const authIndexToRowIndex = new Map<string, number>();
const sourceToAuthIndex = new Map<string, string>();
const fallbackByAuthIndex = new Map<string, CredentialBucket>();
const mergeBucketToRow = (index: number, bucket: CredentialBucket) => {
const target = result[index];
if (!target) return;
target.success += bucket.success;
target.failure += bucket.failure;
target.total = target.success + target.failure;
target.successRate = target.total > 0 ? (target.success / target.total) * 100 : 100;
};
// Aggregate all candidate source IDs for one provider config into a single row
const addConfigRow = (
apiKey: string,
prefix: string | undefined,
name: string,
type: string,
rowKey: string,
) => {
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
let success = 0;
let failure = 0;
candidates.forEach((id) => {
const bucket = bySource[id];
if (bucket) {
success += bucket.success;
failure += bucket.failure;
consumedSourceIds.add(id);
}
});
const total = success + failure;
if (total > 0) {
result.push({
key: rowKey,
displayName: name,
type,
success,
failure,
total,
successRate: (success / total) * 100,
});
}
};
// Provider rows — one row per config, stats merged across all its candidate source IDs
geminiKeys.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Gemini #${i + 1}`, 'gemini', `gemini:${i}`));
claudeConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Claude #${i + 1}`, 'claude', `claude:${i}`));
codexConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Codex #${i + 1}`, 'codex', `codex:${i}`));
vertexConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Vertex #${i + 1}`, 'vertex', `vertex:${i}`));
// OpenAI compatibility providers — one row per provider, merged across all apiKey entries (prefix counted once).
openaiProviders.forEach((provider, providerIndex) => {
const prefix = provider.prefix;
const displayName = prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
const candidates = new Set<string>();
buildCandidateUsageSourceIds({ prefix }).forEach((id) => candidates.add(id));
(provider.apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
});
let success = 0;
let failure = 0;
candidates.forEach((id) => {
const bucket = bySource[id];
if (bucket) {
success += bucket.success;
failure += bucket.failure;
consumedSourceIds.add(id);
}
});
const total = success + failure;
if (total > 0) {
result.push({
key: `openai:${providerIndex}`,
displayName,
type: 'openai',
success,
failure,
total,
successRate: (success / total) * 100,
});
}
});
// Build source → auth file name mapping for remaining unmatched entries.
// Also collect fallback stats for details without source but with auth_index.
const sourceToAuthFile = new Map<string, CredentialInfo>();
details.forEach((d) => {
const authIdx = normalizeAuthIndexValue(d.auth_index);
if (!d.source) {
if (!authIdx) return;
const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 };
if (d.failed === true) {
fallback.failure += 1;
} else {
fallback.success += 1;
}
fallbackByAuthIndex.set(authIdx, fallback);
return;
}
if (!authIdx || consumedSourceIds.has(d.source)) return;
if (!sourceToAuthIndex.has(d.source)) {
sourceToAuthIndex.set(d.source, authIdx);
}
if (!sourceToAuthFile.has(d.source)) {
const mapped = authFileMap.get(authIdx);
if (mapped) sourceToAuthFile.set(d.source, mapped);
}
});
// Remaining unmatched bySource entries — resolve name from auth files if possible
Object.entries(bySource).forEach(([key, bucket]) => {
if (consumedSourceIds.has(key)) return;
const total = bucket.success + bucket.failure;
const authFile = sourceToAuthFile.get(key);
const row = {
key,
displayName: authFile?.name || (key.startsWith('t:') ? key.slice(2) : key),
type: authFile?.type || '',
success: bucket.success,
failure: bucket.failure,
total,
successRate: total > 0 ? (bucket.success / total) * 100 : 100,
};
const rowIndex = result.push(row) - 1;
const authIdx = sourceToAuthIndex.get(key);
if (authIdx && !authIndexToRowIndex.has(authIdx)) {
authIndexToRowIndex.set(authIdx, rowIndex);
}
});
// Include requests that have auth_index but missing source.
fallbackByAuthIndex.forEach((bucket, authIdx) => {
if (bucket.success + bucket.failure === 0) return;
const mapped = authFileMap.get(authIdx);
let targetRowIndex = authIndexToRowIndex.get(authIdx);
if (targetRowIndex === undefined && mapped) {
const matchedIndex = result.findIndex(
(row) => row.displayName === mapped.name && row.type === mapped.type
);
if (matchedIndex >= 0) {
targetRowIndex = matchedIndex;
authIndexToRowIndex.set(authIdx, matchedIndex);
}
}
if (targetRowIndex !== undefined) {
mergeBucketToRow(targetRowIndex, bucket);
return;
}
const total = bucket.success + bucket.failure;
const rowIndex = result.push({
key: `auth:${authIdx}`,
displayName: mapped?.name || authIdx,
type: mapped?.type || '',
success: bucket.success,
failure: bucket.failure,
total,
successRate: (bucket.success / total) * 100
}) - 1;
authIndexToRowIndex.set(authIdx, rowIndex);
});
return result.sort((a, b) => b.total - a.total);
}, [usage, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs, openaiProviders, authFileMap]);
return (
<Card title={t('usage_stats.credential_stats')} className={styles.detailsFixedCard}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : rows.length > 0 ? (
<div className={styles.detailsScroll}>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.credential_name')}</th>
<th>{t('usage_stats.requests_count')}</th>
<th>{t('usage_stats.success_rate')}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key}>
<td className={styles.modelCell}>
<span>{row.displayName}</span>
{row.type && (
<span className={styles.credentialType}>{row.type}</span>
)}
</td>
<td>
<span className={styles.requestCountCell}>
<span>{formatCompactNumber(row.total)}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{row.success.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{row.failure.toLocaleString()}</span>)
</span>
</span>
</td>
<td>
<span
className={
row.successRate >= 95
? styles.statSuccess
: row.successRate >= 80
? styles.statNeutral
: styles.statFailure
}
>
{row.successRate.toFixed(1)}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}

View File

@@ -1,11 +1,14 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { formatTokensInMillions, formatUsd } from '@/utils/usage'; import { formatCompactNumber, formatUsd } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss'; import styles from '@/pages/UsagePage.module.scss';
export interface ModelStat { export interface ModelStat {
model: string; model: string;
requests: number; requests: number;
successCount: number;
failureCount: number;
tokens: number; tokens: number;
cost: number; cost: number;
} }
@@ -16,35 +19,137 @@ export interface ModelStatsCardProps {
hasPrices: boolean; hasPrices: boolean;
} }
type SortKey = 'model' | 'requests' | 'tokens' | 'cost' | 'successRate';
type SortDir = 'asc' | 'desc';
interface ModelStatWithRate extends ModelStat {
successRate: number;
}
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) { export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>('requests');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const handleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir(key === 'model' ? 'asc' : 'desc');
}
};
const sorted = useMemo((): ModelStatWithRate[] => {
const list: ModelStatWithRate[] = modelStats.map((s) => ({
...s,
successRate: s.requests > 0 ? (s.successCount / s.requests) * 100 : 100,
}));
const dir = sortDir === 'asc' ? 1 : -1;
list.sort((a, b) => {
if (sortKey === 'model') return dir * a.model.localeCompare(b.model);
return dir * ((a[sortKey] as number) - (b[sortKey] as number));
});
return list;
}, [modelStats, sortKey, sortDir]);
const arrow = (key: SortKey) =>
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' =>
sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
return ( return (
<Card title={t('usage_stats.models')}> <Card title={t('usage_stats.models')} className={styles.detailsFixedCard}>
{loading ? ( {loading ? (
<div className={styles.hint}>{t('common.loading')}</div> <div className={styles.hint}>{t('common.loading')}</div>
) : modelStats.length > 0 ? ( ) : sorted.length > 0 ? (
<div className={styles.tableWrapper}> <div className={styles.detailsScroll}>
<table className={styles.table}> <div className={styles.tableWrapper}>
<thead> <table className={styles.table}>
<tr> <thead>
<th>{t('usage_stats.model_name')}</th> <tr>
<th>{t('usage_stats.requests_count')}</th> <th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
<th>{t('usage_stats.tokens_count')}</th> <button
{hasPrices && <th>{t('usage_stats.total_cost')}</th>} type="button"
</tr> className={styles.sortHeaderButton}
</thead> onClick={() => handleSort('model')}
<tbody> >
{modelStats.map((stat) => ( {t('usage_stats.model_name')}{arrow('model')}
<tr key={stat.model}> </button>
<td className={styles.modelCell}>{stat.model}</td> </th>
<td>{stat.requests.toLocaleString()}</td> <th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
<td>{formatTokensInMillions(stat.tokens)}</td> <button
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>} type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('requests')}
>
{t('usage_stats.requests_count')}{arrow('requests')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('tokens')}
>
{t('usage_stats.tokens_count')}{arrow('tokens')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('successRate')}
>
{t('usage_stats.success_rate')}{arrow('successRate')}
</button>
</th>
{hasPrices && (
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('cost')}
>
{t('usage_stats.total_cost')}{arrow('cost')}
</button>
</th>
)}
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {sorted.map((stat) => (
<tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td>
<td>
<span className={styles.requestCountCell}>
<span>{stat.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
</span>
</span>
</td>
<td>{formatCompactNumber(stat.tokens)}</td>
<td>
<span
className={
stat.successRate >= 95
? styles.statSuccess
: stat.successRate >= 80
? styles.statNeutral
: styles.statFailure
}
>
{stat.successRate.toFixed(1)}%
</span>
</td>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
) : ( ) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div> <div className={styles.hint}>{t('usage_stats.no_data')}</div>

View File

@@ -1,8 +1,10 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import type { ModelPrice } from '@/utils/usage'; import type { ModelPrice } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss'; import styles from '@/pages/UsagePage.module.scss';
@@ -19,11 +21,18 @@ export function PriceSettingsCard({
}: PriceSettingsCardProps) { }: PriceSettingsCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// Add form state
const [selectedModel, setSelectedModel] = useState(''); const [selectedModel, setSelectedModel] = useState('');
const [promptPrice, setPromptPrice] = useState(''); const [promptPrice, setPromptPrice] = useState('');
const [completionPrice, setCompletionPrice] = useState(''); const [completionPrice, setCompletionPrice] = useState('');
const [cachePrice, setCachePrice] = useState(''); const [cachePrice, setCachePrice] = useState('');
// Edit modal state
const [editModel, setEditModel] = useState<string | null>(null);
const [editPrompt, setEditPrompt] = useState('');
const [editCompletion, setEditCompletion] = useState('');
const [editCache, setEditCache] = useState('');
const handleSavePrice = () => { const handleSavePrice = () => {
if (!selectedModel) return; if (!selectedModel) return;
const prompt = parseFloat(promptPrice) || 0; const prompt = parseFloat(promptPrice) || 0;
@@ -43,12 +52,22 @@ export function PriceSettingsCard({
onPricesChange(newPrices); onPricesChange(newPrices);
}; };
const handleEditPrice = (model: string) => { const handleOpenEdit = (model: string) => {
const price = modelPrices[model]; const price = modelPrices[model];
setSelectedModel(model); setEditModel(model);
setPromptPrice(price?.prompt?.toString() || ''); setEditPrompt(price?.prompt?.toString() || '');
setCompletionPrice(price?.completion?.toString() || ''); setEditCompletion(price?.completion?.toString() || '');
setCachePrice(price?.cache?.toString() || ''); setEditCache(price?.cache?.toString() || '');
};
const handleSaveEdit = () => {
if (!editModel) return;
const prompt = parseFloat(editPrompt) || 0;
const completion = parseFloat(editCompletion) || 0;
const cache = editCache.trim() === '' ? prompt : parseFloat(editCache) || 0;
const newPrices = { ...modelPrices, [editModel]: { prompt, completion, cache } };
onPricesChange(newPrices);
setEditModel(null);
}; };
const handleModelSelect = (value: string) => { const handleModelSelect = (value: string) => {
@@ -65,6 +84,14 @@ export function PriceSettingsCard({
} }
}; };
const options = useMemo(
() => [
{ value: '', label: t('usage_stats.model_price_select_placeholder') },
...modelNames.map((name) => ({ value: name, label: name }))
],
[modelNames, t]
);
return ( return (
<Card title={t('usage_stats.model_price_settings')}> <Card title={t('usage_stats.model_price_settings')}>
<div className={styles.pricingSection}> <div className={styles.pricingSection}>
@@ -73,18 +100,12 @@ export function PriceSettingsCard({
<div className={styles.formRow}> <div className={styles.formRow}>
<div className={styles.formField}> <div className={styles.formField}>
<label>{t('usage_stats.model_name')}</label> <label>{t('usage_stats.model_name')}</label>
<select <Select
value={selectedModel} value={selectedModel}
onChange={(e) => handleModelSelect(e.target.value)} options={options}
className={styles.select} onChange={handleModelSelect}
> placeholder={t('usage_stats.model_price_select_placeholder')}
<option value="">{t('usage_stats.model_price_select_placeholder')}</option> />
{modelNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div> </div>
<div className={styles.formField}> <div className={styles.formField}>
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label> <label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
@@ -144,7 +165,7 @@ export function PriceSettingsCard({
</div> </div>
</div> </div>
<div className={styles.priceActions}> <div className={styles.priceActions}>
<Button variant="secondary" size="sm" onClick={() => handleEditPrice(model)}> <Button variant="secondary" size="sm" onClick={() => handleOpenEdit(model)}>
{t('common.edit')} {t('common.edit')}
</Button> </Button>
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}> <Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
@@ -159,6 +180,57 @@ export function PriceSettingsCard({
)} )}
</div> </div>
</div> </div>
{/* Edit Modal */}
<Modal
open={editModel !== null}
title={editModel ?? ''}
onClose={() => setEditModel(null)}
footer={
<div className={styles.priceActions}>
<Button variant="secondary" onClick={() => setEditModel(null)}>
{t('common.cancel')}
</Button>
<Button variant="primary" onClick={handleSaveEdit}>
{t('common.save')}
</Button>
</div>
}
width={420}
>
<div className={styles.editModalBody}>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
<Input
type="number"
value={editPrompt}
onChange={(e) => setEditPrompt(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
<Input
type="number"
value={editCompletion}
onChange={(e) => setEditCompletion(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_cache')} ($/1M)</label>
<Input
type="number"
value={editCache}
onChange={(e) => setEditCache(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
</div>
</Modal>
</Card> </Card>
); );
} }

View File

@@ -0,0 +1,180 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
collectUsageDetails,
calculateServiceHealthData,
type ServiceHealthData,
type StatusBlockDetail,
} from '@/utils/usage';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
const COLOR_STOPS = [
{ r: 239, g: 68, b: 68 }, // #ef4444
{ r: 250, g: 204, b: 21 }, // #facc15
{ r: 34, g: 197, b: 94 }, // #22c55e
] as const;
function rateToColor(rate: number): string {
const t = Math.max(0, Math.min(1, rate));
const segment = t < 0.5 ? 0 : 1;
const localT = segment === 0 ? t * 2 : (t - 0.5) * 2;
const from = COLOR_STOPS[segment];
const to = COLOR_STOPS[segment + 1];
const r = Math.round(from.r + (to.r - from.r) * localT);
const g = Math.round(from.g + (to.g - from.g) * localT);
const b = Math.round(from.b + (to.b - from.b) * localT);
return `rgb(${r}, ${g}, ${b})`;
}
function formatDateTime(timestamp: number): string {
const date = new Date(timestamp);
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
return `${month}/${day} ${h}:${m}`;
}
export interface ServiceHealthCardProps {
usage: UsagePayload | null;
loading: boolean;
}
export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
const { t } = useTranslation();
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const healthData: ServiceHealthData = useMemo(() => {
const details = usage ? collectUsageDetails(usage) : [];
return calculateServiceHealthData(details);
}, [usage]);
const hasData = healthData.totalSuccess + healthData.totalFailure > 0;
useEffect(() => {
if (activeTooltip === null) return;
const handler = (e: PointerEvent) => {
if (gridRef.current && !gridRef.current.contains(e.target as Node)) {
setActiveTooltip(null);
}
};
document.addEventListener('pointerdown', handler);
return () => document.removeEventListener('pointerdown', handler);
}, [activeTooltip]);
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(idx);
}
}, []);
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(null);
}
}, []);
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'touch') {
e.preventDefault();
setActiveTooltip((prev) => (prev === idx ? null : idx));
}
}, []);
const getTooltipPositionClass = (idx: number): string => {
const col = Math.floor(idx / healthData.rows);
if (col <= 2) return styles.healthTooltipLeft;
if (col >= healthData.cols - 3) return styles.healthTooltipRight;
return '';
};
const getTooltipVerticalClass = (idx: number): string => {
const row = idx % healthData.rows;
if (row <= 1) return styles.healthTooltipBelow;
return '';
};
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
const total = detail.success + detail.failure;
const posClass = getTooltipPositionClass(idx);
const vertClass = getTooltipVerticalClass(idx);
const timeRange = `${formatDateTime(detail.startTime)} ${formatDateTime(detail.endTime)}`;
return (
<div className={`${styles.healthTooltip} ${posClass} ${vertClass}`}>
<span className={styles.healthTooltipTime}>{timeRange}</span>
{total > 0 ? (
<span className={styles.healthTooltipStats}>
<span className={styles.healthTooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
<span className={styles.healthTooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
<span className={styles.healthTooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
</span>
) : (
<span className={styles.healthTooltipStats}>{t('status_bar.no_requests')}</span>
)}
</div>
);
};
const rateClass = !hasData
? ''
: healthData.successRate >= 90
? styles.healthRateHigh
: healthData.successRate >= 50
? styles.healthRateMedium
: styles.healthRateLow;
return (
<div className={styles.healthCard}>
<div className={styles.healthHeader}>
<h3 className={styles.healthTitle}>{t('service_health.title')}</h3>
<div className={styles.healthMeta}>
<span className={styles.healthWindow}>{t('service_health.window')}</span>
<span className={`${styles.healthRate} ${rateClass}`}>
{loading ? '--' : hasData ? `${healthData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
</div>
<div className={styles.healthGridScroller}>
<div
className={styles.healthGrid}
ref={gridRef}
>
{healthData.blockDetails.map((detail, idx) => {
const isIdle = detail.rate === -1;
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
const isActive = activeTooltip === idx;
return (
<div
key={idx}
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
onPointerEnter={(e) => handlePointerEnter(e, idx)}
onPointerLeave={handlePointerLeave}
onPointerDown={(e) => handlePointerDown(e, idx)}
>
<div
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
style={blockStyle}
/>
{isActive && renderTooltip(detail, idx)}
</div>
);
})}
</div>
</div>
<div className={styles.healthLegend}>
<span className={styles.healthLegendLabel}>{t('service_health.oldest')}</span>
<div className={styles.healthLegendColors}>
<div className={`${styles.healthLegendBlock} ${styles.healthBlockIdle}`} />
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#ef4444' }} />
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#facc15' }} />
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#22c55e' }} />
</div>
<span className={styles.healthLegendLabel}>{t('service_health.newest')}</span>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons'; import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
import { import {
formatTokensInMillions, formatCompactNumber,
formatPerMinuteValue, formatPerMinuteValue,
formatUsd, formatUsd,
calculateTokenBreakdown, calculateTokenBreakdown,
@@ -56,9 +56,9 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
key: 'requests', key: 'requests',
label: t('usage_stats.total_requests'), label: t('usage_stats.total_requests'),
icon: <IconSatellite size={16} />, icon: <IconSatellite size={16} />,
accent: '#3b82f6', accent: '#8b8680',
accentSoft: 'rgba(59, 130, 246, 0.18)', accentSoft: 'rgba(139, 134, 128, 0.18)',
accentBorder: 'rgba(59, 130, 246, 0.35)', accentBorder: 'rgba(139, 134, 128, 0.35)',
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(), value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
meta: ( meta: (
<> <>
@@ -67,7 +67,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)} {t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
</span> </span>
<span className={styles.statMetaItem}> <span className={styles.statMetaItem}>
<span className={styles.statMetaDot} style={{ backgroundColor: '#ef4444' }} /> <span className={styles.statMetaDot} style={{ backgroundColor: '#c65746' }} />
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)} {t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
</span> </span>
</> </>
@@ -81,14 +81,14 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
accent: '#8b5cf6', accent: '#8b5cf6',
accentSoft: 'rgba(139, 92, 246, 0.18)', accentSoft: 'rgba(139, 92, 246, 0.18)',
accentBorder: 'rgba(139, 92, 246, 0.35)', accentBorder: 'rgba(139, 92, 246, 0.35)',
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0), value: loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0),
meta: ( meta: (
<> <>
<span className={styles.statMetaItem}> <span className={styles.statMetaItem}>
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)} {t('usage_stats.cached_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
</span> </span>
<span className={styles.statMetaItem}> <span className={styles.statMetaItem}>
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)} {t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
</span> </span>
</> </>
), ),
@@ -119,7 +119,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm), value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
meta: ( meta: (
<span className={styles.statMetaItem}> <span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)} {t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
</span> </span>
), ),
trend: sparklines.tpm trend: sparklines.tpm
@@ -135,7 +135,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
meta: ( meta: (
<> <>
<span className={styles.statMetaItem}> <span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)} {t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
</span> </span>
{!hasPrices && ( {!hasPrices && (
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}> <span className={`${styles.statMetaItem} ${styles.statSubtle}`}>

View File

@@ -0,0 +1,145 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Line } from 'react-chartjs-2';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import {
buildHourlyTokenBreakdown,
buildDailyTokenBreakdown,
type TokenCategory
} from '@/utils/usage';
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
const TOKEN_COLORS: Record<TokenCategory, { border: string; bg: string }> = {
input: { border: '#8b8680', bg: 'rgba(139, 134, 128, 0.25)' },
output: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.25)' },
cached: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.25)' },
reasoning: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.25)' }
};
const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning'];
export interface TokenBreakdownChartProps {
usage: UsagePayload | null;
loading: boolean;
isDark: boolean;
isMobile: boolean;
hourWindowHours?: number;
}
export function TokenBreakdownChart({
usage,
loading,
isDark,
isMobile,
hourWindowHours
}: TokenBreakdownChartProps) {
const { t } = useTranslation();
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
const { chartData, chartOptions } = useMemo(() => {
const series =
period === 'hour'
? buildHourlyTokenBreakdown(usage, hourWindowHours)
: buildDailyTokenBreakdown(usage);
const categoryLabels: Record<TokenCategory, string> = {
input: t('usage_stats.input_tokens'),
output: t('usage_stats.output_tokens'),
cached: t('usage_stats.cached_tokens'),
reasoning: t('usage_stats.reasoning_tokens')
};
const data = {
labels: series.labels,
datasets: CATEGORIES.map((cat) => ({
label: categoryLabels[cat],
data: series.dataByCategory[cat],
borderColor: TOKEN_COLORS[cat].border,
backgroundColor: TOKEN_COLORS[cat].bg,
pointBackgroundColor: TOKEN_COLORS[cat].border,
pointBorderColor: TOKEN_COLORS[cat].border,
fill: true,
tension: 0.35
}))
};
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
const options = {
...baseOptions,
scales: {
...baseOptions.scales,
y: {
...baseOptions.scales?.y,
stacked: true
},
x: {
...baseOptions.scales?.x,
stacked: true
}
}
};
return { chartData: data, chartOptions: options };
}, [usage, period, isDark, isMobile, hourWindowHours, t]);
return (
<Card
title={t('usage_stats.token_breakdown')}
extra={
<div className={styles.periodButtons}>
<Button
variant={period === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={period === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : chartData.labels.length > 0 ? (
<div className={styles.chartWrapper}>
<div className={styles.chartLegend} aria-label="Chart legend">
{chartData.datasets.map((dataset, index) => (
<div
key={`${dataset.label}-${index}`}
className={styles.legendItem}
title={dataset.label}
>
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
<span className={styles.legendLabel}>{dataset.label}</span>
</div>
))}
</div>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
period === 'hour'
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
: undefined
}
>
<Line data={chartData} options={chartOptions} />
</div>
</div>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}

View File

@@ -9,6 +9,7 @@ export interface UseChartDataOptions {
chartLines: string[]; chartLines: string[];
isDark: boolean; isDark: boolean;
isMobile: boolean; isMobile: boolean;
hourWindowHours?: number;
} }
export interface UseChartDataReturn { export interface UseChartDataReturn {
@@ -26,20 +27,21 @@ export function useChartData({
usage, usage,
chartLines, chartLines,
isDark, isDark,
isMobile isMobile,
hourWindowHours
}: UseChartDataOptions): UseChartDataReturn { }: UseChartDataOptions): UseChartDataReturn {
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day'); const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day'); const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
const requestsChartData = useMemo(() => { const requestsChartData = useMemo(() => {
if (!usage) return { labels: [], datasets: [] }; if (!usage) return { labels: [], datasets: [] };
return buildChartData(usage, requestsPeriod, 'requests', chartLines); return buildChartData(usage, requestsPeriod, 'requests', chartLines, { hourWindowHours });
}, [usage, requestsPeriod, chartLines]); }, [usage, requestsPeriod, chartLines, hourWindowHours]);
const tokensChartData = useMemo(() => { const tokensChartData = useMemo(() => {
if (!usage) return { labels: [], datasets: [] }; if (!usage) return { labels: [], datasets: [] };
return buildChartData(usage, tokensPeriod, 'tokens', chartLines); return buildChartData(usage, tokensPeriod, 'tokens', chartLines, { hourWindowHours });
}, [usage, tokensPeriod, chartLines]); }, [usage, tokensPeriod, chartLines, hourWindowHours]);
const requestsChartOptions = useMemo( const requestsChartOptions = useMemo(
() => () =>

View File

@@ -104,7 +104,7 @@ export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSpar
); );
const requestsSparkline = useMemo( const requestsSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('requests'), '#3b82f6', 'rgba(59, 130, 246, 0.18)'), () => buildSparkline(buildLastHourSeries('requests'), '#8b8680', 'rgba(139, 134, 128, 0.18)'),
[buildLastHourSeries, buildSparkline] [buildLastHourSeries, buildSparkline]
); );

View File

@@ -17,6 +17,7 @@ export interface UseUsageDataReturn {
usage: UsagePayload | null; usage: UsagePayload | null;
loading: boolean; loading: boolean;
error: string; error: string;
lastRefreshedAt: Date | null;
modelPrices: Record<string, ModelPrice>; modelPrices: Record<string, ModelPrice>;
setModelPrices: (prices: Record<string, ModelPrice>) => void; setModelPrices: (prices: Record<string, ModelPrice>) => void;
loadUsage: () => Promise<void>; loadUsage: () => Promise<void>;
@@ -38,6 +39,7 @@ export function useUsageData(): UseUsageDataReturn {
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({}); const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
const importInputRef = useRef<HTMLInputElement | null>(null); const importInputRef = useRef<HTMLInputElement | null>(null);
const loadUsage = useCallback(async () => { const loadUsage = useCallback(async () => {
@@ -45,8 +47,9 @@ export function useUsageData(): UseUsageDataReturn {
setError(''); setError('');
try { try {
const data = await usageApi.getUsage(); const data = await usageApi.getUsage();
const payload = data?.usage ?? data; const payload = (data?.usage ?? data) as unknown;
setUsage(payload); setUsage(payload && typeof payload === 'object' ? (payload as UsagePayload) : null);
setLastRefreshedAt(new Date());
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : t('usage_stats.loading_error'); const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
setError(message); setError(message);
@@ -140,6 +143,7 @@ export function useUsageData(): UseUsageDataReturn {
usage, usage,
loading, loading,
error, error,
lastRefreshedAt,
modelPrices, modelPrices,
setModelPrices: handleSetModelPrices, setModelPrices: handleSetModelPrices,
loadUsage, loadUsage,

View File

@@ -26,3 +26,15 @@ export type { ModelStatsCardProps, ModelStat } from './ModelStatsCard';
export { PriceSettingsCard } from './PriceSettingsCard'; export { PriceSettingsCard } from './PriceSettingsCard';
export type { PriceSettingsCardProps } from './PriceSettingsCard'; export type { PriceSettingsCardProps } from './PriceSettingsCard';
export { CredentialStatsCard } from './CredentialStatsCard';
export type { CredentialStatsCardProps } from './CredentialStatsCard';
export { TokenBreakdownChart } from './TokenBreakdownChart';
export type { TokenBreakdownChartProps } from './TokenBreakdownChart';
export { CostTrendChart } from './CostTrendChart';
export type { CostTrendChartProps } from './CostTrendChart';
export { ServiceHealthCard } from './ServiceHealthCard';
export type { ServiceHealthCardProps } from './ServiceHealthCard';

View File

@@ -0,0 +1,232 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconBot, IconCheck, IconCode, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
import type { AuthFileItem } from '@/types';
import { resolveAuthProvider } from '@/utils/quota';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { formatFileSize } from '@/utils/format';
import {
QUOTA_PROVIDER_TYPES,
formatModified,
getTypeColor,
getTypeLabel,
isRuntimeOnlyAuthFile,
normalizeAuthIndexValue,
resolveAuthFileStats,
type QuotaProviderType,
type ResolvedTheme
} from '@/features/authFiles/constants';
import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection';
import styles from '@/pages/AuthFilesPage.module.scss';
export type AuthFileCardProps = {
file: AuthFileItem;
selected: boolean;
resolvedTheme: ResolvedTheme;
disableControls: boolean;
deleting: string | null;
statusUpdating: Record<string, boolean>;
quotaFilterType: QuotaProviderType | null;
keyStats: KeyStats;
statusBarCache: Map<string, AuthFileStatusBarData>;
onShowModels: (file: AuthFileItem) => void;
onShowDetails: (file: AuthFileItem) => void;
onDownload: (name: string) => void;
onOpenPrefixProxyEditor: (name: string) => void;
onDelete: (name: string) => void;
onToggleStatus: (file: AuthFileItem, enabled: boolean) => void;
onToggleSelect: (name: string) => void;
};
const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
const provider = resolveAuthProvider(file);
if (!QUOTA_PROVIDER_TYPES.has(provider as QuotaProviderType)) return null;
return provider as QuotaProviderType;
};
export function AuthFileCard(props: AuthFileCardProps) {
const { t } = useTranslation();
const {
file,
selected,
resolvedTheme,
disableControls,
deleting,
statusUpdating,
quotaFilterType,
keyStats,
statusBarCache,
onShowModels,
onShowDetails,
onDownload,
onOpenPrefixProxyEditor,
onDelete,
onToggleStatus,
onToggleSelect
} = props;
const fileStats = resolveAuthFileStats(file, keyStats);
const isRuntimeOnly = isRuntimeOnlyAuthFile(file);
const isAistudio = (file.type || '').toLowerCase() === 'aistudio';
const showModelsButton = !isRuntimeOnly || isAistudio;
const typeColor = getTypeColor(file.type || 'unknown', resolvedTheme);
const quotaType =
quotaFilterType && resolveQuotaType(file) === quotaFilterType ? quotaFilterType : null;
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
const providerCardClass =
quotaType === 'antigravity'
? styles.antigravityCard
: quotaType === 'codex'
? styles.codexCard
: quotaType === 'gemini-cli'
? styles.geminiCliCard
: '';
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
const statusData =
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
return (
<div
className={`${styles.fileCard} ${providerCardClass} ${selected ? styles.fileCardSelected : ''} ${file.disabled ? styles.fileCardDisabled : ''}`}
>
<div className={styles.fileCardLayout}>
<div className={styles.fileCardMain}>
<div className={styles.cardHeader}>
{!isRuntimeOnly && (
<button
type="button"
className={`${styles.selectionToggle} ${selected ? styles.selectionToggleActive : ''}`}
onClick={() => onToggleSelect(file.name)}
aria-label={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
aria-pressed={selected}
title={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
>
{selected && <IconCheck size={12} />}
</button>
)}
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
}}
>
{getTypeLabel(t, file.type || 'unknown')}
</span>
<span className={styles.fileName}>{file.name}</span>
</div>
<div className={styles.cardMeta}>
<span>
{t('auth_files.file_size')}: {file.size ? formatFileSize(file.size) : '-'}
</span>
<span>
{t('auth_files.file_modified')}: {formatModified(file)}
</span>
</div>
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {fileStats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {fileStats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} styles={styles} />
{showQuotaLayout && quotaType && (
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />
)}
<div className={styles.cardActions}>
{showModelsButton && (
<Button
variant="secondary"
size="sm"
onClick={() => onShowModels(file)}
className={styles.iconButton}
title={t('auth_files.models_button', { defaultValue: '模型' })}
disabled={disableControls}
>
<IconBot className={styles.actionIcon} size={16} />
</Button>
)}
{!isRuntimeOnly && (
<>
<Button
variant="secondary"
size="sm"
onClick={() => onShowDetails(file)}
className={styles.iconButton}
title={t('common.info', { defaultValue: '关于' })}
disabled={disableControls}
>
<IconInfo className={styles.actionIcon} size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onDownload(file.name)}
className={styles.iconButton}
title={t('auth_files.download_button')}
disabled={disableControls}
>
<IconDownload className={styles.actionIcon} size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onOpenPrefixProxyEditor(file.name)}
className={styles.iconButton}
title={t('auth_files.prefix_proxy_button')}
disabled={disableControls}
>
<IconCode className={styles.actionIcon} size={16} />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => onDelete(file.name)}
className={styles.iconButton}
title={t('auth_files.delete_button')}
disabled={disableControls || deleting === file.name}
>
{deleting === file.name ? (
<LoadingSpinner size={14} />
) : (
<IconTrash2 className={styles.actionIcon} size={16} />
)}
</Button>
</>
)}
{!isRuntimeOnly && (
<div className={styles.statusToggle}>
<ToggleSwitch
ariaLabel={t('auth_files.status_toggle_label')}
checked={!file.disabled}
disabled={disableControls || statusUpdating[file.name] === true}
onChange={(value) => onToggleStatus(file, value)}
/>
</div>
)}
{isRuntimeOnly && (
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import type { AuthFileItem } from '@/types';
import styles from '@/pages/AuthFilesPage.module.scss';
export type AuthFileDetailModalProps = {
open: boolean;
file: AuthFileItem | null;
onClose: () => void;
onCopyText: (text: string) => void;
};
export function AuthFileDetailModal({ open, file, onClose, onCopyText }: AuthFileDetailModalProps) {
const { t } = useTranslation();
return (
<Modal
open={open}
onClose={onClose}
title={file?.name || t('auth_files.title_section')}
footer={
<>
<Button variant="secondary" onClick={onClose}>
{t('common.close')}
</Button>
<Button
onClick={() => {
if (!file) return;
const text = JSON.stringify(file, null, 2);
onCopyText(text);
}}
>
{t('common.copy')}
</Button>
</>
}
>
{file && (
<div className={styles.detailContent}>
<pre className={styles.jsonContent}>{JSON.stringify(file, null, 2)}</pre>
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,91 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import type { AuthFileModelItem } from '@/features/authFiles/constants';
import { isModelExcluded } from '@/features/authFiles/constants';
import styles from '@/pages/AuthFilesPage.module.scss';
export type AuthFileModelsModalProps = {
open: boolean;
fileName: string;
fileType: string;
loading: boolean;
error: 'unsupported' | null;
models: AuthFileModelItem[];
excluded: Record<string, string[]>;
onClose: () => void;
onCopyText: (text: string) => void;
};
export function AuthFileModelsModal(props: AuthFileModelsModalProps) {
const { t } = useTranslation();
const { open, fileName, fileType, loading, error, models, excluded, onClose, onCopyText } = props;
return (
<Modal
open={open}
onClose={onClose}
title={t('auth_files.models_title', { defaultValue: '支持的模型' }) + ` - ${fileName}`}
footer={
<Button variant="secondary" onClick={onClose}>
{t('common.close')}
</Button>
}
>
{loading ? (
<div className={styles.hint}>
{t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })}
</div>
) : error === 'unsupported' ? (
<EmptyState
title={t('auth_files.models_unsupported', { defaultValue: '当前版本不支持此功能' })}
description={t('auth_files.models_unsupported_desc', {
defaultValue: '请更新 CLI Proxy API 到最新版本后重试'
})}
/>
) : models.length === 0 ? (
<EmptyState
title={t('auth_files.models_empty', { defaultValue: '该凭证暂无可用模型' })}
description={t('auth_files.models_empty_desc', {
defaultValue: '该认证凭证可能尚未被服务器加载或没有绑定任何模型'
})}
/>
) : (
<div className={styles.modelsList}>
{models.map((model) => {
const excludedModel = isModelExcluded(model.id, fileType, excluded);
return (
<div
key={model.id}
className={`${styles.modelItem} ${excludedModel ? styles.modelItemExcluded : ''}`}
onClick={() => {
onCopyText(model.id);
}}
title={
excludedModel
? t('auth_files.models_excluded_hint', {
defaultValue: '此 OAuth 模型已被禁用'
})
: t('common.copy', { defaultValue: '点击复制' })
}
>
<span className={styles.modelId}>{model.id}</span>
{model.display_name && model.display_name !== model.id && (
<span className={styles.modelDisplayName}>{model.display_name}</span>
)}
{model.type && <span className={styles.modelType}>{model.type}</span>}
{excludedModel && (
<span className={styles.modelExcludedBadge}>
{t('auth_files.models_excluded_badge', { defaultValue: '已禁用' })}
</span>
)}
</div>
);
})}
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,124 @@
import { useCallback, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota';
import { useNotificationStore, useQuotaStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import { getStatusFromError } from '@/utils/quota';
import {
isRuntimeOnlyAuthFile,
resolveQuotaErrorMessage,
type QuotaProviderType
} from '@/features/authFiles/constants';
import { QuotaProgressBar } from '@/features/authFiles/components/QuotaProgressBar';
import styles from '@/pages/AuthFilesPage.module.scss';
type QuotaState = { status?: string; error?: string; errorStatus?: number } | undefined;
const getQuotaConfig = (type: QuotaProviderType) => {
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
if (type === 'codex') return CODEX_CONFIG;
return GEMINI_CLI_CONFIG;
};
export type AuthFileQuotaSectionProps = {
file: AuthFileItem;
quotaType: QuotaProviderType;
disableControls: boolean;
};
export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
const { file, quotaType, disableControls } = props;
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
const quota = useQuotaStore((state) => {
if (quotaType === 'antigravity') return state.antigravityQuota[file.name] as QuotaState;
if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaState;
return state.geminiCliQuota[file.name] as QuotaState;
});
const updateQuotaState = useQuotaStore((state) => {
if (quotaType === 'antigravity') return state.setAntigravityQuota as unknown as (updater: unknown) => void;
if (quotaType === 'codex') return state.setCodexQuota as unknown as (updater: unknown) => void;
return state.setGeminiCliQuota as unknown as (updater: unknown) => void;
});
const refreshQuotaForFile = useCallback(async () => {
if (disableControls) return;
if (isRuntimeOnlyAuthFile(file)) return;
if (file.disabled) return;
if (quota?.status === 'loading') return;
const config = getQuotaConfig(quotaType) as unknown as {
i18nPrefix: string;
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<unknown>;
buildLoadingState: () => unknown;
buildSuccessState: (data: unknown) => unknown;
buildErrorState: (message: string, status?: number) => unknown;
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
};
updateQuotaState((prev: Record<string, unknown>) => ({
...prev,
[file.name]: config.buildLoadingState()
}));
try {
const data = await config.fetchQuota(file, t);
updateQuotaState((prev: Record<string, unknown>) => ({
...prev,
[file.name]: config.buildSuccessState(data)
}));
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
updateQuotaState((prev: Record<string, unknown>) => ({
...prev,
[file.name]: config.buildErrorState(message, status)
}));
showNotification(t('auth_files.quota_refresh_failed', { name: file.name, message }), 'error');
}
}, [disableControls, file, quota?.status, quotaType, showNotification, t, updateQuotaState]);
const config = getQuotaConfig(quotaType) as unknown as {
i18nPrefix: string;
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
};
const quotaStatus = quota?.status ?? 'idle';
const canRefreshQuota = !disableControls && !file.disabled;
const quotaErrorMessage = resolveQuotaErrorMessage(
t,
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
return (
<div className={styles.quotaSection}>
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.loading`)}</div>
) : quotaStatus === 'idle' ? (
<button
type="button"
className={`${styles.quotaMessage} ${styles.quotaMessageAction}`}
onClick={() => void refreshQuotaForFile()}
disabled={!canRefreshQuota}
>
{t(`${config.i18nPrefix}.idle`)}
</button>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${config.i18nPrefix}.load_failed`, {
message: quotaErrorMessage
})}
</div>
) : quota ? (
(config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode)
) : (
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
)}
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Input } from '@/components/ui/Input';
import type {
PrefixProxyEditorField,
PrefixProxyEditorState
} from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
import styles from '@/pages/AuthFilesPage.module.scss';
export type AuthFilesPrefixProxyEditorModalProps = {
disableControls: boolean;
editor: PrefixProxyEditorState | null;
updatedText: string;
dirty: boolean;
onClose: () => void;
onSave: () => void;
onChange: (field: PrefixProxyEditorField, value: string) => void;
};
export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEditorModalProps) {
const { t } = useTranslation();
const { disableControls, editor, updatedText, dirty, onClose, onSave, onChange } = props;
return (
<Modal
open={Boolean(editor)}
onClose={onClose}
closeDisabled={editor?.saving === true}
width={720}
title={
editor?.fileName
? t('auth_files.auth_field_editor_title', { name: editor.fileName })
: t('auth_files.prefix_proxy_button')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={editor?.saving === true}>
{t('common.cancel')}
</Button>
<Button
onClick={onSave}
loading={editor?.saving === true}
disabled={
disableControls || editor?.saving === true || !dirty || !editor?.json
}
>
{t('common.save')}
</Button>
</>
}
>
{editor && (
<div className={styles.prefixProxyEditor}>
{editor.loading ? (
<div className={styles.prefixProxyLoading}>
<LoadingSpinner size={14} />
<span>{t('auth_files.prefix_proxy_loading')}</span>
</div>
) : (
<>
{editor.error && <div className={styles.prefixProxyError}>{editor.error}</div>}
<div className={styles.prefixProxyJsonWrapper}>
<label className={styles.prefixProxyLabel}>
{t('auth_files.prefix_proxy_source_label')}
</label>
<textarea
className={styles.prefixProxyTextarea}
rows={10}
readOnly
value={updatedText}
/>
</div>
<div className={styles.prefixProxyFields}>
<Input
label={t('auth_files.prefix_label')}
value={editor.prefix}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('prefix', e.target.value)}
/>
<Input
label={t('auth_files.proxy_url_label')}
value={editor.proxyUrl}
placeholder={t('auth_files.proxy_url_placeholder')}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('proxyUrl', e.target.value)}
/>
<Input
label={t('auth_files.priority_label')}
value={editor.priority}
placeholder={t('auth_files.priority_placeholder')}
hint={t('auth_files.priority_hint')}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('priority', e.target.value)}
/>
<div className="form-group">
<label>{t('auth_files.excluded_models_label')}</label>
<textarea
className="input"
value={editor.excludedModelsText}
placeholder={t('auth_files.excluded_models_placeholder')}
rows={4}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('excludedModelsText', e.target.value)}
/>
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
</div>
<Input
label={t('auth_files.disable_cooling_label')}
value={editor.disableCooling}
placeholder={t('auth_files.disable_cooling_placeholder')}
hint={t('auth_files.disable_cooling_hint')}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('disableCooling', e.target.value)}
/>
</div>
</>
)}
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,65 @@
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import styles from '@/pages/AuthFilesPage.module.scss';
type UnsupportedError = 'unsupported' | null;
export type OAuthExcludedCardProps = {
disableControls: boolean;
excludedError: UnsupportedError;
excluded: Record<string, string[]>;
onAdd: () => void;
onEdit: (provider: string) => void;
onDelete: (provider: string) => void;
};
export function OAuthExcludedCard(props: OAuthExcludedCardProps) {
const { t } = useTranslation();
const { disableControls, excludedError, excluded, onAdd, onEdit, onDelete } = props;
return (
<Card
title={t('oauth_excluded.title')}
extra={
<Button size="sm" onClick={onAdd} disabled={disableControls || excludedError === 'unsupported'}>
{t('oauth_excluded.add')}
</Button>
}
>
{excludedError === 'unsupported' ? (
<EmptyState
title={t('oauth_excluded.upgrade_required_title')}
description={t('oauth_excluded.upgrade_required_desc')}
/>
) : Object.keys(excluded).length === 0 ? (
<EmptyState title={t('oauth_excluded.list_empty_all')} />
) : (
<div className={styles.excludedList}>
{Object.entries(excluded).map(([provider, models]) => (
<div key={provider} className={styles.excludedItem}>
<div className={styles.excludedInfo}>
<div className={styles.excludedProvider}>{provider}</div>
<div className={styles.excludedModels}>
{models?.length
? t('oauth_excluded.model_count', { count: models.length })
: t('oauth_excluded.no_models')}
</div>
</div>
<div className={styles.excludedActions}>
<Button variant="secondary" size="sm" onClick={() => onEdit(provider)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => onDelete(provider)}>
{t('oauth_excluded.delete')}
</Button>
</div>
</div>
))}
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,152 @@
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { ModelMappingDiagram, type ModelMappingDiagramRef } from '@/components/modelAlias';
import { IconChevronUp } from '@/components/ui/icons';
import type { OAuthModelAliasEntry } from '@/types';
import type { AuthFileModelItem } from '@/features/authFiles/constants';
import styles from '@/pages/AuthFilesPage.module.scss';
type UnsupportedError = 'unsupported' | null;
type ViewMode = 'diagram' | 'list';
export type OAuthModelAliasCardProps = {
disableControls: boolean;
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
onAdd: () => void;
onEditProvider: (provider?: string) => void;
onDeleteProvider: (provider: string) => void;
modelAliasError: UnsupportedError;
modelAlias: Record<string, OAuthModelAliasEntry[]>;
allProviderModels: Record<string, AuthFileModelItem[]>;
onUpdate: (provider: string, sourceModel: string, newAlias: string) => Promise<void>;
onDeleteLink: (provider: string, sourceModel: string, alias: string) => void;
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => Promise<void>;
onRenameAlias: (oldAlias: string, newAlias: string) => Promise<void>;
onDeleteAlias: (aliasName: string) => void;
};
export function OAuthModelAliasCard(props: OAuthModelAliasCardProps) {
const { t } = useTranslation();
const diagramRef = useRef<ModelMappingDiagramRef | null>(null);
const {
disableControls,
viewMode,
onViewModeChange,
onAdd,
onEditProvider,
onDeleteProvider,
modelAliasError,
modelAlias,
allProviderModels,
onUpdate,
onDeleteLink,
onToggleFork,
onRenameAlias,
onDeleteAlias
} = props;
return (
<Card
title={t('oauth_model_alias.title')}
extra={
<div className={styles.cardExtraButtons}>
<div className={styles.viewModeSwitch}>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => onViewModeChange('list')}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{t('oauth_model_alias.view_mode_list')}
</Button>
<Button
variant={viewMode === 'diagram' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => onViewModeChange('diagram')}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{t('oauth_model_alias.view_mode_diagram')}
</Button>
</div>
<Button
size="sm"
onClick={onAdd}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{t('oauth_model_alias.add')}
</Button>
</div>
}
>
{modelAliasError === 'unsupported' ? (
<EmptyState
title={t('oauth_model_alias.upgrade_required_title')}
description={t('oauth_model_alias.upgrade_required_desc')}
/>
) : viewMode === 'diagram' ? (
Object.keys(modelAlias).length === 0 ? (
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
) : (
<div className={styles.aliasChartSection}>
<div className={styles.aliasChartHeader}>
<h4 className={styles.aliasChartTitle}>{t('oauth_model_alias.chart_title')}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => diagramRef.current?.collapseAll()}
disabled={disableControls || modelAliasError === 'unsupported'}
title={t('oauth_model_alias.diagram_collapse')}
aria-label={t('oauth_model_alias.diagram_collapse')}
>
<IconChevronUp size={16} />
</Button>
</div>
<ModelMappingDiagram
ref={diagramRef}
modelAlias={modelAlias}
allProviderModels={allProviderModels}
onUpdate={onUpdate}
onDeleteLink={onDeleteLink}
onToggleFork={onToggleFork}
onRenameAlias={onRenameAlias}
onDeleteAlias={onDeleteAlias}
onEditProvider={onEditProvider}
onDeleteProvider={onDeleteProvider}
className={styles.aliasChart}
/>
</div>
)
) : Object.keys(modelAlias).length === 0 ? (
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
) : (
<div className={styles.excludedList}>
{Object.entries(modelAlias).map(([provider, mappings]) => (
<div key={provider} className={styles.excludedItem}>
<div className={styles.excludedInfo}>
<div className={styles.excludedProvider}>{provider}</div>
<div className={styles.excludedModels}>
{mappings?.length
? t('oauth_model_alias.model_count', { count: mappings.length })
: t('oauth_model_alias.no_models')}
</div>
</div>
<div className={styles.excludedActions}>
<Button variant="secondary" size="sm" onClick={() => onEditProvider(provider)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => onDeleteProvider(provider)}>
{t('oauth_model_alias.delete')}
</Button>
</div>
</div>
))}
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,28 @@
import styles from '@/pages/AuthFilesPage.module.scss';
export type QuotaProgressBarProps = {
percent: number | null;
highThreshold: number;
mediumThreshold: number;
};
export function QuotaProgressBar({ percent, highThreshold, mediumThreshold }: QuotaProgressBarProps) {
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const normalized = percent === null ? null : clamp(percent, 0, 100);
const fillClass =
normalized === null
? styles.quotaBarFillMedium
: normalized >= highThreshold
? styles.quotaBarFillHigh
: normalized >= mediumThreshold
? styles.quotaBarFillMedium
: styles.quotaBarFillLow;
const widthPercent = Math.round(normalized ?? 0);
return (
<div className={styles.quotaBar}>
<div className={`${styles.quotaBarFill} ${fillClass}`} style={{ width: `${widthPercent}%` }} />
</div>
);
}

View File

@@ -0,0 +1,235 @@
import type { TFunction } from 'i18next';
import type { AuthFileItem } from '@/types';
import {
normalizeUsageSourceId,
type KeyStatBucket,
type KeyStats
} from '@/utils/usage';
export type ThemeColors = { bg: string; text: string; border?: string };
export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
export type ResolvedTheme = 'light' | 'dark';
export type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
export type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli';
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli']);
export const MIN_CARD_PAGE_SIZE = 3;
export const MAX_CARD_PAGE_SIZE = 30;
export const INTEGER_STRING_PATTERN = /^[+-]?\d+$/;
export const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']);
export const FALSY_TEXT_VALUES = new Set(['false', '0', 'no', 'n', 'off']);
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
export const TYPE_COLORS: Record<string, TypeColorSet> = {
qwen: {
light: { bg: '#e8f5e9', text: '#2e7d32' },
dark: { bg: '#1b5e20', text: '#81c784' }
},
kimi: {
light: { bg: '#fff4e5', text: '#ad6800' },
dark: { bg: '#7c4a03', text: '#ffd591' }
},
gemini: {
light: { bg: '#e3f2fd', text: '#1565c0' },
dark: { bg: '#0d47a1', text: '#64b5f6' }
},
'gemini-cli': {
light: { bg: '#e7efff', text: '#1e4fa3' },
dark: { bg: '#1c3f73', text: '#a8c7ff' }
},
aistudio: {
light: { bg: '#f0f2f5', text: '#2f343c' },
dark: { bg: '#373c42', text: '#cfd3db' }
},
claude: {
light: { bg: '#fce4ec', text: '#c2185b' },
dark: { bg: '#880e4f', text: '#f48fb1' }
},
codex: {
light: { bg: '#fff3e0', text: '#ef6c00' },
dark: { bg: '#e65100', text: '#ffb74d' }
},
antigravity: {
light: { bg: '#e0f7fa', text: '#006064' },
dark: { bg: '#004d40', text: '#80deea' }
},
iflow: {
light: { bg: '#f3e5f5', text: '#7b1fa2' },
dark: { bg: '#4a148c', text: '#ce93d8' }
},
empty: {
light: { bg: '#f5f5f5', text: '#616161' },
dark: { bg: '#424242', text: '#bdbdbd' }
},
unknown: {
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' }
}
};
export const clampCardPageSize = (value: number) =>
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
export const resolveQuotaErrorMessage = (
t: TFunction,
status: number | undefined,
fallback: string
): string => {
if (status === 404) return t('common.quota_update_required');
if (status === 403) return t('common.quota_check_credential');
return fallback;
};
export const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
export const getTypeLabel = (t: TFunction, type: string): string => {
const key = `auth_files.filter_${type}`;
const translated = t(key);
if (translated !== key) return translated;
if (type.toLowerCase() === 'iflow') return 'iFlow';
return type.charAt(0).toUpperCase() + type.slice(1);
};
export const getTypeColor = (type: string, resolvedTheme: ResolvedTheme): ThemeColors => {
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
};
export const parsePriorityValue = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isInteger(value) ? value : undefined;
}
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed || !INTEGER_STRING_PATTERN.test(trimmed)) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isSafeInteger(parsed) ? parsed : undefined;
};
export const normalizeExcludedModels = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const normalized: string[] = [];
value.forEach((entry) => {
const model = String(entry ?? '')
.trim()
.toLowerCase();
if (!model || seen.has(model)) return;
seen.add(model);
normalized.push(model);
});
return normalized.sort((a, b) => a.localeCompare(b));
};
export const parseExcludedModelsText = (value: string): string[] =>
normalizeExcludedModels(value.split(/[\n,]+/));
export const parseDisableCoolingValue = (value: unknown): boolean | undefined => {
if (typeof value === 'boolean') return value;
if (typeof value === 'number' && Number.isFinite(value)) return value !== 0;
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (!normalized) return undefined;
if (TRUTHY_TEXT_VALUES.has(normalized)) return true;
if (FALSY_TEXT_VALUES.has(normalized)) return false;
return undefined;
};
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
export function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
return null;
}
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
const raw = file['runtime_only'] ?? file.runtimeOnly;
if (typeof raw === 'boolean') return raw;
if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true';
return false;
}
export function resolveAuthFileStats(file: AuthFileItem, stats: KeyStats): KeyStatBucket {
const defaultStats: KeyStatBucket = { success: 0, failure: 0 };
const rawFileName = file?.name || '';
// 兼容 auth_index 和 authIndex 两种字段名API 返回的是 auth_index
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
// 尝试根据 authIndex 匹配
if (authIndexKey && stats.byAuthIndex?.[authIndexKey]) {
return stats.byAuthIndex[authIndexKey];
}
// 尝试根据 source (文件名) 匹配
const fileNameId = rawFileName ? normalizeUsageSourceId(rawFileName) : '';
if (fileNameId && stats.bySource?.[fileNameId]) {
const fromName = stats.bySource[fileNameId];
if (fromName.success > 0 || fromName.failure > 0) {
return fromName;
}
}
// 尝试去掉扩展名后匹配
if (rawFileName) {
const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, '');
if (nameWithoutExt && nameWithoutExt !== rawFileName) {
const nameWithoutExtId = normalizeUsageSourceId(nameWithoutExt);
const fromNameWithoutExt = nameWithoutExtId ? stats.bySource?.[nameWithoutExtId] : undefined;
if (
fromNameWithoutExt &&
(fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0)
) {
return fromNameWithoutExt;
}
}
}
return defaultStats;
}
export const formatModified = (item: AuthFileItem): string => {
const raw = item['modtime'] ?? item.modified;
if (!raw) return '-';
const asNumber = Number(raw);
const date =
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
: new Date(String(raw));
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
};
// 检查模型是否被 OAuth 排除
export const isModelExcluded = (
modelId: string,
providerType: string,
excluded: Record<string, string[]>
): boolean => {
const providerKey = normalizeProviderKey(providerType);
const excludedModels = excluded[providerKey] || excluded[providerType] || [];
return excludedModels.some((pattern) => {
if (pattern.includes('*')) {
// 支持通配符匹配:先转义正则特殊字符,再将 * 视为通配符
const regexSafePattern = pattern
.split('*')
.map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('.*');
const regex = new RegExp(`^${regexSafePattern}$`, 'i');
return regex.test(modelId);
}
return pattern.toLowerCase() === modelId.toLowerCase();
});
};

View File

@@ -0,0 +1,519 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { apiClient } from '@/services/api/client';
import { useNotificationStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import { formatFileSize } from '@/utils/format';
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
import { getTypeLabel, isRuntimeOnlyAuthFile } from '@/features/authFiles/constants';
type DeleteAllOptions = {
filter: string;
onResetFilterToAll: () => void;
};
export type UseAuthFilesDataResult = {
files: AuthFileItem[];
selectedFiles: Set<string>;
selectionCount: number;
loading: boolean;
error: string;
uploading: boolean;
deleting: string | null;
deletingAll: boolean;
statusUpdating: Record<string, boolean>;
fileInputRef: RefObject<HTMLInputElement | null>;
loadFiles: () => Promise<void>;
handleUploadClick: () => void;
handleFileChange: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
handleDelete: (name: string) => void;
handleDeleteAll: (options: DeleteAllOptions) => void;
handleDownload: (name: string) => Promise<void>;
handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise<void>;
toggleSelect: (name: string) => void;
selectAllVisible: (visibleFiles: AuthFileItem[]) => void;
deselectAll: () => void;
batchSetStatus: (names: string[], enabled: boolean) => Promise<void>;
batchDelete: (names: string[]) => void;
};
export type UseAuthFilesDataOptions = {
refreshKeyStats: () => Promise<void>;
};
export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFilesDataResult {
const { refreshKeyStats } = options;
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const [files, setFiles] = useState<AuthFileItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [deletingAll, setDeletingAll] = useState(false);
const [statusUpdating, setStatusUpdating] = useState<Record<string, boolean>>({});
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const fileInputRef = useRef<HTMLInputElement | null>(null);
const selectionCount = selectedFiles.size;
const toggleSelect = useCallback((name: string) => {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
}, []);
const selectAllVisible = useCallback((visibleFiles: AuthFileItem[]) => {
const nextSelected = visibleFiles
.filter((file) => !isRuntimeOnlyAuthFile(file))
.map((file) => file.name);
setSelectedFiles(new Set(nextSelected));
}, []);
const deselectAll = useCallback(() => {
setSelectedFiles(new Set());
}, []);
useEffect(() => {
if (selectedFiles.size === 0) return;
const existingNames = new Set(files.map((file) => file.name));
setSelectedFiles((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (existingNames.has(name)) {
next.add(name);
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [files, selectedFiles.size]);
const loadFiles = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await authFilesApi.list();
setFiles(data?.files || []);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
setError(errorMessage);
} finally {
setLoading(false);
}
}, [t]);
const handleUploadClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const fileList = event.target.files;
if (!fileList || fileList.length === 0) return;
const filesToUpload = Array.from(fileList);
const validFiles: File[] = [];
const invalidFiles: string[] = [];
const oversizedFiles: string[] = [];
filesToUpload.forEach((file) => {
if (!file.name.endsWith('.json')) {
invalidFiles.push(file.name);
return;
}
if (file.size > MAX_AUTH_FILE_SIZE) {
oversizedFiles.push(file.name);
return;
}
validFiles.push(file);
});
if (invalidFiles.length > 0) {
showNotification(t('auth_files.upload_error_json'), 'error');
}
if (oversizedFiles.length > 0) {
showNotification(
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
'error'
);
}
if (validFiles.length === 0) {
event.target.value = '';
return;
}
setUploading(true);
let successCount = 0;
const failed: { name: string; message: string }[] = [];
for (const file of validFiles) {
try {
await authFilesApi.upload(file);
successCount++;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
failed.push({ name: file.name, message: errorMessage });
}
}
if (successCount > 0) {
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
showNotification(
`${t('auth_files.upload_success')}${suffix}`,
failed.length ? 'warning' : 'success'
);
await loadFiles();
await refreshKeyStats();
}
if (failed.length > 0) {
const details = failed.map((item) => `${item.name}: ${item.message}`).join('; ');
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
}
setUploading(false);
event.target.value = '';
},
[loadFiles, refreshKeyStats, showNotification, t]
);
const handleDelete = useCallback(
(name: string) => {
showConfirmation({
title: t('auth_files.delete_title', { defaultValue: 'Delete File' }),
message: `${t('auth_files.delete_confirm')} "${name}" ?`,
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
setDeleting(name);
try {
await authFilesApi.deleteFile(name);
showNotification(t('auth_files.delete_success'), 'success');
setFiles((prev) => prev.filter((item) => item.name !== name));
setSelectedFiles((prev) => {
if (!prev.has(name)) return prev;
const next = new Set(prev);
next.delete(name);
return next;
});
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
} finally {
setDeleting(null);
}
}
});
},
[showConfirmation, showNotification, t]
);
const handleDeleteAll = useCallback(
(deleteAllOptions: DeleteAllOptions) => {
const { filter, onResetFilterToAll } = deleteAllOptions;
const isFiltered = filter !== 'all';
const typeLabel = isFiltered ? getTypeLabel(t, filter) : t('auth_files.filter_all');
const confirmMessage = isFiltered
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
: t('auth_files.delete_all_confirm');
showConfirmation({
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
message: confirmMessage,
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
setDeletingAll(true);
try {
if (!isFiltered) {
await authFilesApi.deleteAll();
showNotification(t('auth_files.delete_all_success'), 'success');
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
deselectAll();
} else {
const filesToDelete = files.filter(
(f) => f.type === filter && !isRuntimeOnlyAuthFile(f)
);
if (filesToDelete.length === 0) {
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
setDeletingAll(false);
return;
}
let success = 0;
let failed = 0;
const deletedNames: string[] = [];
for (const file of filesToDelete) {
try {
await authFilesApi.deleteFile(file.name);
success++;
deletedNames.push(file.name);
} catch {
failed++;
}
}
setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name)));
setSelectedFiles((prev) => {
if (prev.size === 0) return prev;
const deletedSet = new Set(deletedNames);
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (deletedSet.has(name)) {
changed = true;
} else {
next.add(name);
}
});
return changed ? next : prev;
});
if (failed === 0) {
showNotification(
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
'success'
);
} else {
showNotification(
t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }),
'warning'
);
}
onResetFilterToAll();
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
} finally {
setDeletingAll(false);
}
}
});
},
[deselectAll, files, showConfirmation, showNotification, t]
);
const handleDownload = useCallback(
async (name: string) => {
try {
const response = await apiClient.getRaw(
`/auth-files/download?name=${encodeURIComponent(name)}`,
{ responseType: 'blob' }
);
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('auth_files.download_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
}
},
[showNotification, t]
);
const handleStatusToggle = useCallback(
async (item: AuthFileItem, enabled: boolean) => {
const name = item.name;
const nextDisabled = !enabled;
const previousDisabled = item.disabled === true;
setStatusUpdating((prev) => ({ ...prev, [name]: true }));
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f)));
try {
const res = await authFilesApi.setStatus(name, nextDisabled);
setFiles((prev) =>
prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f))
);
showNotification(
enabled
? t('auth_files.status_enabled_success', { name })
: t('auth_files.status_disabled_success', { name }),
'success'
);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
setFiles((prev) =>
prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f))
);
showNotification(`${t('notification.update_failed')}: ${errorMessage}`, 'error');
} finally {
setStatusUpdating((prev) => {
if (!prev[name]) return prev;
const next = { ...prev };
delete next[name];
return next;
});
}
},
[showNotification, t]
);
const batchSetStatus = useCallback(
async (names: string[], enabled: boolean) => {
const uniqueNames = Array.from(new Set(names));
if (uniqueNames.length === 0) return;
const targetNames = new Set(uniqueNames);
const nextDisabled = !enabled;
setFiles((prev) =>
prev.map((file) =>
targetNames.has(file.name) ? { ...file, disabled: nextDisabled } : file
)
);
const results = await Promise.allSettled(
uniqueNames.map((name) => authFilesApi.setStatus(name, nextDisabled))
);
let successCount = 0;
let failCount = 0;
const failedNames = new Set<string>();
const confirmedDisabled = new Map<string, boolean>();
results.forEach((result, index) => {
const name = uniqueNames[index];
if (result.status === 'fulfilled') {
successCount++;
confirmedDisabled.set(name, result.value.disabled);
} else {
failCount++;
failedNames.add(name);
}
});
setFiles((prev) =>
prev.map((file) => {
if (failedNames.has(file.name)) {
return { ...file, disabled: !nextDisabled };
}
if (confirmedDisabled.has(file.name)) {
return { ...file, disabled: confirmedDisabled.get(file.name) };
}
return file;
})
);
if (failCount === 0) {
showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success');
} else {
showNotification(
t('auth_files.batch_status_partial', { success: successCount, failed: failCount }),
'warning'
);
}
deselectAll();
},
[deselectAll, showNotification, t]
);
const batchDelete = useCallback(
(names: string[]) => {
const uniqueNames = Array.from(new Set(names));
if (uniqueNames.length === 0) return;
showConfirmation({
title: t('auth_files.batch_delete_title'),
message: t('auth_files.batch_delete_confirm', { count: uniqueNames.length }),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
const results = await Promise.allSettled(
uniqueNames.map((name) => authFilesApi.deleteFile(name))
);
const deleted: string[] = [];
let failCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
deleted.push(uniqueNames[index]);
} else {
failCount++;
}
});
if (deleted.length > 0) {
const deletedSet = new Set(deleted);
setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name)));
}
setSelectedFiles((prev) => {
if (prev.size === 0) return prev;
const deletedSet = new Set(deleted);
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (deletedSet.has(name)) {
changed = true;
} else {
next.add(name);
}
});
return changed ? next : prev;
});
if (failCount === 0) {
showNotification(`${t('auth_files.delete_all_success')} (${deleted.length})`, 'success');
} else {
showNotification(
t('auth_files.delete_filtered_partial', {
success: deleted.length,
failed: failCount,
type: t('auth_files.filter_all')
}),
'warning'
);
}
}
});
},
[showConfirmation, showNotification, t]
);
return {
files,
selectedFiles,
selectionCount,
loading,
error,
uploading,
deleting,
deletingAll,
statusUpdating,
fileInputRef,
loadFiles,
handleUploadClick,
handleFileChange,
handleDelete,
handleDeleteAll,
handleDownload,
handleStatusToggle,
toggleSelect,
selectAllVisible,
deselectAll,
batchSetStatus,
batchDelete
};
}

View File

@@ -0,0 +1,86 @@
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { useNotificationStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import type { AuthFileModelItem } from '@/features/authFiles/constants';
type ModelsError = 'unsupported' | null;
export type UseAuthFilesModelsResult = {
modelsModalOpen: boolean;
modelsLoading: boolean;
modelsList: AuthFileModelItem[];
modelsFileName: string;
modelsFileType: string;
modelsError: ModelsError;
showModels: (item: AuthFileItem) => Promise<void>;
closeModelsModal: () => void;
};
export function useAuthFilesModels(): UseAuthFilesModelsResult {
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
const [modelsModalOpen, setModelsModalOpen] = useState(false);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
const [modelsFileName, setModelsFileName] = useState('');
const [modelsFileType, setModelsFileType] = useState('');
const [modelsError, setModelsError] = useState<ModelsError>(null);
const modelsCacheRef = useRef<Map<string, AuthFileModelItem[]>>(new Map());
const closeModelsModal = useCallback(() => {
setModelsModalOpen(false);
}, []);
const showModels = useCallback(
async (item: AuthFileItem) => {
setModelsFileName(item.name);
setModelsFileType(item.type || '');
setModelsList([]);
setModelsError(null);
setModelsModalOpen(true);
const cached = modelsCacheRef.current.get(item.name);
if (cached) {
setModelsList(cached);
setModelsLoading(false);
return;
}
setModelsLoading(true);
try {
const models = await authFilesApi.getModelsForAuthFile(item.name);
modelsCacheRef.current.set(item.name, models);
setModelsList(models);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '';
if (
errorMessage.includes('404') ||
errorMessage.includes('not found') ||
errorMessage.includes('Not Found')
) {
setModelsError('unsupported');
} else {
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
}
} finally {
setModelsLoading(false);
}
},
[showNotification, t]
);
return {
modelsModalOpen,
modelsLoading,
modelsList,
modelsFileName,
modelsFileType,
modelsError,
showModels,
closeModelsModal
};
}

View File

@@ -0,0 +1,504 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { useNotificationStore } from '@/stores';
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
import type { AuthFileModelItem } from '@/features/authFiles/constants';
import { normalizeProviderKey } from '@/features/authFiles/constants';
type UnsupportedError = 'unsupported' | null;
type ViewMode = 'diagram' | 'list';
export type UseAuthFilesOauthResult = {
excluded: Record<string, string[]>;
excludedError: UnsupportedError;
modelAlias: Record<string, OAuthModelAliasEntry[]>;
modelAliasError: UnsupportedError;
allProviderModels: Record<string, AuthFileModelItem[]>;
providerList: string[];
loadExcluded: () => Promise<void>;
loadModelAlias: () => Promise<void>;
deleteExcluded: (provider: string) => void;
deleteModelAlias: (provider: string) => void;
handleMappingUpdate: (provider: string, sourceModel: string, newAlias: string) => Promise<void>;
handleDeleteLink: (provider: string, sourceModel: string, alias: string) => void;
handleToggleFork: (
provider: string,
sourceModel: string,
alias: string,
fork: boolean
) => Promise<void>;
handleRenameAlias: (oldAlias: string, newAlias: string) => Promise<void>;
handleDeleteAlias: (aliasName: string) => void;
};
export type UseAuthFilesOauthOptions = {
viewMode: ViewMode;
files: AuthFileItem[];
};
export function useAuthFilesOauth(options: UseAuthFilesOauthOptions): UseAuthFilesOauthResult {
const { viewMode, files } = options;
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
const [excludedError, setExcludedError] = useState<UnsupportedError>(null);
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
const [modelAliasError, setModelAliasError] = useState<UnsupportedError>(null);
const [allProviderModels, setAllProviderModels] = useState<Record<string, AuthFileModelItem[]>>(
{}
);
const excludedUnsupportedRef = useRef(false);
const mappingsUnsupportedRef = useRef(false);
const providerList = useMemo(() => {
const providers = new Set<string>();
Object.keys(modelAlias).forEach((provider) => {
const key = provider.trim().toLowerCase();
if (key) providers.add(key);
});
files.forEach((file) => {
if (typeof file.type === 'string') {
const key = file.type.trim().toLowerCase();
if (key) providers.add(key);
}
if (typeof file.provider === 'string') {
const key = file.provider.trim().toLowerCase();
if (key) providers.add(key);
}
});
return Array.from(providers);
}, [files, modelAlias]);
useEffect(() => {
if (viewMode !== 'diagram') return;
let cancelled = false;
const loadAllModels = async () => {
if (providerList.length === 0) {
if (!cancelled) setAllProviderModels({});
return;
}
const results = await Promise.all(
providerList.map(async (provider) => {
try {
const models = await authFilesApi.getModelDefinitions(provider);
return { provider, models };
} catch {
return { provider, models: [] as AuthFileModelItem[] };
}
})
);
if (cancelled) return;
const nextModels: Record<string, AuthFileModelItem[]> = {};
results.forEach(({ provider, models }) => {
if (models.length > 0) {
nextModels[provider] = models;
}
});
setAllProviderModels(nextModels);
};
void loadAllModels();
return () => {
cancelled = true;
};
}, [providerList, viewMode]);
const loadExcluded = useCallback(async () => {
try {
const res = await authFilesApi.getOauthExcludedModels();
excludedUnsupportedRef.current = false;
setExcluded(res || {});
setExcludedError(null);
} catch (err: unknown) {
const status =
typeof err === 'object' && err !== null && 'status' in err
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
setExcluded({});
setExcludedError('unsupported');
if (!excludedUnsupportedRef.current) {
excludedUnsupportedRef.current = true;
showNotification(t('oauth_excluded.upgrade_required'), 'warning');
}
return;
}
// 静默失败
}
}, [showNotification, t]);
const loadModelAlias = useCallback(async () => {
try {
const res = await authFilesApi.getOauthModelAlias();
mappingsUnsupportedRef.current = false;
setModelAlias(res || {});
setModelAliasError(null);
} catch (err: unknown) {
const status =
typeof err === 'object' && err !== null && 'status' in err
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
setModelAlias({});
setModelAliasError('unsupported');
if (!mappingsUnsupportedRef.current) {
mappingsUnsupportedRef.current = true;
showNotification(t('oauth_model_alias.upgrade_required'), 'warning');
}
return;
}
// 静默失败
}
}, [showNotification, t]);
const deleteExcluded = useCallback(
(provider: string) => {
const providerLabel = provider.trim() || provider;
showConfirmation({
title: t('oauth_excluded.delete_title', { defaultValue: 'Delete Exclusion' }),
message: t('oauth_excluded.delete_confirm', { provider: providerLabel }),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
const providerKey = normalizeProviderKey(provider);
if (!providerKey) {
showNotification(t('oauth_excluded.provider_required'), 'error');
return;
}
try {
await authFilesApi.deleteOauthExcludedEntry(providerKey);
await loadExcluded();
showNotification(t('oauth_excluded.delete_success'), 'success');
} catch (err: unknown) {
try {
const current = await authFilesApi.getOauthExcludedModels();
const next: Record<string, string[]> = {};
Object.entries(current).forEach(([key, models]) => {
if (normalizeProviderKey(key) === providerKey) return;
next[key] = models;
});
await authFilesApi.replaceOauthExcludedModels(next);
await loadExcluded();
showNotification(t('oauth_excluded.delete_success'), 'success');
} catch (fallbackErr: unknown) {
const errorMessage =
fallbackErr instanceof Error
? fallbackErr.message
: err instanceof Error
? err.message
: '';
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
}
}
}
});
},
[loadExcluded, showConfirmation, showNotification, t]
);
const deleteModelAlias = useCallback(
(provider: string) => {
showConfirmation({
title: t('oauth_model_alias.delete_title', { defaultValue: 'Delete Mappings' }),
message: t('oauth_model_alias.delete_confirm', { provider }),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await authFilesApi.deleteOauthModelAlias(provider);
await loadModelAlias();
showNotification(t('oauth_model_alias.delete_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.delete_failed')}: ${errorMessage}`, 'error');
}
}
});
},
[loadModelAlias, showConfirmation, showNotification, t]
);
const handleMappingUpdate = useCallback(
async (provider: string, sourceModel: string, newAlias: string) => {
if (!provider || !sourceModel || !newAlias) return;
const normalizedProvider = normalizeProviderKey(provider);
if (!normalizedProvider) return;
const providerKey = Object.keys(modelAlias).find(
(key) => normalizeProviderKey(key) === normalizedProvider
);
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
const nameTrim = sourceModel.trim();
const aliasTrim = newAlias.trim();
const nameKey = nameTrim.toLowerCase();
const aliasKey = aliasTrim.toLowerCase();
if (
currentMappings.some(
(m) =>
(m.name ?? '').trim().toLowerCase() === nameKey &&
(m.alias ?? '').trim().toLowerCase() === aliasKey
)
) {
return;
}
const nextMappings: OAuthModelAliasEntry[] = [
...currentMappings,
{ name: nameTrim, alias: aliasTrim, fork: true }
];
try {
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
await loadModelAlias();
showNotification(t('oauth_model_alias.save_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
}
},
[loadModelAlias, modelAlias, showNotification, t]
);
const handleDeleteLink = useCallback(
(provider: string, sourceModel: string, alias: string) => {
const nameTrim = sourceModel.trim();
const aliasTrim = alias.trim();
if (!provider || !nameTrim || !aliasTrim) return;
showConfirmation({
title: t('oauth_model_alias.delete_link_title', { defaultValue: 'Unlink mapping' }),
message: (
<Trans
i18nKey="oauth_model_alias.delete_link_confirm"
values={{ provider, sourceModel: nameTrim, alias: aliasTrim }}
components={{ code: <code /> }}
/>
),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
const normalizedProvider = normalizeProviderKey(provider);
const providerKey = Object.keys(modelAlias).find(
(key) => normalizeProviderKey(key) === normalizedProvider
);
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
const nameKey = nameTrim.toLowerCase();
const aliasKey = aliasTrim.toLowerCase();
const nextMappings = currentMappings.filter(
(m) =>
(m.name ?? '').trim().toLowerCase() !== nameKey ||
(m.alias ?? '').trim().toLowerCase() !== aliasKey
);
if (nextMappings.length === currentMappings.length) return;
try {
if (nextMappings.length === 0) {
await authFilesApi.deleteOauthModelAlias(normalizedProvider);
} else {
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
}
await loadModelAlias();
showNotification(t('oauth_model_alias.save_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
}
}
});
},
[loadModelAlias, modelAlias, showConfirmation, showNotification, t]
);
const handleToggleFork = useCallback(
async (provider: string, sourceModel: string, alias: string, fork: boolean) => {
const normalizedProvider = normalizeProviderKey(provider);
if (!normalizedProvider) return;
const providerKey = Object.keys(modelAlias).find(
(key) => normalizeProviderKey(key) === normalizedProvider
);
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
const nameKey = sourceModel.trim().toLowerCase();
const aliasKey = alias.trim().toLowerCase();
let changed = false;
const nextMappings = currentMappings.map((m) => {
const mName = (m.name ?? '').trim().toLowerCase();
const mAlias = (m.alias ?? '').trim().toLowerCase();
if (mName === nameKey && mAlias === aliasKey) {
changed = true;
return fork ? { ...m, fork: true } : { name: m.name, alias: m.alias };
}
return m;
});
if (!changed) return;
try {
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
await loadModelAlias();
showNotification(t('oauth_model_alias.save_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
}
},
[loadModelAlias, modelAlias, showNotification, t]
);
const handleRenameAlias = useCallback(
async (oldAlias: string, newAlias: string) => {
const oldTrim = oldAlias.trim();
const newTrim = newAlias.trim();
if (!oldTrim || !newTrim || oldTrim === newTrim) return;
const oldKey = oldTrim.toLowerCase();
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === oldKey)
);
if (providersToUpdate.length === 0) return;
let hadFailure = false;
let failureMessage = '';
try {
const results = await Promise.allSettled(
providersToUpdate.map(([provider, mappings]) => {
const nextMappings = mappings.map((m) =>
(m.alias ?? '').trim().toLowerCase() === oldKey ? { ...m, alias: newTrim } : m
);
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
})
);
const failures = results.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
if (failures.length > 0) {
hadFailure = true;
const reason = failures[0].reason;
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
}
} finally {
await loadModelAlias();
}
if (hadFailure) {
showNotification(
failureMessage
? `${t('oauth_model_alias.save_failed')}: ${failureMessage}`
: t('oauth_model_alias.save_failed'),
'error'
);
} else {
showNotification(t('oauth_model_alias.save_success'), 'success');
}
},
[loadModelAlias, modelAlias, showNotification, t]
);
const handleDeleteAlias = useCallback(
(aliasName: string) => {
const aliasTrim = aliasName.trim();
if (!aliasTrim) return;
const aliasKey = aliasTrim.toLowerCase();
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === aliasKey)
);
if (providersToUpdate.length === 0) return;
showConfirmation({
title: t('oauth_model_alias.delete_alias_title', { defaultValue: 'Delete Alias' }),
message: (
<Trans
i18nKey="oauth_model_alias.delete_alias_confirm"
values={{ alias: aliasTrim }}
components={{ code: <code /> }}
/>
),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
let hadFailure = false;
let failureMessage = '';
try {
const results = await Promise.allSettled(
providersToUpdate.map(([provider, mappings]) => {
const nextMappings = mappings.filter(
(m) => (m.alias ?? '').trim().toLowerCase() !== aliasKey
);
if (nextMappings.length === 0) {
return authFilesApi.deleteOauthModelAlias(provider);
}
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
})
);
const failures = results.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
if (failures.length > 0) {
hadFailure = true;
const reason = failures[0].reason;
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
}
} finally {
await loadModelAlias();
}
if (hadFailure) {
showNotification(
failureMessage
? `${t('oauth_model_alias.delete_failed')}: ${failureMessage}`
: t('oauth_model_alias.delete_failed'),
'error'
);
} else {
showNotification(t('oauth_model_alias.delete_success'), 'success');
}
}
});
},
[loadModelAlias, modelAlias, showConfirmation, showNotification, t]
);
return {
excluded,
excludedError,
modelAlias,
modelAliasError,
allProviderModels,
providerList,
loadExcluded,
loadModelAlias,
deleteExcluded,
deleteModelAlias,
handleMappingUpdate,
handleDeleteLink,
handleToggleFork,
handleRenameAlias,
handleDeleteAlias
};
}

View File

@@ -0,0 +1,254 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { useNotificationStore } from '@/stores';
import { formatFileSize } from '@/utils/format';
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
import {
normalizeExcludedModels,
parseDisableCoolingValue,
parseExcludedModelsText,
parsePriorityValue
} from '@/features/authFiles/constants';
export type PrefixProxyEditorField =
| 'prefix'
| 'proxyUrl'
| 'priority'
| 'excludedModelsText'
| 'disableCooling';
export type PrefixProxyEditorState = {
fileName: string;
loading: boolean;
saving: boolean;
error: string | null;
originalText: string;
rawText: string;
json: Record<string, unknown> | null;
prefix: string;
proxyUrl: string;
priority: string;
excludedModelsText: string;
disableCooling: string;
};
export type UseAuthFilesPrefixProxyEditorOptions = {
disableControls: boolean;
loadFiles: () => Promise<void>;
loadKeyStats: () => Promise<void>;
};
export type UseAuthFilesPrefixProxyEditorResult = {
prefixProxyEditor: PrefixProxyEditorState | null;
prefixProxyUpdatedText: string;
prefixProxyDirty: boolean;
openPrefixProxyEditor: (name: string) => Promise<void>;
closePrefixProxyEditor: () => void;
handlePrefixProxyChange: (field: PrefixProxyEditorField, value: string) => void;
handlePrefixProxySave: () => Promise<void>;
};
const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): string => {
if (!editor?.json) return editor?.rawText ?? '';
const next: Record<string, unknown> = { ...editor.json };
if ('prefix' in next || editor.prefix.trim()) {
next.prefix = editor.prefix;
}
if ('proxy_url' in next || editor.proxyUrl.trim()) {
next.proxy_url = editor.proxyUrl;
}
const parsedPriority = parsePriorityValue(editor.priority);
if (parsedPriority !== undefined) {
next.priority = parsedPriority;
} else if ('priority' in next) {
delete next.priority;
}
const excludedModels = parseExcludedModelsText(editor.excludedModelsText);
if (excludedModels.length > 0) {
next.excluded_models = excludedModels;
} else if ('excluded_models' in next) {
delete next.excluded_models;
}
const parsedDisableCooling = parseDisableCoolingValue(editor.disableCooling);
if (parsedDisableCooling !== undefined) {
next.disable_cooling = parsedDisableCooling;
} else if ('disable_cooling' in next) {
delete next.disable_cooling;
}
return JSON.stringify(next);
};
export function useAuthFilesPrefixProxyEditor(
options: UseAuthFilesPrefixProxyEditorOptions
): UseAuthFilesPrefixProxyEditorResult {
const { disableControls, loadFiles, loadKeyStats } = options;
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
const prefixProxyUpdatedText = buildPrefixProxyUpdatedText(prefixProxyEditor);
const prefixProxyDirty =
Boolean(prefixProxyEditor?.json) &&
Boolean(prefixProxyEditor?.originalText) &&
prefixProxyUpdatedText !== prefixProxyEditor?.originalText;
const closePrefixProxyEditor = () => {
setPrefixProxyEditor(null);
};
const openPrefixProxyEditor = async (name: string) => {
if (disableControls) return;
if (prefixProxyEditor?.fileName === name) {
setPrefixProxyEditor(null);
return;
}
setPrefixProxyEditor({
fileName: name,
loading: true,
saving: false,
error: null,
originalText: '',
rawText: '',
json: null,
prefix: '',
proxyUrl: '',
priority: '',
excludedModelsText: '',
disableCooling: ''
});
try {
const rawText = await authFilesApi.downloadText(name);
const trimmed = rawText.trim();
let parsed: unknown;
try {
parsed = JSON.parse(trimmed) as unknown;
} catch {
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return {
...prev,
loading: false,
error: t('auth_files.prefix_proxy_invalid_json'),
rawText: trimmed,
originalText: trimmed
};
});
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return {
...prev,
loading: false,
error: t('auth_files.prefix_proxy_invalid_json'),
rawText: trimmed,
originalText: trimmed
};
});
return;
}
const json = parsed as Record<string, unknown>;
const originalText = JSON.stringify(json);
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
const proxyUrl = typeof json.proxy_url === 'string' ? json.proxy_url : '';
const priority = parsePriorityValue(json.priority);
const excludedModels = normalizeExcludedModels(json.excluded_models);
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return {
...prev,
loading: false,
originalText,
rawText: originalText,
json,
prefix,
proxyUrl,
priority: priority !== undefined ? String(priority) : '',
excludedModelsText: excludedModels.join('\n'),
disableCooling:
disableCoolingValue === undefined ? '' : disableCoolingValue ? 'true' : 'false',
error: null
};
});
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : t('notification.download_failed');
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return { ...prev, loading: false, error: errorMessage, rawText: '' };
});
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
}
};
const handlePrefixProxyChange = (field: PrefixProxyEditorField, value: string) => {
setPrefixProxyEditor((prev) => {
if (!prev) return prev;
if (field === 'prefix') return { ...prev, prefix: value };
if (field === 'proxyUrl') return { ...prev, proxyUrl: value };
if (field === 'priority') return { ...prev, priority: value };
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: value };
return { ...prev, disableCooling: value };
});
};
const handlePrefixProxySave = async () => {
if (!prefixProxyEditor?.json) return;
if (!prefixProxyDirty) return;
const name = prefixProxyEditor.fileName;
const payload = prefixProxyUpdatedText;
const fileSize = new Blob([payload]).size;
if (fileSize > MAX_AUTH_FILE_SIZE) {
showNotification(
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
'error'
);
return;
}
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return { ...prev, saving: true };
});
try {
const file = new File([payload], name, { type: 'application/json' });
await authFilesApi.upload(file);
showNotification(t('auth_files.prefix_proxy_saved_success', { name }), 'success');
await loadFiles();
await loadKeyStats();
setPrefixProxyEditor(null);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error');
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return { ...prev, saving: false };
});
}
};
return {
prefixProxyEditor,
prefixProxyUpdatedText,
prefixProxyDirty,
openPrefixProxyEditor,
closePrefixProxyEditor,
handlePrefixProxyChange,
handlePrefixProxySave
};
}

View File

@@ -0,0 +1,35 @@
import { useCallback, useRef, useState } from 'react';
import { usageApi } from '@/services/api';
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
export type UseAuthFilesStatsResult = {
keyStats: KeyStats;
usageDetails: UsageDetail[];
loadKeyStats: () => Promise<void>;
};
export function useAuthFilesStats(): UseAuthFilesStatsResult {
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
const loadingKeyStatsRef = useRef(false);
const loadKeyStats = useCallback(async () => {
if (loadingKeyStatsRef.current) return;
loadingKeyStatsRef.current = true;
try {
const usageResponse = await usageApi.getUsage();
const usageData = usageResponse?.usage ?? usageResponse;
const stats = await usageApi.getKeyStats(usageData);
setKeyStats(stats);
const details = collectUsageDetails(usageData);
setUsageDetails(details);
} catch {
// 静默失败
} finally {
loadingKeyStatsRef.current = false;
}
}, []);
return { keyStats, usageDetails, loadKeyStats };
}

View File

@@ -0,0 +1,28 @@
import { useMemo } from 'react';
import type { AuthFileItem } from '@/types';
import { calculateStatusBarData, type UsageDetail } from '@/utils/usage';
import { normalizeAuthIndexValue } from '@/features/authFiles/constants';
export type AuthFileStatusBarData = ReturnType<typeof calculateStatusBarData>;
export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails: UsageDetail[]) {
return useMemo(() => {
const cache = new Map<string, AuthFileStatusBarData>();
files.forEach((file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
if (authIndexKey) {
const filteredDetails = usageDetails.filter((detail) => {
const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index);
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
});
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
}
});
return cache;
}, [files, usageDetails]);
}

View File

@@ -0,0 +1,30 @@
export type AuthFilesUiState = {
filter?: string;
search?: string;
page?: number;
pageSize?: number;
};
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
export const readAuthFilesUiState = (): AuthFilesUiState | null => {
if (typeof window === 'undefined') return null;
try {
const raw = window.sessionStorage.getItem(AUTH_FILES_UI_STATE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as AuthFilesUiState;
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
};
export const writeAuthFilesUiState = (state: AuthFilesUiState) => {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(AUTH_FILES_UI_STATE_KEY, JSON.stringify(state));
} catch {
// ignore
}
};

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