mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
883059b031 | ||
|
|
d077b5dd26 | ||
|
|
d79ccc480d | ||
|
|
7b0d6dc7e9 | ||
|
|
b8d7b8997c | ||
|
|
0bb34ca74b | ||
|
|
99c4fbc30d | ||
|
|
a44257edda | ||
|
|
ebb80df24a | ||
|
|
5165715d37 | ||
|
|
73ee6eb2f3 | ||
|
|
161d5d1e7f | ||
|
|
3cbd04b296 | ||
|
|
859f7f120c | ||
|
|
fea29f7318 | ||
|
|
f663b83ac8 | ||
|
|
ee99836285 | ||
|
|
2086c348a9 | ||
|
|
a8abf71bfe | ||
|
|
8dca670358 | ||
|
|
71556a51c5 | ||
|
|
2a92ea8862 | ||
|
|
681fc3cee5 | ||
|
|
916dd3ec26 | ||
|
|
692f7f3cde | ||
|
|
bf20f3d86e | ||
|
|
b7e720133d | ||
|
|
e914337e57 | ||
|
|
6364bac1f2 | ||
|
|
38a3e20427 | ||
|
|
334d75f2dd | ||
|
|
42eb783395 | ||
|
|
84b219957e | ||
|
|
f5c1ef36ce | ||
|
|
fae4fb0fed | ||
|
|
1d8729ec53 | ||
|
|
c6ef8a259f | ||
|
|
0efef5a789 | ||
|
|
db376c7504 | ||
|
|
8232812ac2 | ||
|
|
2ae06a8860 | ||
|
|
dc58a0701f | ||
|
|
3446280987 | ||
|
|
82bf1806ed | ||
|
|
47f0042bf0 | ||
|
|
58154063ed | ||
|
|
cc467889d0 | ||
|
|
469e5d2ed4 | ||
|
|
6ce301d7e0 | ||
|
|
8461de124f | ||
|
|
276f416ec9 | ||
|
|
583a844771 | ||
|
|
62fa437285 | ||
|
|
daab589c49 | ||
|
|
e18e9b25ce | ||
|
|
4cfb77dd44 | ||
|
|
7cab1e8782 | ||
|
|
079f37ec93 | ||
|
|
7ce97a616f | ||
|
|
946ed36af0 | ||
|
|
f139598526 | ||
|
|
40ddd3c066 | ||
|
|
3a66dc225d | ||
|
|
eadfd7a957 | ||
|
|
f739e0b372 | ||
|
|
23fb88e5fd | ||
|
|
49b9259452 | ||
|
|
4e26b6c92d | ||
|
|
215ce61b48 | ||
|
|
a48e06a28c | ||
|
|
8a59ab73a1 | ||
|
|
66d58288b4 | ||
|
|
be3f58f0a8 | ||
|
|
c299e403cc | ||
|
|
769c05e459 | ||
|
|
5ef3406068 | ||
|
|
95cbfb8c59 | ||
|
|
c17217875c | ||
|
|
981f7ac9b2 | ||
|
|
762db81252 | ||
|
|
79f6d87d7b | ||
|
|
c5d4356d6c | ||
|
|
c989dbf1b6 | ||
|
|
3cffa19319 | ||
|
|
2367f122a8 | ||
|
|
69a8e1657e | ||
|
|
987ce0ec4b | ||
|
|
03bf58671e | ||
|
|
cb6b810d6d | ||
|
|
408e6e5872 | ||
|
|
b3808add0f | ||
|
|
0b2e6efe28 | ||
|
|
8ca6d31a26 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@ api.md
|
|||||||
usage.json
|
usage.json
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
antigravity_usage.json
|
||||||
|
codex_usage.json
|
||||||
|
style.md
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20aria-hidden%3D%22true%22%20role%3D%22img%22%20class%3D%22iconify%20iconify--logos%22%20width%3D%2231.88%22%20height%3D%2232%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20viewBox%3D%220%200%20256%20257%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb466%22%20x1%3D%22-.828%25%22%20x2%3D%2257.636%25%22%20y1%3D%227.652%25%22%20y2%3D%2278.411%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%2341D1FF%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23BD34FE%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb467%22%20x1%3D%2243.376%25%22%20x2%3D%2250.316%25%22%20y1%3D%222.242%25%22%20y2%3D%2289.03%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23FFEA83%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%228.333%25%22%20stop-color%3D%22%23FFDD35%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23FFA800%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb466)%22%20d%3D%22M255.153%2037.938L134.897%20252.976c-2.483%204.44-8.862%204.466-11.382.048L.875%2037.958c-2.746-4.814%201.371-10.646%206.827-9.67l120.385%2021.517a6.537%206.537%200%200%200%202.322-.004l117.867-21.483c5.438-.991%209.574%204.796%206.877%209.62Z%22%3E%3C%2Fpath%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb467)%22%20d%3D%22M185.432.063L96.44%2017.501a3.268%203.268%200%200%200-2.634%203.014l-5.474%2092.456a3.268%203.268%200%200%200%203.997%203.378l24.777-5.718c2.318-.535%204.413%201.507%203.936%203.838l-7.361%2036.047c-.495%202.426%201.782%204.5%204.151%203.78l15.304-4.649c2.372-.72%204.652%201.36%204.15%203.788l-11.698%2056.621c-.732%203.542%203.979%205.473%205.943%202.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505%204.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E" />
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20aria-hidden%3D%22true%22%20role%3D%22img%22%20class%3D%22iconify%20iconify--logos%22%20width%3D%2231.88%22%20height%3D%2232%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20viewBox%3D%220%200%20256%20257%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb466%22%20x1%3D%22-.828%25%22%20x2%3D%2257.636%25%22%20y1%3D%227.652%25%22%20y2%3D%2278.411%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%2341D1FF%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23BD34FE%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb467%22%20x1%3D%2243.376%25%22%20x2%3D%2250.316%25%22%20y1%3D%222.242%25%22%20y2%3D%2289.03%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23FFEA83%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%228.333%25%22%20stop-color%3D%22%23FFDD35%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23FFA800%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb466)%22%20d%3D%22M255.153%2037.938L134.897%20252.976c-2.483%204.44-8.862%204.466-11.382.048L.875%2037.958c-2.746-4.814%201.371-10.646%206.827-9.67l120.385%2021.517a6.537%206.537%200%200%200%202.322-.004l117.867-21.483c5.438-.991%209.574%204.796%206.877%209.62Z%22%3E%3C%2Fpath%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb467)%22%20d%3D%22M185.432.063L96.44%2017.501a3.268%203.268%200%200%200-2.634%203.014l-5.474%2092.456a3.268%203.268%200%200%200%203.997%203.378l24.777-5.718c2.318-.535%204.413%201.507%203.936%203.838l-7.361%2036.047c-.495%202.426%201.782%204.5%204.151%203.78l15.304-4.649c2.372-.72%204.652%201.36%204.15%203.788l-11.698%2056.621c-.732%203.542%203.979%205.473%205.943%202.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505%204.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E" />
|
||||||
|
|||||||
36
package-lock.json
generated
36
package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@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",
|
||||||
|
"gsap": "^3.14.2",
|
||||||
"i18next": "^25.7.1",
|
"i18next": "^25.7.1",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -464,6 +466,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
|
||||||
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
|
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"crelt": "^1.0.6",
|
"crelt": "^1.0.6",
|
||||||
@@ -1929,6 +1932,7 @@
|
|||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -2016,6 +2020,7 @@
|
|||||||
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
|
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.48.1",
|
"@typescript-eslint/scope-manager": "8.48.1",
|
||||||
"@typescript-eslint/types": "8.48.1",
|
"@typescript-eslint/types": "8.48.1",
|
||||||
@@ -2333,6 +2338,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2544,6 +2550,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kurkle/color": "^0.3.0"
|
"@kurkle/color": "^0.3.0"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2815,7 @@
|
|||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3194,6 +3202,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gsap": {
|
||||||
|
"version": "3.14.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz",
|
||||||
|
"integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==",
|
||||||
|
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -3278,6 +3292,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4"
|
"@babel/runtime": "^7.28.4"
|
||||||
},
|
},
|
||||||
@@ -3607,6 +3622,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3713,6 +3729,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||||
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -3730,6 +3747,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
||||||
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -3773,9 +3791,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",
|
||||||
@@ -3795,12 +3813,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"
|
||||||
@@ -3838,6 +3856,7 @@
|
|||||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -4020,6 +4039,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -4096,6 +4116,7 @@
|
|||||||
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
|
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -4237,6 +4258,7 @@
|
|||||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
|
||||||
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
|
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"@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",
|
||||||
|
"gsap": "^3.14.2",
|
||||||
"i18next": "^25.7.1",
|
"i18next": "^25.7.1",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
|
|||||||
35
src/App.tsx
35
src/App.tsx
@@ -1,17 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { HashRouter, Navigate, 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 { DashboardPage } from '@/pages/DashboardPage';
|
|
||||||
import { SettingsPage } from '@/pages/SettingsPage';
|
|
||||||
import { ApiKeysPage } from '@/pages/ApiKeysPage';
|
|
||||||
import { AiProvidersPage } from '@/pages/AiProvidersPage';
|
|
||||||
import { AuthFilesPage } from '@/pages/AuthFilesPage';
|
|
||||||
import { OAuthPage } from '@/pages/OAuthPage';
|
|
||||||
import { UsagePage } from '@/pages/UsagePage';
|
|
||||||
import { ConfigPage } from '@/pages/ConfigPage';
|
|
||||||
import { LogsPage } from '@/pages/LogsPage';
|
|
||||||
import { SystemPage } from '@/pages/SystemPage';
|
|
||||||
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||||
|
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
|
||||||
import { SplashScreen } from '@/components/common/SplashScreen';
|
import { SplashScreen } from '@/components/common/SplashScreen';
|
||||||
import { MainLayout } from '@/components/layout/MainLayout';
|
import { MainLayout } from '@/components/layout/MainLayout';
|
||||||
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
||||||
@@ -43,6 +34,10 @@ function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []); // 仅用于首屏同步 i18n 语言
|
}, []); // 仅用于首屏同步 i18n 语言
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = language;
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setSplashReadyToFade(true);
|
setSplashReadyToFade(true);
|
||||||
@@ -67,29 +62,17 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<NotificationContainer />
|
<NotificationContainer />
|
||||||
|
<ConfirmationModal />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/*"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<MainLayout />
|
<MainLayout />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
<Route index element={<DashboardPage />} />
|
|
||||||
<Route path="dashboard" element={<DashboardPage />} />
|
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
|
||||||
<Route path="api-keys" element={<ApiKeysPage />} />
|
|
||||||
<Route path="ai-providers" element={<AiProvidersPage />} />
|
|
||||||
<Route path="auth-files" element={<AuthFilesPage />} />
|
|
||||||
<Route path="oauth" element={<OAuthPage />} />
|
|
||||||
<Route path="usage" element={<UsagePage />} />
|
|
||||||
<Route path="config" element={<ConfigPage />} />
|
|
||||||
<Route path="logs" element={<LogsPage />} />
|
|
||||||
<Route path="system" element={<SystemPage />} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
1
src/assets/icons/vertex.svg
Normal file
1
src/assets/icons/vertex.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M20,13.89A.77.77,0,0,0,19,13.73l-7,5.14v.22a.72.72,0,1,1,0,1.43v0a.74.74,0,0,0,.45-.15l7.41-5.47A.76.76,0,0,0,20,13.89Z" style="fill:#669df6"/><path d="M12,20.52a.72.72,0,0,1,0-1.43h0v-.22L5,13.73a.76.76,0,0,0-1,.16.74.74,0,0,0,.16,1l7.41,5.47a.73.73,0,0,0,.44.15v0Z" style="fill:#aecbfa"/><path d="M12,18.34a1.47,1.47,0,1,0,1.47,1.47A1.47,1.47,0,0,0,12,18.34Zm0,2.18a.72.72,0,1,1,.72-.71A.71.71,0,0,1,12,20.52Z" style="fill:#4285f4"/><path d="M6,6.11a.76.76,0,0,1-.75-.75V3.48a.76.76,0,1,1,1.51,0V5.36A.76.76,0,0,1,6,6.11Z" style="fill:#aecbfa"/><circle cx="5.98" cy="12" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="9.79" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="7.57" r="0.76" style="fill:#aecbfa"/><path d="M18,8.31a.76.76,0,0,1-.75-.76V5.67a.75.75,0,1,1,1.5,0V7.55A.75.75,0,0,1,18,8.31Z" style="fill:#4285f4"/><circle cx="18.02" cy="12.01" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="9.76" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="3.48" r="0.76" style="fill:#4285f4"/><path d="M12,15a.76.76,0,0,1-.75-.75V12.34a.76.76,0,0,1,1.51,0v1.89A.76.76,0,0,1,12,15Z" style="fill:#669df6"/><circle cx="12" cy="16.45" r="0.76" style="fill:#669df6"/><circle cx="12" cy="10.14" r="0.76" style="fill:#669df6"/><circle cx="12" cy="7.92" r="0.76" style="fill:#669df6"/><path d="M15,10.54a.76.76,0,0,1-.75-.75V7.91a.76.76,0,1,1,1.51,0V9.79A.76.76,0,0,1,15,10.54Z" style="fill:#4285f4"/><circle cx="15.01" cy="5.69" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="14.19" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="11.97" r="0.76" style="fill:#4285f4"/><circle cx="8.99" cy="14.19" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="7.92" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="5.69" r="0.76" style="fill:#aecbfa"/><path d="M9,12.73A.76.76,0,0,1,8.24,12V10.1a.75.75,0,1,1,1.5,0V12A.75.75,0,0,1,9,12.73Z" style="fill:#aecbfa"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
61
src/components/common/ConfirmationModal.tsx
Normal file
61
src/components/common/ConfirmationModal.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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}>
|
||||||
|
<p style={{ margin: '1rem 0' }}>{message}</p>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/common/PageTransition.scss
Normal file
39
src/components/common/PageTransition.scss
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.page-transition {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__layer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
// During animation, exit layer uses absolute positioning
|
||||||
|
&--exit {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--animating &__layer {
|
||||||
|
will-change: transform, opacity;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When both layers exist, current layer also needs positioning
|
||||||
|
&--animating &__layer:not(&__layer--exit) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/components/common/PageTransition.tsx
Normal file
182
src/components/common/PageTransition.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { useLocation, type Location } from 'react-router-dom';
|
||||||
|
import gsap from 'gsap';
|
||||||
|
import './PageTransition.scss';
|
||||||
|
|
||||||
|
interface PageTransitionProps {
|
||||||
|
render: (location: Location) => ReactNode;
|
||||||
|
getRouteOrder?: (pathname: string) => number | null;
|
||||||
|
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSITION_DURATION = 0.5;
|
||||||
|
const EXIT_DURATION = 0.45;
|
||||||
|
const ENTER_DELAY = 0.08;
|
||||||
|
|
||||||
|
type LayerStatus = 'current' | 'exiting';
|
||||||
|
|
||||||
|
type Layer = {
|
||||||
|
key: string;
|
||||||
|
location: Location;
|
||||||
|
status: LayerStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TransitionDirection = 'forward' | 'backward';
|
||||||
|
|
||||||
|
export function PageTransition({
|
||||||
|
render,
|
||||||
|
getRouteOrder,
|
||||||
|
scrollContainerRef,
|
||||||
|
}: PageTransitionProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
const currentLayerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const exitingLayerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const exitScrollOffsetRef = useRef(0);
|
||||||
|
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
const [transitionDirection, setTransitionDirection] = useState<TransitionDirection>('forward');
|
||||||
|
const [layers, setLayers] = useState<Layer[]>(() => [
|
||||||
|
{
|
||||||
|
key: location.key,
|
||||||
|
location,
|
||||||
|
status: 'current',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const currentLayerKey = layers[layers.length - 1]?.key ?? location.key;
|
||||||
|
const currentLayerPathname = layers[layers.length - 1]?.location.pathname;
|
||||||
|
|
||||||
|
const resolveScrollContainer = useCallback(() => {
|
||||||
|
if (scrollContainerRef?.current) return scrollContainerRef.current;
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
return document.scrollingElement as HTMLElement | null;
|
||||||
|
}, [scrollContainerRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAnimating) return;
|
||||||
|
if (location.key === currentLayerKey) return;
|
||||||
|
if (currentLayerPathname === location.pathname) return;
|
||||||
|
const scrollContainer = resolveScrollContainer();
|
||||||
|
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
|
||||||
|
const resolveOrderIndex = (pathname?: string) => {
|
||||||
|
if (!getRouteOrder || !pathname) return null;
|
||||||
|
const index = getRouteOrder(pathname);
|
||||||
|
return typeof index === 'number' && index >= 0 ? index : null;
|
||||||
|
};
|
||||||
|
const fromIndex = resolveOrderIndex(currentLayerPathname);
|
||||||
|
const toIndex = resolveOrderIndex(location.pathname);
|
||||||
|
const nextDirection: TransitionDirection =
|
||||||
|
fromIndex === null || toIndex === null || fromIndex === toIndex
|
||||||
|
? 'forward'
|
||||||
|
: toIndex > fromIndex
|
||||||
|
? 'forward'
|
||||||
|
: 'backward';
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setTransitionDirection(nextDirection);
|
||||||
|
setLayers((prev) => {
|
||||||
|
const prevCurrent = prev[prev.length - 1];
|
||||||
|
return [
|
||||||
|
prevCurrent
|
||||||
|
? { ...prevCurrent, status: 'exiting' }
|
||||||
|
: { key: location.key, location, status: 'exiting' },
|
||||||
|
{ key: location.key, location, status: 'current' },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
setIsAnimating(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
isAnimating,
|
||||||
|
location,
|
||||||
|
currentLayerKey,
|
||||||
|
currentLayerPathname,
|
||||||
|
getRouteOrder,
|
||||||
|
resolveScrollContainer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Run GSAP animation when animating starts
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isAnimating) return;
|
||||||
|
|
||||||
|
if (!currentLayerRef.current) return;
|
||||||
|
|
||||||
|
const scrollContainer = resolveScrollContainer();
|
||||||
|
const scrollOffset = exitScrollOffsetRef.current;
|
||||||
|
if (scrollContainer && scrollOffset > 0) {
|
||||||
|
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerHeight = scrollContainer?.clientHeight ?? 0;
|
||||||
|
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight;
|
||||||
|
const travelDistance = Math.max(containerHeight, viewportHeight, 1);
|
||||||
|
const enterFromY = transitionDirection === 'forward' ? travelDistance : -travelDistance;
|
||||||
|
const exitToY = transitionDirection === 'forward' ? -travelDistance : travelDistance;
|
||||||
|
const exitBaseY = scrollOffset ? -scrollOffset : 0;
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting'));
|
||||||
|
setIsAnimating(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exit animation: fly out to top (slow-to-fast)
|
||||||
|
if (exitingLayerRef.current) {
|
||||||
|
gsap.set(exitingLayerRef.current, { y: exitBaseY });
|
||||||
|
tl.fromTo(
|
||||||
|
exitingLayerRef.current,
|
||||||
|
{ y: exitBaseY, opacity: 1 },
|
||||||
|
{
|
||||||
|
y: exitBaseY + exitToY,
|
||||||
|
opacity: 0,
|
||||||
|
duration: EXIT_DURATION,
|
||||||
|
ease: 'power2.in', // fast finish to clear screen
|
||||||
|
force3D: true,
|
||||||
|
},
|
||||||
|
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 () => {
|
||||||
|
tl.kill();
|
||||||
|
gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]);
|
||||||
|
};
|
||||||
|
}, [isAnimating, transitionDirection, resolveScrollContainer]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
||||||
|
{layers.map((layer) => (
|
||||||
|
<div
|
||||||
|
key={layer.key}
|
||||||
|
className={`page-transition__layer${
|
||||||
|
layer.status === 'exiting' ? ' page-transition__layer--exit' : ''
|
||||||
|
}`}
|
||||||
|
ref={layer.status === 'exiting' ? exitingLayerRef : currentLayerRef}
|
||||||
|
>
|
||||||
|
{render(layer.location)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,11 +7,13 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { NavLink, Outlet } 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 { Modal } from '@/components/ui/Modal';
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import { PageTransition } from '@/components/common/PageTransition';
|
||||||
|
import { MainRoutes } from '@/router/MainRoutes';
|
||||||
import {
|
import {
|
||||||
IconBot,
|
IconBot,
|
||||||
IconChartLine,
|
IconChartLine,
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
IconSettings,
|
IconSettings,
|
||||||
IconShield,
|
IconShield,
|
||||||
IconSlidersHorizontal,
|
IconSlidersHorizontal,
|
||||||
|
IconTimer,
|
||||||
} from '@/components/ui/icons';
|
} from '@/components/ui/icons';
|
||||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +36,7 @@ import {
|
|||||||
useThemeStore,
|
useThemeStore,
|
||||||
} from '@/stores';
|
} from '@/stores';
|
||||||
import { configApi, versionApi } from '@/services/api';
|
import { configApi, versionApi } from '@/services/api';
|
||||||
|
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
|
|
||||||
const sidebarIcons: Record<string, ReactNode> = {
|
const sidebarIcons: Record<string, ReactNode> = {
|
||||||
dashboard: <IconLayoutDashboard size={18} />,
|
dashboard: <IconLayoutDashboard size={18} />,
|
||||||
@@ -41,6 +45,7 @@ const sidebarIcons: Record<string, ReactNode> = {
|
|||||||
aiProviders: <IconBot size={18} />,
|
aiProviders: <IconBot size={18} />,
|
||||||
authFiles: <IconFileText size={18} />,
|
authFiles: <IconFileText size={18} />,
|
||||||
oauth: <IconShield size={18} />,
|
oauth: <IconShield size={18} />,
|
||||||
|
quota: <IconTimer size={18} />,
|
||||||
usage: <IconChartLine size={18} />,
|
usage: <IconChartLine size={18} />,
|
||||||
config: <IconSettings size={18} />,
|
config: <IconSettings size={18} />,
|
||||||
logs: <IconScrollText size={18} />,
|
logs: <IconScrollText size={18} />,
|
||||||
@@ -173,6 +178,7 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
|
|||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
|
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);
|
||||||
@@ -197,6 +203,7 @@ export function MainLayout() {
|
|||||||
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
||||||
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
||||||
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
||||||
|
const contentRef = 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 versionTapCount = useRef(0);
|
||||||
@@ -207,6 +214,7 @@ export function MainLayout() {
|
|||||||
const requestLogEnabled = config?.requestLog ?? false;
|
const requestLogEnabled = config?.requestLog ?? false;
|
||||||
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
||||||
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
|
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
|
||||||
|
const isLogsPage = location.pathname.startsWith('/logs');
|
||||||
|
|
||||||
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -337,6 +345,7 @@ export function MainLayout() {
|
|||||||
});
|
});
|
||||||
}, [fetchConfig]);
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
|
||||||
const statusClass =
|
const statusClass =
|
||||||
connectionStatus === 'connected'
|
connectionStatus === 'connected'
|
||||||
? 'success'
|
? 'success'
|
||||||
@@ -353,6 +362,7 @@ export function MainLayout() {
|
|||||||
{ 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: '/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 },
|
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
||||||
...(config?.loggingToFile
|
...(config?.loggingToFile
|
||||||
@@ -360,15 +370,37 @@ export function MainLayout() {
|
|||||||
: []),
|
: []),
|
||||||
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
|
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
|
||||||
];
|
];
|
||||||
|
const navOrder = navItems.map((item) => item.path);
|
||||||
|
const getRouteOrder = (pathname: string) => {
|
||||||
|
const trimmedPath =
|
||||||
|
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||||
|
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
|
||||||
|
const exactIndex = navOrder.indexOf(normalizedPath);
|
||||||
|
if (exactIndex !== -1) return exactIndex;
|
||||||
|
const nestedIndex = navOrder.findIndex(
|
||||||
|
(path) => path !== '/' && normalizedPath.startsWith(`${path}/`)
|
||||||
|
);
|
||||||
|
return nestedIndex === -1 ? null : nestedIndex;
|
||||||
|
};
|
||||||
|
|
||||||
const handleRefreshAll = async () => {
|
const handleRefreshAll = async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
try {
|
const results = await Promise.allSettled([
|
||||||
await fetchConfig(undefined, true);
|
fetchConfig(undefined, true),
|
||||||
showNotification(t('notification.data_refreshed'), 'success');
|
triggerHeaderRefresh()
|
||||||
} catch (error: any) {
|
]);
|
||||||
showNotification(`${t('notification.refresh_failed')}: ${error?.message || ''}`, 'error');
|
const rejected = results.find((result) => result.status === 'rejected');
|
||||||
|
if (rejected && rejected.status === 'rejected') {
|
||||||
|
const reason = rejected.reason;
|
||||||
|
const message =
|
||||||
|
typeof reason === 'string' ? reason : reason instanceof Error ? reason.message : '';
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
showNotification(t('notification.data_refreshed'), 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVersionCheck = async () => {
|
const handleVersionCheck = async () => {
|
||||||
@@ -503,16 +535,20 @@ export function MainLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="content">
|
<div className={`content${isLogsPage ? ' content-logs' : ''}`} ref={contentRef}>
|
||||||
<main className="main-content">
|
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
|
||||||
<Outlet />
|
<PageTransition
|
||||||
|
render={(location) => <MainRoutes location={location} />}
|
||||||
|
getRouteOrder={getRouteOrder}
|
||||||
|
scrollContainerRef={contentRef}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
<span>
|
<span>
|
||||||
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
||||||
</span>
|
</span>
|
||||||
<span onClick={handleVersionTap}>
|
<span className="footer-version" onClick={handleVersionTap}>
|
||||||
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
|
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
264
src/components/providers/AmpcodeSection/AmpcodeModal.tsx
Normal file
264
src/components/providers/AmpcodeSection/AmpcodeModal.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/providers/AmpcodeSection/AmpcodeSection.tsx
Normal file
111
src/components/providers/AmpcodeSection/AmpcodeSection.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import iconAmp from '@/assets/icons/amp.svg';
|
||||||
|
import type { AmpcodeConfig } from '@/types';
|
||||||
|
import { maskApiKey } from '@/utils/format';
|
||||||
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AmpcodeModal } from './AmpcodeModal';
|
||||||
|
|
||||||
|
interface AmpcodeSectionProps {
|
||||||
|
config: AmpcodeConfig | null | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
disableControls: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
isSwitching: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
|
isModalOpen: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
onCloseModal: () => void;
|
||||||
|
onBusyChange: (busy: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpcodeSection({
|
||||||
|
config,
|
||||||
|
loading,
|
||||||
|
disableControls,
|
||||||
|
isSaving,
|
||||||
|
isSwitching,
|
||||||
|
isBusy,
|
||||||
|
isModalOpen,
|
||||||
|
onOpen,
|
||||||
|
onCloseModal,
|
||||||
|
onBusyChange,
|
||||||
|
}: AmpcodeSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img src={iconAmp} alt="" className={styles.cardTitleIcon} />
|
||||||
|
{t('ai_providers.ampcode_title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpen}
|
||||||
|
disabled={disableControls || isSaving || isBusy || isSwitching}
|
||||||
|
>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="hint">{t('common.loading')}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_upstream_url_label')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{config?.upstreamUrl || t('common.not_set')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>
|
||||||
|
{t('ai_providers.ampcode_upstream_api_key_label')}:
|
||||||
|
</span>
|
||||||
|
<span className={styles.fieldValue}>
|
||||||
|
{config?.upstreamApiKey ? maskApiKey(config.upstreamApiKey) : t('common.not_set')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>
|
||||||
|
{t('ai_providers.ampcode_force_model_mappings_label')}:
|
||||||
|
</span>
|
||||||
|
<span className={styles.fieldValue}>
|
||||||
|
{(config?.forceModelMappings ?? false) ? t('common.yes') : t('common.no')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldRow} style={{ marginTop: 8 }}>
|
||||||
|
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_model_mappings_count')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{config?.modelMappings?.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
{config?.modelMappings?.length ? (
|
||||||
|
<div className={styles.modelTagList}>
|
||||||
|
{config.modelMappings.slice(0, 5).map((mapping) => (
|
||||||
|
<span key={`${mapping.from}→${mapping.to}`} className={styles.modelTag}>
|
||||||
|
<span className={styles.modelName}>{mapping.from}</span>
|
||||||
|
<span className={styles.modelAlias}>{mapping.to}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{config.modelMappings.length > 5 && (
|
||||||
|
<span className={styles.modelTag}>
|
||||||
|
<span className={styles.modelName}>+{config.modelMappings.length - 5}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AmpcodeModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
disableControls={disableControls}
|
||||||
|
onClose={onCloseModal}
|
||||||
|
onBusyChange={onBusyChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/AmpcodeSection/index.ts
Normal file
1
src/components/providers/AmpcodeSection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AmpcodeSection } from './AmpcodeSection';
|
||||||
128
src/components/providers/ClaudeSection/ClaudeModal.tsx
Normal file
128
src/components/providers/ClaudeSection/ClaudeModal.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
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 { headersToEntries } from '@/utils/headers';
|
||||||
|
import { excludedModelsToText } from '../utils';
|
||||||
|
import type { ProviderFormState, ProviderModalProps } from '../types';
|
||||||
|
|
||||||
|
interface ClaudeModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
||||||
|
isSaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildEmptyForm = (): ProviderFormState => ({
|
||||||
|
apiKey: '',
|
||||||
|
prefix: '',
|
||||||
|
baseUrl: '',
|
||||||
|
proxyUrl: '',
|
||||||
|
headers: [],
|
||||||
|
models: [],
|
||||||
|
excludedModels: [],
|
||||||
|
modelEntries: [{ name: '', alias: '' }],
|
||||||
|
excludedText: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ClaudeModal({
|
||||||
|
isOpen,
|
||||||
|
editIndex,
|
||||||
|
initialData,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: ClaudeModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (initialData) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setForm({
|
||||||
|
...initialData,
|
||||||
|
headers: headersToEntries(initialData.headers),
|
||||||
|
modelEntries: modelsToEntries(initialData.models),
|
||||||
|
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm(buildEmptyForm());
|
||||||
|
}, [initialData, isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={
|
||||||
|
editIndex !== null
|
||||||
|
? t('ai_providers.claude_edit_modal_title')
|
||||||
|
: t('ai_providers.claude_add_modal_title')
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.claude_add_modal_key_label')}
|
||||||
|
value={form.apiKey}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.prefix_label')}
|
||||||
|
placeholder={t('ai_providers.prefix_placeholder')}
|
||||||
|
value={form.prefix ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||||
|
hint={t('ai_providers.prefix_hint')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.claude_add_modal_url_label')}
|
||||||
|
value={form.baseUrl ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.claude_add_modal_proxy_label')}
|
||||||
|
value={form.proxyUrl ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<HeaderInputList
|
||||||
|
entries={form.headers}
|
||||||
|
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||||
|
addLabel={t('common.custom_headers_add')}
|
||||||
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
/>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.claude_models_label')}</label>
|
||||||
|
<ModelInputList
|
||||||
|
entries={form.modelEntries}
|
||||||
|
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||||
|
addLabel={t('ai_providers.claude_models_add_btn')}
|
||||||
|
namePlaceholder={t('common.model_name_placeholder')}
|
||||||
|
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||||
|
value={form.excludedText}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
src/components/providers/ClaudeSection/ClaudeSection.tsx
Normal file
214
src/components/providers/ClaudeSection/ClaudeSection.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { Fragment, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import iconClaude from '@/assets/icons/claude.svg';
|
||||||
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
|
import { maskApiKey } from '@/utils/format';
|
||||||
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
import { ProviderList } from '../ProviderList';
|
||||||
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
|
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||||
|
import type { ProviderFormState } from '../types';
|
||||||
|
import { ClaudeModal } from './ClaudeModal';
|
||||||
|
|
||||||
|
interface ClaudeSectionProps {
|
||||||
|
configs: ProviderKeyConfig[];
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
loading: boolean;
|
||||||
|
disableControls: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
isSwitching: boolean;
|
||||||
|
isModalOpen: boolean;
|
||||||
|
modalIndex: number | null;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
onToggle: (index: number, enabled: boolean) => void;
|
||||||
|
onCloseModal: () => void;
|
||||||
|
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClaudeSection({
|
||||||
|
configs,
|
||||||
|
keyStats,
|
||||||
|
usageDetails,
|
||||||
|
loading,
|
||||||
|
disableControls,
|
||||||
|
isSaving,
|
||||||
|
isSwitching,
|
||||||
|
isModalOpen,
|
||||||
|
modalIndex,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggle,
|
||||||
|
onCloseModal,
|
||||||
|
onSave,
|
||||||
|
}: ClaudeSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||||
|
const toggleDisabled = disableControls || loading || isSaving || 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]);
|
||||||
|
|
||||||
|
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img src={iconClaude} alt="" className={styles.cardTitleIcon} />
|
||||||
|
{t('ai_providers.claude_title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||||
|
{t('ai_providers.claude_add_button')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProviderList<ProviderKeyConfig>
|
||||||
|
items={configs}
|
||||||
|
loading={loading}
|
||||||
|
keyField={(item) => item.apiKey}
|
||||||
|
emptyTitle={t('ai_providers.claude_empty_title')}
|
||||||
|
emptyDescription={t('ai_providers.claude_empty_desc')}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
actionsDisabled={actionsDisabled}
|
||||||
|
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||||
|
renderExtraActions={(item, index) => (
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('ai_providers.config_toggle_label')}
|
||||||
|
checked={!hasDisableAllModelsRule(item.excludedModels)}
|
||||||
|
disabled={toggleDisabled}
|
||||||
|
onChange={(value) => void onToggle(index, value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderContent={(item) => {
|
||||||
|
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||||
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
|
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||||
|
const excludedModels = item.excludedModels ?? [];
|
||||||
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div className="item-title">{t('ai_providers.claude_item_title')}</div>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||||
|
</div>
|
||||||
|
{item.prefix && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.baseUrl && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.proxyUrl && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.proxyUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{headerEntries.length > 0 && (
|
||||||
|
<div className={styles.headerBadgeList}>
|
||||||
|
{headerEntries.map(([key, value]) => (
|
||||||
|
<span key={key} className={styles.headerBadge}>
|
||||||
|
<strong>{key}:</strong> {value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{configDisabled && (
|
||||||
|
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||||
|
{t('ai_providers.config_disabled_badge')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.models?.length ? (
|
||||||
|
<div className={styles.modelTagList}>
|
||||||
|
<span className={styles.modelCountLabel}>
|
||||||
|
{t('ai_providers.claude_models_count')}: {item.models.length}
|
||||||
|
</span>
|
||||||
|
{item.models.map((model) => (
|
||||||
|
<span key={model.name} className={styles.modelTag}>
|
||||||
|
<span className={styles.modelName}>{model.name}</span>
|
||||||
|
{model.alias && model.alias !== model.name && (
|
||||||
|
<span className={styles.modelAlias}>{model.alias}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{excludedModels.length ? (
|
||||||
|
<div className={styles.excludedModelsSection}>
|
||||||
|
<div className={styles.excludedModelsLabel}>
|
||||||
|
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||||
|
</div>
|
||||||
|
<div className={styles.modelTagList}>
|
||||||
|
{excludedModels.map((model) => (
|
||||||
|
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||||
|
<span className={styles.modelName}>{model}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.cardStats}>
|
||||||
|
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||||
|
{t('stats.success')}: {stats.success}
|
||||||
|
</span>
|
||||||
|
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||||
|
{t('stats.failure')}: {stats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProviderStatusBar statusData={statusData} />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ClaudeModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
editIndex={modalIndex}
|
||||||
|
initialData={initialData}
|
||||||
|
onClose={onCloseModal}
|
||||||
|
onSave={onSave}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/ClaudeSection/index.ts
Normal file
1
src/components/providers/ClaudeSection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ClaudeSection } from './ClaudeSection';
|
||||||
117
src/components/providers/CodexSection/CodexModal.tsx
Normal file
117
src/components/providers/CodexSection/CodexModal.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
|
import { headersToEntries } from '@/utils/headers';
|
||||||
|
import { modelsToEntries } from '@/components/ui/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: headersToEntries(initialData.headers),
|
||||||
|
modelEntries: modelsToEntries(initialData.models),
|
||||||
|
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm(buildEmptyForm());
|
||||||
|
}, [initialData, isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={
|
||||||
|
editIndex !== null
|
||||||
|
? t('ai_providers.codex_edit_modal_title')
|
||||||
|
: t('ai_providers.codex_add_modal_title')
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.codex_add_modal_key_label')}
|
||||||
|
value={form.apiKey}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.prefix_label')}
|
||||||
|
placeholder={t('ai_providers.prefix_placeholder')}
|
||||||
|
value={form.prefix ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||||
|
hint={t('ai_providers.prefix_hint')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.codex_add_modal_url_label')}
|
||||||
|
value={form.baseUrl ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.codex_add_modal_proxy_label')}
|
||||||
|
value={form.proxyUrl ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<HeaderInputList
|
||||||
|
entries={form.headers}
|
||||||
|
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||||
|
addLabel={t('common.custom_headers_add')}
|
||||||
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
/>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||||
|
value={form.excludedText}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
src/components/providers/CodexSection/CodexSection.tsx
Normal file
206
src/components/providers/CodexSection/CodexSection.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { Fragment, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
|
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||||
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
|
import { maskApiKey } from '@/utils/format';
|
||||||
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
import { ProviderList } from '../ProviderList';
|
||||||
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
|
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||||
|
import type { ProviderFormState } from '../types';
|
||||||
|
import { CodexModal } from './CodexModal';
|
||||||
|
|
||||||
|
interface CodexSectionProps {
|
||||||
|
configs: ProviderKeyConfig[];
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
loading: boolean;
|
||||||
|
disableControls: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
isSwitching: boolean;
|
||||||
|
resolvedTheme: string;
|
||||||
|
isModalOpen: boolean;
|
||||||
|
modalIndex: number | null;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
onToggle: (index: number, enabled: boolean) => void;
|
||||||
|
onCloseModal: () => void;
|
||||||
|
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodexSection({
|
||||||
|
configs,
|
||||||
|
keyStats,
|
||||||
|
usageDetails,
|
||||||
|
loading,
|
||||||
|
disableControls,
|
||||||
|
isSaving,
|
||||||
|
isSwitching,
|
||||||
|
resolvedTheme,
|
||||||
|
isModalOpen,
|
||||||
|
modalIndex,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggle,
|
||||||
|
onCloseModal,
|
||||||
|
onSave,
|
||||||
|
}: CodexSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||||
|
const toggleDisabled = disableControls || loading || isSaving || 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]);
|
||||||
|
|
||||||
|
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img
|
||||||
|
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
||||||
|
alt=""
|
||||||
|
className={styles.cardTitleIcon}
|
||||||
|
/>
|
||||||
|
{t('ai_providers.codex_title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||||
|
{t('ai_providers.codex_add_button')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProviderList<ProviderKeyConfig>
|
||||||
|
items={configs}
|
||||||
|
loading={loading}
|
||||||
|
keyField={(item) => item.apiKey}
|
||||||
|
emptyTitle={t('ai_providers.codex_empty_title')}
|
||||||
|
emptyDescription={t('ai_providers.codex_empty_desc')}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
actionsDisabled={actionsDisabled}
|
||||||
|
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||||
|
renderExtraActions={(item, index) => (
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('ai_providers.config_toggle_label')}
|
||||||
|
checked={!hasDisableAllModelsRule(item.excludedModels)}
|
||||||
|
disabled={toggleDisabled}
|
||||||
|
onChange={(value) => void onToggle(index, value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderContent={(item) => {
|
||||||
|
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||||
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
|
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||||
|
const excludedModels = item.excludedModels ?? [];
|
||||||
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div className="item-title">{t('ai_providers.codex_item_title')}</div>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||||
|
</div>
|
||||||
|
{item.prefix && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.baseUrl && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.proxyUrl && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.proxyUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{headerEntries.length > 0 && (
|
||||||
|
<div className={styles.headerBadgeList}>
|
||||||
|
{headerEntries.map(([key, value]) => (
|
||||||
|
<span key={key} className={styles.headerBadge}>
|
||||||
|
<strong>{key}:</strong> {value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{configDisabled && (
|
||||||
|
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||||
|
{t('ai_providers.config_disabled_badge')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{excludedModels.length ? (
|
||||||
|
<div className={styles.excludedModelsSection}>
|
||||||
|
<div className={styles.excludedModelsLabel}>
|
||||||
|
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||||
|
</div>
|
||||||
|
<div className={styles.modelTagList}>
|
||||||
|
{excludedModels.map((model) => (
|
||||||
|
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||||
|
<span className={styles.modelName}>{model}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.cardStats}>
|
||||||
|
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||||
|
{t('stats.success')}: {stats.success}
|
||||||
|
</span>
|
||||||
|
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||||
|
{t('stats.failure')}: {stats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProviderStatusBar statusData={statusData} />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<CodexModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
editIndex={modalIndex}
|
||||||
|
initialData={initialData}
|
||||||
|
onClose={onCloseModal}
|
||||||
|
onSave={onSave}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/CodexSection/index.ts
Normal file
1
src/components/providers/CodexSection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { CodexSection } from './CodexSection';
|
||||||
113
src/components/providers/GeminiSection/GeminiModal.tsx
Normal file
113
src/components/providers/GeminiSection/GeminiModal.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import type { GeminiKeyConfig } from '@/types';
|
||||||
|
import { headersToEntries } from '@/utils/headers';
|
||||||
|
import { excludedModelsToText } from '../utils';
|
||||||
|
import type { GeminiFormState, ProviderModalProps } from '../types';
|
||||||
|
|
||||||
|
interface GeminiModalProps extends ProviderModalProps<GeminiKeyConfig, GeminiFormState> {
|
||||||
|
isSaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildEmptyForm = (): GeminiFormState => ({
|
||||||
|
apiKey: '',
|
||||||
|
prefix: '',
|
||||||
|
baseUrl: '',
|
||||||
|
headers: [],
|
||||||
|
excludedModels: [],
|
||||||
|
excludedText: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function GeminiModal({
|
||||||
|
isOpen,
|
||||||
|
editIndex,
|
||||||
|
initialData,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: GeminiModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [form, setForm] = useState<GeminiFormState>(buildEmptyForm);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (initialData) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setForm({
|
||||||
|
...initialData,
|
||||||
|
headers: headersToEntries(initialData.headers),
|
||||||
|
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm(buildEmptyForm());
|
||||||
|
}, [initialData, isOpen]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
void onSave(form, editIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={
|
||||||
|
editIndex !== null
|
||||||
|
? t('ai_providers.gemini_edit_modal_title')
|
||||||
|
: t('ai_providers.gemini_add_modal_title')
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} loading={isSaving}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.gemini_add_modal_key_label')}
|
||||||
|
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
|
||||||
|
value={form.apiKey}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.prefix_label')}
|
||||||
|
placeholder={t('ai_providers.prefix_placeholder')}
|
||||||
|
value={form.prefix ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||||
|
hint={t('ai_providers.prefix_hint')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.gemini_base_url_label')}
|
||||||
|
placeholder={t('ai_providers.gemini_base_url_placeholder')}
|
||||||
|
value={form.baseUrl ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<HeaderInputList
|
||||||
|
entries={form.headers}
|
||||||
|
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||||
|
addLabel={t('common.custom_headers_add')}
|
||||||
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
/>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||||
|
value={form.excludedText}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
src/components/providers/GeminiSection/GeminiSection.tsx
Normal file
195
src/components/providers/GeminiSection/GeminiSection.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Fragment, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
|
import type { GeminiKeyConfig } from '@/types';
|
||||||
|
import { maskApiKey } from '@/utils/format';
|
||||||
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
import type { GeminiFormState } from '../types';
|
||||||
|
import { ProviderList } from '../ProviderList';
|
||||||
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
|
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||||
|
import { GeminiModal } from './GeminiModal';
|
||||||
|
|
||||||
|
interface GeminiSectionProps {
|
||||||
|
configs: GeminiKeyConfig[];
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
loading: boolean;
|
||||||
|
disableControls: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
isSwitching: boolean;
|
||||||
|
isModalOpen: boolean;
|
||||||
|
modalIndex: number | null;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
onToggle: (index: number, enabled: boolean) => void;
|
||||||
|
onCloseModal: () => void;
|
||||||
|
onSave: (data: GeminiFormState, index: number | null) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeminiSection({
|
||||||
|
configs,
|
||||||
|
keyStats,
|
||||||
|
usageDetails,
|
||||||
|
loading,
|
||||||
|
disableControls,
|
||||||
|
isSaving,
|
||||||
|
isSwitching,
|
||||||
|
isModalOpen,
|
||||||
|
modalIndex,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggle,
|
||||||
|
onCloseModal,
|
||||||
|
onSave,
|
||||||
|
}: GeminiSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||||
|
const toggleDisabled = disableControls || loading || isSaving || 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]);
|
||||||
|
|
||||||
|
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img src={iconGemini} alt="" className={styles.cardTitleIcon} />
|
||||||
|
{t('ai_providers.gemini_title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||||
|
{t('ai_providers.gemini_add_button')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProviderList<GeminiKeyConfig>
|
||||||
|
items={configs}
|
||||||
|
loading={loading}
|
||||||
|
keyField={(item) => item.apiKey}
|
||||||
|
emptyTitle={t('ai_providers.gemini_empty_title')}
|
||||||
|
emptyDescription={t('ai_providers.gemini_empty_desc')}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
actionsDisabled={actionsDisabled}
|
||||||
|
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||||
|
renderExtraActions={(item, index) => (
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('ai_providers.config_toggle_label')}
|
||||||
|
checked={!hasDisableAllModelsRule(item.excludedModels)}
|
||||||
|
disabled={toggleDisabled}
|
||||||
|
onChange={(value) => void onToggle(index, value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderContent={(item, index) => {
|
||||||
|
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||||
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
|
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||||
|
const excludedModels = item.excludedModels ?? [];
|
||||||
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div className="item-title">
|
||||||
|
{t('ai_providers.gemini_item_title')} #{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||||
|
</div>
|
||||||
|
{item.prefix && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.baseUrl && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{headerEntries.length > 0 && (
|
||||||
|
<div className={styles.headerBadgeList}>
|
||||||
|
{headerEntries.map(([key, value]) => (
|
||||||
|
<span key={key} className={styles.headerBadge}>
|
||||||
|
<strong>{key}:</strong> {value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{configDisabled && (
|
||||||
|
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||||
|
{t('ai_providers.config_disabled_badge')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{excludedModels.length ? (
|
||||||
|
<div className={styles.excludedModelsSection}>
|
||||||
|
<div className={styles.excludedModelsLabel}>
|
||||||
|
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||||
|
</div>
|
||||||
|
<div className={styles.modelTagList}>
|
||||||
|
{excludedModels.map((model) => (
|
||||||
|
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||||
|
<span className={styles.modelName}>{model}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.cardStats}>
|
||||||
|
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||||
|
{t('stats.success')}: {stats.success}
|
||||||
|
</span>
|
||||||
|
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||||
|
{t('stats.failure')}: {stats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProviderStatusBar statusData={statusData} />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<GeminiModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
editIndex={modalIndex}
|
||||||
|
initialData={initialData}
|
||||||
|
onClose={onCloseModal}
|
||||||
|
onSave={onSave}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/GeminiSection/index.ts
Normal file
1
src/components/providers/GeminiSection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { GeminiSection } from './GeminiSection';
|
||||||
194
src/components/providers/OpenAISection/OpenAIDiscoveryModal.tsx
Normal file
194
src/components/providers/OpenAISection/OpenAIDiscoveryModal.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { modelsApi } from '@/services/api';
|
||||||
|
import type { ApiKeyEntry } from '@/types';
|
||||||
|
import type { ModelInfo } from '@/utils/models';
|
||||||
|
import { buildHeaderObject, type HeaderEntry } from '@/utils/headers';
|
||||||
|
import { buildOpenAIModelsEndpoint } from '../utils';
|
||||||
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
|
||||||
|
interface OpenAIDiscoveryModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
baseUrl: string;
|
||||||
|
headers: HeaderEntry[];
|
||||||
|
apiKeyEntries: ApiKeyEntry[];
|
||||||
|
onClose: () => void;
|
||||||
|
onApply: (selected: ModelInfo[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenAIDiscoveryModal({
|
||||||
|
isOpen,
|
||||||
|
baseUrl,
|
||||||
|
headers,
|
||||||
|
apiKeyEntries,
|
||||||
|
onClose,
|
||||||
|
onApply,
|
||||||
|
}: OpenAIDiscoveryModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [endpoint, setEndpoint] = useState('');
|
||||||
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const getErrorMessage = (err: unknown) => {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
if (typeof err === 'string') return err;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredModels = useMemo(() => {
|
||||||
|
const filter = search.trim().toLowerCase();
|
||||||
|
if (!filter) return models;
|
||||||
|
return models.filter((model) => {
|
||||||
|
const name = (model.name || '').toLowerCase();
|
||||||
|
const alias = (model.alias || '').toLowerCase();
|
||||||
|
const desc = (model.description || '').toLowerCase();
|
||||||
|
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
||||||
|
});
|
||||||
|
}, [models, search]);
|
||||||
|
|
||||||
|
const fetchOpenaiModelDiscovery = useCallback(
|
||||||
|
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
|
||||||
|
const trimmedBaseUrl = baseUrl.trim();
|
||||||
|
if (!trimmedBaseUrl) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const headerObject = buildHeaderObject(headers);
|
||||||
|
const firstKey = apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
|
||||||
|
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
|
||||||
|
const list = await modelsApi.fetchModelsViaApiCall(
|
||||||
|
trimmedBaseUrl,
|
||||||
|
hasAuthHeader ? undefined : firstKey,
|
||||||
|
headerObject
|
||||||
|
);
|
||||||
|
setModels(list);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (allowFallback) {
|
||||||
|
try {
|
||||||
|
const list = await modelsApi.fetchModelsViaApiCall(trimmedBaseUrl);
|
||||||
|
setModels(list);
|
||||||
|
return;
|
||||||
|
} catch (fallbackErr: unknown) {
|
||||||
|
const message = getErrorMessage(fallbackErr) || getErrorMessage(err);
|
||||||
|
setModels([]);
|
||||||
|
setError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setModels([]);
|
||||||
|
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiKeyEntries, baseUrl, headers, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setEndpoint(buildOpenAIModelsEndpoint(baseUrl));
|
||||||
|
setModels([]);
|
||||||
|
setSearch('');
|
||||||
|
setSelected(new Set());
|
||||||
|
setError('');
|
||||||
|
void fetchOpenaiModelDiscovery();
|
||||||
|
}, [baseUrl, fetchOpenaiModelDiscovery, isOpen]);
|
||||||
|
|
||||||
|
const toggleSelection = (name: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(name)) {
|
||||||
|
next.delete(name);
|
||||||
|
} else {
|
||||||
|
next.add(name);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
const selectedModels = models.filter((model) => selected.has(model.name));
|
||||||
|
onApply(selectedModels);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('ai_providers.openai_models_fetch_title')}
|
||||||
|
width={720}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||||
|
{t('ai_providers.openai_models_fetch_back')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleApply} disabled={loading}>
|
||||||
|
{t('ai_providers.openai_models_fetch_apply')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="hint" style={{ marginBottom: 8 }}>
|
||||||
|
{t('ai_providers.openai_models_fetch_hint')}
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input className="input" readOnly value={endpoint} />
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{t('ai_providers.openai_models_fetch_refresh')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.openai_models_search_label')}
|
||||||
|
placeholder={t('ai_providers.openai_models_search_placeholder')}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
{loading ? (
|
||||||
|
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
|
||||||
|
) : models.length === 0 ? (
|
||||||
|
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
|
||||||
|
) : filteredModels.length === 0 ? (
|
||||||
|
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.modelDiscoveryList}>
|
||||||
|
{filteredModels.map((model) => {
|
||||||
|
const checked = selected.has(model.name);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={model.name}
|
||||||
|
className={`${styles.modelDiscoveryRow} ${checked ? styles.modelDiscoveryRowSelected : ''}`}
|
||||||
|
>
|
||||||
|
<input type="checkbox" checked={checked} onChange={() => toggleSelection(model.name)} />
|
||||||
|
<div className={styles.modelDiscoveryMeta}>
|
||||||
|
<div className={styles.modelDiscoveryName}>
|
||||||
|
{model.name}
|
||||||
|
{model.alias && <span className={styles.modelDiscoveryAlias}>{model.alias}</span>}
|
||||||
|
</div>
|
||||||
|
{model.description && (
|
||||||
|
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
432
src/components/providers/OpenAISection/OpenAIModal.tsx
Normal file
432
src/components/providers/OpenAISection/OpenAIModal.tsx
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
src/components/providers/OpenAISection/OpenAISection.tsx
Normal file
218
src/components/providers/OpenAISection/OpenAISection.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { Fragment, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { IconCheck, IconX } from '@/components/ui/icons';
|
||||||
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
|
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||||
|
import type { OpenAIProviderConfig } from '@/types';
|
||||||
|
import { maskApiKey } from '@/utils/format';
|
||||||
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
import { ProviderList } from '../ProviderList';
|
||||||
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
|
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
|
||||||
|
import type { OpenAIFormState } from '../types';
|
||||||
|
import { OpenAIModal } from './OpenAIModal';
|
||||||
|
|
||||||
|
interface OpenAISectionProps {
|
||||||
|
configs: OpenAIProviderConfig[];
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
loading: boolean;
|
||||||
|
disableControls: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
isSwitching: boolean;
|
||||||
|
resolvedTheme: string;
|
||||||
|
isModalOpen: boolean;
|
||||||
|
modalIndex: number | null;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
onCloseModal: () => void;
|
||||||
|
onSave: (data: OpenAIFormState, index: number | null) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenAISection({
|
||||||
|
configs,
|
||||||
|
keyStats,
|
||||||
|
usageDetails,
|
||||||
|
loading,
|
||||||
|
disableControls,
|
||||||
|
isSaving,
|
||||||
|
isSwitching,
|
||||||
|
resolvedTheme,
|
||||||
|
isModalOpen,
|
||||||
|
modalIndex,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onCloseModal,
|
||||||
|
onSave,
|
||||||
|
}: OpenAISectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||||
|
|
||||||
|
const statusBarCache = useMemo(() => {
|
||||||
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
|
|
||||||
|
configs.forEach((provider) => {
|
||||||
|
const sourceIds = new Set<string>();
|
||||||
|
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
|
||||||
|
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||||
|
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredDetails = sourceIds.size
|
||||||
|
? usageDetails.filter((detail) => sourceIds.has(detail.source))
|
||||||
|
: [];
|
||||||
|
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}, [configs, usageDetails]);
|
||||||
|
|
||||||
|
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img
|
||||||
|
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
||||||
|
alt=""
|
||||||
|
className={styles.cardTitleIcon}
|
||||||
|
/>
|
||||||
|
{t('ai_providers.openai_title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||||
|
{t('ai_providers.openai_add_button')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProviderList<OpenAIProviderConfig>
|
||||||
|
items={configs}
|
||||||
|
loading={loading}
|
||||||
|
keyField={(item) => item.name}
|
||||||
|
emptyTitle={t('ai_providers.openai_empty_title')}
|
||||||
|
emptyDescription={t('ai_providers.openai_empty_desc')}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
actionsDisabled={actionsDisabled}
|
||||||
|
renderContent={(item) => {
|
||||||
|
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
|
||||||
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
|
const apiKeyEntries = item.apiKeyEntries || [];
|
||||||
|
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div className="item-title">{item.name}</div>
|
||||||
|
{item.prefix && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||||
|
</div>
|
||||||
|
{headerEntries.length > 0 && (
|
||||||
|
<div className={styles.headerBadgeList}>
|
||||||
|
{headerEntries.map(([key, value]) => (
|
||||||
|
<span key={key} className={styles.headerBadge}>
|
||||||
|
<strong>{key}:</strong> {value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{apiKeyEntries.length > 0 && (
|
||||||
|
<div className={styles.apiKeyEntriesSection}>
|
||||||
|
<div className={styles.apiKeyEntriesLabel}>
|
||||||
|
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
|
||||||
|
</div>
|
||||||
|
<div className={styles.apiKeyEntryList}>
|
||||||
|
{apiKeyEntries.map((entry, entryIndex) => {
|
||||||
|
const entryStats = getStatsBySource(entry.apiKey, keyStats);
|
||||||
|
return (
|
||||||
|
<div key={entryIndex} className={styles.apiKeyEntryCard}>
|
||||||
|
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||||
|
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
|
||||||
|
{entry.proxyUrl && (
|
||||||
|
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
|
||||||
|
)}
|
||||||
|
<div className={styles.apiKeyEntryStats}>
|
||||||
|
<span
|
||||||
|
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
|
||||||
|
>
|
||||||
|
<IconCheck size={12} /> {entryStats.success}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
|
||||||
|
>
|
||||||
|
<IconX size={12} /> {entryStats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
|
||||||
|
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.models?.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
{item.models?.length ? (
|
||||||
|
<div className={styles.modelTagList}>
|
||||||
|
{item.models.map((model) => (
|
||||||
|
<span key={model.name} className={styles.modelTag}>
|
||||||
|
<span className={styles.modelName}>{model.name}</span>
|
||||||
|
{model.alias && model.alias !== model.name && (
|
||||||
|
<span className={styles.modelAlias}>{model.alias}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.testModel && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>Test Model:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.testModel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.cardStats}>
|
||||||
|
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||||
|
{t('stats.success')}: {stats.success}
|
||||||
|
</span>
|
||||||
|
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||||
|
{t('stats.failure')}: {stats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProviderStatusBar statusData={statusData} />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<OpenAIModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
editIndex={modalIndex}
|
||||||
|
initialData={initialData}
|
||||||
|
onClose={onCloseModal}
|
||||||
|
onSave={onSave}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/OpenAISection/index.ts
Normal file
1
src/components/providers/OpenAISection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { OpenAISection } from './OpenAISection';
|
||||||
80
src/components/providers/ProviderList.tsx
Normal file
80
src/components/providers/ProviderList.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
|
||||||
|
interface ProviderListProps<T> {
|
||||||
|
items: T[];
|
||||||
|
loading: boolean;
|
||||||
|
keyField: (item: T) => string;
|
||||||
|
renderContent: (item: T, index: number) => ReactNode;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
emptyTitle: string;
|
||||||
|
emptyDescription: string;
|
||||||
|
deleteLabel?: string;
|
||||||
|
actionsDisabled?: boolean;
|
||||||
|
getRowDisabled?: (item: T, index: number) => boolean;
|
||||||
|
renderExtraActions?: (item: T, index: number) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderList<T>({
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
keyField,
|
||||||
|
renderContent,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
deleteLabel,
|
||||||
|
actionsDisabled = false,
|
||||||
|
getRowDisabled,
|
||||||
|
renderExtraActions,
|
||||||
|
}: ProviderListProps<T>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="hint">{t('common.loading')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return <EmptyState title={emptyTitle} description={emptyDescription} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="item-list">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={keyField(item)}
|
||||||
|
className="item-row"
|
||||||
|
style={rowDisabled ? { opacity: 0.6 } : undefined}
|
||||||
|
>
|
||||||
|
<div className="item-meta">{renderContent(item, index)}</div>
|
||||||
|
<div className="item-actions">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit(index)}
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(index)}
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
>
|
||||||
|
{deleteLabel || t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
{renderExtraActions ? renderExtraActions(item, index) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/providers/ProviderStatusBar.tsx
Normal file
38
src/components/providers/ProviderStatusBar.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { calculateStatusBarData } from '@/utils/usage';
|
||||||
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
|
||||||
|
interface ProviderStatusBarProps {
|
||||||
|
statusData: ReturnType<typeof calculateStatusBarData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderStatusBar({ statusData }: ProviderStatusBarProps) {
|
||||||
|
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||||
|
const rateClass = !hasData
|
||||||
|
? ''
|
||||||
|
: statusData.successRate >= 90
|
||||||
|
? styles.statusRateHigh
|
||||||
|
: statusData.successRate >= 50
|
||||||
|
? styles.statusRateMedium
|
||||||
|
: styles.statusRateLow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statusBar}>
|
||||||
|
<div className={styles.statusBlocks}>
|
||||||
|
{statusData.blocks.map((state, idx) => {
|
||||||
|
const blockClass =
|
||||||
|
state === 'success'
|
||||||
|
? styles.statusBlockSuccess
|
||||||
|
: state === 'failure'
|
||||||
|
? styles.statusBlockFailure
|
||||||
|
: state === 'mixed'
|
||||||
|
? styles.statusBlockMixed
|
||||||
|
: styles.statusBlockIdle;
|
||||||
|
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className={`${styles.statusRate} ${rateClass}`}>
|
||||||
|
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/components/providers/VertexSection/VertexModal.tsx
Normal file
117
src/components/providers/VertexSection/VertexModal.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
|
||||||
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
|
import { headersToEntries } from '@/utils/headers';
|
||||||
|
import type { ProviderModalProps, VertexFormState } from '../types';
|
||||||
|
|
||||||
|
interface VertexModalProps extends ProviderModalProps<ProviderKeyConfig, VertexFormState> {
|
||||||
|
isSaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildEmptyForm = (): VertexFormState => ({
|
||||||
|
apiKey: '',
|
||||||
|
prefix: '',
|
||||||
|
baseUrl: '',
|
||||||
|
proxyUrl: '',
|
||||||
|
headers: [],
|
||||||
|
models: [],
|
||||||
|
modelEntries: [{ name: '', alias: '' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function VertexModal({
|
||||||
|
isOpen,
|
||||||
|
editIndex,
|
||||||
|
initialData,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: VertexModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [form, setForm] = useState<VertexFormState>(buildEmptyForm);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (initialData) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setForm({
|
||||||
|
...initialData,
|
||||||
|
headers: headersToEntries(initialData.headers),
|
||||||
|
modelEntries: modelsToEntries(initialData.models),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm(buildEmptyForm());
|
||||||
|
}, [initialData, isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={
|
||||||
|
editIndex !== null
|
||||||
|
? t('ai_providers.vertex_edit_modal_title')
|
||||||
|
: t('ai_providers.vertex_add_modal_title')
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.vertex_add_modal_key_label')}
|
||||||
|
placeholder={t('ai_providers.vertex_add_modal_key_placeholder')}
|
||||||
|
value={form.apiKey}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.prefix_label')}
|
||||||
|
placeholder={t('ai_providers.prefix_placeholder')}
|
||||||
|
value={form.prefix ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||||
|
hint={t('ai_providers.prefix_hint')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.vertex_add_modal_url_label')}
|
||||||
|
placeholder={t('ai_providers.vertex_add_modal_url_placeholder')}
|
||||||
|
value={form.baseUrl ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.vertex_add_modal_proxy_label')}
|
||||||
|
placeholder={t('ai_providers.vertex_add_modal_proxy_placeholder')}
|
||||||
|
value={form.proxyUrl ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<HeaderInputList
|
||||||
|
entries={form.headers}
|
||||||
|
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||||
|
addLabel={t('common.custom_headers_add')}
|
||||||
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
/>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.vertex_models_label')}</label>
|
||||||
|
<ModelInputList
|
||||||
|
entries={form.modelEntries}
|
||||||
|
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||||
|
addLabel={t('ai_providers.vertex_models_add_btn')}
|
||||||
|
namePlaceholder={t('common.model_name_placeholder')}
|
||||||
|
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
src/components/providers/VertexSection/VertexSection.tsx
Normal file
182
src/components/providers/VertexSection/VertexSection.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
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';
|
||||||
|
import type { VertexFormState } from '../types';
|
||||||
|
import { VertexModal } from './VertexModal';
|
||||||
|
|
||||||
|
interface VertexSectionProps {
|
||||||
|
configs: ProviderKeyConfig[];
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
loading: boolean;
|
||||||
|
disableControls: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
isSwitching: boolean;
|
||||||
|
isModalOpen: boolean;
|
||||||
|
modalIndex: number | null;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
onCloseModal: () => void;
|
||||||
|
onSave: (data: VertexFormState, index: number | null) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VertexSection({
|
||||||
|
configs,
|
||||||
|
keyStats,
|
||||||
|
usageDetails,
|
||||||
|
loading,
|
||||||
|
disableControls,
|
||||||
|
isSaving,
|
||||||
|
isSwitching,
|
||||||
|
isModalOpen,
|
||||||
|
modalIndex,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onCloseModal,
|
||||||
|
onSave,
|
||||||
|
}: VertexSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const actionsDisabled = disableControls || isSaving || 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]);
|
||||||
|
|
||||||
|
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<VertexModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
editIndex={modalIndex}
|
||||||
|
initialData={initialData}
|
||||||
|
onClose={onCloseModal}
|
||||||
|
onSave={onSave}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/VertexSection/index.ts
Normal file
1
src/components/providers/VertexSection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { VertexSection } from './VertexSection';
|
||||||
37
src/components/providers/hooks/useProviderStats.ts
Normal file
37
src/components/providers/hooks/useProviderStats.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useInterval } from '@/hooks/useInterval';
|
||||||
|
import { usageApi } from '@/services/api';
|
||||||
|
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||||
|
|
||||||
|
const EMPTY_STATS: KeyStats = { bySource: {}, byAuthIndex: {} };
|
||||||
|
|
||||||
|
export const useProviderStats = () => {
|
||||||
|
const [keyStats, setKeyStats] = useState<KeyStats>(EMPTY_STATS);
|
||||||
|
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
|
// 加载 key 统计和 usage 明细(API 层已有60秒超时)
|
||||||
|
const loadKeyStats = useCallback(async () => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const usageResponse = await usageApi.getUsage();
|
||||||
|
const usageData = usageResponse?.usage ?? usageResponse;
|
||||||
|
const stats = await usageApi.getKeyStats(usageData);
|
||||||
|
setKeyStats(stats);
|
||||||
|
setUsageDetails(collectUsageDetails(usageData));
|
||||||
|
} catch {
|
||||||
|
// 静默失败
|
||||||
|
} finally {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 定时刷新状态数据(每240秒)
|
||||||
|
useInterval(loadKeyStats, 240_000);
|
||||||
|
|
||||||
|
return { keyStats, usageDetails, loadKeyStats, isLoading };
|
||||||
|
};
|
||||||
11
src/components/providers/index.ts
Normal file
11
src/components/providers/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { AmpcodeSection } from './AmpcodeSection';
|
||||||
|
export { ClaudeSection } from './ClaudeSection';
|
||||||
|
export { CodexSection } from './CodexSection';
|
||||||
|
export { GeminiSection } from './GeminiSection';
|
||||||
|
export { OpenAISection } from './OpenAISection';
|
||||||
|
export { VertexSection } from './VertexSection';
|
||||||
|
export { ProviderList } from './ProviderList';
|
||||||
|
export { ProviderStatusBar } from './ProviderStatusBar';
|
||||||
|
export * from './hooks/useProviderStats';
|
||||||
|
export * from './types';
|
||||||
|
export * from './utils';
|
||||||
69
src/components/providers/types.ts
Normal file
69
src/components/providers/types.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
|
||||||
|
import type { HeaderEntry } from '@/utils/headers';
|
||||||
|
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
||||||
|
|
||||||
|
export type ProviderModal =
|
||||||
|
| { type: 'gemini'; index: number | null }
|
||||||
|
| { type: 'codex'; index: number | null }
|
||||||
|
| { type: 'claude'; index: number | null }
|
||||||
|
| { type: 'vertex'; index: number | null }
|
||||||
|
| { type: 'ampcode'; index: null }
|
||||||
|
| { type: 'openai'; index: number | null };
|
||||||
|
|
||||||
|
export interface ModelEntry {
|
||||||
|
name: string;
|
||||||
|
alias: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAIFormState {
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
baseUrl: string;
|
||||||
|
headers: HeaderEntry[];
|
||||||
|
testModel?: string;
|
||||||
|
modelEntries: ModelEntry[];
|
||||||
|
apiKeyEntries: ApiKeyEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmpcodeFormState {
|
||||||
|
upstreamUrl: string;
|
||||||
|
upstreamApiKey: string;
|
||||||
|
forceModelMappings: boolean;
|
||||||
|
mappingEntries: ModelEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers'> & {
|
||||||
|
headers: HeaderEntry[];
|
||||||
|
excludedText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
|
||||||
|
headers: HeaderEntry[];
|
||||||
|
modelEntries: ModelEntry[];
|
||||||
|
excludedText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
|
||||||
|
headers: HeaderEntry[];
|
||||||
|
modelEntries: ModelEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ProviderSectionProps<TConfig> {
|
||||||
|
configs: TConfig[];
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
disabled: boolean;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
onToggle?: (index: number, enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderModalProps<TConfig, TPayload = TConfig> {
|
||||||
|
isOpen: boolean;
|
||||||
|
editIndex: number | null;
|
||||||
|
initialData?: TConfig;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (data: TPayload, index: number | null) => Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
149
src/components/providers/utils.ts
Normal file
149
src/components/providers/utils.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
||||||
|
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
|
||||||
|
import type { AmpcodeFormState, ModelEntry } from './types';
|
||||||
|
|
||||||
|
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||||
|
|
||||||
|
export const hasDisableAllModelsRule = (models?: string[]) =>
|
||||||
|
Array.isArray(models) &&
|
||||||
|
models.some((model) => String(model ?? '').trim() === DISABLE_ALL_MODELS_RULE);
|
||||||
|
|
||||||
|
export const stripDisableAllModelsRule = (models?: string[]) =>
|
||||||
|
Array.isArray(models)
|
||||||
|
? models.filter((model) => String(model ?? '').trim() !== DISABLE_ALL_MODELS_RULE)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
export const withDisableAllModelsRule = (models?: string[]) => {
|
||||||
|
const base = stripDisableAllModelsRule(models);
|
||||||
|
return [...base, DISABLE_ALL_MODELS_RULE];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withoutDisableAllModelsRule = (models?: string[]) => {
|
||||||
|
const base = stripDisableAllModelsRule(models);
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseExcludedModels = (text: string): string[] =>
|
||||||
|
text
|
||||||
|
.split(/[\n,]+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
export const excludedModelsToText = (models?: string[]) =>
|
||||||
|
Array.isArray(models) ? models.join('\n') : '';
|
||||||
|
|
||||||
|
export const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
|
||||||
|
let trimmed = String(baseUrl || '').trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
|
||||||
|
trimmed = trimmed.replace(/\/+$/g, '');
|
||||||
|
if (!/^https?:\/\//i.test(trimmed)) {
|
||||||
|
trimmed = `http://${trimmed}`;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
||||||
|
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||||
|
if (!trimmed) return '';
|
||||||
|
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||||
|
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||||
|
if (!trimmed) return '';
|
||||||
|
if (trimmed.endsWith('/chat/completions')) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return trimmed.endsWith('/v1') ? `${trimmed}/chat/completions` : `${trimmed}/v1/chat/completions`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
|
||||||
|
export const getStatsBySource = (
|
||||||
|
apiKey: string,
|
||||||
|
keyStats: KeyStats,
|
||||||
|
prefix?: string
|
||||||
|
): KeyStatBucket => {
|
||||||
|
const bySource = keyStats.bySource ?? {};
|
||||||
|
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
|
||||||
|
if (!candidates.length) {
|
||||||
|
return { success: 0, failure: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let success = 0;
|
||||||
|
let failure = 0;
|
||||||
|
candidates.forEach((candidate) => {
|
||||||
|
const stats = bySource[candidate];
|
||||||
|
if (!stats) return;
|
||||||
|
success += stats.success;
|
||||||
|
failure += stats.failure;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success, failure };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||||
|
export const getOpenAIProviderStats = (
|
||||||
|
apiKeyEntries: ApiKeyEntry[] | undefined,
|
||||||
|
keyStats: KeyStats,
|
||||||
|
providerPrefix?: string
|
||||||
|
): KeyStatBucket => {
|
||||||
|
const bySource = keyStats.bySource ?? {};
|
||||||
|
|
||||||
|
const sourceIds = new Set<string>();
|
||||||
|
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
|
||||||
|
(apiKeyEntries || []).forEach((entry) => {
|
||||||
|
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
let success = 0;
|
||||||
|
let failure = 0;
|
||||||
|
sourceIds.forEach((id) => {
|
||||||
|
const stats = bySource[id];
|
||||||
|
if (!stats) return;
|
||||||
|
success += stats.success;
|
||||||
|
failure += stats.failure;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success, failure };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
||||||
|
apiKey: input?.apiKey ?? '',
|
||||||
|
proxyUrl: input?.proxyUrl ?? '',
|
||||||
|
headers: input?.headers ?? {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ampcodeMappingsToEntries = (mappings?: AmpcodeModelMapping[]): ModelEntry[] => {
|
||||||
|
if (!Array.isArray(mappings) || mappings.length === 0) {
|
||||||
|
return [{ name: '', alias: '' }];
|
||||||
|
}
|
||||||
|
return mappings.map((mapping) => ({
|
||||||
|
name: mapping.from ?? '',
|
||||||
|
alias: mapping.to ?? '',
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMapping[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const mappings: AmpcodeModelMapping[] = [];
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const from = entry.name.trim();
|
||||||
|
const to = entry.alias.trim();
|
||||||
|
if (!from || !to) return;
|
||||||
|
const key = from.toLowerCase();
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
mappings.push({ from, to });
|
||||||
|
});
|
||||||
|
|
||||||
|
return mappings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
|
||||||
|
upstreamUrl: ampcode?.upstreamUrl ?? '',
|
||||||
|
upstreamApiKey: '',
|
||||||
|
forceModelMappings: ampcode?.forceModelMappings ?? false,
|
||||||
|
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings),
|
||||||
|
});
|
||||||
145
src/components/quota/QuotaCard.tsx
Normal file
145
src/components/quota/QuotaCard.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Generic quota card component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { AuthFileItem, ResolvedTheme, ThemeColors } from '@/types';
|
||||||
|
import { TYPE_COLORS } from '@/utils/quota';
|
||||||
|
import styles from '@/pages/QuotaPage.module.scss';
|
||||||
|
|
||||||
|
type QuotaStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export interface QuotaStatusState {
|
||||||
|
status: QuotaStatus;
|
||||||
|
error?: string;
|
||||||
|
errorStatus?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaProgressBarProps {
|
||||||
|
percent: number | null;
|
||||||
|
highThreshold: number;
|
||||||
|
mediumThreshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuotaProgressBar({
|
||||||
|
percent,
|
||||||
|
highThreshold,
|
||||||
|
mediumThreshold
|
||||||
|
}: QuotaProgressBarProps) {
|
||||||
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
|
Math.min(max, Math.max(min, value));
|
||||||
|
const normalized = percent === null ? null : clamp(percent, 0, 100);
|
||||||
|
const fillClass =
|
||||||
|
normalized === null
|
||||||
|
? styles.quotaBarFillMedium
|
||||||
|
: normalized >= highThreshold
|
||||||
|
? styles.quotaBarFillHigh
|
||||||
|
: normalized >= mediumThreshold
|
||||||
|
? styles.quotaBarFillMedium
|
||||||
|
: styles.quotaBarFillLow;
|
||||||
|
const widthPercent = Math.round(normalized ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.quotaBar}>
|
||||||
|
<div
|
||||||
|
className={`${styles.quotaBarFill} ${fillClass}`}
|
||||||
|
style={{ width: `${widthPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaRenderHelpers {
|
||||||
|
styles: typeof styles;
|
||||||
|
QuotaProgressBar: (props: QuotaProgressBarProps) => ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuotaCardProps<TState extends QuotaStatusState> {
|
||||||
|
item: AuthFileItem;
|
||||||
|
quota?: TState;
|
||||||
|
resolvedTheme: ResolvedTheme;
|
||||||
|
i18nPrefix: string;
|
||||||
|
cardClassName: string;
|
||||||
|
defaultType: string;
|
||||||
|
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuotaCard<TState extends QuotaStatusState>({
|
||||||
|
item,
|
||||||
|
quota,
|
||||||
|
resolvedTheme,
|
||||||
|
i18nPrefix,
|
||||||
|
cardClassName,
|
||||||
|
defaultType,
|
||||||
|
renderQuotaItems
|
||||||
|
}: QuotaCardProps<TState>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const displayType = item.type || item.provider || defaultType;
|
||||||
|
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
|
||||||
|
const typeColor: ThemeColors =
|
||||||
|
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
|
||||||
|
|
||||||
|
const quotaStatus = quota?.status ?? 'idle';
|
||||||
|
const quotaErrorMessage = resolveQuotaErrorMessage(
|
||||||
|
t,
|
||||||
|
quota?.errorStatus,
|
||||||
|
quota?.error || t('common.unknown_error')
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string): string => {
|
||||||
|
const key = `auth_files.filter_${type}`;
|
||||||
|
const translated = t(key);
|
||||||
|
if (translated !== key) return translated;
|
||||||
|
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
||||||
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.fileCard} ${cardClassName}`}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<span
|
||||||
|
className={styles.typeBadge}
|
||||||
|
style={{
|
||||||
|
backgroundColor: typeColor.bg,
|
||||||
|
color: typeColor.text,
|
||||||
|
...(typeColor.border ? { border: typeColor.border } : {})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getTypeLabel(displayType)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.fileName}>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.quotaSection}>
|
||||||
|
{quotaStatus === 'loading' ? (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
|
||||||
|
) : quotaStatus === 'idle' ? (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
|
||||||
|
) : quotaStatus === 'error' ? (
|
||||||
|
<div className={styles.quotaError}>
|
||||||
|
{t(`${i18nPrefix}.load_failed`, {
|
||||||
|
message: quotaErrorMessage
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : quota ? (
|
||||||
|
renderQuotaItems(quota, t, { styles, QuotaProgressBar })
|
||||||
|
) : (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveQuotaErrorMessage = (
|
||||||
|
t: TFunction,
|
||||||
|
status: number | undefined,
|
||||||
|
fallback: string
|
||||||
|
): string => {
|
||||||
|
if (status === 404) return t('common.quota_update_required');
|
||||||
|
if (status === 403) return t('common.quota_check_credential');
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
321
src/components/quota/QuotaSection.tsx
Normal file
321
src/components/quota/QuotaSection.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* Generic quota section component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
|
import { useQuotaStore, useThemeStore } from '@/stores';
|
||||||
|
import type { AuthFileItem, ResolvedTheme } from '@/types';
|
||||||
|
import { QuotaCard } from './QuotaCard';
|
||||||
|
import type { QuotaStatusState } from './QuotaCard';
|
||||||
|
import { useQuotaLoader } from './useQuotaLoader';
|
||||||
|
import type { QuotaConfig } from './quotaConfigs';
|
||||||
|
import { useGridColumns } from './useGridColumns';
|
||||||
|
import { IconRefreshCw } from '@/components/ui/icons';
|
||||||
|
import styles from '@/pages/QuotaPage.module.scss';
|
||||||
|
|
||||||
|
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||||
|
|
||||||
|
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
||||||
|
|
||||||
|
type ViewMode = 'paged' | 'all';
|
||||||
|
|
||||||
|
const MAX_ITEMS_PER_PAGE = 14;
|
||||||
|
const MAX_SHOW_ALL_THRESHOLD = 30;
|
||||||
|
|
||||||
|
interface QuotaPaginationState<T> {
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
pageItems: T[];
|
||||||
|
setPageSize: (size: number) => void;
|
||||||
|
goToPrev: () => void;
|
||||||
|
goToNext: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
loadingScope: 'page' | 'all' | null;
|
||||||
|
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSizeState] = useState(defaultPageSize);
|
||||||
|
const [loading, setLoadingState] = useState(false);
|
||||||
|
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
|
||||||
|
|
||||||
|
const totalPages = useMemo(
|
||||||
|
() => Math.max(1, Math.ceil(items.length / pageSize)),
|
||||||
|
[items.length, pageSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentPage = useMemo(() => Math.min(page, totalPages), [page, totalPages]);
|
||||||
|
|
||||||
|
const pageItems = useMemo(() => {
|
||||||
|
const start = (currentPage - 1) * pageSize;
|
||||||
|
return items.slice(start, start + pageSize);
|
||||||
|
}, [items, currentPage, pageSize]);
|
||||||
|
|
||||||
|
const setPageSize = useCallback((size: number) => {
|
||||||
|
setPageSizeState(size);
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToPrev = useCallback(() => {
|
||||||
|
setPage((prev) => Math.max(1, prev - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToNext = useCallback(() => {
|
||||||
|
setPage((prev) => Math.min(totalPages, prev + 1));
|
||||||
|
}, [totalPages]);
|
||||||
|
|
||||||
|
const setLoading = useCallback((isLoading: boolean, scope?: 'page' | 'all' | null) => {
|
||||||
|
setLoadingState(isLoading);
|
||||||
|
setLoadingScope(isLoading ? (scope ?? null) : null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
currentPage,
|
||||||
|
pageItems,
|
||||||
|
setPageSize,
|
||||||
|
goToPrev,
|
||||||
|
goToNext,
|
||||||
|
loading,
|
||||||
|
loadingScope,
|
||||||
|
setLoading
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QuotaSectionProps<TState extends QuotaStatusState, TData> {
|
||||||
|
config: QuotaConfig<TState, TData>;
|
||||||
|
files: AuthFileItem[];
|
||||||
|
loading: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||||
|
config,
|
||||||
|
files,
|
||||||
|
loading,
|
||||||
|
disabled
|
||||||
|
}: QuotaSectionProps<TState, TData>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
|
||||||
|
Record<string, TState>
|
||||||
|
>;
|
||||||
|
|
||||||
|
/* Removed useRef */
|
||||||
|
const [columns, gridRef] = useGridColumns(380); // Min card width 380px matches SCSS
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('paged');
|
||||||
|
const [showTooManyWarning, setShowTooManyWarning] = useState(false);
|
||||||
|
|
||||||
|
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
|
||||||
|
files,
|
||||||
|
config
|
||||||
|
]);
|
||||||
|
const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD;
|
||||||
|
const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode;
|
||||||
|
|
||||||
|
const {
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
currentPage,
|
||||||
|
pageItems,
|
||||||
|
setPageSize,
|
||||||
|
goToPrev,
|
||||||
|
goToNext,
|
||||||
|
loading: sectionLoading,
|
||||||
|
setLoading
|
||||||
|
} = useQuotaPagination(filteredFiles);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showAllAllowed) return;
|
||||||
|
if (viewMode !== 'all') return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setViewMode('paged');
|
||||||
|
setShowTooManyWarning(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [showAllAllowed, viewMode]);
|
||||||
|
|
||||||
|
// Update page size based on view mode and columns
|
||||||
|
useEffect(() => {
|
||||||
|
if (effectiveViewMode === 'all') {
|
||||||
|
setPageSize(Math.max(1, filteredFiles.length));
|
||||||
|
} else {
|
||||||
|
// Paged mode: 3 rows * columns, capped to avoid oversized pages.
|
||||||
|
setPageSize(Math.min(columns * 3, MAX_ITEMS_PER_PAGE));
|
||||||
|
}
|
||||||
|
}, [effectiveViewMode, columns, filteredFiles.length, setPageSize]);
|
||||||
|
|
||||||
|
const { quota, loadQuota } = useQuotaLoader(config);
|
||||||
|
|
||||||
|
const pendingQuotaRefreshRef = useRef(false);
|
||||||
|
const prevFilesLoadingRef = useRef(loading);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
pendingQuotaRefreshRef.current = true;
|
||||||
|
void triggerHeaderRefresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wasLoading = prevFilesLoadingRef.current;
|
||||||
|
prevFilesLoadingRef.current = loading;
|
||||||
|
|
||||||
|
if (!pendingQuotaRefreshRef.current) return;
|
||||||
|
if (loading) return;
|
||||||
|
if (!wasLoading) return;
|
||||||
|
|
||||||
|
pendingQuotaRefreshRef.current = false;
|
||||||
|
const scope = effectiveViewMode === 'all' ? 'all' : 'page';
|
||||||
|
const targets = effectiveViewMode === 'all' ? filteredFiles : pageItems;
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
loadQuota(targets, scope, setLoading);
|
||||||
|
}, [loading, effectiveViewMode, filteredFiles, pageItems, loadQuota, setLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
if (filteredFiles.length === 0) {
|
||||||
|
setQuota({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setQuota((prev) => {
|
||||||
|
const nextState: Record<string, TState> = {};
|
||||||
|
filteredFiles.forEach((file) => {
|
||||||
|
const cached = prev[file.name];
|
||||||
|
if (cached) {
|
||||||
|
nextState[file.name] = cached;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
}, [filteredFiles, loading, setQuota]);
|
||||||
|
|
||||||
|
const titleNode = (
|
||||||
|
<div className={styles.titleWrapper}>
|
||||||
|
<span>{t(`${config.i18nPrefix}.title`)}</span>
|
||||||
|
{filteredFiles.length > 0 && (
|
||||||
|
<span className={styles.countBadge}>
|
||||||
|
{filteredFiles.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRefreshing = sectionLoading || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={titleNode}
|
||||||
|
extra={
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<div className={styles.viewModeToggle}>
|
||||||
|
<Button
|
||||||
|
variant={effectiveViewMode === 'paged' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode('paged')}
|
||||||
|
>
|
||||||
|
{t('auth_files.view_mode_paged')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={effectiveViewMode === 'all' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (filteredFiles.length > MAX_SHOW_ALL_THRESHOLD) {
|
||||||
|
setShowTooManyWarning(true);
|
||||||
|
} else {
|
||||||
|
setViewMode('all');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('auth_files.view_mode_all')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={disabled || isRefreshing}
|
||||||
|
loading={isRefreshing}
|
||||||
|
title={t('quota_management.refresh_files_and_quota')}
|
||||||
|
aria-label={t('quota_management.refresh_files_and_quota')}
|
||||||
|
>
|
||||||
|
{!isRefreshing && <IconRefreshCw size={16} />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filteredFiles.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t(`${config.i18nPrefix}.empty_title`)}
|
||||||
|
description={t(`${config.i18nPrefix}.empty_desc`)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div ref={gridRef} className={config.gridClassName}>
|
||||||
|
{pageItems.map((item) => (
|
||||||
|
<QuotaCard
|
||||||
|
key={item.name}
|
||||||
|
item={item}
|
||||||
|
quota={quota[item.name]}
|
||||||
|
resolvedTheme={resolvedTheme}
|
||||||
|
i18nPrefix={config.i18nPrefix}
|
||||||
|
cardClassName={config.cardClassName}
|
||||||
|
defaultType={config.type}
|
||||||
|
renderQuotaItems={config.renderQuotaItems}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToPrev}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
>
|
||||||
|
{t('auth_files.pagination_prev')}
|
||||||
|
</Button>
|
||||||
|
<div className={styles.pageInfo}>
|
||||||
|
{t('auth_files.pagination_info', {
|
||||||
|
current: currentPage,
|
||||||
|
total: totalPages,
|
||||||
|
count: filteredFiles.length
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToNext}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
>
|
||||||
|
{t('auth_files.pagination_next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showTooManyWarning && (
|
||||||
|
<div className={styles.warningOverlay} onClick={() => setShowTooManyWarning(false)}>
|
||||||
|
<div className={styles.warningModal} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<p>{t('auth_files.too_many_files_warning')}</p>
|
||||||
|
<Button variant="primary" size="sm" onClick={() => setShowTooManyWarning(false)}>
|
||||||
|
{t('common.confirm')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/components/quota/index.ts
Normal file
9
src/components/quota/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Quota components barrel export.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { QuotaSection } from './QuotaSection';
|
||||||
|
export { QuotaCard } from './QuotaCard';
|
||||||
|
export { useQuotaLoader } from './useQuotaLoader';
|
||||||
|
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
|
||||||
|
export type { QuotaConfig } from './quotaConfigs';
|
||||||
604
src/components/quota/quotaConfigs.ts
Normal file
604
src/components/quota/quotaConfigs.ts
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
/**
|
||||||
|
* Quota configuration definitions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type {
|
||||||
|
AntigravityQuotaGroup,
|
||||||
|
AntigravityModelsPayload,
|
||||||
|
AntigravityQuotaState,
|
||||||
|
AuthFileItem,
|
||||||
|
CodexQuotaState,
|
||||||
|
CodexUsageWindow,
|
||||||
|
CodexQuotaWindow,
|
||||||
|
CodexUsagePayload,
|
||||||
|
GeminiCliParsedBucket,
|
||||||
|
GeminiCliQuotaBucketState,
|
||||||
|
GeminiCliQuotaState
|
||||||
|
} from '@/types';
|
||||||
|
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
|
||||||
|
import {
|
||||||
|
ANTIGRAVITY_QUOTA_URLS,
|
||||||
|
ANTIGRAVITY_REQUEST_HEADERS,
|
||||||
|
CODEX_USAGE_URL,
|
||||||
|
CODEX_REQUEST_HEADERS,
|
||||||
|
GEMINI_CLI_QUOTA_URL,
|
||||||
|
GEMINI_CLI_REQUEST_HEADERS,
|
||||||
|
normalizeAuthIndexValue,
|
||||||
|
normalizeNumberValue,
|
||||||
|
normalizePlanType,
|
||||||
|
normalizeQuotaFraction,
|
||||||
|
normalizeStringValue,
|
||||||
|
parseAntigravityPayload,
|
||||||
|
parseCodexUsagePayload,
|
||||||
|
parseGeminiCliQuotaPayload,
|
||||||
|
resolveCodexChatgptAccountId,
|
||||||
|
resolveCodexPlanType,
|
||||||
|
resolveGeminiCliProjectId,
|
||||||
|
formatCodexResetLabel,
|
||||||
|
formatQuotaResetTime,
|
||||||
|
buildAntigravityQuotaGroups,
|
||||||
|
buildGeminiCliQuotaBuckets,
|
||||||
|
createStatusError,
|
||||||
|
getStatusFromError,
|
||||||
|
isAntigravityFile,
|
||||||
|
isCodexFile,
|
||||||
|
isGeminiCliFile,
|
||||||
|
isRuntimeOnlyAuthFile
|
||||||
|
} from '@/utils/quota';
|
||||||
|
import type { QuotaRenderHelpers } from './QuotaCard';
|
||||||
|
import styles from '@/pages/QuotaPage.module.scss';
|
||||||
|
|
||||||
|
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||||
|
|
||||||
|
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
|
||||||
|
|
||||||
|
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
|
||||||
|
|
||||||
|
export interface QuotaStore {
|
||||||
|
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||||
|
codexQuota: Record<string, CodexQuotaState>;
|
||||||
|
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||||
|
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||||
|
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||||
|
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||||
|
clearQuotaCache: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaConfig<TState, TData> {
|
||||||
|
type: QuotaType;
|
||||||
|
i18nPrefix: string;
|
||||||
|
filterFn: (file: AuthFileItem) => boolean;
|
||||||
|
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<TData>;
|
||||||
|
storeSelector: (state: QuotaStore) => Record<string, TState>;
|
||||||
|
storeSetter: keyof QuotaStore;
|
||||||
|
buildLoadingState: () => TState;
|
||||||
|
buildSuccessState: (data: TData) => TState;
|
||||||
|
buildErrorState: (message: string, status?: number) => TState;
|
||||||
|
cardClassName: string;
|
||||||
|
controlsClassName: string;
|
||||||
|
controlClassName: string;
|
||||||
|
gridClassName: string;
|
||||||
|
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAntigravityProjectId = async (file: AuthFileItem): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const text = await authFilesApi.downloadText(file.name);
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
|
const topLevel = normalizeStringValue(parsed.project_id ?? parsed.projectId);
|
||||||
|
if (topLevel) return topLevel;
|
||||||
|
|
||||||
|
const installed =
|
||||||
|
parsed.installed && typeof parsed.installed === 'object' && parsed.installed !== null
|
||||||
|
? (parsed.installed as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const installedProjectId = installed
|
||||||
|
? normalizeStringValue(installed.project_id ?? installed.projectId)
|
||||||
|
: null;
|
||||||
|
if (installedProjectId) return installedProjectId;
|
||||||
|
|
||||||
|
const web =
|
||||||
|
parsed.web && typeof parsed.web === 'object' && parsed.web !== null
|
||||||
|
? (parsed.web as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const webProjectId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null;
|
||||||
|
if (webProjectId) return webProjectId;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAntigravityUnknownFieldError = (message: string): boolean => {
|
||||||
|
const normalized = message.toLowerCase();
|
||||||
|
return normalized.includes('unknown name') && normalized.includes('cannot find field');
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAntigravityQuota = async (
|
||||||
|
file: AuthFileItem,
|
||||||
|
t: TFunction
|
||||||
|
): Promise<AntigravityQuotaGroup[]> => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
if (!authIndex) {
|
||||||
|
throw new Error(t('antigravity_quota.missing_auth_index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = await resolveAntigravityProjectId(file);
|
||||||
|
const requestBodies = [JSON.stringify({ projectId }), JSON.stringify({ project: projectId })];
|
||||||
|
|
||||||
|
let lastError = '';
|
||||||
|
let lastStatus: number | undefined;
|
||||||
|
let priorityStatus: number | undefined;
|
||||||
|
let hadSuccess = false;
|
||||||
|
|
||||||
|
for (const url of ANTIGRAVITY_QUOTA_URLS) {
|
||||||
|
for (let attempt = 0; attempt < requestBodies.length; attempt++) {
|
||||||
|
try {
|
||||||
|
const result = await apiCallApi.request({
|
||||||
|
authIndex,
|
||||||
|
method: 'POST',
|
||||||
|
url,
|
||||||
|
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
||||||
|
data: requestBodies[attempt]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
lastError = getApiCallErrorMessage(result);
|
||||||
|
lastStatus = result.statusCode;
|
||||||
|
if (result.statusCode === 403 || result.statusCode === 404) {
|
||||||
|
priorityStatus ??= result.statusCode;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result.statusCode === 400 &&
|
||||||
|
isAntigravityUnknownFieldError(lastError) &&
|
||||||
|
attempt < requestBodies.length - 1
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
hadSuccess = true;
|
||||||
|
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
|
||||||
|
const models = payload?.models;
|
||||||
|
if (!models || typeof models !== 'object' || Array.isArray(models)) {
|
||||||
|
lastError = t('antigravity_quota.empty_models');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
|
||||||
|
if (groups.length === 0) {
|
||||||
|
lastError = t('antigravity_quota.empty_models');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
lastError = err instanceof Error ? err.message : t('common.unknown_error');
|
||||||
|
const status = getStatusFromError(err);
|
||||||
|
if (status) {
|
||||||
|
lastStatus = status;
|
||||||
|
if (status === 403 || status === 404) {
|
||||||
|
priorityStatus ??= status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadSuccess) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
|
||||||
|
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
|
||||||
|
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
|
||||||
|
const windows: CodexQuotaWindow[] = [];
|
||||||
|
|
||||||
|
const addWindow = (
|
||||||
|
id: string,
|
||||||
|
labelKey: string,
|
||||||
|
window?: CodexUsageWindow | null,
|
||||||
|
limitReached?: boolean,
|
||||||
|
allowed?: boolean
|
||||||
|
) => {
|
||||||
|
if (!window) return;
|
||||||
|
const resetLabel = formatCodexResetLabel(window);
|
||||||
|
const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent);
|
||||||
|
const isLimitReached = Boolean(limitReached) || allowed === false;
|
||||||
|
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
|
||||||
|
windows.push({
|
||||||
|
id,
|
||||||
|
label: t(labelKey),
|
||||||
|
labelKey,
|
||||||
|
usedPercent,
|
||||||
|
resetLabel
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
addWindow(
|
||||||
|
'primary',
|
||||||
|
'codex_quota.primary_window',
|
||||||
|
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
|
||||||
|
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
||||||
|
rateLimit?.allowed
|
||||||
|
);
|
||||||
|
addWindow(
|
||||||
|
'secondary',
|
||||||
|
'codex_quota.secondary_window',
|
||||||
|
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
|
||||||
|
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
||||||
|
rateLimit?.allowed
|
||||||
|
);
|
||||||
|
addWindow(
|
||||||
|
'code-review',
|
||||||
|
'codex_quota.code_review_window',
|
||||||
|
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
|
||||||
|
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
|
||||||
|
codeReviewLimit?.allowed
|
||||||
|
);
|
||||||
|
|
||||||
|
return windows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCodexQuota = async (
|
||||||
|
file: AuthFileItem,
|
||||||
|
t: TFunction
|
||||||
|
): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
if (!authIndex) {
|
||||||
|
throw new Error(t('codex_quota.missing_auth_index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const planTypeFromFile = resolveCodexPlanType(file);
|
||||||
|
const accountId = resolveCodexChatgptAccountId(file);
|
||||||
|
if (!accountId) {
|
||||||
|
throw new Error(t('codex_quota.missing_account_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestHeader: Record<string, string> = {
|
||||||
|
...CODEX_REQUEST_HEADERS,
|
||||||
|
'Chatgpt-Account-Id': accountId
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await apiCallApi.request({
|
||||||
|
authIndex,
|
||||||
|
method: 'GET',
|
||||||
|
url: CODEX_USAGE_URL,
|
||||||
|
header: requestHeader
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseCodexUsagePayload(result.body ?? result.bodyText);
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error(t('codex_quota.empty_windows'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType);
|
||||||
|
const windows = buildCodexQuotaWindows(payload, t);
|
||||||
|
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchGeminiCliQuota = async (
|
||||||
|
file: AuthFileItem,
|
||||||
|
t: TFunction
|
||||||
|
): Promise<GeminiCliQuotaBucketState[]> => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
if (!authIndex) {
|
||||||
|
throw new Error(t('gemini_cli_quota.missing_auth_index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = resolveGeminiCliProjectId(file);
|
||||||
|
if (!projectId) {
|
||||||
|
throw new Error(t('gemini_cli_quota.missing_project_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiCallApi.request({
|
||||||
|
authIndex,
|
||||||
|
method: 'POST',
|
||||||
|
url: GEMINI_CLI_QUOTA_URL,
|
||||||
|
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
||||||
|
data: JSON.stringify({ project: projectId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
|
||||||
|
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
|
||||||
|
if (buckets.length === 0) return [];
|
||||||
|
|
||||||
|
const parsedBuckets = buckets
|
||||||
|
.map((bucket) => {
|
||||||
|
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
|
||||||
|
if (!modelId) return null;
|
||||||
|
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
||||||
|
const remainingFractionRaw = normalizeQuotaFraction(
|
||||||
|
bucket.remainingFraction ?? bucket.remaining_fraction
|
||||||
|
);
|
||||||
|
const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount);
|
||||||
|
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
|
||||||
|
let fallbackFraction: number | null = null;
|
||||||
|
if (remainingAmount !== null) {
|
||||||
|
fallbackFraction = remainingAmount <= 0 ? 0 : null;
|
||||||
|
} else if (resetTime) {
|
||||||
|
fallbackFraction = 0;
|
||||||
|
}
|
||||||
|
const remainingFraction = remainingFractionRaw ?? fallbackFraction;
|
||||||
|
return {
|
||||||
|
modelId,
|
||||||
|
tokenType,
|
||||||
|
remainingFraction,
|
||||||
|
remainingAmount,
|
||||||
|
resetTime
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
||||||
|
|
||||||
|
return buildGeminiCliQuotaBuckets(parsedBuckets);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAntigravityItems = (
|
||||||
|
quota: AntigravityQuotaState,
|
||||||
|
t: TFunction,
|
||||||
|
helpers: QuotaRenderHelpers
|
||||||
|
): ReactNode => {
|
||||||
|
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||||
|
const { createElement: h } = React;
|
||||||
|
const groups = quota.groups ?? [];
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return h('div', { className: styleMap.quotaMessage }, t('antigravity_quota.empty_models'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.map((group) => {
|
||||||
|
const clamped = Math.max(0, Math.min(1, group.remainingFraction));
|
||||||
|
const percent = Math.round(clamped * 100);
|
||||||
|
const resetLabel = formatQuotaResetTime(group.resetTime);
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ key: group.id, className: styleMap.quotaRow },
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaRowHeader },
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ className: styleMap.quotaModel, title: group.models.join(', ') },
|
||||||
|
group.label
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaMeta },
|
||||||
|
h('span', { className: styleMap.quotaPercent }, `${percent}%`),
|
||||||
|
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCodexItems = (
|
||||||
|
quota: CodexQuotaState,
|
||||||
|
t: TFunction,
|
||||||
|
helpers: QuotaRenderHelpers
|
||||||
|
): ReactNode => {
|
||||||
|
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||||
|
const { createElement: h, Fragment } = React;
|
||||||
|
const windows = quota.windows ?? [];
|
||||||
|
const planType = quota.planType ?? null;
|
||||||
|
|
||||||
|
const getPlanLabel = (pt?: string | null): string | null => {
|
||||||
|
const normalized = normalizePlanType(pt);
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (normalized === 'plus') return t('codex_quota.plan_plus');
|
||||||
|
if (normalized === 'team') return t('codex_quota.plan_team');
|
||||||
|
if (normalized === 'free') return t('codex_quota.plan_free');
|
||||||
|
return pt || normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const planLabel = getPlanLabel(planType);
|
||||||
|
const isFreePlan = normalizePlanType(planType) === 'free';
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
|
||||||
|
if (planLabel) {
|
||||||
|
nodes.push(
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ key: 'plan', className: styleMap.codexPlan },
|
||||||
|
h('span', { className: styleMap.codexPlanLabel }, t('codex_quota.plan_label')),
|
||||||
|
h('span', { className: styleMap.codexPlanValue }, planLabel)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFreePlan) {
|
||||||
|
nodes.push(
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ key: 'warning', className: styleMap.quotaWarning },
|
||||||
|
t('codex_quota.no_access')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return h(Fragment, null, ...nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windows.length === 0) {
|
||||||
|
nodes.push(
|
||||||
|
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
|
||||||
|
);
|
||||||
|
return h(Fragment, null, ...nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.push(
|
||||||
|
...windows.map((window) => {
|
||||||
|
const used = window.usedPercent;
|
||||||
|
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
|
||||||
|
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
|
||||||
|
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
|
||||||
|
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ key: window.id, className: styleMap.quotaRow },
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaRowHeader },
|
||||||
|
h('span', { className: styleMap.quotaModel }, windowLabel),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaMeta },
|
||||||
|
h('span', { className: styleMap.quotaPercent }, percentLabel),
|
||||||
|
h('span', { className: styleMap.quotaReset }, window.resetLabel)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return h(Fragment, null, ...nodes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGeminiCliItems = (
|
||||||
|
quota: GeminiCliQuotaState,
|
||||||
|
t: TFunction,
|
||||||
|
helpers: QuotaRenderHelpers
|
||||||
|
): ReactNode => {
|
||||||
|
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||||
|
const { createElement: h } = React;
|
||||||
|
const buckets = quota.buckets ?? [];
|
||||||
|
|
||||||
|
if (buckets.length === 0) {
|
||||||
|
return h('div', { className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets.map((bucket) => {
|
||||||
|
const fraction = bucket.remainingFraction;
|
||||||
|
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
|
||||||
|
const percent = clamped === null ? null : Math.round(clamped * 100);
|
||||||
|
const percentLabel = percent === null ? '--' : `${percent}%`;
|
||||||
|
const remainingAmountLabel =
|
||||||
|
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
||||||
|
? null
|
||||||
|
: t('gemini_cli_quota.remaining_amount', {
|
||||||
|
count: bucket.remainingAmount
|
||||||
|
});
|
||||||
|
const titleBase =
|
||||||
|
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
|
||||||
|
const title = bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase;
|
||||||
|
|
||||||
|
const resetLabel = formatQuotaResetTime(bucket.resetTime);
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ key: bucket.id, className: styleMap.quotaRow },
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaRowHeader },
|
||||||
|
h('span', { className: styleMap.quotaModel, title }, bucket.label),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaMeta },
|
||||||
|
h('span', { className: styleMap.quotaPercent }, percentLabel),
|
||||||
|
remainingAmountLabel
|
||||||
|
? h('span', { className: styleMap.quotaAmount }, remainingAmountLabel)
|
||||||
|
: null,
|
||||||
|
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
|
||||||
|
type: 'antigravity',
|
||||||
|
i18nPrefix: 'antigravity_quota',
|
||||||
|
filterFn: (file) => isAntigravityFile(file),
|
||||||
|
fetchQuota: fetchAntigravityQuota,
|
||||||
|
storeSelector: (state) => state.antigravityQuota,
|
||||||
|
storeSetter: 'setAntigravityQuota',
|
||||||
|
buildLoadingState: () => ({ status: 'loading', groups: [] }),
|
||||||
|
buildSuccessState: (groups) => ({ status: 'success', groups }),
|
||||||
|
buildErrorState: (message, status) => ({
|
||||||
|
status: 'error',
|
||||||
|
groups: [],
|
||||||
|
error: message,
|
||||||
|
errorStatus: status
|
||||||
|
}),
|
||||||
|
cardClassName: styles.antigravityCard,
|
||||||
|
controlsClassName: styles.antigravityControls,
|
||||||
|
controlClassName: styles.antigravityControl,
|
||||||
|
gridClassName: styles.antigravityGrid,
|
||||||
|
renderQuotaItems: renderAntigravityItems
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CODEX_CONFIG: QuotaConfig<
|
||||||
|
CodexQuotaState,
|
||||||
|
{ planType: string | null; windows: CodexQuotaWindow[] }
|
||||||
|
> = {
|
||||||
|
type: 'codex',
|
||||||
|
i18nPrefix: 'codex_quota',
|
||||||
|
filterFn: (file) => isCodexFile(file),
|
||||||
|
fetchQuota: fetchCodexQuota,
|
||||||
|
storeSelector: (state) => state.codexQuota,
|
||||||
|
storeSetter: 'setCodexQuota',
|
||||||
|
buildLoadingState: () => ({ status: 'loading', windows: [] }),
|
||||||
|
buildSuccessState: (data) => ({
|
||||||
|
status: 'success',
|
||||||
|
windows: data.windows,
|
||||||
|
planType: data.planType
|
||||||
|
}),
|
||||||
|
buildErrorState: (message, status) => ({
|
||||||
|
status: 'error',
|
||||||
|
windows: [],
|
||||||
|
error: message,
|
||||||
|
errorStatus: status
|
||||||
|
}),
|
||||||
|
cardClassName: styles.codexCard,
|
||||||
|
controlsClassName: styles.codexControls,
|
||||||
|
controlClassName: styles.codexControl,
|
||||||
|
gridClassName: styles.codexGrid,
|
||||||
|
renderQuotaItems: renderCodexItems
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
||||||
|
type: 'gemini-cli',
|
||||||
|
i18nPrefix: 'gemini_cli_quota',
|
||||||
|
filterFn: (file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file),
|
||||||
|
fetchQuota: fetchGeminiCliQuota,
|
||||||
|
storeSelector: (state) => state.geminiCliQuota,
|
||||||
|
storeSetter: 'setGeminiCliQuota',
|
||||||
|
buildLoadingState: () => ({ status: 'loading', buckets: [] }),
|
||||||
|
buildSuccessState: (buckets) => ({ status: 'success', buckets }),
|
||||||
|
buildErrorState: (message, status) => ({
|
||||||
|
status: 'error',
|
||||||
|
buckets: [],
|
||||||
|
error: message,
|
||||||
|
errorStatus: status
|
||||||
|
}),
|
||||||
|
cardClassName: styles.geminiCliCard,
|
||||||
|
controlsClassName: styles.geminiCliControls,
|
||||||
|
controlClassName: styles.geminiCliControl,
|
||||||
|
gridClassName: styles.geminiCliGrid,
|
||||||
|
renderQuotaItems: renderGeminiCliItems
|
||||||
|
};
|
||||||
40
src/components/quota/useGridColumns.ts
Normal file
40
src/components/quota/useGridColumns.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to calculate the number of grid columns based on container width and item min-width.
|
||||||
|
* Returns [columns, refCallback].
|
||||||
|
*/
|
||||||
|
export function useGridColumns(
|
||||||
|
itemMinWidth: number,
|
||||||
|
gap: number = 16
|
||||||
|
): [number, (node: HTMLDivElement | null) => void] {
|
||||||
|
const [columns, setColumns] = useState(1);
|
||||||
|
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const refCallback = useCallback((node: HTMLDivElement | null) => {
|
||||||
|
setElement(node);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const updateColumns = () => {
|
||||||
|
const containerWidth = element.clientWidth;
|
||||||
|
const effectiveItemWidth = itemMinWidth + gap;
|
||||||
|
const count = Math.floor((containerWidth + gap) / effectiveItemWidth);
|
||||||
|
setColumns(Math.max(1, count));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateColumns();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
updateColumns();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [element, itemMinWidth, gap]);
|
||||||
|
|
||||||
|
return [columns, refCallback];
|
||||||
|
}
|
||||||
98
src/components/quota/useQuotaLoader.ts
Normal file
98
src/components/quota/useQuotaLoader.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Generic hook for quota data fetching and management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { useQuotaStore } from '@/stores';
|
||||||
|
import { getStatusFromError } from '@/utils/quota';
|
||||||
|
import type { QuotaConfig } from './quotaConfigs';
|
||||||
|
|
||||||
|
type QuotaScope = 'page' | 'all';
|
||||||
|
|
||||||
|
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||||
|
|
||||||
|
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
||||||
|
|
||||||
|
interface LoadQuotaResult<TData> {
|
||||||
|
name: string;
|
||||||
|
status: 'success' | 'error';
|
||||||
|
data?: TData;
|
||||||
|
error?: string;
|
||||||
|
errorStatus?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQuotaLoader<TState, TData>(config: QuotaConfig<TState, TData>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const quota = useQuotaStore(config.storeSelector);
|
||||||
|
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
|
||||||
|
Record<string, TState>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
|
||||||
|
const loadQuota = useCallback(
|
||||||
|
async (
|
||||||
|
targets: AuthFileItem[],
|
||||||
|
scope: QuotaScope,
|
||||||
|
setLoading: (loading: boolean, scope?: QuotaScope | null) => void
|
||||||
|
) => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
|
const requestId = ++requestIdRef.current;
|
||||||
|
setLoading(true, scope);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
|
||||||
|
setQuota((prev) => {
|
||||||
|
const nextState = { ...prev };
|
||||||
|
targets.forEach((file) => {
|
||||||
|
nextState[file.name] = config.buildLoadingState();
|
||||||
|
});
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
targets.map(async (file): Promise<LoadQuotaResult<TData>> => {
|
||||||
|
try {
|
||||||
|
const data = await config.fetchQuota(file, t);
|
||||||
|
return { name: file.name, status: 'success', data };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
||||||
|
const errorStatus = getStatusFromError(err);
|
||||||
|
return { name: file.name, status: 'error', error: message, errorStatus };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requestId !== requestIdRef.current) return;
|
||||||
|
|
||||||
|
setQuota((prev) => {
|
||||||
|
const nextState = { ...prev };
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.status === 'success') {
|
||||||
|
nextState[result.name] = config.buildSuccessState(result.data as TData);
|
||||||
|
} else {
|
||||||
|
nextState[result.name] = config.buildErrorState(
|
||||||
|
result.error || t('common.unknown_error'),
|
||||||
|
result.errorStatus
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (requestId === requestIdRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
loadingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config, setQuota, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { quota, loadQuota };
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export function Button({
|
|||||||
disabled,
|
disabled,
|
||||||
...rest
|
...rest
|
||||||
}: PropsWithChildren<ButtonProps>) {
|
}: PropsWithChildren<ButtonProps>) {
|
||||||
|
const hasChildren = children !== null && children !== undefined && children !== false;
|
||||||
const classes = [
|
const classes = [
|
||||||
'btn',
|
'btn',
|
||||||
`btn-${variant}`,
|
`btn-${variant}`,
|
||||||
@@ -33,7 +34,7 @@ export function Button({
|
|||||||
return (
|
return (
|
||||||
<button className={classes} disabled={disabled || loading} {...rest}>
|
<button className={classes} disabled={disabled || loading} {...rest}>
|
||||||
{loading && <span className="loading-spinner" aria-hidden="true" />}
|
{loading && <span className="loading-spinner" aria-hidden="true" />}
|
||||||
<span>{children}</span>
|
{hasChildren && <span>{children}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PropsWithChildren, ReactNode } from 'react';
|
import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { IconX } from './icons';
|
import { IconX } from './icons';
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
@@ -7,29 +8,134 @@ interface ModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
width?: number | string;
|
width?: number | string;
|
||||||
|
closeDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
const CLOSE_ANIMATION_DURATION = 350;
|
||||||
if (!open) return null;
|
const MODAL_LOCK_CLASS = 'modal-open';
|
||||||
|
let activeModalCount = 0;
|
||||||
|
|
||||||
const handleMaskClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
const lockScroll = () => {
|
||||||
if (event.target === event.currentTarget) {
|
if (typeof document === 'undefined') return;
|
||||||
onClose();
|
if (activeModalCount === 0) {
|
||||||
|
document.body?.classList.add(MODAL_LOCK_CLASS);
|
||||||
|
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
|
||||||
|
}
|
||||||
|
activeModalCount += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlockScroll = () => {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
activeModalCount = Math.max(0, activeModalCount - 1);
|
||||||
|
if (activeModalCount === 0) {
|
||||||
|
document.body?.classList.remove(MODAL_LOCK_CLASS);
|
||||||
|
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
footer,
|
||||||
|
width = 520,
|
||||||
|
closeDisabled = false,
|
||||||
|
children
|
||||||
|
}: PropsWithChildren<ModalProps>) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const startClose = useCallback(
|
||||||
|
(notifyParent: boolean) => {
|
||||||
|
if (closeTimerRef.current !== null) return;
|
||||||
|
setIsClosing(true);
|
||||||
|
closeTimerRef.current = window.setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
setIsClosing(false);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
if (notifyParent) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, CLOSE_ANIMATION_DURATION);
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
if (closeTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(closeTimerRef.current);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setIsVisible(true);
|
||||||
|
setIsClosing(false);
|
||||||
|
});
|
||||||
|
} else if (isVisible) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
startClose(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return () => {
|
||||||
<div className="modal-overlay" onClick={handleMaskClick}>
|
cancelled = true;
|
||||||
<div className="modal" style={{ width }} role="dialog" aria-modal="true">
|
};
|
||||||
|
}, [open, isVisible, startClose]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
startClose(true);
|
||||||
|
}, [startClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (closeTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(closeTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shouldLockScroll = open || isVisible;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldLockScroll) return;
|
||||||
|
lockScroll();
|
||||||
|
return () => unlockScroll();
|
||||||
|
}, [shouldLockScroll]);
|
||||||
|
|
||||||
|
if (!open && !isVisible) return null;
|
||||||
|
|
||||||
|
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
||||||
|
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
|
||||||
|
|
||||||
|
const modalContent = (
|
||||||
|
<div className={overlayClass}>
|
||||||
|
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-close-floating"
|
||||||
|
onClick={closeDisabled ? undefined : handleClose}
|
||||||
|
aria-label="Close"
|
||||||
|
disabled={closeDisabled}
|
||||||
|
>
|
||||||
|
<IconX size={20} />
|
||||||
|
</button>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<div className="modal-title">{title}</div>
|
<div className="modal-title">{title}</div>
|
||||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
|
||||||
<IconX size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">{children}</div>
|
<div className="modal-body">{children}</div>
|
||||||
{footer && <div className="modal-footer">{footer}</div>}
|
{footer && <div className="modal-footer">{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return modalContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/components/usage/ApiDetailsCard.tsx
Normal file
79
src/components/usage/ApiDetailsCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface ApiDetailsCardProps {
|
||||||
|
apiStats: ApiStats[];
|
||||||
|
loading: boolean;
|
||||||
|
hasPrices: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleExpand = (endpoint: string) => {
|
||||||
|
setExpandedApis((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(endpoint)) {
|
||||||
|
newSet.delete(endpoint);
|
||||||
|
} else {
|
||||||
|
newSet.add(endpoint);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={t('usage_stats.api_details')}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
|
) : apiStats.length > 0 ? (
|
||||||
|
<div className={styles.apiList}>
|
||||||
|
{apiStats.map((api) => (
|
||||||
|
<div key={api.endpoint} className={styles.apiItem}>
|
||||||
|
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}>
|
||||||
|
<div className={styles.apiInfo}>
|
||||||
|
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
||||||
|
<div className={styles.apiStats}>
|
||||||
|
<span className={styles.apiBadge}>
|
||||||
|
{t('usage_stats.requests_count')}: {api.totalRequests}
|
||||||
|
</span>
|
||||||
|
<span className={styles.apiBadge}>
|
||||||
|
Tokens: {formatTokensInMillions(api.totalTokens)}
|
||||||
|
</span>
|
||||||
|
{hasPrices && api.totalCost > 0 && (
|
||||||
|
<span className={styles.apiBadge}>
|
||||||
|
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={styles.expandIcon}>
|
||||||
|
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{expandedApis.has(api.endpoint) && (
|
||||||
|
<div className={styles.apiModels}>
|
||||||
|
{Object.entries(api.models).map(([model, stats]) => (
|
||||||
|
<div key={model} className={styles.modelRow}>
|
||||||
|
<span className={styles.modelName}>{model}</span>
|
||||||
|
<span className={styles.modelStat}>
|
||||||
|
{stats.requests} {t('usage_stats.requests_count')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/usage/ChartLineSelector.tsx
Normal file
92
src/components/usage/ChartLineSelector.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface ChartLineSelectorProps {
|
||||||
|
chartLines: string[];
|
||||||
|
modelNames: string[];
|
||||||
|
maxLines?: number;
|
||||||
|
onChange: (lines: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartLineSelector({
|
||||||
|
chartLines,
|
||||||
|
modelNames,
|
||||||
|
maxLines = 9,
|
||||||
|
onChange
|
||||||
|
}: ChartLineSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (chartLines.length >= maxLines) return;
|
||||||
|
const unusedModel = modelNames.find((m) => !chartLines.includes(m));
|
||||||
|
if (unusedModel) {
|
||||||
|
onChange([...chartLines, unusedModel]);
|
||||||
|
} else {
|
||||||
|
onChange([...chartLines, 'all']);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (index: number) => {
|
||||||
|
if (chartLines.length <= 1) return;
|
||||||
|
const newLines = [...chartLines];
|
||||||
|
newLines.splice(index, 1);
|
||||||
|
onChange(newLines);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (index: number, value: string) => {
|
||||||
|
const newLines = [...chartLines];
|
||||||
|
newLines[index] = value;
|
||||||
|
onChange(newLines);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t('usage_stats.chart_line_actions_label')}
|
||||||
|
extra={
|
||||||
|
<div className={styles.chartLineHeader}>
|
||||||
|
<span className={styles.chartLineCount}>
|
||||||
|
{chartLines.length}/{maxLines}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={chartLines.length >= maxLines}
|
||||||
|
>
|
||||||
|
{t('usage_stats.chart_line_add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.chartLineList}>
|
||||||
|
{chartLines.map((line, index) => (
|
||||||
|
<div key={index} className={styles.chartLineItem}>
|
||||||
|
<span className={styles.chartLineLabel}>
|
||||||
|
{t(`usage_stats.chart_line_label_${index + 1}`)}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={line}
|
||||||
|
onChange={(e) => handleChange(index, e.target.value)}
|
||||||
|
className={styles.select}
|
||||||
|
>
|
||||||
|
<option value="all">{t('usage_stats.chart_line_all')}</option>
|
||||||
|
{modelNames.map((name) => (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{chartLines.length > 1 && (
|
||||||
|
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
|
||||||
|
{t('usage_stats.chart_line_delete')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className={styles.chartLineHint}>{t('usage_stats.chart_line_hint')}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/usage/ModelStatsCard.tsx
Normal file
54
src/components/usage/ModelStatsCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { formatTokensInMillions, formatUsd } from '@/utils/usage';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface ModelStat {
|
||||||
|
model: string;
|
||||||
|
requests: number;
|
||||||
|
tokens: number;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelStatsCardProps {
|
||||||
|
modelStats: ModelStat[];
|
||||||
|
loading: boolean;
|
||||||
|
hasPrices: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={t('usage_stats.models')}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
|
) : modelStats.length > 0 ? (
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('usage_stats.model_name')}</th>
|
||||||
|
<th>{t('usage_stats.requests_count')}</th>
|
||||||
|
<th>{t('usage_stats.tokens_count')}</th>
|
||||||
|
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{modelStats.map((stat) => (
|
||||||
|
<tr key={stat.model}>
|
||||||
|
<td className={styles.modelCell}>{stat.model}</td>
|
||||||
|
<td>{stat.requests.toLocaleString()}</td>
|
||||||
|
<td>{formatTokensInMillions(stat.tokens)}</td>
|
||||||
|
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/components/usage/PriceSettingsCard.tsx
Normal file
164
src/components/usage/PriceSettingsCard.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import type { ModelPrice } from '@/utils/usage';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface PriceSettingsCardProps {
|
||||||
|
modelNames: string[];
|
||||||
|
modelPrices: Record<string, ModelPrice>;
|
||||||
|
onPricesChange: (prices: Record<string, ModelPrice>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceSettingsCard({
|
||||||
|
modelNames,
|
||||||
|
modelPrices,
|
||||||
|
onPricesChange
|
||||||
|
}: PriceSettingsCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
|
const [promptPrice, setPromptPrice] = useState('');
|
||||||
|
const [completionPrice, setCompletionPrice] = useState('');
|
||||||
|
const [cachePrice, setCachePrice] = useState('');
|
||||||
|
|
||||||
|
const handleSavePrice = () => {
|
||||||
|
if (!selectedModel) return;
|
||||||
|
const prompt = parseFloat(promptPrice) || 0;
|
||||||
|
const completion = parseFloat(completionPrice) || 0;
|
||||||
|
const cache = cachePrice.trim() === '' ? prompt : parseFloat(cachePrice) || 0;
|
||||||
|
const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion, cache } };
|
||||||
|
onPricesChange(newPrices);
|
||||||
|
setSelectedModel('');
|
||||||
|
setPromptPrice('');
|
||||||
|
setCompletionPrice('');
|
||||||
|
setCachePrice('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePrice = (model: string) => {
|
||||||
|
const newPrices = { ...modelPrices };
|
||||||
|
delete newPrices[model];
|
||||||
|
onPricesChange(newPrices);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditPrice = (model: string) => {
|
||||||
|
const price = modelPrices[model];
|
||||||
|
setSelectedModel(model);
|
||||||
|
setPromptPrice(price?.prompt?.toString() || '');
|
||||||
|
setCompletionPrice(price?.completion?.toString() || '');
|
||||||
|
setCachePrice(price?.cache?.toString() || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelSelect = (value: string) => {
|
||||||
|
setSelectedModel(value);
|
||||||
|
const price = modelPrices[value];
|
||||||
|
if (price) {
|
||||||
|
setPromptPrice(price.prompt.toString());
|
||||||
|
setCompletionPrice(price.completion.toString());
|
||||||
|
setCachePrice(price.cache.toString());
|
||||||
|
} else {
|
||||||
|
setPromptPrice('');
|
||||||
|
setCompletionPrice('');
|
||||||
|
setCachePrice('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={t('usage_stats.model_price_settings')}>
|
||||||
|
<div className={styles.pricingSection}>
|
||||||
|
{/* Price Form */}
|
||||||
|
<div className={styles.priceForm}>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_name')}</label>
|
||||||
|
<select
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => handleModelSelect(e.target.value)}
|
||||||
|
className={styles.select}
|
||||||
|
>
|
||||||
|
<option value="">{t('usage_stats.model_price_select_placeholder')}</option>
|
||||||
|
{modelNames.map((name) => (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={promptPrice}
|
||||||
|
onChange={(e) => setPromptPrice(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.0001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={completionPrice}
|
||||||
|
onChange={(e) => setCompletionPrice(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.0001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_price_cache')} ($/1M)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={cachePrice}
|
||||||
|
onChange={(e) => setCachePrice(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.0001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={handleSavePrice} disabled={!selectedModel}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Saved Prices List */}
|
||||||
|
<div className={styles.pricesList}>
|
||||||
|
<h4 className={styles.pricesTitle}>{t('usage_stats.saved_prices')}</h4>
|
||||||
|
{Object.keys(modelPrices).length > 0 ? (
|
||||||
|
<div className={styles.pricesGrid}>
|
||||||
|
{Object.entries(modelPrices).map(([model, price]) => (
|
||||||
|
<div key={model} className={styles.priceItem}>
|
||||||
|
<div className={styles.priceInfo}>
|
||||||
|
<span className={styles.priceModel}>{model}</span>
|
||||||
|
<div className={styles.priceMeta}>
|
||||||
|
<span>
|
||||||
|
{t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('usage_stats.model_price_cache')}: ${price.cache.toFixed(4)}/1M
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.priceActions}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => handleEditPrice(model)}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.model_price_empty')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
src/components/usage/StatCards.tsx
Normal file
184
src/components/usage/StatCards.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||||
|
import {
|
||||||
|
formatTokensInMillions,
|
||||||
|
formatPerMinuteValue,
|
||||||
|
formatUsd,
|
||||||
|
calculateTokenBreakdown,
|
||||||
|
calculateRecentPerMinuteRates,
|
||||||
|
calculateTotalCost,
|
||||||
|
type ModelPrice
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import { sparklineOptions } from '@/utils/usage/chartConfig';
|
||||||
|
import type { UsagePayload } from './hooks/useUsageData';
|
||||||
|
import type { SparklineBundle } from './hooks/useSparklines';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
interface StatCardData {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
accent: string;
|
||||||
|
accentSoft: string;
|
||||||
|
accentBorder: string;
|
||||||
|
value: string;
|
||||||
|
meta?: ReactNode;
|
||||||
|
trend: SparklineBundle | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatCardsProps {
|
||||||
|
usage: UsagePayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
modelPrices: Record<string, ModelPrice>;
|
||||||
|
sparklines: {
|
||||||
|
requests: SparklineBundle | null;
|
||||||
|
tokens: SparklineBundle | null;
|
||||||
|
rpm: SparklineBundle | null;
|
||||||
|
tpm: SparklineBundle | null;
|
||||||
|
cost: SparklineBundle | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCards({ usage, loading, modelPrices, sparklines }: StatCardsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
||||||
|
const rateStats = usage
|
||||||
|
? calculateRecentPerMinuteRates(30, usage)
|
||||||
|
: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 };
|
||||||
|
const totalCost = usage ? calculateTotalCost(usage, modelPrices) : 0;
|
||||||
|
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||||
|
|
||||||
|
const statsCards: StatCardData[] = [
|
||||||
|
{
|
||||||
|
key: 'requests',
|
||||||
|
label: t('usage_stats.total_requests'),
|
||||||
|
icon: <IconSatellite size={16} />,
|
||||||
|
accent: '#3b82f6',
|
||||||
|
accentSoft: 'rgba(59, 130, 246, 0.18)',
|
||||||
|
accentBorder: 'rgba(59, 130, 246, 0.35)',
|
||||||
|
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
|
||||||
|
meta: (
|
||||||
|
<>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
<span className={styles.statMetaDot} style={{ backgroundColor: '#10b981' }} />
|
||||||
|
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
<span className={styles.statMetaDot} style={{ backgroundColor: '#ef4444' }} />
|
||||||
|
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
trend: sparklines.requests
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tokens',
|
||||||
|
label: t('usage_stats.total_tokens'),
|
||||||
|
icon: <IconDiamond size={16} />,
|
||||||
|
accent: '#8b5cf6',
|
||||||
|
accentSoft: 'rgba(139, 92, 246, 0.18)',
|
||||||
|
accentBorder: 'rgba(139, 92, 246, 0.35)',
|
||||||
|
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
|
||||||
|
meta: (
|
||||||
|
<>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
trend: sparklines.tokens
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rpm',
|
||||||
|
label: t('usage_stats.rpm_30m'),
|
||||||
|
icon: <IconTimer size={16} />,
|
||||||
|
accent: '#22c55e',
|
||||||
|
accentSoft: 'rgba(34, 197, 94, 0.18)',
|
||||||
|
accentBorder: 'rgba(34, 197, 94, 0.32)',
|
||||||
|
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
|
||||||
|
meta: (
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
trend: sparklines.rpm
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tpm',
|
||||||
|
label: t('usage_stats.tpm_30m'),
|
||||||
|
icon: <IconTrendingUp size={16} />,
|
||||||
|
accent: '#f97316',
|
||||||
|
accentSoft: 'rgba(249, 115, 22, 0.18)',
|
||||||
|
accentBorder: 'rgba(249, 115, 22, 0.32)',
|
||||||
|
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
|
||||||
|
meta: (
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
trend: sparklines.tpm
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cost',
|
||||||
|
label: t('usage_stats.total_cost'),
|
||||||
|
icon: <IconDollarSign size={16} />,
|
||||||
|
accent: '#f59e0b',
|
||||||
|
accentSoft: 'rgba(245, 158, 11, 0.18)',
|
||||||
|
accentBorder: 'rgba(245, 158, 11, 0.32)',
|
||||||
|
value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--',
|
||||||
|
meta: (
|
||||||
|
<>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
|
||||||
|
</span>
|
||||||
|
{!hasPrices && (
|
||||||
|
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
|
||||||
|
{t('usage_stats.cost_need_price')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
trend: hasPrices ? sparklines.cost : null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statsGrid}>
|
||||||
|
{statsCards.map((card) => (
|
||||||
|
<div
|
||||||
|
key={card.key}
|
||||||
|
className={styles.statCard}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--accent': card.accent,
|
||||||
|
'--accent-soft': card.accentSoft,
|
||||||
|
'--accent-border': card.accentBorder
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.statCardHeader}>
|
||||||
|
<div className={styles.statLabelGroup}>
|
||||||
|
<span className={styles.statLabel}>{card.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.statIconBadge}>{card.icon}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statValue}>{card.value}</div>
|
||||||
|
{card.meta && <div className={styles.statMetaRow}>{card.meta}</div>}
|
||||||
|
<div className={styles.statTrend}>
|
||||||
|
{card.trend ? (
|
||||||
|
<Line className={styles.sparkline} data={card.trend.data} options={sparklineOptions} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.statTrendPlaceholder}></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/usage/UsageChart.tsx
Normal file
91
src/components/usage/UsageChart.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { ChartOptions } from 'chart.js';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import type { ChartData } from '@/utils/usage';
|
||||||
|
import { getHourChartMinWidth } from '@/utils/usage/chartConfig';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface UsageChartProps {
|
||||||
|
title: string;
|
||||||
|
period: 'hour' | 'day';
|
||||||
|
onPeriodChange: (period: 'hour' | 'day') => void;
|
||||||
|
chartData: ChartData;
|
||||||
|
chartOptions: ChartOptions<'line'>;
|
||||||
|
loading: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
emptyText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsageChart({
|
||||||
|
title,
|
||||||
|
period,
|
||||||
|
onPeriodChange,
|
||||||
|
chartData,
|
||||||
|
chartOptions,
|
||||||
|
loading,
|
||||||
|
isMobile,
|
||||||
|
emptyText
|
||||||
|
}: UsageChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={title}
|
||||||
|
extra={
|
||||||
|
<div className={styles.periodButtons}>
|
||||||
|
<Button
|
||||||
|
variant={period === 'hour' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPeriodChange('hour')}
|
||||||
|
>
|
||||||
|
{t('usage_stats.by_hour')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={period === 'day' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPeriodChange('day')}
|
||||||
|
>
|
||||||
|
{t('usage_stats.by_day')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
|
) : chartData.labels.length > 0 ? (
|
||||||
|
<div className={styles.chartWrapper}>
|
||||||
|
<div className={styles.chartLegend} aria-label="Chart legend">
|
||||||
|
{chartData.datasets.map((dataset, index) => (
|
||||||
|
<div
|
||||||
|
key={`${dataset.label}-${index}`}
|
||||||
|
className={styles.legendItem}
|
||||||
|
title={dataset.label}
|
||||||
|
>
|
||||||
|
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
|
||||||
|
<span className={styles.legendLabel}>{dataset.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartArea}>
|
||||||
|
<div className={styles.chartScroller}>
|
||||||
|
<div
|
||||||
|
className={styles.chartCanvas}
|
||||||
|
style={
|
||||||
|
period === 'hour'
|
||||||
|
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Line data={chartData} options={chartOptions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.hint}>{emptyText}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/components/usage/hooks/index.ts
Normal file
8
src/components/usage/hooks/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { useUsageData } from './useUsageData';
|
||||||
|
export type { UsagePayload, UseUsageDataReturn } from './useUsageData';
|
||||||
|
|
||||||
|
export { useSparklines } from './useSparklines';
|
||||||
|
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './useSparklines';
|
||||||
|
|
||||||
|
export { useChartData } from './useChartData';
|
||||||
|
export type { UseChartDataOptions, UseChartDataReturn } from './useChartData';
|
||||||
76
src/components/usage/hooks/useChartData.ts
Normal file
76
src/components/usage/hooks/useChartData.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import type { ChartOptions } from 'chart.js';
|
||||||
|
import { buildChartData, type ChartData } from '@/utils/usage';
|
||||||
|
import { buildChartOptions } from '@/utils/usage/chartConfig';
|
||||||
|
import type { UsagePayload } from './useUsageData';
|
||||||
|
|
||||||
|
export interface UseChartDataOptions {
|
||||||
|
usage: UsagePayload | null;
|
||||||
|
chartLines: string[];
|
||||||
|
isDark: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseChartDataReturn {
|
||||||
|
requestsPeriod: 'hour' | 'day';
|
||||||
|
setRequestsPeriod: (period: 'hour' | 'day') => void;
|
||||||
|
tokensPeriod: 'hour' | 'day';
|
||||||
|
setTokensPeriod: (period: 'hour' | 'day') => void;
|
||||||
|
requestsChartData: ChartData;
|
||||||
|
tokensChartData: ChartData;
|
||||||
|
requestsChartOptions: ChartOptions<'line'>;
|
||||||
|
tokensChartOptions: ChartOptions<'line'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChartData({
|
||||||
|
usage,
|
||||||
|
chartLines,
|
||||||
|
isDark,
|
||||||
|
isMobile
|
||||||
|
}: UseChartDataOptions): UseChartDataReturn {
|
||||||
|
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
|
||||||
|
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
|
||||||
|
|
||||||
|
const requestsChartData = useMemo(() => {
|
||||||
|
if (!usage) return { labels: [], datasets: [] };
|
||||||
|
return buildChartData(usage, requestsPeriod, 'requests', chartLines);
|
||||||
|
}, [usage, requestsPeriod, chartLines]);
|
||||||
|
|
||||||
|
const tokensChartData = useMemo(() => {
|
||||||
|
if (!usage) return { labels: [], datasets: [] };
|
||||||
|
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
|
||||||
|
}, [usage, tokensPeriod, chartLines]);
|
||||||
|
|
||||||
|
const requestsChartOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
buildChartOptions({
|
||||||
|
period: requestsPeriod,
|
||||||
|
labels: requestsChartData.labels,
|
||||||
|
isDark,
|
||||||
|
isMobile
|
||||||
|
}),
|
||||||
|
[requestsPeriod, requestsChartData.labels, isDark, isMobile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokensChartOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
buildChartOptions({
|
||||||
|
period: tokensPeriod,
|
||||||
|
labels: tokensChartData.labels,
|
||||||
|
isDark,
|
||||||
|
isMobile
|
||||||
|
}),
|
||||||
|
[tokensPeriod, tokensChartData.labels, isDark, isMobile]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestsPeriod,
|
||||||
|
setRequestsPeriod,
|
||||||
|
tokensPeriod,
|
||||||
|
setTokensPeriod,
|
||||||
|
requestsChartData,
|
||||||
|
tokensChartData,
|
||||||
|
requestsChartOptions,
|
||||||
|
tokensChartOptions
|
||||||
|
};
|
||||||
|
}
|
||||||
138
src/components/usage/hooks/useSparklines.ts
Normal file
138
src/components/usage/hooks/useSparklines.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { collectUsageDetails, extractTotalTokens } from '@/utils/usage';
|
||||||
|
import type { UsagePayload } from './useUsageData';
|
||||||
|
|
||||||
|
export interface SparklineData {
|
||||||
|
labels: string[];
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: number[];
|
||||||
|
borderColor: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
fill: boolean;
|
||||||
|
tension: number;
|
||||||
|
pointRadius: number;
|
||||||
|
borderWidth: number;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SparklineBundle {
|
||||||
|
data: SparklineData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSparklinesOptions {
|
||||||
|
usage: UsagePayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSparklinesReturn {
|
||||||
|
requestsSparkline: SparklineBundle | null;
|
||||||
|
tokensSparkline: SparklineBundle | null;
|
||||||
|
rpmSparkline: SparklineBundle | null;
|
||||||
|
tpmSparkline: SparklineBundle | null;
|
||||||
|
costSparkline: SparklineBundle | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSparklinesReturn {
|
||||||
|
const buildLastHourSeries = useCallback(
|
||||||
|
(metric: 'requests' | 'tokens'): { labels: string[]; data: number[] } => {
|
||||||
|
if (!usage) return { labels: [], data: [] };
|
||||||
|
const details = collectUsageDetails(usage);
|
||||||
|
if (!details.length) return { labels: [], data: [] };
|
||||||
|
|
||||||
|
const windowMinutes = 60;
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - windowMinutes * 60 * 1000;
|
||||||
|
const buckets = new Array(windowMinutes).fill(0);
|
||||||
|
|
||||||
|
details.forEach((detail) => {
|
||||||
|
const timestamp = Date.parse(detail.timestamp);
|
||||||
|
if (Number.isNaN(timestamp) || timestamp < windowStart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const minuteIndex = Math.min(
|
||||||
|
windowMinutes - 1,
|
||||||
|
Math.floor((timestamp - windowStart) / 60000)
|
||||||
|
);
|
||||||
|
const increment = metric === 'tokens' ? extractTotalTokens(detail) : 1;
|
||||||
|
buckets[minuteIndex] += increment;
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = buckets.map((_, idx) => {
|
||||||
|
const date = new Date(windowStart + (idx + 1) * 60000);
|
||||||
|
const h = date.getHours().toString().padStart(2, '0');
|
||||||
|
const m = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${h}:${m}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { labels, data: buckets };
|
||||||
|
},
|
||||||
|
[usage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildSparkline = useCallback(
|
||||||
|
(
|
||||||
|
series: { labels: string[]; data: number[] },
|
||||||
|
color: string,
|
||||||
|
backgroundColor: string
|
||||||
|
): SparklineBundle | null => {
|
||||||
|
if (loading || !series?.data?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sliceStart = Math.max(series.data.length - 60, 0);
|
||||||
|
const labels = series.labels.slice(sliceStart);
|
||||||
|
const points = series.data.slice(sliceStart);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: points,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.45,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[loading]
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestsSparkline = useMemo(
|
||||||
|
() => buildSparkline(buildLastHourSeries('requests'), '#3b82f6', 'rgba(59, 130, 246, 0.18)'),
|
||||||
|
[buildLastHourSeries, buildSparkline]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokensSparkline = useMemo(
|
||||||
|
() => buildSparkline(buildLastHourSeries('tokens'), '#8b5cf6', 'rgba(139, 92, 246, 0.18)'),
|
||||||
|
[buildLastHourSeries, buildSparkline]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rpmSparkline = useMemo(
|
||||||
|
() => buildSparkline(buildLastHourSeries('requests'), '#22c55e', 'rgba(34, 197, 94, 0.18)'),
|
||||||
|
[buildLastHourSeries, buildSparkline]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tpmSparkline = useMemo(
|
||||||
|
() => buildSparkline(buildLastHourSeries('tokens'), '#f97316', 'rgba(249, 115, 22, 0.18)'),
|
||||||
|
[buildLastHourSeries, buildSparkline]
|
||||||
|
);
|
||||||
|
|
||||||
|
const costSparkline = useMemo(
|
||||||
|
() => buildSparkline(buildLastHourSeries('tokens'), '#f59e0b', 'rgba(245, 158, 11, 0.18)'),
|
||||||
|
[buildLastHourSeries, buildSparkline]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestsSparkline,
|
||||||
|
tokensSparkline,
|
||||||
|
rpmSparkline,
|
||||||
|
tpmSparkline,
|
||||||
|
costSparkline
|
||||||
|
};
|
||||||
|
}
|
||||||
153
src/components/usage/hooks/useUsageData.ts
Normal file
153
src/components/usage/hooks/useUsageData.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import { usageApi } from '@/services/api/usage';
|
||||||
|
import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage';
|
||||||
|
|
||||||
|
export interface UsagePayload {
|
||||||
|
total_requests?: number;
|
||||||
|
success_count?: number;
|
||||||
|
failure_count?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
apis?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseUsageDataReturn {
|
||||||
|
usage: UsagePayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
modelPrices: Record<string, ModelPrice>;
|
||||||
|
setModelPrices: (prices: Record<string, ModelPrice>) => void;
|
||||||
|
loadUsage: () => Promise<void>;
|
||||||
|
handleExport: () => Promise<void>;
|
||||||
|
handleImport: () => void;
|
||||||
|
handleImportChange: (event: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
|
||||||
|
importInputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
exporting: boolean;
|
||||||
|
importing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUsageData(): UseUsageDataReturn {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
|
|
||||||
|
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const loadUsage = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await usageApi.getUsage();
|
||||||
|
const payload = data?.usage ?? data;
|
||||||
|
setUsage(payload);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsage();
|
||||||
|
setModelPrices(loadModelPrices());
|
||||||
|
}, [loadUsage]);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const data = await usageApi.exportUsage();
|
||||||
|
const exportedAt =
|
||||||
|
typeof data?.exported_at === 'string' ? new Date(data.exported_at) : new Date();
|
||||||
|
const safeTimestamp = Number.isNaN(exportedAt.getTime())
|
||||||
|
? new Date().toISOString()
|
||||||
|
: exportedAt.toISOString();
|
||||||
|
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
|
||||||
|
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
showNotification(t('usage_stats.export_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
importInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
showNotification(t('usage_stats.import_invalid'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await usageApi.importUsage(payload);
|
||||||
|
showNotification(
|
||||||
|
t('usage_stats.import_success', {
|
||||||
|
added: result?.added ?? 0,
|
||||||
|
skipped: result?.skipped ?? 0,
|
||||||
|
total: result?.total_requests ?? 0,
|
||||||
|
failed: result?.failed_requests ?? 0
|
||||||
|
}),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
await loadUsage();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.upload_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetModelPrices = useCallback((prices: Record<string, ModelPrice>) => {
|
||||||
|
setModelPrices(prices);
|
||||||
|
saveModelPrices(prices);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
usage,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
modelPrices,
|
||||||
|
setModelPrices: handleSetModelPrices,
|
||||||
|
loadUsage,
|
||||||
|
handleExport,
|
||||||
|
handleImport,
|
||||||
|
handleImportChange,
|
||||||
|
importInputRef,
|
||||||
|
exporting,
|
||||||
|
importing
|
||||||
|
};
|
||||||
|
}
|
||||||
28
src/components/usage/index.ts
Normal file
28
src/components/usage/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Hooks
|
||||||
|
export { useUsageData } from './hooks/useUsageData';
|
||||||
|
export type { UsagePayload, UseUsageDataReturn } from './hooks/useUsageData';
|
||||||
|
|
||||||
|
export { useSparklines } from './hooks/useSparklines';
|
||||||
|
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './hooks/useSparklines';
|
||||||
|
|
||||||
|
export { useChartData } from './hooks/useChartData';
|
||||||
|
export type { UseChartDataOptions, UseChartDataReturn } from './hooks/useChartData';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export { StatCards } from './StatCards';
|
||||||
|
export type { StatCardsProps } from './StatCards';
|
||||||
|
|
||||||
|
export { UsageChart } from './UsageChart';
|
||||||
|
export type { UsageChartProps } from './UsageChart';
|
||||||
|
|
||||||
|
export { ChartLineSelector } from './ChartLineSelector';
|
||||||
|
export type { ChartLineSelectorProps } from './ChartLineSelector';
|
||||||
|
|
||||||
|
export { ApiDetailsCard } from './ApiDetailsCard';
|
||||||
|
export type { ApiDetailsCardProps } from './ApiDetailsCard';
|
||||||
|
|
||||||
|
export { ModelStatsCard } from './ModelStatsCard';
|
||||||
|
export type { ModelStatsCardProps, ModelStat } from './ModelStatsCard';
|
||||||
|
|
||||||
|
export { PriceSettingsCard } from './PriceSettingsCard';
|
||||||
|
export type { PriceSettingsCardProps } from './PriceSettingsCard';
|
||||||
@@ -8,3 +8,4 @@ export { useLocalStorage } from './useLocalStorage';
|
|||||||
export { useInterval } from './useInterval';
|
export { useInterval } from './useInterval';
|
||||||
export { useMediaQuery } from './useMediaQuery';
|
export { useMediaQuery } from './useMediaQuery';
|
||||||
export { usePagination } from './usePagination';
|
export { usePagination } from './usePagination';
|
||||||
|
export { useHeaderRefresh } from './useHeaderRefresh';
|
||||||
|
|||||||
24
src/hooks/useHeaderRefresh.ts
Normal file
24
src/hooks/useHeaderRefresh.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export type HeaderRefreshHandler = () => void | Promise<void>;
|
||||||
|
|
||||||
|
let activeHeaderRefreshHandler: HeaderRefreshHandler | null = null;
|
||||||
|
|
||||||
|
export const triggerHeaderRefresh = async () => {
|
||||||
|
if (!activeHeaderRefreshHandler) return;
|
||||||
|
await activeHeaderRefreshHandler();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!handler) return;
|
||||||
|
|
||||||
|
activeHeaderRefreshHandler = handler;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (activeHeaderRefreshHandler === handler) {
|
||||||
|
activeHeaderRefreshHandler = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [handler]);
|
||||||
|
};
|
||||||
@@ -6,14 +6,14 @@ import i18n from 'i18next';
|
|||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
import zhCN from './locales/zh-CN.json';
|
import zhCN from './locales/zh-CN.json';
|
||||||
import en from './locales/en.json';
|
import en from './locales/en.json';
|
||||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
import { getInitialLanguage } from '@/utils/language';
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
resources: {
|
resources: {
|
||||||
'zh-CN': { translation: zhCN },
|
'zh-CN': { translation: zhCN },
|
||||||
en: { translation: en }
|
en: { translation: en }
|
||||||
},
|
},
|
||||||
lng: localStorage.getItem(STORAGE_KEY_LANGUAGE) || 'zh-CN',
|
lng: getInitialLanguage(),
|
||||||
fallbackLng: 'zh-CN',
|
fallbackLng: 'zh-CN',
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false // React 已经转义
|
escapeValue: false // React 已经转义
|
||||||
|
|||||||
@@ -34,6 +34,8 @@
|
|||||||
"alias": "Alias",
|
"alias": "Alias",
|
||||||
"failure": "Failure",
|
"failure": "Failure",
|
||||||
"unknown_error": "Unknown error",
|
"unknown_error": "Unknown error",
|
||||||
|
"quota_update_required": "Please update the CPA version or check for updates",
|
||||||
|
"quota_check_credential": "Please check the credential status",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"custom_headers_label": "Custom Headers",
|
"custom_headers_label": "Custom Headers",
|
||||||
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
|
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
|
||||||
@@ -61,6 +63,7 @@
|
|||||||
"custom_connection_placeholder": "Eg: https://example.com:8317",
|
"custom_connection_placeholder": "Eg: https://example.com:8317",
|
||||||
"custom_connection_hint": "By default the current URL is used. Override it here if needed.",
|
"custom_connection_hint": "By default the current URL is used. Override it here if needed.",
|
||||||
"use_current_address": "Use Current URL",
|
"use_current_address": "Use Current URL",
|
||||||
|
"remember_password_label": "Remember password",
|
||||||
"management_key_label": "Management Key:",
|
"management_key_label": "Management Key:",
|
||||||
"management_key_placeholder": "Enter the management key",
|
"management_key_placeholder": "Enter the management key",
|
||||||
"connect_button": "Connect",
|
"connect_button": "Connect",
|
||||||
@@ -88,6 +91,7 @@
|
|||||||
"ai_providers": "AI Providers",
|
"ai_providers": "AI Providers",
|
||||||
"auth_files": "Auth Files",
|
"auth_files": "Auth Files",
|
||||||
"oauth": "OAuth Login",
|
"oauth": "OAuth Login",
|
||||||
|
"quota_management": "Quota Management",
|
||||||
"usage_stats": "Usage Statistics",
|
"usage_stats": "Usage Statistics",
|
||||||
"config_management": "Config Management",
|
"config_management": "Config Management",
|
||||||
"logs": "Logs Viewer",
|
"logs": "Logs Viewer",
|
||||||
@@ -133,11 +137,22 @@
|
|||||||
"usage_statistics_enable": "Enable usage statistics",
|
"usage_statistics_enable": "Enable usage statistics",
|
||||||
"logging_title": "Logging",
|
"logging_title": "Logging",
|
||||||
"logging_to_file_enable": "Enable logging to file",
|
"logging_to_file_enable": "Enable logging to file",
|
||||||
|
"logs_max_total_size_title": "Log Size Limit",
|
||||||
|
"logs_max_total_size_label": "Total log size cap (MB):",
|
||||||
|
"logs_max_total_size_hint": "Set to 0 to disable the limit.",
|
||||||
|
"logs_max_total_size_update": "Update",
|
||||||
"request_log_title": "Request Logging",
|
"request_log_title": "Request Logging",
|
||||||
"request_log_enable": "Enable request logging",
|
"request_log_enable": "Enable request logging",
|
||||||
"request_log_warning": "Keep this off unless you need detailed troubleshooting.",
|
"request_log_warning": "Keep this off unless you need detailed troubleshooting.",
|
||||||
|
"force_model_prefix_enable": "Force model prefix",
|
||||||
"ws_auth_title": "WebSocket Authentication",
|
"ws_auth_title": "WebSocket Authentication",
|
||||||
"ws_auth_enable": "Require auth for /ws/*"
|
"ws_auth_enable": "Require auth for /ws/*",
|
||||||
|
"routing_title": "Routing Strategy",
|
||||||
|
"routing_strategy_label": "Routing strategy:",
|
||||||
|
"routing_strategy_hint": "round-robin cycles through keys; fill-first prioritizes the first available key.",
|
||||||
|
"routing_strategy_update": "Update",
|
||||||
|
"routing_strategy_round_robin": "round-robin (cycle)",
|
||||||
|
"routing_strategy_fill_first": "fill-first (prioritize)"
|
||||||
},
|
},
|
||||||
"api_keys": {
|
"api_keys": {
|
||||||
"title": "API Keys Management",
|
"title": "API Keys Management",
|
||||||
@@ -217,6 +232,27 @@
|
|||||||
"claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
|
"claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
|
||||||
"claude_models_add_btn": "Add Model",
|
"claude_models_add_btn": "Add Model",
|
||||||
"claude_models_count": "Models Count",
|
"claude_models_count": "Models Count",
|
||||||
|
"vertex_title": "Vertex API Configuration",
|
||||||
|
"vertex_add_button": "Add Configuration",
|
||||||
|
"vertex_empty_title": "No Vertex Configuration",
|
||||||
|
"vertex_empty_desc": "Click the button above to add the first configuration",
|
||||||
|
"vertex_item_title": "Vertex Configuration",
|
||||||
|
"vertex_add_modal_title": "Add Vertex API Configuration",
|
||||||
|
"vertex_add_modal_key_label": "API Key:",
|
||||||
|
"vertex_add_modal_key_placeholder": "Please enter Vertex API key",
|
||||||
|
"vertex_add_modal_url_label": "Base URL (Required):",
|
||||||
|
"vertex_add_modal_url_placeholder": "e.g.: https://example.com/api",
|
||||||
|
"vertex_add_modal_proxy_label": "Proxy URL (Optional):",
|
||||||
|
"vertex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
|
||||||
|
"vertex_edit_modal_title": "Edit Vertex API Configuration",
|
||||||
|
"vertex_edit_modal_key_label": "API Key:",
|
||||||
|
"vertex_edit_modal_url_label": "Base URL (Required):",
|
||||||
|
"vertex_edit_modal_proxy_label": "Proxy URL (Optional):",
|
||||||
|
"vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?",
|
||||||
|
"vertex_models_label": "Model mappings (alias required):",
|
||||||
|
"vertex_models_add_btn": "Add Mapping",
|
||||||
|
"vertex_models_hint": "Each mapping needs both the original model and its alias.",
|
||||||
|
"vertex_models_count": "Mapping count",
|
||||||
"ampcode_title": "Amp CLI Integration (ampcode)",
|
"ampcode_title": "Amp CLI Integration (ampcode)",
|
||||||
"ampcode_modal_title": "Configure Ampcode",
|
"ampcode_modal_title": "Configure Ampcode",
|
||||||
"ampcode_upstream_url_label": "Upstream URL",
|
"ampcode_upstream_url_label": "Upstream URL",
|
||||||
@@ -308,6 +344,7 @@
|
|||||||
"delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!",
|
"delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!",
|
||||||
"delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!",
|
"delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!",
|
||||||
"upload_error_json": "Only JSON files are allowed",
|
"upload_error_json": "Only JSON files are allowed",
|
||||||
|
"upload_error_size": "File size cannot exceed {{maxSize}}",
|
||||||
"upload_success": "File uploaded successfully",
|
"upload_success": "File uploaded successfully",
|
||||||
"download_success": "File downloaded successfully",
|
"download_success": "File downloaded successfully",
|
||||||
"delete_success": "File deleted successfully",
|
"delete_success": "File deleted successfully",
|
||||||
@@ -323,6 +360,9 @@
|
|||||||
"search_placeholder": "Filter by name, type, or provider",
|
"search_placeholder": "Filter by name, type, or provider",
|
||||||
"page_size_label": "Per page",
|
"page_size_label": "Per page",
|
||||||
"page_size_unit": "items",
|
"page_size_unit": "items",
|
||||||
|
"view_mode_paged": "Paged",
|
||||||
|
"view_mode_all": "Show all",
|
||||||
|
"too_many_files_warning": "Too many credentials. Showing all may cause performance issues, please use paged view.",
|
||||||
"filter_all": "All",
|
"filter_all": "All",
|
||||||
"filter_qwen": "Qwen",
|
"filter_qwen": "Qwen",
|
||||||
"filter_gemini": "Gemini",
|
"filter_gemini": "Gemini",
|
||||||
@@ -357,8 +397,55 @@
|
|||||||
"models_excluded_badge": "Excluded",
|
"models_excluded_badge": "Excluded",
|
||||||
"models_excluded_hint": "This model is excluded by OAuth"
|
"models_excluded_hint": "This model is excluded by OAuth"
|
||||||
},
|
},
|
||||||
|
"antigravity_quota": {
|
||||||
|
"title": "Antigravity Quota",
|
||||||
|
"empty_title": "No Antigravity Auth Files",
|
||||||
|
"empty_desc": "Upload an Antigravity credential to view remaining quota.",
|
||||||
|
"idle": "Not loaded. Click Refresh Button.",
|
||||||
|
"loading": "Loading quota...",
|
||||||
|
"load_failed": "Failed to load quota: {{message}}",
|
||||||
|
"missing_auth_index": "Auth file missing auth_index",
|
||||||
|
"empty_models": "No quota data available",
|
||||||
|
"refresh_button": "Refresh Quota",
|
||||||
|
"fetch_all": "Fetch All"
|
||||||
|
},
|
||||||
|
"codex_quota": {
|
||||||
|
"title": "Codex Quota",
|
||||||
|
"empty_title": "No Codex Auth Files",
|
||||||
|
"empty_desc": "Upload a Codex credential to view quota.",
|
||||||
|
"idle": "Not loaded. Click Refresh Button.",
|
||||||
|
"loading": "Loading quota...",
|
||||||
|
"load_failed": "Failed to load quota: {{message}}",
|
||||||
|
"missing_auth_index": "Auth file missing auth_index",
|
||||||
|
"missing_account_id": "Codex credential missing ChatGPT account ID",
|
||||||
|
"empty_windows": "No quota data available",
|
||||||
|
"no_access": "This credential has no Codex access (plan: free).",
|
||||||
|
"refresh_button": "Refresh Quota",
|
||||||
|
"fetch_all": "Fetch All",
|
||||||
|
"primary_window": "5-hour limit",
|
||||||
|
"secondary_window": "Weekly limit",
|
||||||
|
"code_review_window": "Code review limit",
|
||||||
|
"plan_label": "Plan",
|
||||||
|
"plan_plus": "Plus",
|
||||||
|
"plan_team": "Team",
|
||||||
|
"plan_free": "Free"
|
||||||
|
},
|
||||||
|
"gemini_cli_quota": {
|
||||||
|
"title": "Gemini CLI Quota",
|
||||||
|
"empty_title": "No Gemini CLI Auth Files",
|
||||||
|
"empty_desc": "Upload a Gemini CLI credential to view remaining quota.",
|
||||||
|
"idle": "Not loaded. Click Refresh Button.",
|
||||||
|
"loading": "Loading quota...",
|
||||||
|
"load_failed": "Failed to load quota: {{message}}",
|
||||||
|
"missing_auth_index": "Auth file missing auth_index",
|
||||||
|
"missing_project_id": "Gemini CLI credential missing project ID",
|
||||||
|
"empty_buckets": "No quota data available",
|
||||||
|
"refresh_button": "Refresh Quota",
|
||||||
|
"fetch_all": "Fetch All",
|
||||||
|
"remaining_amount": "Remaining {{count}}"
|
||||||
|
},
|
||||||
"vertex_import": {
|
"vertex_import": {
|
||||||
"title": "Vertex AI Credential Import",
|
"title": "Vertex JSON Login",
|
||||||
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
|
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
|
||||||
"location_label": "Region (optional)",
|
"location_label": "Region (optional)",
|
||||||
"location_placeholder": "us-central1",
|
"location_placeholder": "us-central1",
|
||||||
@@ -413,6 +500,34 @@
|
|||||||
"upgrade_required_title": "Please upgrade CLI Proxy API",
|
"upgrade_required_title": "Please upgrade CLI Proxy API",
|
||||||
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version."
|
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version."
|
||||||
},
|
},
|
||||||
|
"oauth_model_mappings": {
|
||||||
|
"title": "OAuth Model Mappings",
|
||||||
|
"add": "Add Mapping",
|
||||||
|
"add_title": "Add provider model mappings",
|
||||||
|
"provider_label": "Provider",
|
||||||
|
"provider_placeholder": "e.g. gemini-cli / vertex",
|
||||||
|
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
||||||
|
"mappings_label": "Model mappings",
|
||||||
|
"mapping_name_placeholder": "Source model name",
|
||||||
|
"mapping_alias_placeholder": "Alias (required)",
|
||||||
|
"mapping_fork_label": "Keep original",
|
||||||
|
"mappings_hint": "Saving an empty list removes that provider. Enable “Keep original” to keep the original name while adding the alias.",
|
||||||
|
"add_mapping": "Add mapping",
|
||||||
|
"save": "Save/Update",
|
||||||
|
"save_success": "Model mappings updated",
|
||||||
|
"save_failed": "Failed to update model mappings",
|
||||||
|
"delete": "Delete Provider",
|
||||||
|
"delete_confirm": "Delete model mappings for {{provider}}?",
|
||||||
|
"delete_success": "Model mappings removed",
|
||||||
|
"delete_failed": "Failed to delete model mappings",
|
||||||
|
"no_models": "No model mappings",
|
||||||
|
"model_count": "{{count}} mappings",
|
||||||
|
"list_empty_all": "No model mappings yet—use “Add Mapping” to create one.",
|
||||||
|
"provider_required": "Please enter a provider first",
|
||||||
|
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
|
||||||
|
"upgrade_required_title": "Please upgrade CLI Proxy API",
|
||||||
|
"upgrade_required_desc": "The current server does not support the OAuth model mappings API. Please upgrade to the latest CLI Proxy API (CPA) version."
|
||||||
|
},
|
||||||
"auth_login": {
|
"auth_login": {
|
||||||
"codex_oauth_title": "Codex OAuth",
|
"codex_oauth_title": "Codex OAuth",
|
||||||
"codex_oauth_button": "Start Codex Login",
|
"codex_oauth_button": "Start Codex Login",
|
||||||
@@ -450,9 +565,9 @@
|
|||||||
"gemini_cli_oauth_title": "Gemini CLI OAuth",
|
"gemini_cli_oauth_title": "Gemini CLI OAuth",
|
||||||
"gemini_cli_oauth_button": "Start Gemini CLI Login",
|
"gemini_cli_oauth_button": "Start Gemini CLI Login",
|
||||||
"gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.",
|
"gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.",
|
||||||
"gemini_cli_project_id_label": "Google Cloud Project ID:",
|
"gemini_cli_project_id_label": "Google Cloud Project ID (Optional):",
|
||||||
"gemini_cli_project_id_placeholder": "Enter Google Cloud Project ID",
|
"gemini_cli_project_id_placeholder": "Leave blank to auto-select first available project",
|
||||||
"gemini_cli_project_id_hint": "Project ID is required for Gemini CLI OAuth.",
|
"gemini_cli_project_id_hint": "Optional. If not provided, the system will automatically select the first available project from your account.",
|
||||||
"gemini_cli_project_id_required": "Please enter a Google Cloud project ID.",
|
"gemini_cli_project_id_required": "Please enter a Google Cloud project ID.",
|
||||||
"gemini_cli_oauth_url_label": "Authorization URL:",
|
"gemini_cli_oauth_url_label": "Authorization URL:",
|
||||||
"gemini_cli_open_link": "Open Link",
|
"gemini_cli_open_link": "Open Link",
|
||||||
@@ -497,7 +612,7 @@
|
|||||||
"iflow_oauth_polling_error": "Failed to check authentication status:",
|
"iflow_oauth_polling_error": "Failed to check authentication status:",
|
||||||
"iflow_cookie_title": "iFlow Cookie Login",
|
"iflow_cookie_title": "iFlow Cookie Login",
|
||||||
"iflow_cookie_label": "Cookie Value:",
|
"iflow_cookie_label": "Cookie Value:",
|
||||||
"iflow_cookie_placeholder": "Paste browser cookie, e.g. sessionid=...;",
|
"iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=",
|
||||||
"iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.",
|
"iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.",
|
||||||
"iflow_cookie_key_hint": "Note: Create a key on the platform first.",
|
"iflow_cookie_key_hint": "Note: Create a key on the platform first.",
|
||||||
"iflow_cookie_button": "Submit Cookie Login",
|
"iflow_cookie_button": "Submit Cookie Login",
|
||||||
@@ -604,8 +719,6 @@
|
|||||||
"request_log_download_title": "Download Request Log",
|
"request_log_download_title": "Download Request Log",
|
||||||
"request_log_download_confirm": "Download request log for ID {{id}}?",
|
"request_log_download_confirm": "Download request log for ID {{id}}?",
|
||||||
"request_log_download_success": "Request log downloaded successfully",
|
"request_log_download_success": "Request log downloaded successfully",
|
||||||
"action_hint": "Double-click a log line to copy the raw text. Long-press a line with a request ID to download the request log.",
|
|
||||||
"action_hint_disabled": "Double-click a log line to copy the raw text. Enable request logging to long-press a line with a request ID and download the request log.",
|
|
||||||
"empty_title": "No Logs Available",
|
"empty_title": "No Logs Available",
|
||||||
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
|
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
|
||||||
"log_content": "Log Content",
|
"log_content": "Log Content",
|
||||||
@@ -657,6 +770,12 @@
|
|||||||
"search_prev": "Previous",
|
"search_prev": "Previous",
|
||||||
"search_next": "Next"
|
"search_next": "Next"
|
||||||
},
|
},
|
||||||
|
"quota_management": {
|
||||||
|
"title": "Quota Management",
|
||||||
|
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
|
||||||
|
"refresh_files": "Refresh auth files",
|
||||||
|
"refresh_files_and_quota": "Refresh files & quota"
|
||||||
|
},
|
||||||
"system_info": {
|
"system_info": {
|
||||||
"title": "Management Center Info",
|
"title": "Management Center Info",
|
||||||
"connection_status_title": "Connection Status",
|
"connection_status_title": "Connection Status",
|
||||||
@@ -692,7 +811,11 @@
|
|||||||
"link_webui_repo": "WebUI Repository",
|
"link_webui_repo": "WebUI Repository",
|
||||||
"link_webui_repo_desc": "Management Center frontend source code",
|
"link_webui_repo_desc": "Management Center frontend source code",
|
||||||
"link_docs": "Documentation",
|
"link_docs": "Documentation",
|
||||||
"link_docs_desc": "Usage tutorials and configuration guides"
|
"link_docs_desc": "Usage tutorials and configuration guides",
|
||||||
|
"clear_login_title": "Local Login Data",
|
||||||
|
"clear_login_desc": "Clear locally saved login data and sign out. Usage stats pricing settings will remain untouched.",
|
||||||
|
"clear_login_button": "Clear login data",
|
||||||
|
"clear_login_confirm": "Clear local login data and sign out now?"
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"debug_updated": "Debug settings updated",
|
"debug_updated": "Debug settings updated",
|
||||||
@@ -703,11 +826,16 @@
|
|||||||
"quota_switch_preview_updated": "Preview model switch settings updated",
|
"quota_switch_preview_updated": "Preview model switch settings updated",
|
||||||
"usage_statistics_updated": "Usage statistics settings updated",
|
"usage_statistics_updated": "Usage statistics settings updated",
|
||||||
"logging_to_file_updated": "Logging settings updated",
|
"logging_to_file_updated": "Logging settings updated",
|
||||||
|
"logs_max_total_size_updated": "Log size limit updated",
|
||||||
"request_log_updated": "Request logging setting updated",
|
"request_log_updated": "Request logging setting updated",
|
||||||
|
"force_model_prefix_updated": "Model prefix setting updated",
|
||||||
"ws_auth_updated": "WebSocket authentication setting updated",
|
"ws_auth_updated": "WebSocket authentication setting updated",
|
||||||
|
"routing_strategy_updated": "Routing strategy updated",
|
||||||
|
"login_storage_cleared": "Local login data cleared",
|
||||||
"api_key_added": "API key added successfully",
|
"api_key_added": "API key added successfully",
|
||||||
"api_key_updated": "API key updated successfully",
|
"api_key_updated": "API key updated successfully",
|
||||||
"api_key_deleted": "API key deleted successfully",
|
"api_key_deleted": "API key deleted successfully",
|
||||||
|
"api_key_invalid_chars": "API key can only contain letters, numbers, and symbols",
|
||||||
"gemini_key_added": "Gemini key added successfully",
|
"gemini_key_added": "Gemini key added successfully",
|
||||||
"gemini_key_updated": "Gemini key updated successfully",
|
"gemini_key_updated": "Gemini key updated successfully",
|
||||||
"gemini_key_deleted": "Gemini key deleted successfully",
|
"gemini_key_deleted": "Gemini key deleted successfully",
|
||||||
@@ -721,6 +849,10 @@
|
|||||||
"claude_config_added": "Claude configuration added successfully",
|
"claude_config_added": "Claude configuration added successfully",
|
||||||
"claude_config_updated": "Claude configuration updated successfully",
|
"claude_config_updated": "Claude configuration updated successfully",
|
||||||
"claude_config_deleted": "Claude configuration deleted successfully",
|
"claude_config_deleted": "Claude configuration deleted successfully",
|
||||||
|
"vertex_config_added": "Vertex configuration added successfully",
|
||||||
|
"vertex_config_updated": "Vertex configuration updated successfully",
|
||||||
|
"vertex_config_deleted": "Vertex configuration deleted successfully",
|
||||||
|
"vertex_base_url_required": "Please enter the Vertex Base URL",
|
||||||
"config_enabled": "Configuration enabled",
|
"config_enabled": "Configuration enabled",
|
||||||
"config_disabled": "Configuration disabled",
|
"config_disabled": "Configuration disabled",
|
||||||
"field_required": "Required fields cannot be empty",
|
"field_required": "Required fields cannot be empty",
|
||||||
|
|||||||
@@ -34,6 +34,8 @@
|
|||||||
"alias": "别名",
|
"alias": "别名",
|
||||||
"failure": "失败",
|
"failure": "失败",
|
||||||
"unknown_error": "未知错误",
|
"unknown_error": "未知错误",
|
||||||
|
"quota_update_required": "请更新 CPA 版本或检查更新",
|
||||||
|
"quota_check_credential": "请检查凭证状态",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"custom_headers_label": "自定义请求头",
|
"custom_headers_label": "自定义请求头",
|
||||||
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
|
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
|
||||||
@@ -61,6 +63,7 @@
|
|||||||
"custom_connection_placeholder": "例如: https://example.com:8317",
|
"custom_connection_placeholder": "例如: https://example.com:8317",
|
||||||
"custom_connection_hint": "默认使用当前访问地址,若需要可手动输入其他地址。",
|
"custom_connection_hint": "默认使用当前访问地址,若需要可手动输入其他地址。",
|
||||||
"use_current_address": "使用当前地址",
|
"use_current_address": "使用当前地址",
|
||||||
|
"remember_password_label": "记住密码",
|
||||||
"management_key_label": "管理密钥:",
|
"management_key_label": "管理密钥:",
|
||||||
"management_key_placeholder": "请输入管理密钥",
|
"management_key_placeholder": "请输入管理密钥",
|
||||||
"connect_button": "连接",
|
"connect_button": "连接",
|
||||||
@@ -88,6 +91,7 @@
|
|||||||
"ai_providers": "AI 提供商",
|
"ai_providers": "AI 提供商",
|
||||||
"auth_files": "认证文件",
|
"auth_files": "认证文件",
|
||||||
"oauth": "OAuth 登录",
|
"oauth": "OAuth 登录",
|
||||||
|
"quota_management": "配额管理",
|
||||||
"usage_stats": "使用统计",
|
"usage_stats": "使用统计",
|
||||||
"config_management": "配置管理",
|
"config_management": "配置管理",
|
||||||
"logs": "日志查看",
|
"logs": "日志查看",
|
||||||
@@ -133,11 +137,22 @@
|
|||||||
"usage_statistics_enable": "启用使用统计",
|
"usage_statistics_enable": "启用使用统计",
|
||||||
"logging_title": "日志记录",
|
"logging_title": "日志记录",
|
||||||
"logging_to_file_enable": "启用日志记录到文件",
|
"logging_to_file_enable": "启用日志记录到文件",
|
||||||
|
"logs_max_total_size_title": "日志容量限制",
|
||||||
|
"logs_max_total_size_label": "日志总大小上限 (MB):",
|
||||||
|
"logs_max_total_size_hint": "设置为 0 表示不限制。",
|
||||||
|
"logs_max_total_size_update": "更新",
|
||||||
"request_log_title": "请求日志",
|
"request_log_title": "请求日志",
|
||||||
"request_log_enable": "启用请求日志",
|
"request_log_enable": "启用请求日志",
|
||||||
"request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。",
|
"request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。",
|
||||||
|
"force_model_prefix_enable": "强制模型前缀",
|
||||||
"ws_auth_title": "WebSocket 鉴权",
|
"ws_auth_title": "WebSocket 鉴权",
|
||||||
"ws_auth_enable": "启用 /ws/* 鉴权"
|
"ws_auth_enable": "启用 /ws/* 鉴权",
|
||||||
|
"routing_title": "路由策略",
|
||||||
|
"routing_strategy_label": "路由策略:",
|
||||||
|
"routing_strategy_hint": "round-robin 为轮询,fill-first 为优先填充。",
|
||||||
|
"routing_strategy_update": "更新",
|
||||||
|
"routing_strategy_round_robin": "round-robin (轮询)",
|
||||||
|
"routing_strategy_fill_first": "fill-first (优先填充)"
|
||||||
},
|
},
|
||||||
"api_keys": {
|
"api_keys": {
|
||||||
"title": "API 密钥管理",
|
"title": "API 密钥管理",
|
||||||
@@ -217,6 +232,27 @@
|
|||||||
"claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
|
"claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
|
||||||
"claude_models_add_btn": "添加模型",
|
"claude_models_add_btn": "添加模型",
|
||||||
"claude_models_count": "模型数量",
|
"claude_models_count": "模型数量",
|
||||||
|
"vertex_title": "Vertex API 配置",
|
||||||
|
"vertex_add_button": "添加配置",
|
||||||
|
"vertex_empty_title": "暂无Vertex配置",
|
||||||
|
"vertex_empty_desc": "点击上方按钮添加第一个配置",
|
||||||
|
"vertex_item_title": "Vertex配置",
|
||||||
|
"vertex_add_modal_title": "添加Vertex API配置",
|
||||||
|
"vertex_add_modal_key_label": "API密钥:",
|
||||||
|
"vertex_add_modal_key_placeholder": "请输入Vertex API密钥",
|
||||||
|
"vertex_add_modal_url_label": "Base URL (必填):",
|
||||||
|
"vertex_add_modal_url_placeholder": "例如: https://example.com/api",
|
||||||
|
"vertex_add_modal_proxy_label": "代理 URL (可选):",
|
||||||
|
"vertex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
|
||||||
|
"vertex_edit_modal_title": "编辑Vertex API配置",
|
||||||
|
"vertex_edit_modal_key_label": "API密钥:",
|
||||||
|
"vertex_edit_modal_url_label": "Base URL (必填):",
|
||||||
|
"vertex_edit_modal_proxy_label": "代理 URL (可选):",
|
||||||
|
"vertex_delete_confirm": "确定要删除这个Vertex配置吗?",
|
||||||
|
"vertex_models_label": "模型映射 (别名必填):",
|
||||||
|
"vertex_models_add_btn": "添加映射",
|
||||||
|
"vertex_models_hint": "每条映射需要填写原模型与别名。",
|
||||||
|
"vertex_models_count": "映射数量",
|
||||||
"ampcode_title": "Amp CLI 集成 (ampcode)",
|
"ampcode_title": "Amp CLI 集成 (ampcode)",
|
||||||
"ampcode_modal_title": "配置 Ampcode",
|
"ampcode_modal_title": "配置 Ampcode",
|
||||||
"ampcode_upstream_url_label": "Upstream URL",
|
"ampcode_upstream_url_label": "Upstream URL",
|
||||||
@@ -308,6 +344,7 @@
|
|||||||
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
|
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
|
||||||
"delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!",
|
"delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!",
|
||||||
"upload_error_json": "只能上传JSON文件",
|
"upload_error_json": "只能上传JSON文件",
|
||||||
|
"upload_error_size": "文件大小不能超过 {{maxSize}}",
|
||||||
"upload_success": "文件上传成功",
|
"upload_success": "文件上传成功",
|
||||||
"download_success": "文件下载成功",
|
"download_success": "文件下载成功",
|
||||||
"delete_success": "文件删除成功",
|
"delete_success": "文件删除成功",
|
||||||
@@ -323,6 +360,9 @@
|
|||||||
"search_placeholder": "输入名称、类型或提供方关键字",
|
"search_placeholder": "输入名称、类型或提供方关键字",
|
||||||
"page_size_label": "单页数量",
|
"page_size_label": "单页数量",
|
||||||
"page_size_unit": "个/页",
|
"page_size_unit": "个/页",
|
||||||
|
"view_mode_paged": "按页显示",
|
||||||
|
"view_mode_all": "显示全部",
|
||||||
|
"too_many_files_warning": "您的凭证总数过多,全部加载会导致页面卡顿,请保持单页浏览。",
|
||||||
"filter_all": "全部",
|
"filter_all": "全部",
|
||||||
"filter_qwen": "Qwen",
|
"filter_qwen": "Qwen",
|
||||||
"filter_gemini": "Gemini",
|
"filter_gemini": "Gemini",
|
||||||
@@ -357,8 +397,55 @@
|
|||||||
"models_excluded_badge": "已排除",
|
"models_excluded_badge": "已排除",
|
||||||
"models_excluded_hint": "此模型已被 OAuth 排除"
|
"models_excluded_hint": "此模型已被 OAuth 排除"
|
||||||
},
|
},
|
||||||
|
"antigravity_quota": {
|
||||||
|
"title": "Antigravity 额度",
|
||||||
|
"empty_title": "暂无 Antigravity 认证",
|
||||||
|
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
|
||||||
|
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||||
|
"loading": "正在加载额度...",
|
||||||
|
"load_failed": "额度获取失败:{{message}}",
|
||||||
|
"missing_auth_index": "认证文件缺少 auth_index",
|
||||||
|
"empty_models": "暂无额度数据",
|
||||||
|
"refresh_button": "刷新额度",
|
||||||
|
"fetch_all": "获取全部"
|
||||||
|
},
|
||||||
|
"codex_quota": {
|
||||||
|
"title": "Codex 额度",
|
||||||
|
"empty_title": "暂无 Codex 认证",
|
||||||
|
"empty_desc": "上传 Codex 认证文件后即可查看额度。",
|
||||||
|
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||||
|
"loading": "正在加载额度...",
|
||||||
|
"load_failed": "额度获取失败:{{message}}",
|
||||||
|
"missing_auth_index": "认证文件缺少 auth_index",
|
||||||
|
"missing_account_id": "Codex 凭证缺少 ChatGPT 账号 ID",
|
||||||
|
"empty_windows": "暂无额度数据",
|
||||||
|
"no_access": "该凭证已无 Codex 访问权限(free)。",
|
||||||
|
"refresh_button": "刷新额度",
|
||||||
|
"fetch_all": "获取全部",
|
||||||
|
"primary_window": "5 小时限额",
|
||||||
|
"secondary_window": "周限额",
|
||||||
|
"code_review_window": "代码审查限额",
|
||||||
|
"plan_label": "套餐",
|
||||||
|
"plan_plus": "Plus",
|
||||||
|
"plan_team": "Team",
|
||||||
|
"plan_free": "Free"
|
||||||
|
},
|
||||||
|
"gemini_cli_quota": {
|
||||||
|
"title": "Gemini CLI 额度",
|
||||||
|
"empty_title": "暂无 Gemini CLI 认证",
|
||||||
|
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
|
||||||
|
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||||
|
"loading": "正在加载额度...",
|
||||||
|
"load_failed": "额度获取失败:{{message}}",
|
||||||
|
"missing_auth_index": "认证文件缺少 auth_index",
|
||||||
|
"missing_project_id": "Gemini CLI 凭证缺少 Project ID",
|
||||||
|
"empty_buckets": "暂无额度数据",
|
||||||
|
"refresh_button": "刷新额度",
|
||||||
|
"fetch_all": "获取全部",
|
||||||
|
"remaining_amount": "剩余 {{count}}"
|
||||||
|
},
|
||||||
"vertex_import": {
|
"vertex_import": {
|
||||||
"title": "Vertex AI 凭证导入",
|
"title": "Vertex JSON 登录",
|
||||||
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
|
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
|
||||||
"location_label": "目标区域 (可选)",
|
"location_label": "目标区域 (可选)",
|
||||||
"location_placeholder": "us-central1",
|
"location_placeholder": "us-central1",
|
||||||
@@ -413,6 +500,34 @@
|
|||||||
"upgrade_required_title": "需要升级 CPA 版本",
|
"upgrade_required_title": "需要升级 CPA 版本",
|
||||||
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
||||||
},
|
},
|
||||||
|
"oauth_model_mappings": {
|
||||||
|
"title": "OAuth 模型映射",
|
||||||
|
"add": "新增映射",
|
||||||
|
"add_title": "新增提供商模型映射",
|
||||||
|
"provider_label": "提供商",
|
||||||
|
"provider_placeholder": "例如 gemini-cli / vertex",
|
||||||
|
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
||||||
|
"mappings_label": "模型映射",
|
||||||
|
"mapping_name_placeholder": "原模型名称",
|
||||||
|
"mapping_alias_placeholder": "别名 (必填)",
|
||||||
|
"mapping_fork_label": "保留原名",
|
||||||
|
"mappings_hint": "留空保存将删除该提供商记录;开启“保留原名”会在保留原模型名的同时新增别名。",
|
||||||
|
"add_mapping": "添加映射",
|
||||||
|
"save": "保存/更新",
|
||||||
|
"save_success": "模型映射已更新",
|
||||||
|
"save_failed": "更新模型映射失败",
|
||||||
|
"delete": "删除提供商",
|
||||||
|
"delete_confirm": "确定要删除 {{provider}} 的模型映射吗?",
|
||||||
|
"delete_success": "已删除该提供商的模型映射",
|
||||||
|
"delete_failed": "删除模型映射失败",
|
||||||
|
"no_models": "未配置模型映射",
|
||||||
|
"model_count": "映射 {{count}} 条模型",
|
||||||
|
"list_empty_all": "暂无任何提供商的模型映射,点击“新增映射”创建。",
|
||||||
|
"provider_required": "请先填写提供商名称",
|
||||||
|
"upgrade_required": "当前 CPA 版本不支持模型映射功能,请升级 CPA 版本",
|
||||||
|
"upgrade_required_title": "需要升级 CPA 版本",
|
||||||
|
"upgrade_required_desc": "当前服务器版本不支持 OAuth 模型映射功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
||||||
|
},
|
||||||
"auth_login": {
|
"auth_login": {
|
||||||
"codex_oauth_title": "Codex OAuth",
|
"codex_oauth_title": "Codex OAuth",
|
||||||
"codex_oauth_button": "开始 Codex 登录",
|
"codex_oauth_button": "开始 Codex 登录",
|
||||||
@@ -450,9 +565,9 @@
|
|||||||
"gemini_cli_oauth_title": "Gemini CLI OAuth",
|
"gemini_cli_oauth_title": "Gemini CLI OAuth",
|
||||||
"gemini_cli_oauth_button": "开始 Gemini CLI 登录",
|
"gemini_cli_oauth_button": "开始 Gemini CLI 登录",
|
||||||
"gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。",
|
"gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。",
|
||||||
"gemini_cli_project_id_label": "Google Cloud 项目 ID:",
|
"gemini_cli_project_id_label": "Google Cloud 项目 ID (可选):",
|
||||||
"gemini_cli_project_id_placeholder": "输入 Google Cloud 项目 ID",
|
"gemini_cli_project_id_placeholder": "留空将自动选择第一个可用项目",
|
||||||
"gemini_cli_project_id_hint": "请填写项目 ID,用于 Gemini CLI OAuth 登录。",
|
"gemini_cli_project_id_hint": "可选填写项目 ID。如不填写,系统将自动选择您账号下的第一个可用项目。",
|
||||||
"gemini_cli_project_id_required": "请填写 Google Cloud 项目 ID。",
|
"gemini_cli_project_id_required": "请填写 Google Cloud 项目 ID。",
|
||||||
"gemini_cli_oauth_url_label": "授权链接:",
|
"gemini_cli_oauth_url_label": "授权链接:",
|
||||||
"gemini_cli_open_link": "打开链接",
|
"gemini_cli_open_link": "打开链接",
|
||||||
@@ -497,7 +612,7 @@
|
|||||||
"iflow_oauth_polling_error": "检查认证状态失败:",
|
"iflow_oauth_polling_error": "检查认证状态失败:",
|
||||||
"iflow_cookie_title": "iFlow Cookie 登录",
|
"iflow_cookie_title": "iFlow Cookie 登录",
|
||||||
"iflow_cookie_label": "Cookie 内容:",
|
"iflow_cookie_label": "Cookie 内容:",
|
||||||
"iflow_cookie_placeholder": "粘贴浏览器中的 Cookie,例如 sessionid=...;",
|
"iflow_cookie_placeholder": "填入BXAuth值 以BXAuth=开头",
|
||||||
"iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。",
|
"iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。",
|
||||||
"iflow_cookie_key_hint": "提示:需在平台上先创建 Key。",
|
"iflow_cookie_key_hint": "提示:需在平台上先创建 Key。",
|
||||||
"iflow_cookie_button": "提交 Cookie 登录",
|
"iflow_cookie_button": "提交 Cookie 登录",
|
||||||
@@ -604,8 +719,6 @@
|
|||||||
"request_log_download_title": "下载报文",
|
"request_log_download_title": "下载报文",
|
||||||
"request_log_download_confirm": "是否要下载id为{{id}}的报文?",
|
"request_log_download_confirm": "是否要下载id为{{id}}的报文?",
|
||||||
"request_log_download_success": "报文下载成功",
|
"request_log_download_success": "报文下载成功",
|
||||||
"action_hint": "双击日志行可复制原文,长按带有请求 ID 的日志可下载报文。",
|
|
||||||
"action_hint_disabled": "双击日志行可复制原文,启用请求日志后可长按带请求 ID 的日志下载报文。",
|
|
||||||
"empty_title": "暂无日志记录",
|
"empty_title": "暂无日志记录",
|
||||||
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
|
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
|
||||||
"log_content": "日志内容",
|
"log_content": "日志内容",
|
||||||
@@ -657,6 +770,12 @@
|
|||||||
"search_prev": "上一个",
|
"search_prev": "上一个",
|
||||||
"search_next": "下一个"
|
"search_next": "下一个"
|
||||||
},
|
},
|
||||||
|
"quota_management": {
|
||||||
|
"title": "配额管理",
|
||||||
|
"description": "集中查看 OAuth 额度与剩余情况",
|
||||||
|
"refresh_files": "刷新认证文件",
|
||||||
|
"refresh_files_and_quota": "刷新认证文件&额度"
|
||||||
|
},
|
||||||
"system_info": {
|
"system_info": {
|
||||||
"title": "管理中心信息",
|
"title": "管理中心信息",
|
||||||
"connection_status_title": "连接状态",
|
"connection_status_title": "连接状态",
|
||||||
@@ -692,7 +811,11 @@
|
|||||||
"link_webui_repo": "WebUI 仓库",
|
"link_webui_repo": "WebUI 仓库",
|
||||||
"link_webui_repo_desc": "管理中心前端界面源代码",
|
"link_webui_repo_desc": "管理中心前端界面源代码",
|
||||||
"link_docs": "使用教程",
|
"link_docs": "使用教程",
|
||||||
"link_docs_desc": "配置指南和使用说明"
|
"link_docs_desc": "配置指南和使用说明",
|
||||||
|
"clear_login_title": "本地登录信息",
|
||||||
|
"clear_login_desc": "清理本地保存的登录信息并退出登录,不会影响使用统计中的价格设置。",
|
||||||
|
"clear_login_button": "清理登录信息",
|
||||||
|
"clear_login_confirm": "确认清理本地登录信息并退出登录?"
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"debug_updated": "调试设置已更新",
|
"debug_updated": "调试设置已更新",
|
||||||
@@ -703,11 +826,16 @@
|
|||||||
"quota_switch_preview_updated": "预览模型切换设置已更新",
|
"quota_switch_preview_updated": "预览模型切换设置已更新",
|
||||||
"usage_statistics_updated": "使用统计设置已更新",
|
"usage_statistics_updated": "使用统计设置已更新",
|
||||||
"logging_to_file_updated": "日志记录设置已更新",
|
"logging_to_file_updated": "日志记录设置已更新",
|
||||||
|
"logs_max_total_size_updated": "日志容量设置已更新",
|
||||||
"request_log_updated": "请求日志设置已更新",
|
"request_log_updated": "请求日志设置已更新",
|
||||||
|
"force_model_prefix_updated": "模型前缀设置已更新",
|
||||||
"ws_auth_updated": "WebSocket 鉴权设置已更新",
|
"ws_auth_updated": "WebSocket 鉴权设置已更新",
|
||||||
|
"routing_strategy_updated": "路由策略已更新",
|
||||||
|
"login_storage_cleared": "本地登录信息已清理",
|
||||||
"api_key_added": "API密钥添加成功",
|
"api_key_added": "API密钥添加成功",
|
||||||
"api_key_updated": "API密钥更新成功",
|
"api_key_updated": "API密钥更新成功",
|
||||||
"api_key_deleted": "API密钥删除成功",
|
"api_key_deleted": "API密钥删除成功",
|
||||||
|
"api_key_invalid_chars": "API密钥仅支持英文字母、数字和符号",
|
||||||
"gemini_key_added": "Gemini密钥添加成功",
|
"gemini_key_added": "Gemini密钥添加成功",
|
||||||
"gemini_key_updated": "Gemini密钥更新成功",
|
"gemini_key_updated": "Gemini密钥更新成功",
|
||||||
"gemini_key_deleted": "Gemini密钥删除成功",
|
"gemini_key_deleted": "Gemini密钥删除成功",
|
||||||
@@ -721,6 +849,10 @@
|
|||||||
"claude_config_added": "Claude配置添加成功",
|
"claude_config_added": "Claude配置添加成功",
|
||||||
"claude_config_updated": "Claude配置更新成功",
|
"claude_config_updated": "Claude配置更新成功",
|
||||||
"claude_config_deleted": "Claude配置删除成功",
|
"claude_config_deleted": "Claude配置删除成功",
|
||||||
|
"vertex_config_added": "Vertex配置添加成功",
|
||||||
|
"vertex_config_updated": "Vertex配置更新成功",
|
||||||
|
"vertex_config_deleted": "Vertex配置删除成功",
|
||||||
|
"vertex_base_url_required": "请填写Vertex Base URL",
|
||||||
"config_enabled": "配置已启用",
|
"config_enabled": "配置已启用",
|
||||||
"config_disabled": "配置已停用",
|
"config_disabled": "配置已停用",
|
||||||
"field_required": "必填字段不能为空",
|
"field_required": "必填字段不能为空",
|
||||||
|
|||||||
@@ -397,6 +397,79 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 状态监测栏
|
||||||
|
.statusBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlocks {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlock {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
min-width: 6px;
|
||||||
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scaleY(1.5);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockSuccess {
|
||||||
|
background-color: var(--success-color, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockFailure {
|
||||||
|
background-color: var(--danger-color, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockMixed {
|
||||||
|
background-color: var(--warning-color, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockIdle {
|
||||||
|
background-color: var(--border-secondary, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRate {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateHigh {
|
||||||
|
color: var(--success-badge-text, #065f46);
|
||||||
|
background: var(--success-badge-bg, #d1fae5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateMedium {
|
||||||
|
color: var(--warning-text, #92400e);
|
||||||
|
background: var(--warning-bg, #fef3c7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateLow {
|
||||||
|
color: var(--failure-badge-text, #991b1b);
|
||||||
|
background: var(--failure-badge-bg, #fee2e2);
|
||||||
|
}
|
||||||
|
|
||||||
// 暗色主题适配
|
// 暗色主题适配
|
||||||
:global([data-theme='dark']) {
|
:global([data-theme='dark']) {
|
||||||
.headerBadge {
|
.headerBadge {
|
||||||
@@ -436,4 +509,23 @@
|
|||||||
.apiKeyEntryIndex {
|
.apiKeyEntryIndex {
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusBlockIdle {
|
||||||
|
background-color: var(--border-primary, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateHigh {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateMedium {
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateLow {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,12 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
|||||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
import { apiKeysApi } from '@/services/api';
|
import { apiKeysApi } from '@/services/api';
|
||||||
import { maskApiKey } from '@/utils/format';
|
import { maskApiKey } from '@/utils/format';
|
||||||
|
import { isValidApiKeyCharset } from '@/utils/validation';
|
||||||
import styles from './ApiKeysPage.module.scss';
|
import styles from './ApiKeysPage.module.scss';
|
||||||
|
|
||||||
export function ApiKeysPage() {
|
export function ApiKeysPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
|
||||||
const config = useConfigStore((state) => state.config);
|
const config = useConfigStore((state) => state.config);
|
||||||
@@ -28,7 +29,6 @@ export function ApiKeysPage() {
|
|||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deletingIndex, setDeletingIndex] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
|
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
|
||||||
|
|
||||||
@@ -83,6 +83,10 @@ export function ApiKeysPage() {
|
|||||||
showNotification(`${t('notification.please_enter')} ${t('notification.api_key')}`, 'error');
|
showNotification(`${t('notification.please_enter')} ${t('notification.api_key')}`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isValidApiKeyCharset(trimmed)) {
|
||||||
|
showNotification(t('notification.api_key_invalid_chars'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isEdit = editingIndex !== null;
|
const isEdit = editingIndex !== null;
|
||||||
const nextKeys = isEdit
|
const nextKeys = isEdit
|
||||||
@@ -110,21 +114,42 @@ export function ApiKeysPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (index: number) => {
|
const handleDelete = (index: number) => {
|
||||||
if (!window.confirm(t('api_keys.delete_confirm'))) return;
|
const apiKeyToDelete = apiKeys[index];
|
||||||
setDeletingIndex(index);
|
if (!apiKeyToDelete) {
|
||||||
try {
|
showNotification(t('notification.delete_failed'), 'error');
|
||||||
await apiKeysApi.delete(index);
|
return;
|
||||||
const nextKeys = apiKeys.filter((_, idx) => idx !== index);
|
|
||||||
setApiKeys(nextKeys);
|
|
||||||
updateConfigValue('api-keys', nextKeys);
|
|
||||||
clearCache('api-keys');
|
|
||||||
showNotification(t('notification.api_key_deleted'), 'success');
|
|
||||||
} catch (err: any) {
|
|
||||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setDeletingIndex(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showConfirmation({
|
||||||
|
title: t('common.delete'),
|
||||||
|
message: t('api_keys.delete_confirm'),
|
||||||
|
variant: 'danger',
|
||||||
|
onConfirm: async () => {
|
||||||
|
const latestKeys = useConfigStore.getState().config?.apiKeys;
|
||||||
|
const currentKeys = Array.isArray(latestKeys) ? latestKeys : [];
|
||||||
|
const deleteIndex =
|
||||||
|
currentKeys[index] === apiKeyToDelete
|
||||||
|
? index
|
||||||
|
: currentKeys.findIndex((key) => key === apiKeyToDelete);
|
||||||
|
|
||||||
|
if (deleteIndex < 0) {
|
||||||
|
showNotification(t('notification.delete_failed'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiKeysApi.delete(deleteIndex);
|
||||||
|
const nextKeys = currentKeys.filter((_, idx) => idx !== deleteIndex);
|
||||||
|
setApiKeys(nextKeys);
|
||||||
|
updateConfigValue('api-keys', nextKeys);
|
||||||
|
clearCache('api-keys');
|
||||||
|
showNotification(t('notification.api_key_deleted'), 'success');
|
||||||
|
} catch (err: any) {
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionButtons = (
|
const actionButtons = (
|
||||||
@@ -176,8 +201,7 @@ export function ApiKeysPage() {
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDelete(index)}
|
onClick={() => handleDelete(index)}
|
||||||
disabled={disableControls || deletingIndex === index}
|
disabled={disableControls}
|
||||||
loading={deletingIndex === index}
|
|
||||||
>
|
>
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -32,6 +32,28 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.titleWrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--count-badge-text);
|
||||||
|
background-color: var(--count-badge-bg);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.errorBox {
|
.errorBox {
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
background-color: rgba(239, 68, 68, 0.1);
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
@@ -124,7 +146,7 @@
|
|||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: text;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
@@ -134,19 +156,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.statsInfo {
|
|
||||||
padding: 8px 12px;
|
|
||||||
background-color: var(--bg-secondary);
|
|
||||||
border-radius: $radius-md;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
height: 38px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 卡片网格
|
// 卡片网格
|
||||||
.fileGrid {
|
.fileGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -162,6 +171,272 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.antigravityGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: $spacing-md;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.codexGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: $spacing-md;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.geminiCliGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: $spacing-md;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.antigravityControls {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.antigravityControl {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.codexControls {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codexControl {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.geminiCliControls {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geminiCliControl {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.antigravityCard {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(224, 247, 250, 0.12),
|
||||||
|
rgba(224, 247, 250, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codexCard {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(255, 243, 224, 0.18),
|
||||||
|
rgba(255, 243, 224, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geminiCliCard {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(231, 239, 255, 0.2),
|
||||||
|
rgba(231, 239, 255, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding-top: $spacing-sm;
|
||||||
|
margin-top: $spacing-xs;
|
||||||
|
border-top: 1px dashed var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaRowHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaModel {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaBar {
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaBarFill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--success-color, #22c55e);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaBarFillHigh {
|
||||||
|
background-color: var(--success-color, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaBarFillMedium {
|
||||||
|
background-color: var(--warning-color, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaBarFillLow {
|
||||||
|
background-color: var(--danger-color, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaPercent {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaReset {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaAmount {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaMessage {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
padding: $spacing-sm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaError {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--danger-color);
|
||||||
|
background-color: rgba(239, 68, 68, 0.08);
|
||||||
|
border: 1px solid var(--danger-color);
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
padding: $spacing-xs $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaWarning {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--warning-color, #f59e0b);
|
||||||
|
background-color: rgba(245, 158, 11, 0.12);
|
||||||
|
border: 1px solid var(--warning-color, #f59e0b);
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
padding: $spacing-xs $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codexPlan {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codexPlanLabel {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codexPlanValue {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
// 单个认证文件卡片
|
// 单个认证文件卡片
|
||||||
.fileCard {
|
.fileCard {
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
@@ -250,6 +525,78 @@
|
|||||||
border-color: var(--failure-badge-border, #fca5a5);
|
border-color: var(--failure-badge-border, #fca5a5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 状态监测栏
|
||||||
|
.statusBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlocks {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlock {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
min-width: 6px;
|
||||||
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scaleY(1.5);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockSuccess {
|
||||||
|
background-color: var(--success-color, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockFailure {
|
||||||
|
background-color: var(--danger-color, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockMixed {
|
||||||
|
background-color: var(--warning-color, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockIdle {
|
||||||
|
background-color: var(--border-secondary, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRate {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateHigh {
|
||||||
|
color: var(--success-badge-text, #065f46);
|
||||||
|
background: var(--success-badge-bg, #d1fae5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateMedium {
|
||||||
|
color: var(--warning-text, #92400e);
|
||||||
|
background: var(--warning-bg, #fef3c7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateLow {
|
||||||
|
color: var(--failure-badge-text, #991b1b);
|
||||||
|
background: var(--failure-badge-bg, #fee2e2);
|
||||||
|
}
|
||||||
|
|
||||||
.cardActions {
|
.cardActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-xs;
|
gap: $spacing-xs;
|
||||||
@@ -350,6 +697,86 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuth 排除列表表单:提供商快捷标签
|
||||||
|
.providerField {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
|
||||||
|
:global(.form-group) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerTagList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerTag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerTagActive {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth 模型映射表单
|
||||||
|
.mappingRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mappingSeparator {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mappingFork {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
// 详情弹窗
|
// 详情弹窗
|
||||||
.detailContent {
|
.detailContent {
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useInterval } from '@/hooks/useInterval';
|
||||||
|
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
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 { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
import { IconBot, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import { IconBot, IconDownload, IconInfo, IconTrash2, IconX } from '@/components/ui/icons';
|
||||||
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||||
import { authFilesApi, usageApi } from '@/services/api';
|
import { authFilesApi, usageApi } from '@/services/api';
|
||||||
import { apiClient } from '@/services/api/client';
|
import { apiClient } from '@/services/api/client';
|
||||||
import type { AuthFileItem } from '@/types';
|
import type { AuthFileItem, OAuthModelMappingEntry } from '@/types';
|
||||||
import type { KeyStats, KeyStatBucket } from '@/utils/usage';
|
import {
|
||||||
|
calculateStatusBarData,
|
||||||
|
collectUsageDetails,
|
||||||
|
normalizeUsageSourceId,
|
||||||
|
type KeyStatBucket,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
import { formatFileSize } from '@/utils/format';
|
import { formatFileSize } from '@/utils/format';
|
||||||
|
import { generateId } from '@/utils/helpers';
|
||||||
import styles from './AuthFilesPage.module.scss';
|
import styles from './AuthFilesPage.module.scss';
|
||||||
|
|
||||||
type ThemeColors = { bg: string; text: string; border?: string };
|
type ThemeColors = { bg: string; text: string; border?: string };
|
||||||
@@ -63,11 +74,43 @@ const TYPE_COLORS: Record<string, TypeColorSet> = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OAUTH_PROVIDER_PRESETS = [
|
||||||
|
'gemini-cli',
|
||||||
|
'vertex',
|
||||||
|
'aistudio',
|
||||||
|
'antigravity',
|
||||||
|
'claude',
|
||||||
|
'codex',
|
||||||
|
'qwen',
|
||||||
|
'iflow'
|
||||||
|
];
|
||||||
|
|
||||||
|
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
|
||||||
|
const MIN_CARD_PAGE_SIZE = 3;
|
||||||
|
const MAX_CARD_PAGE_SIZE = 30;
|
||||||
|
const MAX_AUTH_FILE_SIZE = 50 * 1024;
|
||||||
|
|
||||||
|
const clampCardPageSize = (value: number) =>
|
||||||
|
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
||||||
|
|
||||||
interface ExcludedFormState {
|
interface ExcludedFormState {
|
||||||
provider: string;
|
provider: string;
|
||||||
modelsText: string;
|
modelsText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OAuthModelMappingFormEntry = OAuthModelMappingEntry & { id: string };
|
||||||
|
|
||||||
|
interface ModelMappingsFormState {
|
||||||
|
provider: string;
|
||||||
|
mappings: OAuthModelMappingFormEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({
|
||||||
|
id: generateId(),
|
||||||
|
name: '',
|
||||||
|
alias: '',
|
||||||
|
fork: false
|
||||||
|
});
|
||||||
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
|
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
|
||||||
function normalizeAuthIndexValue(value: unknown): string | null {
|
function normalizeAuthIndexValue(value: unknown): string | null {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
@@ -105,8 +148,9 @@ function resolveAuthFileStats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 尝试根据 source (文件名) 匹配
|
// 尝试根据 source (文件名) 匹配
|
||||||
if (rawFileName && stats.bySource?.[rawFileName]) {
|
const fileNameId = rawFileName ? normalizeUsageSourceId(rawFileName) : '';
|
||||||
const fromName = stats.bySource[rawFileName];
|
if (fileNameId && stats.bySource?.[fileNameId]) {
|
||||||
|
const fromName = stats.bySource[fileNameId];
|
||||||
if (fromName.success > 0 || fromName.failure > 0) {
|
if (fromName.success > 0 || fromName.failure > 0) {
|
||||||
return fromName;
|
return fromName;
|
||||||
}
|
}
|
||||||
@@ -116,7 +160,8 @@ function resolveAuthFileStats(
|
|||||||
if (rawFileName) {
|
if (rawFileName) {
|
||||||
const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, '');
|
const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, '');
|
||||||
if (nameWithoutExt && nameWithoutExt !== rawFileName) {
|
if (nameWithoutExt && nameWithoutExt !== rawFileName) {
|
||||||
const fromNameWithoutExt = stats.bySource?.[nameWithoutExt];
|
const nameWithoutExtId = normalizeUsageSourceId(nameWithoutExt);
|
||||||
|
const fromNameWithoutExt = nameWithoutExtId ? stats.bySource?.[nameWithoutExtId] : undefined;
|
||||||
if (fromNameWithoutExt && (fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0)) {
|
if (fromNameWithoutExt && (fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0)) {
|
||||||
return fromNameWithoutExt;
|
return fromNameWithoutExt;
|
||||||
}
|
}
|
||||||
@@ -143,6 +188,7 @@ export function AuthFilesPage() {
|
|||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
const [deletingAll, setDeletingAll] = useState(false);
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
||||||
|
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||||
|
|
||||||
// 详情弹窗相关
|
// 详情弹窗相关
|
||||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||||
@@ -163,11 +209,32 @@ export function AuthFilesPage() {
|
|||||||
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' });
|
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' });
|
||||||
const [savingExcluded, setSavingExcluded] = useState(false);
|
const [savingExcluded, setSavingExcluded] = useState(false);
|
||||||
|
|
||||||
|
// OAuth 模型映射相关
|
||||||
|
const [modelMappings, setModelMappings] = useState<Record<string, OAuthModelMappingEntry[]>>({});
|
||||||
|
const [modelMappingsError, setModelMappingsError] = useState<'unsupported' | null>(null);
|
||||||
|
const [mappingModalOpen, setMappingModalOpen] = useState(false);
|
||||||
|
const [mappingForm, setMappingForm] = useState<ModelMappingsFormState>({
|
||||||
|
provider: '',
|
||||||
|
mappings: [buildEmptyMappingEntry()]
|
||||||
|
});
|
||||||
|
const [savingMappings, setSavingMappings] = useState(false);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const loadingKeyStatsRef = useRef(false);
|
||||||
const excludedUnsupportedRef = useRef(false);
|
const excludedUnsupportedRef = useRef(false);
|
||||||
|
const mappingsUnsupportedRef = useRef(false);
|
||||||
|
|
||||||
const disableControls = connectionStatus !== 'connected';
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
|
const handlePageSizeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.currentTarget.valueAsNumber;
|
||||||
|
if (!Number.isFinite(value)) return;
|
||||||
|
setPageSize(clampCardPageSize(value));
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
// 格式化修改时间
|
// 格式化修改时间
|
||||||
const formatModified = (item: AuthFileItem): string => {
|
const formatModified = (item: AuthFileItem): string => {
|
||||||
const raw = item['modtime'] ?? item.modified;
|
const raw = item['modtime'] ?? item.modified;
|
||||||
@@ -195,13 +262,23 @@ export function AuthFilesPage() {
|
|||||||
}
|
}
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
// 加载 key 统计
|
// 加载 key 统计和 usage 明细(API 层已有60秒超时)
|
||||||
const loadKeyStats = useCallback(async () => {
|
const loadKeyStats = useCallback(async () => {
|
||||||
|
// 防止重复请求
|
||||||
|
if (loadingKeyStatsRef.current) return;
|
||||||
|
loadingKeyStatsRef.current = true;
|
||||||
try {
|
try {
|
||||||
const stats = await usageApi.getKeyStats();
|
const usageResponse = await usageApi.getUsage();
|
||||||
|
const usageData = usageResponse?.usage ?? usageResponse;
|
||||||
|
const stats = await usageApi.getKeyStats(usageData);
|
||||||
setKeyStats(stats);
|
setKeyStats(stats);
|
||||||
|
// 收集 usage 明细用于状态栏
|
||||||
|
const details = collectUsageDetails(usageData);
|
||||||
|
setUsageDetails(details);
|
||||||
} catch {
|
} catch {
|
||||||
// 静默失败
|
// 静默失败
|
||||||
|
} finally {
|
||||||
|
loadingKeyStatsRef.current = false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -231,11 +308,47 @@ export function AuthFilesPage() {
|
|||||||
}
|
}
|
||||||
}, [showNotification, t]);
|
}, [showNotification, t]);
|
||||||
|
|
||||||
|
// 加载 OAuth 模型映射
|
||||||
|
const loadModelMappings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFilesApi.getOauthModelMappings();
|
||||||
|
mappingsUnsupportedRef.current = false;
|
||||||
|
setModelMappings(res || {});
|
||||||
|
setModelMappingsError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status =
|
||||||
|
typeof err === 'object' && err !== null && 'status' in err
|
||||||
|
? (err as { status?: unknown }).status
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
setModelMappings({});
|
||||||
|
setModelMappingsError('unsupported');
|
||||||
|
if (!mappingsUnsupportedRef.current) {
|
||||||
|
mappingsUnsupportedRef.current = true;
|
||||||
|
showNotification(t('oauth_model_mappings.upgrade_required'), 'warning');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
|
}, [showNotification, t]);
|
||||||
|
|
||||||
|
const handleHeaderRefresh = useCallback(async () => {
|
||||||
|
await Promise.all([loadFiles(), loadKeyStats(), loadExcluded(), loadModelMappings()]);
|
||||||
|
}, [loadFiles, loadKeyStats, loadExcluded, loadModelMappings]);
|
||||||
|
|
||||||
|
useHeaderRefresh(handleHeaderRefresh);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFiles();
|
loadFiles();
|
||||||
loadKeyStats();
|
loadKeyStats();
|
||||||
loadExcluded();
|
loadExcluded();
|
||||||
}, [loadFiles, loadKeyStats, loadExcluded]);
|
loadModelMappings();
|
||||||
|
}, [loadFiles, loadKeyStats, loadExcluded, loadModelMappings]);
|
||||||
|
|
||||||
|
// 定时刷新状态数据(每240秒)
|
||||||
|
useInterval(loadKeyStats, 240_000);
|
||||||
|
|
||||||
// 提取所有存在的类型
|
// 提取所有存在的类型
|
||||||
const existingTypes = useMemo(() => {
|
const existingTypes = useMemo(() => {
|
||||||
@@ -248,6 +361,59 @@ export function AuthFilesPage() {
|
|||||||
return Array.from(types);
|
return Array.from(types);
|
||||||
}, [files]);
|
}, [files]);
|
||||||
|
|
||||||
|
|
||||||
|
const excludedProviderLookup = useMemo(() => {
|
||||||
|
const lookup = new Map<string, string>();
|
||||||
|
Object.keys(excluded).forEach((provider) => {
|
||||||
|
const key = provider.trim().toLowerCase();
|
||||||
|
if (key && !lookup.has(key)) {
|
||||||
|
lookup.set(key, provider);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return lookup;
|
||||||
|
}, [excluded]);
|
||||||
|
|
||||||
|
const mappingProviderLookup = useMemo(() => {
|
||||||
|
const lookup = new Map<string, string>();
|
||||||
|
Object.keys(modelMappings).forEach((provider) => {
|
||||||
|
const key = provider.trim().toLowerCase();
|
||||||
|
if (key && !lookup.has(key)) {
|
||||||
|
lookup.set(key, provider);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return lookup;
|
||||||
|
}, [modelMappings]);
|
||||||
|
|
||||||
|
const providerOptions = useMemo(() => {
|
||||||
|
const extraProviders = new Set<string>();
|
||||||
|
|
||||||
|
Object.keys(excluded).forEach((provider) => {
|
||||||
|
extraProviders.add(provider);
|
||||||
|
});
|
||||||
|
Object.keys(modelMappings).forEach((provider) => {
|
||||||
|
extraProviders.add(provider);
|
||||||
|
});
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (typeof file.type === 'string') {
|
||||||
|
extraProviders.add(file.type);
|
||||||
|
}
|
||||||
|
if (typeof file.provider === 'string') {
|
||||||
|
extraProviders.add(file.provider);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedExtras = Array.from(extraProviders)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value) => value && !OAUTH_PROVIDER_EXCLUDES.has(value.toLowerCase()));
|
||||||
|
|
||||||
|
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => value.toLowerCase()));
|
||||||
|
const extraList = normalizedExtras
|
||||||
|
.filter((value) => !baseSet.has(value.toLowerCase()))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
|
||||||
|
}, [excluded, files, modelMappings]);
|
||||||
|
|
||||||
// 过滤和搜索
|
// 过滤和搜索
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return files.filter((item) => {
|
return files.filter((item) => {
|
||||||
@@ -268,9 +434,6 @@ export function AuthFilesPage() {
|
|||||||
const start = (currentPage - 1) * pageSize;
|
const start = (currentPage - 1) * pageSize;
|
||||||
const pageItems = filtered.slice(start, start + pageSize);
|
const pageItems = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
// 统计信息
|
|
||||||
const totalSize = useMemo(() => files.reduce((sum, item) => sum + (item.size || 0), 0), [files]);
|
|
||||||
|
|
||||||
// 点击上传
|
// 点击上传
|
||||||
const handleUploadClick = () => {
|
const handleUploadClick = () => {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
@@ -284,18 +447,29 @@ export function AuthFilesPage() {
|
|||||||
const filesToUpload = Array.from(fileList);
|
const filesToUpload = Array.from(fileList);
|
||||||
const validFiles: File[] = [];
|
const validFiles: File[] = [];
|
||||||
const invalidFiles: string[] = [];
|
const invalidFiles: string[] = [];
|
||||||
|
const oversizedFiles: string[] = [];
|
||||||
|
|
||||||
filesToUpload.forEach((file) => {
|
filesToUpload.forEach((file) => {
|
||||||
if (file.name.endsWith('.json')) {
|
if (!file.name.endsWith('.json')) {
|
||||||
validFiles.push(file);
|
|
||||||
} else {
|
|
||||||
invalidFiles.push(file.name);
|
invalidFiles.push(file.name);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (file.size > MAX_AUTH_FILE_SIZE) {
|
||||||
|
oversizedFiles.push(file.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
validFiles.push(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (invalidFiles.length > 0) {
|
if (invalidFiles.length > 0) {
|
||||||
showNotification(t('auth_files.upload_error_json'), 'error');
|
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) {
|
if (validFiles.length === 0) {
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
@@ -466,7 +640,8 @@ export function AuthFilesPage() {
|
|||||||
|
|
||||||
// 检查模型是否被 OAuth 排除
|
// 检查模型是否被 OAuth 排除
|
||||||
const isModelExcluded = (modelId: string, providerType: string): boolean => {
|
const isModelExcluded = (modelId: string, providerType: string): boolean => {
|
||||||
const excludedModels = excluded[providerType] || [];
|
const providerKey = normalizeProviderKey(providerType);
|
||||||
|
const excludedModels = excluded[providerKey] || excluded[providerType] || [];
|
||||||
return excludedModels.some(pattern => {
|
return excludedModels.some(pattern => {
|
||||||
if (pattern.includes('*')) {
|
if (pattern.includes('*')) {
|
||||||
// 支持通配符匹配
|
// 支持通配符匹配
|
||||||
@@ -494,16 +669,20 @@ export function AuthFilesPage() {
|
|||||||
|
|
||||||
// OAuth 排除相关方法
|
// OAuth 排除相关方法
|
||||||
const openExcludedModal = (provider?: string) => {
|
const openExcludedModal = (provider?: string) => {
|
||||||
const models = provider ? excluded[provider] : [];
|
const normalizedProvider = normalizeProviderKey(provider || '');
|
||||||
|
const fallbackProvider =
|
||||||
|
normalizedProvider || (filter !== 'all' ? normalizeProviderKey(String(filter)) : '');
|
||||||
|
const lookupKey = fallbackProvider ? excludedProviderLookup.get(fallbackProvider) : undefined;
|
||||||
|
const models = lookupKey ? excluded[lookupKey] : [];
|
||||||
setExcludedForm({
|
setExcludedForm({
|
||||||
provider: provider || '',
|
provider: lookupKey || fallbackProvider,
|
||||||
modelsText: Array.isArray(models) ? models.join('\n') : ''
|
modelsText: Array.isArray(models) ? models.join('\n') : ''
|
||||||
});
|
});
|
||||||
setExcludedModalOpen(true);
|
setExcludedModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveExcludedModels = async () => {
|
const saveExcludedModels = async () => {
|
||||||
const provider = excludedForm.provider.trim();
|
const provider = normalizeProviderKey(excludedForm.provider);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
showNotification(t('oauth_excluded.provider_required'), 'error');
|
showNotification(t('oauth_excluded.provider_required'), 'error');
|
||||||
return;
|
return;
|
||||||
@@ -531,14 +710,135 @@ export function AuthFilesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteExcluded = async (provider: string) => {
|
const deleteExcluded = async (provider: string) => {
|
||||||
if (!window.confirm(t('oauth_excluded.delete_confirm', { provider }))) return;
|
const providerLabel = provider.trim() || provider;
|
||||||
|
if (!window.confirm(t('oauth_excluded.delete_confirm', { provider: providerLabel }))) return;
|
||||||
|
const providerKey = normalizeProviderKey(provider);
|
||||||
|
if (!providerKey) {
|
||||||
|
showNotification(t('oauth_excluded.provider_required'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await authFilesApi.deleteOauthExcludedEntry(provider);
|
await authFilesApi.deleteOauthExcludedEntry(providerKey);
|
||||||
await loadExcluded();
|
await loadExcluded();
|
||||||
showNotification(t('oauth_excluded.delete_success'), 'success');
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// OAuth 模型映射相关方法
|
||||||
|
const normalizeMappingEntries = (entries?: OAuthModelMappingEntry[]): OAuthModelMappingFormEntry[] => {
|
||||||
|
if (!Array.isArray(entries) || entries.length === 0) {
|
||||||
|
return [buildEmptyMappingEntry()];
|
||||||
|
}
|
||||||
|
return entries.map((entry) => ({
|
||||||
|
id: generateId(),
|
||||||
|
name: entry.name ?? '',
|
||||||
|
alias: entry.alias ?? '',
|
||||||
|
fork: Boolean(entry.fork),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMappingsModal = (provider?: string) => {
|
||||||
|
const normalizedProvider = (provider || '').trim();
|
||||||
|
const fallbackProvider = normalizedProvider || (filter !== 'all' ? String(filter) : '');
|
||||||
|
const lookupKey = fallbackProvider
|
||||||
|
? mappingProviderLookup.get(fallbackProvider.toLowerCase())
|
||||||
|
: undefined;
|
||||||
|
const mappings = lookupKey ? modelMappings[lookupKey] : [];
|
||||||
|
setMappingForm({
|
||||||
|
provider: lookupKey || fallbackProvider,
|
||||||
|
mappings: normalizeMappingEntries(mappings),
|
||||||
|
});
|
||||||
|
setMappingModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMappingEntry = (index: number, field: keyof OAuthModelMappingEntry, value: string | boolean) => {
|
||||||
|
setMappingForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
mappings: prev.mappings.map((entry, idx) =>
|
||||||
|
idx === index ? { ...entry, [field]: value } : entry
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMappingEntry = () => {
|
||||||
|
setMappingForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
mappings: [...prev.mappings, buildEmptyMappingEntry()],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMappingEntry = (index: number) => {
|
||||||
|
setMappingForm((prev) => {
|
||||||
|
const next = prev.mappings.filter((_, idx) => idx !== index);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
mappings: next.length ? next : [buildEmptyMappingEntry()],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveModelMappings = async () => {
|
||||||
|
const provider = mappingForm.provider.trim();
|
||||||
|
if (!provider) {
|
||||||
|
showNotification(t('oauth_model_mappings.provider_required'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const mappings = mappingForm.mappings
|
||||||
|
.map((entry) => {
|
||||||
|
const name = String(entry.name ?? '').trim();
|
||||||
|
const alias = String(entry.alias ?? '').trim();
|
||||||
|
if (!name || !alias) return null;
|
||||||
|
const key = `${name.toLowerCase()}::${alias.toLowerCase()}::${entry.fork ? '1' : '0'}`;
|
||||||
|
if (seen.has(key)) return null;
|
||||||
|
seen.add(key);
|
||||||
|
return entry.fork ? { name, alias, fork: true } : { name, alias };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as OAuthModelMappingEntry[];
|
||||||
|
|
||||||
|
setSavingMappings(true);
|
||||||
|
try {
|
||||||
|
if (mappings.length) {
|
||||||
|
await authFilesApi.saveOauthModelMappings(provider, mappings);
|
||||||
|
} else {
|
||||||
|
await authFilesApi.deleteOauthModelMappings(provider);
|
||||||
|
}
|
||||||
|
await loadModelMappings();
|
||||||
|
showNotification(t('oauth_model_mappings.save_success'), 'success');
|
||||||
|
setMappingModalOpen(false);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage = err instanceof Error ? err.message : '';
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
|
showNotification(`${t('oauth_model_mappings.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSavingMappings(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteModelMappings = async (provider: string) => {
|
||||||
|
if (!window.confirm(t('oauth_model_mappings.delete_confirm', { provider }))) return;
|
||||||
|
try {
|
||||||
|
await authFilesApi.deleteOauthModelMappings(provider);
|
||||||
|
await loadModelMappings();
|
||||||
|
showNotification(t('oauth_model_mappings.delete_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_mappings.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -570,10 +870,71 @@ export function AuthFilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 预计算所有认证文件的状态栏数据(避免每次渲染重复计算)
|
||||||
|
const statusBarCache = useMemo(() => {
|
||||||
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
|
||||||
|
if (authIndexKey) {
|
||||||
|
// 过滤出属于该认证文件的 usage 明细
|
||||||
|
const filteredDetails = usageDetails.filter((detail) => {
|
||||||
|
const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index);
|
||||||
|
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
|
||||||
|
});
|
||||||
|
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}, [usageDetails, files]);
|
||||||
|
|
||||||
|
// 渲染状态监测栏
|
||||||
|
const renderStatusBar = (item: AuthFileItem) => {
|
||||||
|
// 认证文件使用 authIndex 来匹配 usage 数据
|
||||||
|
const rawAuthIndex = item['auth_index'] ?? item.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
|
||||||
|
const statusData = (authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
||||||
|
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||||
|
const rateClass = !hasData
|
||||||
|
? ''
|
||||||
|
: statusData.successRate >= 90
|
||||||
|
? styles.statusRateHigh
|
||||||
|
: statusData.successRate >= 50
|
||||||
|
? styles.statusRateMedium
|
||||||
|
: styles.statusRateLow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statusBar}>
|
||||||
|
<div className={styles.statusBlocks}>
|
||||||
|
{statusData.blocks.map((state, idx) => {
|
||||||
|
const blockClass =
|
||||||
|
state === 'success'
|
||||||
|
? styles.statusBlockSuccess
|
||||||
|
: state === 'failure'
|
||||||
|
? styles.statusBlockFailure
|
||||||
|
: state === 'mixed'
|
||||||
|
? styles.statusBlockMixed
|
||||||
|
: styles.statusBlockIdle;
|
||||||
|
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className={`${styles.statusRate} ${rateClass}`}>
|
||||||
|
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 渲染单个认证文件卡片
|
// 渲染单个认证文件卡片
|
||||||
const renderFileCard = (item: AuthFileItem) => {
|
const renderFileCard = (item: AuthFileItem) => {
|
||||||
const fileStats = resolveAuthFileStats(item, keyStats);
|
const fileStats = resolveAuthFileStats(item, keyStats);
|
||||||
const isRuntimeOnly = isRuntimeOnlyAuthFile(item);
|
const isRuntimeOnly = isRuntimeOnlyAuthFile(item);
|
||||||
|
const isAistudio = (item.type || '').toLowerCase() === 'aistudio';
|
||||||
|
const showModelsButton = !isRuntimeOnly || isAistudio;
|
||||||
const typeColor = getTypeColor(item.type || 'unknown');
|
const typeColor = getTypeColor(item.type || 'unknown');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -606,21 +967,24 @@ export function AuthFilesPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 状态监测栏 */}
|
||||||
|
{renderStatusBar(item)}
|
||||||
|
|
||||||
<div className={styles.cardActions}>
|
<div className={styles.cardActions}>
|
||||||
{isRuntimeOnly ? (
|
{showModelsButton && (
|
||||||
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
|
<Button
|
||||||
) : (
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => showModels(item)}
|
||||||
|
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={() => showModels(item)}
|
|
||||||
className={styles.iconButton}
|
|
||||||
title={t('auth_files.models_button', { defaultValue: '模型' })}
|
|
||||||
disabled={disableControls}
|
|
||||||
>
|
|
||||||
<IconBot className={styles.actionIcon} size={16} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -657,11 +1021,21 @@ export function AuthFilesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isRuntimeOnly && (
|
||||||
|
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const titleNode = (
|
||||||
|
<div className={styles.titleWrapper}>
|
||||||
|
<span>{t('auth_files.title_section')}</span>
|
||||||
|
{files.length > 0 && <span className={styles.countBadge}>{files.length}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
@@ -670,10 +1044,15 @@ export function AuthFilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
title={t('auth_files.title_section')}
|
title={titleNode}
|
||||||
extra={
|
extra={
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<Button variant="secondary" size="sm" onClick={() => { loadFiles(); loadKeyStats(); }} disabled={loading}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleHeaderRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
{t('common.refresh')}
|
{t('common.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -719,26 +1098,15 @@ export function AuthFilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.filterItem}>
|
<div className={styles.filterItem}>
|
||||||
<label>{t('auth_files.page_size_label')}</label>
|
<label>{t('auth_files.page_size_label')}</label>
|
||||||
<select
|
<input
|
||||||
className={styles.pageSizeSelect}
|
className={styles.pageSizeSelect}
|
||||||
|
type="number"
|
||||||
|
min={MIN_CARD_PAGE_SIZE}
|
||||||
|
max={MAX_CARD_PAGE_SIZE}
|
||||||
|
step={1}
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => {
|
onChange={handlePageSizeChange}
|
||||||
setPageSize(Number(e.target.value) || 9);
|
/>
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value={6}>6</option>
|
|
||||||
<option value={9}>9</option>
|
|
||||||
<option value={12}>12</option>
|
|
||||||
<option value={18}>18</option>
|
|
||||||
<option value={24}>24</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className={styles.filterItem}>
|
|
||||||
<label>{t('common.info')}</label>
|
|
||||||
<div className={styles.statsInfo}>
|
|
||||||
{files.length} {t('auth_files.files_count')} · {formatFileSize(totalSize)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -830,6 +1198,52 @@ export function AuthFilesPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* OAuth 模型映射卡片 */}
|
||||||
|
<Card
|
||||||
|
title={t('oauth_model_mappings.title')}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openMappingsModal()}
|
||||||
|
disabled={disableControls || modelMappingsError === 'unsupported'}
|
||||||
|
>
|
||||||
|
{t('oauth_model_mappings.add')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{modelMappingsError === 'unsupported' ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('oauth_model_mappings.upgrade_required_title')}
|
||||||
|
description={t('oauth_model_mappings.upgrade_required_desc')}
|
||||||
|
/>
|
||||||
|
) : Object.keys(modelMappings).length === 0 ? (
|
||||||
|
<EmptyState title={t('oauth_model_mappings.list_empty_all')} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.excludedList}>
|
||||||
|
{Object.entries(modelMappings).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_mappings.model_count', { count: mappings.length })
|
||||||
|
: t('oauth_model_mappings.no_models')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.excludedActions}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => openMappingsModal(provider)}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => deleteModelMappings(provider)}>
|
||||||
|
{t('oauth_model_mappings.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 详情弹窗 */}
|
{/* 详情弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
open={detailModalOpen}
|
open={detailModalOpen}
|
||||||
@@ -932,12 +1346,41 @@ export function AuthFilesPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Input
|
<div className={styles.providerField}>
|
||||||
label={t('oauth_excluded.provider_label')}
|
<Input
|
||||||
placeholder={t('oauth_excluded.provider_placeholder')}
|
id="oauth-excluded-provider"
|
||||||
value={excludedForm.provider}
|
list="oauth-excluded-provider-options"
|
||||||
onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))}
|
label={t('oauth_excluded.provider_label')}
|
||||||
/>
|
hint={t('oauth_excluded.provider_hint')}
|
||||||
|
placeholder={t('oauth_excluded.provider_placeholder')}
|
||||||
|
value={excludedForm.provider}
|
||||||
|
onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<datalist id="oauth-excluded-provider-options">
|
||||||
|
{providerOptions.map((provider) => (
|
||||||
|
<option key={provider} value={provider} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
{providerOptions.length > 0 && (
|
||||||
|
<div className={styles.providerTagList}>
|
||||||
|
{providerOptions.map((provider) => {
|
||||||
|
const isActive =
|
||||||
|
excludedForm.provider.trim().toLowerCase() === provider.toLowerCase();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={provider}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.providerTag} ${isActive ? styles.providerTagActive : ''}`}
|
||||||
|
onClick={() => setExcludedForm((prev) => ({ ...prev, provider }))}
|
||||||
|
disabled={savingExcluded}
|
||||||
|
>
|
||||||
|
{getTypeLabel(provider)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>{t('oauth_excluded.models_label')}</label>
|
<label>{t('oauth_excluded.models_label')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -950,6 +1393,114 @@ export function AuthFilesPage() {
|
|||||||
<div className={styles.hint}>{t('oauth_excluded.models_hint')}</div>
|
<div className={styles.hint}>{t('oauth_excluded.models_hint')}</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* OAuth 模型映射弹窗 */}
|
||||||
|
<Modal
|
||||||
|
open={mappingModalOpen}
|
||||||
|
onClose={() => setMappingModalOpen(false)}
|
||||||
|
title={t('oauth_model_mappings.add_title')}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={() => setMappingModalOpen(false)} disabled={savingMappings}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveModelMappings} loading={savingMappings}>
|
||||||
|
{t('oauth_model_mappings.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.providerField}>
|
||||||
|
<Input
|
||||||
|
id="oauth-model-alias-provider"
|
||||||
|
list="oauth-model-alias-provider-options"
|
||||||
|
label={t('oauth_model_mappings.provider_label')}
|
||||||
|
hint={t('oauth_model_mappings.provider_hint')}
|
||||||
|
placeholder={t('oauth_model_mappings.provider_placeholder')}
|
||||||
|
value={mappingForm.provider}
|
||||||
|
onChange={(e) => setMappingForm((prev) => ({ ...prev, provider: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<datalist id="oauth-model-alias-provider-options">
|
||||||
|
{providerOptions.map((provider) => (
|
||||||
|
<option key={provider} value={provider} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
{providerOptions.length > 0 && (
|
||||||
|
<div className={styles.providerTagList}>
|
||||||
|
{providerOptions.map((provider) => {
|
||||||
|
const isActive =
|
||||||
|
mappingForm.provider.trim().toLowerCase() === provider.toLowerCase();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={provider}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.providerTag} ${isActive ? styles.providerTagActive : ''}`}
|
||||||
|
onClick={() => setMappingForm((prev) => ({ ...prev, provider }))}
|
||||||
|
disabled={savingMappings}
|
||||||
|
>
|
||||||
|
{getTypeLabel(provider)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>{t('oauth_model_mappings.mappings_label')}</label>
|
||||||
|
<div className="header-input-list">
|
||||||
|
{(mappingForm.mappings.length ? mappingForm.mappings : [buildEmptyMappingEntry()]).map(
|
||||||
|
(entry, index) => (
|
||||||
|
<div key={entry.id} className={styles.mappingRow}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder={t('oauth_model_mappings.mapping_name_placeholder')}
|
||||||
|
value={entry.name}
|
||||||
|
onChange={(e) => updateMappingEntry(index, 'name', e.target.value)}
|
||||||
|
disabled={savingMappings}
|
||||||
|
/>
|
||||||
|
<span className={styles.mappingSeparator}>→</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder={t('oauth_model_mappings.mapping_alias_placeholder')}
|
||||||
|
value={entry.alias}
|
||||||
|
onChange={(e) => updateMappingEntry(index, 'alias', e.target.value)}
|
||||||
|
disabled={savingMappings}
|
||||||
|
/>
|
||||||
|
<div className={styles.mappingFork}>
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('oauth_model_mappings.mapping_fork_label')}
|
||||||
|
labelPosition="left"
|
||||||
|
checked={Boolean(entry.fork)}
|
||||||
|
onChange={(value) => updateMappingEntry(index, 'fork', value)}
|
||||||
|
disabled={savingMappings}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeMappingEntry(index)}
|
||||||
|
disabled={savingMappings || mappingForm.mappings.length <= 1}
|
||||||
|
title={t('common.delete')}
|
||||||
|
aria-label={t('common.delete')}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={addMappingEntry}
|
||||||
|
disabled={savingMappings}
|
||||||
|
className="align-start"
|
||||||
|
>
|
||||||
|
{t('oauth_model_mappings.add_mapping')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.hint}>{t('oauth_model_mappings.mappings_hint')}</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
@@ -135,13 +134,17 @@
|
|||||||
.editorWrapper {
|
.editorWrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
height: 480px;
|
height: clamp(360px, 60vh, 920px);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: $radius-lg;
|
border-radius: $radius-lg;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
--floating-controls-height: 0px;
|
--floating-controls-height: 0px;
|
||||||
|
|
||||||
|
@supports (height: 100dvh) {
|
||||||
|
height: clamp(360px, 60dvh, 920px);
|
||||||
|
}
|
||||||
|
|
||||||
// Floating search toolbar on top of the editor (but not covering content).
|
// Floating search toolbar on top of the editor (but not covering content).
|
||||||
.floatingControls {
|
.floatingControls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -222,7 +225,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
@@ -256,8 +259,4 @@
|
|||||||
.configCard {
|
.configCard {
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorWrapper {
|
|
||||||
height: 360px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||||
import { Navigate, useNavigate, useLocation } from 'react-router-dom';
|
import { Navigate, useNavigate, 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 { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
||||||
import { useAuthStore, useNotificationStore } from '@/stores';
|
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
||||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
@@ -12,21 +12,26 @@ export function LoginPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
|
const language = useLanguageStore((state) => state.language);
|
||||||
|
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const login = useAuthStore((state) => state.login);
|
const login = useAuthStore((state) => state.login);
|
||||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||||
const storedBase = useAuthStore((state) => state.apiBase);
|
const storedBase = useAuthStore((state) => state.apiBase);
|
||||||
const storedKey = useAuthStore((state) => state.managementKey);
|
const storedKey = useAuthStore((state) => state.managementKey);
|
||||||
|
const storedRememberPassword = useAuthStore((state) => state.rememberPassword);
|
||||||
|
|
||||||
const [apiBase, setApiBase] = useState('');
|
const [apiBase, setApiBase] = useState('');
|
||||||
const [managementKey, setManagementKey] = useState('');
|
const [managementKey, setManagementKey] = useState('');
|
||||||
const [showCustomBase, setShowCustomBase] = useState(false);
|
const [showCustomBase, setShowCustomBase] = useState(false);
|
||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
const [rememberPassword, setRememberPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [autoLoading, setAutoLoading] = useState(true);
|
const [autoLoading, setAutoLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
||||||
|
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@@ -35,6 +40,7 @@ export function LoginPage() {
|
|||||||
if (!autoLoggedIn) {
|
if (!autoLoggedIn) {
|
||||||
setApiBase(storedBase || detectedBase);
|
setApiBase(storedBase || detectedBase);
|
||||||
setManagementKey(storedKey || '');
|
setManagementKey(storedKey || '');
|
||||||
|
setRememberPassword(storedRememberPassword || Boolean(storedKey));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setAutoLoading(false);
|
setAutoLoading(false);
|
||||||
@@ -42,16 +48,7 @@ export function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
init();
|
init();
|
||||||
}, [detectedBase, restoreSession, storedBase, storedKey]);
|
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
|
||||||
|
|
||||||
if (isAuthenticated) {
|
|
||||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
|
||||||
return <Navigate to={redirect} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUseCurrent = () => {
|
|
||||||
setApiBase(detectedBase);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!managementKey.trim()) {
|
if (!managementKey.trim()) {
|
||||||
@@ -63,7 +60,11 @@ export function LoginPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await login({ apiBase: baseToUse, managementKey: managementKey.trim() });
|
await login({
|
||||||
|
apiBase: baseToUse,
|
||||||
|
managementKey: managementKey.trim(),
|
||||||
|
rememberPassword
|
||||||
|
});
|
||||||
showNotification(t('common.connected_status'), 'success');
|
showNotification(t('common.connected_status'), 'success');
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -75,11 +76,39 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmitKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === 'Enter' && !loading) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loading, handleSubmit]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
const redirect = (location.state as any)?.from?.pathname || '/';
|
||||||
|
return <Navigate to={redirect} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-page">
|
<div className="login-page">
|
||||||
<div className="login-card">
|
<div className="login-card">
|
||||||
<div className="login-header">
|
<div className="login-header">
|
||||||
<div className="title">{t('title.login')}</div>
|
<div className="login-title-row">
|
||||||
|
<div className="title">{t('title.login')}</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="login-language-btn"
|
||||||
|
onClick={toggleLanguage}
|
||||||
|
title={t('language.switch')}
|
||||||
|
aria-label={t('language.switch')}
|
||||||
|
>
|
||||||
|
{nextLanguageLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="subtitle">{t('login.subtitle')}</div>
|
<div className="subtitle">{t('login.subtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,11 +139,13 @@ export function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
|
autoFocus
|
||||||
label={t('login.management_key_label')}
|
label={t('login.management_key_label')}
|
||||||
placeholder={t('login.management_key_placeholder')}
|
placeholder={t('login.management_key_placeholder')}
|
||||||
type={showKey ? 'text' : 'password'}
|
type={showKey ? 'text' : 'password'}
|
||||||
value={managementKey}
|
value={managementKey}
|
||||||
onChange={(e) => setManagementKey(e.target.value)}
|
onChange={(e) => setManagementKey(e.target.value)}
|
||||||
|
onKeyDown={handleSubmitKeyDown}
|
||||||
rightElement={
|
rightElement={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -136,15 +167,20 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
<div className="toggle-advanced">
|
||||||
<Button variant="secondary" onClick={handleUseCurrent}>
|
<input
|
||||||
{t('login.use_current_address')}
|
id="remember-password-toggle"
|
||||||
</Button>
|
type="checkbox"
|
||||||
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
checked={rememberPassword}
|
||||||
{loading ? t('login.submitting') : t('login.submit_button')}
|
onChange={(e) => setRememberPassword(e.target.checked)}
|
||||||
</Button>
|
/>
|
||||||
|
<label htmlFor="remember-password-toggle">{t('login.remember_password_label')}</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
||||||
|
{loading ? t('login.submitting') : t('login.submit_button')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
|
||||||
{autoLoading && (
|
{autoLoading && (
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
@@ -53,6 +57,11 @@
|
|||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
gap: $spacing-md;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logCard {
|
.logCard {
|
||||||
@@ -61,6 +70,12 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -87,6 +102,11 @@
|
|||||||
:global(.form-group) {
|
:global(.form-group) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchWrapper {
|
.searchWrapper {
|
||||||
@@ -161,13 +181,26 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
flex: 0 0 auto;
|
flex: 1 1 auto;
|
||||||
height: 480px;
|
min-height: 280px;
|
||||||
|
max-height: calc(100vh - 320px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
touch-action: pan-y;
|
touch-action: pan-y;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
min-height: 240px;
|
||||||
|
max-height: calc(100vh - 300px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
min-height: 360px;
|
||||||
|
max-height: 480px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorPanel {
|
.errorPanel {
|
||||||
@@ -190,6 +223,17 @@
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loadMoreCount {
|
.loadMoreCount {
|
||||||
@@ -201,6 +245,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logList {
|
.logList {
|
||||||
@@ -227,9 +281,18 @@
|
|||||||
background: rgba(59, 130, 246, 0.06);
|
background: rgba(59, 130, 246, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: $spacing-xs;
|
gap: $spacing-xs;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11.5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,12 +434,17 @@
|
|||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-height: 820px) {
|
@media (max-height: 820px) {
|
||||||
@@ -402,10 +470,65 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logPanel {
|
.logPanel {
|
||||||
height: 360px;
|
min-height: 200px;
|
||||||
|
max-height: calc(100vh - 280px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logRow {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorPanel {
|
.errorPanel {
|
||||||
height: 360px;
|
height: 360px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-height: 600px) {
|
||||||
|
.pageTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBar {
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItem {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logCard {
|
||||||
|
padding: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logPanel {
|
||||||
|
min-height: 160px;
|
||||||
|
max-height: calc(100vh - 220px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logRow {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
grid-template-columns: 130px 1fr;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMoreBanner {
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorPanel {
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
IconTrash2,
|
IconTrash2,
|
||||||
IconX,
|
IconX,
|
||||||
} from '@/components/ui/icons';
|
} from '@/components/ui/icons';
|
||||||
|
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
import { logsApi } from '@/services/api/logs';
|
import { logsApi } from '@/services/api/logs';
|
||||||
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
|
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
|
||||||
@@ -50,7 +51,8 @@ const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
|
|||||||
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
|
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
|
||||||
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\s*\]?(?=\s|\[|$)\s*/i;
|
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\s*\]?(?=\s|\[|$)\s*/i;
|
||||||
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
|
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
|
||||||
const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i;
|
const LOG_LATENCY_REGEX =
|
||||||
|
/\b(?:\d+(?:\.\d+)?\s*(?:µs|us|ms|s|m))(?:\s*\d+(?:\.\d+)?\s*(?:µs|us|ms|s|m))*\b/i;
|
||||||
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
|
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
|
||||||
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
|
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
|
||||||
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
|
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
|
||||||
@@ -102,6 +104,12 @@ const normalizeTimestampToSeconds = (value: string): string => {
|
|||||||
return `${match[1]} ${match[2]}`;
|
return `${match[1]} ${match[2]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const extractLatency = (text: string): string | undefined => {
|
||||||
|
const match = text.match(LOG_LATENCY_REGEX);
|
||||||
|
if (!match) return undefined;
|
||||||
|
return match[0].replace(/\s+/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
type ParsedLogLine = {
|
type ParsedLogLine = {
|
||||||
raw: string;
|
raw: string;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
@@ -244,9 +252,9 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
|||||||
// latency
|
// latency
|
||||||
const latencyIndex = segments.findIndex((segment) => LOG_LATENCY_REGEX.test(segment));
|
const latencyIndex = segments.findIndex((segment) => LOG_LATENCY_REGEX.test(segment));
|
||||||
if (latencyIndex >= 0) {
|
if (latencyIndex >= 0) {
|
||||||
const match = segments[latencyIndex].match(LOG_LATENCY_REGEX);
|
const extracted = extractLatency(segments[latencyIndex]);
|
||||||
if (match) {
|
if (extracted) {
|
||||||
latency = `${match[1]}${match[2]}`;
|
latency = extracted;
|
||||||
consumed.add(latencyIndex);
|
consumed.add(latencyIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,8 +295,8 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
|||||||
} else {
|
} else {
|
||||||
statusCode = detectHttpStatusCode(remaining);
|
statusCode = detectHttpStatusCode(remaining);
|
||||||
|
|
||||||
const latencyMatch = remaining.match(LOG_LATENCY_REGEX);
|
const extracted = extractLatency(remaining);
|
||||||
if (latencyMatch) latency = `${latencyMatch[1]}${latencyMatch[2]}`;
|
if (extracted) latency = extracted;
|
||||||
|
|
||||||
ip = extractIp(remaining);
|
ip = extractIp(remaining);
|
||||||
|
|
||||||
@@ -467,6 +475,8 @@ export function LogsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useHeaderRefresh(() => loadLogs(false));
|
||||||
|
|
||||||
const clearLogs = async () => {
|
const clearLogs = async () => {
|
||||||
if (!window.confirm(t('logs.clear_confirm'))) return;
|
if (!window.confirm(t('logs.clear_confirm'))) return;
|
||||||
try {
|
try {
|
||||||
@@ -851,10 +861,6 @@ export function LogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hint">
|
|
||||||
{requestLogEnabled ? t('logs.action_hint') : t('logs.action_hint_disabled')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="hint">{t('logs.loading')}</div>
|
<div className="hint">{t('logs.loading')}</div>
|
||||||
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
|
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
|
||||||
|
|||||||
@@ -114,3 +114,32 @@
|
|||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
margin-top: $spacing-sm;
|
margin-top: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.geminiProjectField {
|
||||||
|
:global(.form-group) {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filePicker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileNamePlaceholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, type ChangeEvent } 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 { useNotificationStore, useThemeStore } from '@/stores';
|
import { useNotificationStore, useThemeStore } from '@/stores';
|
||||||
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
|
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
|
||||||
|
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
|
||||||
import styles from './OAuthPage.module.scss';
|
import styles from './OAuthPage.module.scss';
|
||||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||||
@@ -13,6 +14,7 @@ import iconAntigravity from '@/assets/icons/antigravity.svg';
|
|||||||
import iconGemini from '@/assets/icons/gemini.svg';
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
import iconQwen from '@/assets/icons/qwen.svg';
|
import iconQwen from '@/assets/icons/qwen.svg';
|
||||||
import iconIflow from '@/assets/icons/iflow.svg';
|
import iconIflow from '@/assets/icons/iflow.svg';
|
||||||
|
import iconVertex from '@/assets/icons/vertex.svg';
|
||||||
|
|
||||||
interface ProviderState {
|
interface ProviderState {
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -36,16 +38,34 @@ interface IFlowCookieState {
|
|||||||
errorType?: 'error' | 'warning';
|
errorType?: 'error' | 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VertexImportResult {
|
||||||
|
projectId?: string;
|
||||||
|
email?: string;
|
||||||
|
location?: string;
|
||||||
|
authFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VertexImportState {
|
||||||
|
file?: File;
|
||||||
|
fileName: string;
|
||||||
|
location: string;
|
||||||
|
loading: boolean;
|
||||||
|
error?: string;
|
||||||
|
result?: VertexImportResult;
|
||||||
|
}
|
||||||
|
|
||||||
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
|
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
|
||||||
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } },
|
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } },
|
||||||
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
|
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
|
||||||
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
|
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
|
||||||
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
|
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
|
||||||
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen },
|
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen }
|
||||||
{ id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label', icon: iconIflow }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow'];
|
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
|
||||||
|
const getProviderI18nPrefix = (provider: OAuthProvider) => provider.replace('-', '_');
|
||||||
|
const getAuthKey = (provider: OAuthProvider, suffix: string) =>
|
||||||
|
`auth_login.${getProviderI18nPrefix(provider)}_${suffix}`;
|
||||||
|
|
||||||
const getIcon = (icon: string | { light: string; dark: string }, theme: 'light' | 'dark') => {
|
const getIcon = (icon: string | { light: string; dark: string }, theme: 'light' | 'dark') => {
|
||||||
return typeof icon === 'string' ? icon : icon[theme];
|
return typeof icon === 'string' ? icon : icon[theme];
|
||||||
@@ -57,7 +77,13 @@ export function OAuthPage() {
|
|||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
||||||
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
|
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
|
||||||
|
const [vertexState, setVertexState] = useState<VertexImportState>({
|
||||||
|
fileName: '',
|
||||||
|
location: '',
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
const timers = useRef<Record<string, number>>({});
|
const timers = useRef<Record<string, number>>({});
|
||||||
|
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -81,12 +107,15 @@ export function OAuthPage() {
|
|||||||
const res = await oauthApi.getAuthStatus(state);
|
const res = await oauthApi.getAuthStatus(state);
|
||||||
if (res.status === 'ok') {
|
if (res.status === 'ok') {
|
||||||
updateProviderState(provider, { status: 'success', polling: false });
|
updateProviderState(provider, { status: 'success', polling: false });
|
||||||
showNotification(t('auth_login.codex_oauth_status_success'), 'success');
|
showNotification(t(getAuthKey(provider, 'oauth_status_success')), 'success');
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
delete timers.current[provider];
|
delete timers.current[provider];
|
||||||
} else if (res.status === 'error') {
|
} else if (res.status === 'error') {
|
||||||
updateProviderState(provider, { status: 'error', error: res.error, polling: false });
|
updateProviderState(provider, { status: 'error', error: res.error, polling: false });
|
||||||
showNotification(`${t('auth_login.codex_oauth_status_error')} ${res.error || ''}`, 'error');
|
showNotification(
|
||||||
|
`${t(getAuthKey(provider, 'oauth_status_error'))} ${res.error || ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
delete timers.current[provider];
|
delete timers.current[provider];
|
||||||
}
|
}
|
||||||
@@ -101,12 +130,7 @@ export function OAuthPage() {
|
|||||||
|
|
||||||
const startAuth = async (provider: OAuthProvider) => {
|
const startAuth = async (provider: OAuthProvider) => {
|
||||||
const projectId = provider === 'gemini-cli' ? (states[provider]?.projectId || '').trim() : undefined;
|
const projectId = provider === 'gemini-cli' ? (states[provider]?.projectId || '').trim() : undefined;
|
||||||
if (provider === 'gemini-cli' && !projectId) {
|
// 项目 ID 现在是可选的,如果不输入将自动选择第一个可用项目
|
||||||
const message = t('auth_login.gemini_cli_project_id_required');
|
|
||||||
updateProviderState(provider, { projectIdError: message });
|
|
||||||
showNotification(message, 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (provider === 'gemini-cli') {
|
if (provider === 'gemini-cli') {
|
||||||
updateProviderState(provider, { projectIdError: undefined });
|
updateProviderState(provider, { projectIdError: undefined });
|
||||||
}
|
}
|
||||||
@@ -121,7 +145,7 @@ export function OAuthPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await oauthApi.startAuth(
|
const res = await oauthApi.startAuth(
|
||||||
provider,
|
provider,
|
||||||
provider === 'gemini-cli' ? { projectId: projectId! } : undefined
|
provider === 'gemini-cli' ? { projectId: projectId || undefined } : undefined
|
||||||
);
|
);
|
||||||
updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
|
updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
|
||||||
if (res.state) {
|
if (res.state) {
|
||||||
@@ -129,7 +153,7 @@ export function OAuthPage() {
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
|
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
|
||||||
showNotification(`${t('auth_login.codex_oauth_start_error')} ${err?.message || ''}`, 'error');
|
showNotification(`${t(getAuthKey(provider, 'oauth_start_error'))} ${err?.message || ''}`, 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -216,6 +240,64 @@ export function OAuthPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVertexFilePick = () => {
|
||||||
|
vertexFileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVertexFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.name.endsWith('.json')) {
|
||||||
|
showNotification(t('vertex_import.file_required'), 'warning');
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVertexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
file,
|
||||||
|
fileName: file.name,
|
||||||
|
error: undefined,
|
||||||
|
result: undefined
|
||||||
|
}));
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVertexImport = async () => {
|
||||||
|
if (!vertexState.file) {
|
||||||
|
const message = t('vertex_import.file_required');
|
||||||
|
setVertexState((prev) => ({ ...prev, error: message }));
|
||||||
|
showNotification(message, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const location = vertexState.location.trim();
|
||||||
|
setVertexState((prev) => ({ ...prev, loading: true, error: undefined, result: undefined }));
|
||||||
|
try {
|
||||||
|
const res: VertexImportResponse = await vertexApi.importCredential(
|
||||||
|
vertexState.file,
|
||||||
|
location || undefined
|
||||||
|
);
|
||||||
|
const result: VertexImportResult = {
|
||||||
|
projectId: res.project_id,
|
||||||
|
email: res.email,
|
||||||
|
location: res.location,
|
||||||
|
authFile: res['auth-file'] ?? res.auth_file
|
||||||
|
};
|
||||||
|
setVertexState((prev) => ({ ...prev, loading: false, result }));
|
||||||
|
showNotification(t('vertex_import.success'), 'success');
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.message || '';
|
||||||
|
setVertexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: message || t('notification.upload_failed')
|
||||||
|
}));
|
||||||
|
const notification = message
|
||||||
|
? `${t('notification.upload_failed')}: ${message}`
|
||||||
|
: t('notification.upload_failed');
|
||||||
|
showNotification(notification, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
|
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
|
||||||
@@ -245,19 +327,21 @@ export function OAuthPage() {
|
|||||||
>
|
>
|
||||||
<div className="hint">{t(provider.hintKey)}</div>
|
<div className="hint">{t(provider.hintKey)}</div>
|
||||||
{provider.id === 'gemini-cli' && (
|
{provider.id === 'gemini-cli' && (
|
||||||
<Input
|
<div className={styles.geminiProjectField}>
|
||||||
label={t('auth_login.gemini_cli_project_id_label')}
|
<Input
|
||||||
hint={t('auth_login.gemini_cli_project_id_hint')}
|
label={t('auth_login.gemini_cli_project_id_label')}
|
||||||
value={state.projectId || ''}
|
hint={t('auth_login.gemini_cli_project_id_hint')}
|
||||||
error={state.projectIdError}
|
value={state.projectId || ''}
|
||||||
onChange={(e) =>
|
error={state.projectIdError}
|
||||||
updateProviderState(provider.id, {
|
onChange={(e) =>
|
||||||
projectId: e.target.value,
|
updateProviderState(provider.id, {
|
||||||
projectIdError: undefined
|
projectId: e.target.value,
|
||||||
})
|
projectIdError: undefined
|
||||||
}
|
})
|
||||||
placeholder={t('auth_login.gemini_cli_project_id_placeholder')}
|
}
|
||||||
/>
|
placeholder={t('auth_login.gemini_cli_project_id_placeholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{state.url && (
|
{state.url && (
|
||||||
<div className={`connection-box ${styles.authUrlBox}`}>
|
<div className={`connection-box ${styles.authUrlBox}`}>
|
||||||
@@ -265,14 +349,14 @@ export function OAuthPage() {
|
|||||||
<div className={styles.authUrlValue}>{state.url}</div>
|
<div className={styles.authUrlValue}>{state.url}</div>
|
||||||
<div className={styles.authUrlActions}>
|
<div className={styles.authUrlActions}>
|
||||||
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
|
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
|
||||||
{t('auth_login.codex_copy_link')}
|
{t(getAuthKey(provider.id, 'copy_link'))}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
|
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
|
||||||
>
|
>
|
||||||
{t('auth_login.codex_open_link')}
|
{t(getAuthKey(provider.id, 'open_link'))}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,10 +401,10 @@ export function OAuthPage() {
|
|||||||
{state.status && state.status !== 'idle' && (
|
{state.status && state.status !== 'idle' && (
|
||||||
<div className="status-badge" style={{ marginTop: 8 }}>
|
<div className="status-badge" style={{ marginTop: 8 }}>
|
||||||
{state.status === 'success'
|
{state.status === 'success'
|
||||||
? t('auth_login.codex_oauth_status_success')
|
? t(getAuthKey(provider.id, 'oauth_status_success'))
|
||||||
: state.status === 'error'
|
: state.status === 'error'
|
||||||
? `${t('auth_login.codex_oauth_status_error')} ${state.error || ''}`
|
? `${t(getAuthKey(provider.id, 'oauth_status_error'))} ${state.error || ''}`
|
||||||
: t('auth_login.codex_oauth_status_waiting')}
|
: t(getAuthKey(provider.id, 'oauth_status_waiting'))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -328,6 +412,94 @@ export function OAuthPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Vertex JSON 登录 */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
|
||||||
|
{t('vertex_import.title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button onClick={handleVertexImport} loading={vertexState.loading}>
|
||||||
|
{t('vertex_import.import_button')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="hint">{t('vertex_import.description')}</div>
|
||||||
|
<Input
|
||||||
|
label={t('vertex_import.location_label')}
|
||||||
|
hint={t('vertex_import.location_hint')}
|
||||||
|
value={vertexState.location}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVertexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
location: e.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={t('vertex_import.location_placeholder')}
|
||||||
|
/>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('vertex_import.file_label')}</label>
|
||||||
|
<div className={styles.filePicker}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={handleVertexFilePick}>
|
||||||
|
{t('vertex_import.choose_file')}
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
className={`${styles.fileName} ${
|
||||||
|
vertexState.fileName ? '' : styles.fileNamePlaceholder
|
||||||
|
}`.trim()}
|
||||||
|
>
|
||||||
|
{vertexState.fileName || t('vertex_import.file_placeholder')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hint">{t('vertex_import.file_hint')}</div>
|
||||||
|
<input
|
||||||
|
ref={vertexFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleVertexFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{vertexState.error && (
|
||||||
|
<div className="status-badge error" style={{ marginTop: 8 }}>
|
||||||
|
{vertexState.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vertexState.result && (
|
||||||
|
<div className="connection-box" style={{ marginTop: 12 }}>
|
||||||
|
<div className="label">{t('vertex_import.result_title')}</div>
|
||||||
|
<div className="key-value-list">
|
||||||
|
{vertexState.result.projectId && (
|
||||||
|
<div className="key-value-item">
|
||||||
|
<span className="key">{t('vertex_import.result_project')}</span>
|
||||||
|
<span className="value">{vertexState.result.projectId}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vertexState.result.email && (
|
||||||
|
<div className="key-value-item">
|
||||||
|
<span className="key">{t('vertex_import.result_email')}</span>
|
||||||
|
<span className="value">{vertexState.result.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vertexState.result.location && (
|
||||||
|
<div className="key-value-item">
|
||||||
|
<span className="key">{t('vertex_import.result_location')}</span>
|
||||||
|
<span className="value">{vertexState.result.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vertexState.result.authFile && (
|
||||||
|
<div className="key-value-item">
|
||||||
|
<span className="key">{t('vertex_import.result_file')}</span>
|
||||||
|
<span className="value">{vertexState.result.authFile}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* iFlow Cookie 登录 */}
|
{/* iFlow Cookie 登录 */}
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
|
|||||||
389
src/pages/QuotaPage.module.scss
Normal file
389
src/pages/QuotaPage.module.scss
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
@use '../styles/variables' as *;
|
||||||
|
@use '../styles/mixins' as *;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
:global(.btn-sm) {
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleWrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--count-badge-text);
|
||||||
|
background-color: var(--count-badge-bg);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorBox {
|
||||||
|
padding: $spacing-md;
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid var(--danger-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
color: var(--danger-color);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageSizeSelect {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: text;
|
||||||
|
height: 38px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsInfo {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
height: 38px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.antigravityGrid,
|
||||||
|
.codexGrid,
|
||||||
|
.geminiCliGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: $spacing-md;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.antigravityControls,
|
||||||
|
.codexControls,
|
||||||
|
.geminiCliControls {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.antigravityControl,
|
||||||
|
.codexControl,
|
||||||
|
.geminiCliControl {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewModeToggle {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.antigravityCard {
|
||||||
|
background-image: linear-gradient(180deg,
|
||||||
|
rgba(224, 247, 250, 0.12),
|
||||||
|
rgba(224, 247, 250, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.codexCard {
|
||||||
|
background-image: linear-gradient(180deg,
|
||||||
|
rgba(255, 243, 224, 0.18),
|
||||||
|
rgba(255, 243, 224, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.geminiCliCard {
|
||||||
|
background-image: linear-gradient(180deg,
|
||||||
|
rgba(231, 239, 255, 0.2),
|
||||||
|
rgba(231, 239, 255, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding-top: $spacing-sm;
|
||||||
|
margin-top: $spacing-xs;
|
||||||
|
border-top: 1px dashed var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaRowHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaModel {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaBar {
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaBarFill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--success-color, #22c55e);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaBarFillHigh {
|
||||||
|
background-color: var(--success-color, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaBarFillMedium {
|
||||||
|
background-color: var(--warning-color, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaBarFillLow {
|
||||||
|
background-color: var(--danger-color, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaPercent {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaReset {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaAmount {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaMessage {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
padding: $spacing-sm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaError {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--danger-color);
|
||||||
|
background-color: rgba(239, 68, 68, 0.08);
|
||||||
|
border: 1px solid var(--danger-color);
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
padding: $spacing-xs $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaWarning {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--warning-color, #f59e0b);
|
||||||
|
background-color: rgba(245, 158, 11, 0.12);
|
||||||
|
border: 1px solid var(--warning-color, #f59e0b);
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
padding: $spacing-xs $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codexPlan {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codexPlanLabel {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codexPlanValue {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileCard {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: $spacing-md;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
border-color: rgba(37, 99, 235, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeBadge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
margin-top: $spacing-lg;
|
||||||
|
padding-top: $spacing-md;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: $spacing-xs $spacing-md;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningOverlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningModal {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: $shadow-lg;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 $spacing-md 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/pages/QuotaPage.tsx
Normal file
92
src/pages/QuotaPage.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Quota management page - coordinates the three quota sections.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
|
import { useAuthStore } from '@/stores';
|
||||||
|
import { authFilesApi, configFileApi } from '@/services/api';
|
||||||
|
import {
|
||||||
|
QuotaSection,
|
||||||
|
ANTIGRAVITY_CONFIG,
|
||||||
|
CODEX_CONFIG,
|
||||||
|
GEMINI_CLI_CONFIG
|
||||||
|
} from '@/components/quota';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import styles from './QuotaPage.module.scss';
|
||||||
|
|
||||||
|
export function QuotaPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
const loadConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await configFileApi.fetchConfigYaml();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
|
||||||
|
setError((prev) => prev || errorMessage);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
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 handleHeaderRefresh = useCallback(async () => {
|
||||||
|
await Promise.all([loadConfig(), loadFiles()]);
|
||||||
|
}, [loadConfig, loadFiles]);
|
||||||
|
|
||||||
|
useHeaderRefresh(handleHeaderRefresh);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFiles();
|
||||||
|
loadConfig();
|
||||||
|
}, [loadFiles, loadConfig]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<h1 className={styles.pageTitle}>{t('quota_management.title')}</h1>
|
||||||
|
<p className={styles.description}>{t('quota_management.description')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
|
|
||||||
|
<QuotaSection
|
||||||
|
config={ANTIGRAVITY_CONFIG}
|
||||||
|
files={files}
|
||||||
|
loading={loading}
|
||||||
|
disabled={disableControls}
|
||||||
|
/>
|
||||||
|
<QuotaSection
|
||||||
|
config={CODEX_CONFIG}
|
||||||
|
files={files}
|
||||||
|
loading={loading}
|
||||||
|
disabled={disableControls}
|
||||||
|
/>
|
||||||
|
<QuotaSection
|
||||||
|
config={GEMINI_CLI_CONFIG}
|
||||||
|
files={files}
|
||||||
|
loading={loading}
|
||||||
|
disabled={disableControls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -122,6 +122,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.retryRowAligned {
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.retryButton {
|
||||||
|
margin-top: calc(1.5em + #{$spacing-xs});
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
.retryButton {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.retryRowInputGrow {
|
||||||
|
:global(.form-group) {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retryInput {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.retryInput {
|
.retryInput {
|
||||||
width: 140px;
|
width: 140px;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ type PendingKey =
|
|||||||
| 'debug'
|
| 'debug'
|
||||||
| 'proxy'
|
| 'proxy'
|
||||||
| 'retry'
|
| 'retry'
|
||||||
|
| 'logsMaxSize'
|
||||||
|
| 'forceModelPrefix'
|
||||||
|
| 'routingStrategy'
|
||||||
| 'switchProject'
|
| 'switchProject'
|
||||||
| 'switchPreview'
|
| 'switchPreview'
|
||||||
| 'usage'
|
| 'usage'
|
||||||
@@ -31,6 +34,8 @@ export function SettingsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [proxyValue, setProxyValue] = useState('');
|
const [proxyValue, setProxyValue] = useState('');
|
||||||
const [retryValue, setRetryValue] = useState(0);
|
const [retryValue, setRetryValue] = useState(0);
|
||||||
|
const [logsMaxTotalSizeMb, setLogsMaxTotalSizeMb] = useState(0);
|
||||||
|
const [routingStrategy, setRoutingStrategy] = useState('round-robin');
|
||||||
const [pending, setPending] = useState<Record<PendingKey, boolean>>({} as Record<PendingKey, boolean>);
|
const [pending, setPending] = useState<Record<PendingKey, boolean>>({} as Record<PendingKey, boolean>);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@@ -41,9 +46,34 @@ export function SettingsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = (await fetchConfig()) as Config;
|
const [configResult, logsResult, prefixResult, routingResult] = await Promise.allSettled([
|
||||||
|
fetchConfig(),
|
||||||
|
configApi.getLogsMaxTotalSizeMb(),
|
||||||
|
configApi.getForceModelPrefix(),
|
||||||
|
configApi.getRoutingStrategy(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (configResult.status !== 'fulfilled') {
|
||||||
|
throw configResult.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = configResult.value as Config;
|
||||||
setProxyValue(data?.proxyUrl ?? '');
|
setProxyValue(data?.proxyUrl ?? '');
|
||||||
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
|
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
|
||||||
|
|
||||||
|
if (logsResult.status === 'fulfilled' && Number.isFinite(logsResult.value)) {
|
||||||
|
setLogsMaxTotalSizeMb(Math.max(0, Number(logsResult.value)));
|
||||||
|
updateConfigValue('logs-max-total-size-mb', Math.max(0, Number(logsResult.value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefixResult.status === 'fulfilled') {
|
||||||
|
updateConfigValue('force-model-prefix', Boolean(prefixResult.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routingResult.status === 'fulfilled' && routingResult.value) {
|
||||||
|
setRoutingStrategy(String(routingResult.value));
|
||||||
|
updateConfigValue('routing/strategy', String(routingResult.value));
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || t('notification.refresh_failed'));
|
setError(err?.message || t('notification.refresh_failed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -52,7 +82,7 @@ export function SettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}, [fetchConfig, t]);
|
}, [fetchConfig, t, updateConfigValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config) {
|
if (config) {
|
||||||
@@ -60,8 +90,14 @@ export function SettingsPage() {
|
|||||||
if (typeof config.requestRetry === 'number') {
|
if (typeof config.requestRetry === 'number') {
|
||||||
setRetryValue(config.requestRetry);
|
setRetryValue(config.requestRetry);
|
||||||
}
|
}
|
||||||
|
if (typeof config.logsMaxTotalSizeMb === 'number') {
|
||||||
|
setLogsMaxTotalSizeMb(config.logsMaxTotalSizeMb);
|
||||||
|
}
|
||||||
|
if (config.routingStrategy) {
|
||||||
|
setRoutingStrategy(config.routingStrategy);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [config?.proxyUrl, config?.requestRetry]);
|
}, [config?.proxyUrl, config?.requestRetry, config?.logsMaxTotalSizeMb, config?.routingStrategy]);
|
||||||
|
|
||||||
const setPendingFlag = (key: PendingKey, value: boolean) => {
|
const setPendingFlag = (key: PendingKey, value: boolean) => {
|
||||||
setPending((prev) => ({ ...prev, [key]: value }));
|
setPending((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -69,7 +105,7 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
const toggleSetting = async (
|
const toggleSetting = async (
|
||||||
section: PendingKey,
|
section: PendingKey,
|
||||||
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth',
|
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth' | 'force-model-prefix',
|
||||||
value: boolean,
|
value: boolean,
|
||||||
updater: (val: boolean) => Promise<any>,
|
updater: (val: boolean) => Promise<any>,
|
||||||
successMessage: string
|
successMessage: string
|
||||||
@@ -84,6 +120,8 @@ export function SettingsPage() {
|
|||||||
return config?.loggingToFile ?? false;
|
return config?.loggingToFile ?? false;
|
||||||
case 'ws-auth':
|
case 'ws-auth':
|
||||||
return config?.wsAuth ?? false;
|
return config?.wsAuth ?? false;
|
||||||
|
case 'force-model-prefix':
|
||||||
|
return config?.forceModelPrefix ?? false;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -162,6 +200,52 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogsMaxTotalSizeUpdate = async () => {
|
||||||
|
const previous = config?.logsMaxTotalSizeMb ?? 0;
|
||||||
|
const parsed = Number(logsMaxTotalSizeMb);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
|
showNotification(t('login.error_invalid'), 'error');
|
||||||
|
setLogsMaxTotalSizeMb(previous);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalized = Math.max(0, parsed);
|
||||||
|
setPendingFlag('logsMaxSize', true);
|
||||||
|
updateConfigValue('logs-max-total-size-mb', normalized);
|
||||||
|
try {
|
||||||
|
await configApi.updateLogsMaxTotalSizeMb(normalized);
|
||||||
|
clearCache('logs-max-total-size-mb');
|
||||||
|
showNotification(t('notification.logs_max_total_size_updated'), 'success');
|
||||||
|
} catch (err: any) {
|
||||||
|
setLogsMaxTotalSizeMb(previous);
|
||||||
|
updateConfigValue('logs-max-total-size-mb', previous);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setPendingFlag('logsMaxSize', false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoutingStrategyUpdate = async () => {
|
||||||
|
const strategy = routingStrategy.trim();
|
||||||
|
if (!strategy) {
|
||||||
|
showNotification(t('login.error_invalid'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previous = config?.routingStrategy ?? 'round-robin';
|
||||||
|
setPendingFlag('routingStrategy', true);
|
||||||
|
updateConfigValue('routing/strategy', strategy);
|
||||||
|
try {
|
||||||
|
await configApi.updateRoutingStrategy(strategy);
|
||||||
|
clearCache('routing/strategy');
|
||||||
|
showNotification(t('notification.routing_strategy_updated'), 'success');
|
||||||
|
} catch (err: any) {
|
||||||
|
setRoutingStrategy(previous);
|
||||||
|
updateConfigValue('routing/strategy', previous);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setPendingFlag('routingStrategy', false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const quotaSwitchProject = config?.quotaExceeded?.switchProject ?? false;
|
const quotaSwitchProject = config?.quotaExceeded?.switchProject ?? false;
|
||||||
const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false;
|
const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false;
|
||||||
|
|
||||||
@@ -171,63 +255,78 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
<Card>
|
<Card>
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label={t('basic_settings.debug_enable')}
|
label={t('basic_settings.debug_enable')}
|
||||||
checked={config?.debug ?? false}
|
checked={config?.debug ?? false}
|
||||||
disabled={disableControls || pending.debug || loading}
|
disabled={disableControls || pending.debug || loading}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated'))
|
toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated'))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label={t('basic_settings.usage_statistics_enable')}
|
label={t('basic_settings.usage_statistics_enable')}
|
||||||
checked={config?.usageStatisticsEnabled ?? false}
|
checked={config?.usageStatisticsEnabled ?? false}
|
||||||
disabled={disableControls || pending.usage || loading}
|
disabled={disableControls || pending.usage || loading}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
toggleSetting(
|
toggleSetting(
|
||||||
'usage',
|
'usage',
|
||||||
'usage-statistics-enabled',
|
'usage-statistics-enabled',
|
||||||
value,
|
value,
|
||||||
configApi.updateUsageStatistics,
|
configApi.updateUsageStatistics,
|
||||||
t('notification.usage_statistics_updated')
|
t('notification.usage_statistics_updated')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label={t('basic_settings.logging_to_file_enable')}
|
label={t('basic_settings.logging_to_file_enable')}
|
||||||
checked={config?.loggingToFile ?? false}
|
checked={config?.loggingToFile ?? false}
|
||||||
disabled={disableControls || pending.loggingToFile || loading}
|
disabled={disableControls || pending.loggingToFile || loading}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
toggleSetting(
|
toggleSetting(
|
||||||
'loggingToFile',
|
'loggingToFile',
|
||||||
'logging-to-file',
|
'logging-to-file',
|
||||||
value,
|
value,
|
||||||
configApi.updateLoggingToFile,
|
configApi.updateLoggingToFile,
|
||||||
t('notification.logging_to_file_updated')
|
t('notification.logging_to_file_updated')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label={t('basic_settings.ws_auth_enable')}
|
label={t('basic_settings.ws_auth_enable')}
|
||||||
checked={config?.wsAuth ?? false}
|
checked={config?.wsAuth ?? false}
|
||||||
disabled={disableControls || pending.wsAuth || loading}
|
disabled={disableControls || pending.wsAuth || loading}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
toggleSetting(
|
toggleSetting(
|
||||||
'wsAuth',
|
'wsAuth',
|
||||||
'ws-auth',
|
'ws-auth',
|
||||||
value,
|
value,
|
||||||
configApi.updateWsAuth,
|
configApi.updateWsAuth,
|
||||||
t('notification.ws_auth_updated')
|
t('notification.ws_auth_updated')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Card>
|
<ToggleSwitch
|
||||||
|
label={t('basic_settings.force_model_prefix_enable')}
|
||||||
|
checked={config?.forceModelPrefix ?? false}
|
||||||
|
disabled={disableControls || pending.forceModelPrefix || loading}
|
||||||
|
onChange={(value) =>
|
||||||
|
toggleSetting(
|
||||||
|
'forceModelPrefix',
|
||||||
|
'force-model-prefix',
|
||||||
|
value,
|
||||||
|
configApi.updateForceModelPrefix,
|
||||||
|
t('notification.force_model_prefix_updated')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title={t('basic_settings.proxy_title')}>
|
<Card title={t('basic_settings.proxy_title')}>
|
||||||
<Input
|
<Input
|
||||||
@@ -271,6 +370,57 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('basic_settings.logs_max_total_size_title')}>
|
||||||
|
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
|
||||||
|
<Input
|
||||||
|
label={t('basic_settings.logs_max_total_size_label')}
|
||||||
|
hint={t('basic_settings.logs_max_total_size_hint')}
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={logsMaxTotalSizeMb}
|
||||||
|
onChange={(e) => setLogsMaxTotalSizeMb(Number(e.target.value))}
|
||||||
|
disabled={disableControls || loading}
|
||||||
|
className={styles.retryInput}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className={styles.retryButton}
|
||||||
|
onClick={handleLogsMaxTotalSizeUpdate}
|
||||||
|
loading={pending.logsMaxSize}
|
||||||
|
disabled={disableControls || loading}
|
||||||
|
>
|
||||||
|
{t('basic_settings.logs_max_total_size_update')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('basic_settings.routing_title')}>
|
||||||
|
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('basic_settings.routing_strategy_label')}</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={routingStrategy}
|
||||||
|
onChange={(e) => setRoutingStrategy(e.target.value)}
|
||||||
|
disabled={disableControls || loading}
|
||||||
|
>
|
||||||
|
<option value="round-robin">{t('basic_settings.routing_strategy_round_robin')}</option>
|
||||||
|
<option value="fill-first">{t('basic_settings.routing_strategy_fill_first')}</option>
|
||||||
|
</select>
|
||||||
|
<div className="hint">{t('basic_settings.routing_strategy_hint')}</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={styles.retryButton}
|
||||||
|
onClick={handleRoutingStrategyUpdate}
|
||||||
|
loading={pending.routingStrategy}
|
||||||
|
disabled={disableControls || loading}
|
||||||
|
>
|
||||||
|
{t('basic_settings.routing_strategy_update')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title={t('basic_settings.quota_title')}>
|
<Card title={t('basic_settings.quota_title')}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
|
|||||||
@@ -34,6 +34,12 @@
|
|||||||
margin: 0 0 $spacing-md 0;
|
margin: 0 0 $spacing-md 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clearLoginActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.infoGrid {
|
.infoGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/componen
|
|||||||
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores';
|
||||||
import { apiKeysApi } from '@/services/api/apiKeys';
|
import { apiKeysApi } from '@/services/api/apiKeys';
|
||||||
import { classifyModels } from '@/utils/models';
|
import { classifyModels } from '@/utils/models';
|
||||||
|
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
||||||
import styles from './SystemPage.module.scss';
|
import styles from './SystemPage.module.scss';
|
||||||
|
|
||||||
export function SystemPage() {
|
export function SystemPage() {
|
||||||
@@ -104,6 +105,15 @@ export function SystemPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClearLoginStorage = () => {
|
||||||
|
if (!window.confirm(t('system_info.clear_login_confirm'))) return;
|
||||||
|
auth.logout();
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
|
||||||
|
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||||
|
showNotification(t('notification.login_storage_cleared'), 'success');
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig().catch(() => {
|
fetchConfig().catch(() => {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -248,6 +258,15 @@ export function SystemPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('system_info.clear_login_title')}>
|
||||||
|
<p className={styles.sectionDescription}>{t('system_info.clear_login_desc')}</p>
|
||||||
|
<div className={styles.clearLoginActions}>
|
||||||
|
<Button variant="danger" onClick={handleClearLoginStorage}>
|
||||||
|
{t('system_info.clear_login_button')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
32
src/router/MainRoutes.tsx
Normal file
32
src/router/MainRoutes.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Navigate, useRoutes, type Location } from 'react-router-dom';
|
||||||
|
import { DashboardPage } from '@/pages/DashboardPage';
|
||||||
|
import { SettingsPage } from '@/pages/SettingsPage';
|
||||||
|
import { ApiKeysPage } from '@/pages/ApiKeysPage';
|
||||||
|
import { AiProvidersPage } from '@/pages/AiProvidersPage';
|
||||||
|
import { AuthFilesPage } from '@/pages/AuthFilesPage';
|
||||||
|
import { OAuthPage } from '@/pages/OAuthPage';
|
||||||
|
import { QuotaPage } from '@/pages/QuotaPage';
|
||||||
|
import { UsagePage } from '@/pages/UsagePage';
|
||||||
|
import { ConfigPage } from '@/pages/ConfigPage';
|
||||||
|
import { LogsPage } from '@/pages/LogsPage';
|
||||||
|
import { SystemPage } from '@/pages/SystemPage';
|
||||||
|
|
||||||
|
const mainRoutes = [
|
||||||
|
{ path: '/', element: <DashboardPage /> },
|
||||||
|
{ path: '/dashboard', element: <DashboardPage /> },
|
||||||
|
{ path: '/settings', element: <SettingsPage /> },
|
||||||
|
{ path: '/api-keys', element: <ApiKeysPage /> },
|
||||||
|
{ path: '/ai-providers', element: <AiProvidersPage /> },
|
||||||
|
{ path: '/auth-files', element: <AuthFilesPage /> },
|
||||||
|
{ path: '/oauth', element: <OAuthPage /> },
|
||||||
|
{ path: '/quota', element: <QuotaPage /> },
|
||||||
|
{ path: '/usage', element: <UsagePage /> },
|
||||||
|
{ path: '/config', element: <ConfigPage /> },
|
||||||
|
{ path: '/logs', element: <LogsPage /> },
|
||||||
|
{ path: '/system', element: <SystemPage /> },
|
||||||
|
{ path: '*', element: <Navigate to="/" replace /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MainRoutes({ location }: { location?: Location }) {
|
||||||
|
return useRoutes(mainRoutes, location);
|
||||||
|
}
|
||||||
86
src/services/api/apiCall.ts
Normal file
86
src/services/api/apiCall.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Generic API call helper (proxied via management API).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface ApiCallRequest {
|
||||||
|
authIndex?: string;
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
header?: Record<string, string>;
|
||||||
|
data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiCallResult<T = any> {
|
||||||
|
statusCode: number;
|
||||||
|
header: Record<string, string[]>;
|
||||||
|
bodyText: string;
|
||||||
|
body: T | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeBody = (input: unknown): { bodyText: string; body: any | null } => {
|
||||||
|
if (input === undefined || input === null) {
|
||||||
|
return { bodyText: '', body: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
const text = input;
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { bodyText: text, body: null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { bodyText: text, body: JSON.parse(trimmed) };
|
||||||
|
} catch {
|
||||||
|
return { bodyText: text, body: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return { bodyText: JSON.stringify(input), body: input };
|
||||||
|
} catch {
|
||||||
|
return { bodyText: String(input), body: input };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getApiCallErrorMessage = (result: ApiCallResult): string => {
|
||||||
|
const status = result.statusCode;
|
||||||
|
const body = result.body;
|
||||||
|
const bodyText = result.bodyText;
|
||||||
|
let message = '';
|
||||||
|
|
||||||
|
if (body && typeof body === 'object') {
|
||||||
|
message = body?.error?.message || body?.error || body?.message || '';
|
||||||
|
} else if (typeof body === 'string') {
|
||||||
|
message = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message && bodyText) {
|
||||||
|
message = bodyText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && message) return `${status} ${message}`.trim();
|
||||||
|
if (status) return `HTTP ${status}`;
|
||||||
|
return message || 'Request failed';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiCallApi = {
|
||||||
|
request: async (
|
||||||
|
payload: ApiCallRequest,
|
||||||
|
config?: AxiosRequestConfig
|
||||||
|
): Promise<ApiCallResult> => {
|
||||||
|
const response = await apiClient.post('/api-call', payload, config);
|
||||||
|
const statusCode = Number(response?.status_code ?? response?.statusCode ?? 0);
|
||||||
|
const header = (response?.header ?? response?.headers ?? {}) as Record<string, string[]>;
|
||||||
|
const { bodyText, body } = normalizeBody(response?.body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
header,
|
||||||
|
bodyText,
|
||||||
|
body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,6 +4,101 @@
|
|||||||
|
|
||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
import type { AuthFilesResponse } from '@/types/authFile';
|
import type { AuthFilesResponse } from '@/types/authFile';
|
||||||
|
import type { OAuthModelMappingEntry } from '@/types';
|
||||||
|
|
||||||
|
type StatusError = { status?: number };
|
||||||
|
|
||||||
|
const getStatusCode = (err: unknown): number | undefined => {
|
||||||
|
if (!err || typeof err !== 'object') return undefined;
|
||||||
|
if ('status' in err) return (err as StatusError).status;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
|
||||||
|
if (!payload || typeof payload !== 'object') return {};
|
||||||
|
|
||||||
|
const source = (payload as any)['oauth-excluded-models'] ?? (payload as any).items ?? payload;
|
||||||
|
if (!source || typeof source !== 'object') return {};
|
||||||
|
|
||||||
|
const result: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
Object.entries(source as Record<string, unknown>).forEach(([provider, models]) => {
|
||||||
|
const key = String(provider ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
const rawList = Array.isArray(models)
|
||||||
|
? models
|
||||||
|
: typeof models === 'string'
|
||||||
|
? models.split(/[\n,]+/)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const normalized: string[] = [];
|
||||||
|
rawList.forEach((item) => {
|
||||||
|
const trimmed = String(item ?? '').trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const modelKey = trimmed.toLowerCase();
|
||||||
|
if (seen.has(modelKey)) return;
|
||||||
|
seen.add(modelKey);
|
||||||
|
normalized.push(trimmed);
|
||||||
|
});
|
||||||
|
|
||||||
|
result[key] = normalized;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeOauthModelMappings = (payload: unknown): Record<string, OAuthModelMappingEntry[]> => {
|
||||||
|
if (!payload || typeof payload !== 'object') return {};
|
||||||
|
|
||||||
|
const source =
|
||||||
|
(payload as any)['oauth-model-mappings'] ??
|
||||||
|
(payload as any)['oauth-model-alias'] ??
|
||||||
|
(payload as any).items ??
|
||||||
|
payload;
|
||||||
|
if (!source || typeof source !== 'object') return {};
|
||||||
|
|
||||||
|
const result: Record<string, OAuthModelMappingEntry[]> = {};
|
||||||
|
|
||||||
|
Object.entries(source as Record<string, unknown>).forEach(([channel, mappings]) => {
|
||||||
|
const key = String(channel ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!key) return;
|
||||||
|
if (!Array.isArray(mappings)) return;
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const normalized = mappings
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object') return null;
|
||||||
|
const name = String((item as any).name ?? (item as any).id ?? (item as any).model ?? '').trim();
|
||||||
|
const alias = String((item as any).alias ?? '').trim();
|
||||||
|
if (!name || !alias) return null;
|
||||||
|
const fork = (item as any).fork === true;
|
||||||
|
return fork ? { name, alias, fork } : { name, alias };
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((entry) => {
|
||||||
|
const mapping = entry as OAuthModelMappingEntry;
|
||||||
|
const dedupeKey = `${mapping.name.toLowerCase()}::${mapping.alias.toLowerCase()}::${mapping.fork ? '1' : '0'}`;
|
||||||
|
if (seen.has(dedupeKey)) return false;
|
||||||
|
seen.add(dedupeKey);
|
||||||
|
return true;
|
||||||
|
}) as OAuthModelMappingEntry[];
|
||||||
|
|
||||||
|
if (normalized.length) {
|
||||||
|
result[key] = normalized;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OAUTH_MODEL_MAPPINGS_ENDPOINT = '/oauth-model-mappings';
|
||||||
|
const OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT = '/oauth-model-alias';
|
||||||
|
|
||||||
export const authFilesApi = {
|
export const authFilesApi = {
|
||||||
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
|
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
|
||||||
@@ -18,11 +113,18 @@ export const authFilesApi = {
|
|||||||
|
|
||||||
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),
|
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),
|
||||||
|
|
||||||
|
downloadText: async (name: string): Promise<string> => {
|
||||||
|
const response = await apiClient.getRaw(`/auth-files/download?name=${encodeURIComponent(name)}`, {
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
const blob = response.data as Blob;
|
||||||
|
return blob.text();
|
||||||
|
},
|
||||||
|
|
||||||
// OAuth 排除模型
|
// OAuth 排除模型
|
||||||
async getOauthExcludedModels(): Promise<Record<string, string[]>> {
|
async getOauthExcludedModels(): Promise<Record<string, string[]>> {
|
||||||
const data = await apiClient.get('/oauth-excluded-models');
|
const data = await apiClient.get('/oauth-excluded-models');
|
||||||
const payload = (data && (data['oauth-excluded-models'] ?? data.items ?? data)) as any;
|
return normalizeOauthExcludedModels(data);
|
||||||
return payload && typeof payload === 'object' ? payload : {};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
saveOauthExcludedModels: (provider: string, models: string[]) =>
|
saveOauthExcludedModels: (provider: string, models: string[]) =>
|
||||||
@@ -31,6 +133,69 @@ export const authFilesApi = {
|
|||||||
deleteOauthExcludedEntry: (provider: string) =>
|
deleteOauthExcludedEntry: (provider: string) =>
|
||||||
apiClient.delete(`/oauth-excluded-models?provider=${encodeURIComponent(provider)}`),
|
apiClient.delete(`/oauth-excluded-models?provider=${encodeURIComponent(provider)}`),
|
||||||
|
|
||||||
|
replaceOauthExcludedModels: (map: Record<string, string[]>) =>
|
||||||
|
apiClient.put('/oauth-excluded-models', normalizeOauthExcludedModels(map)),
|
||||||
|
|
||||||
|
// OAuth 模型映射
|
||||||
|
async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> {
|
||||||
|
try {
|
||||||
|
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_ENDPOINT);
|
||||||
|
return normalizeOauthModelMappings(data);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (getStatusCode(err) !== 404) throw err;
|
||||||
|
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT);
|
||||||
|
return normalizeOauthModelMappings(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveOauthModelMappings: async (channel: string, mappings: OAuthModelMappingEntry[]) => {
|
||||||
|
const normalizedChannel = String(channel ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const normalizedMappings = normalizeOauthModelMappings({ [normalizedChannel]: mappings })[normalizedChannel] ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.patch(OAUTH_MODEL_MAPPINGS_ENDPOINT, { channel: normalizedChannel, mappings: normalizedMappings });
|
||||||
|
return;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (getStatusCode(err) !== 404) throw err;
|
||||||
|
await apiClient.patch(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT, { channel: normalizedChannel, aliases: normalizedMappings });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteOauthModelMappings: async (channel: string) => {
|
||||||
|
const normalizedChannel = String(channel ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
const deleteViaPatch = async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.patch(OAUTH_MODEL_MAPPINGS_ENDPOINT, { channel: normalizedChannel, mappings: [] });
|
||||||
|
return true;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (getStatusCode(err) !== 404) throw err;
|
||||||
|
await apiClient.patch(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT, { channel: normalizedChannel, aliases: [] });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteViaPatch();
|
||||||
|
return;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status = getStatusCode(err);
|
||||||
|
if (status !== 405) throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`${OAUTH_MODEL_MAPPINGS_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
|
||||||
|
return;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (getStatusCode(err) !== 404) throw err;
|
||||||
|
await apiClient.delete(`${OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 获取认证凭证支持的模型
|
// 获取认证凭证支持的模型
|
||||||
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
|
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
|
||||||
const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`);
|
const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`);
|
||||||
|
|||||||
@@ -62,12 +62,37 @@ class ApiClient {
|
|||||||
return `${normalized}${MANAGEMENT_API_PREFIX}`;
|
return `${normalized}${MANAGEMENT_API_PREFIX}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readHeader(headers: Record<string, any>, keys: string[]): string | null {
|
private readHeader(headers: Record<string, any> | undefined, keys: string[]): string | null {
|
||||||
|
if (!headers) return null;
|
||||||
|
|
||||||
|
const normalizeValue = (value: unknown): string | null => {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const first = value.find((entry) => entry !== undefined && entry !== null && String(entry).trim());
|
||||||
|
return first !== undefined ? String(first) : null;
|
||||||
|
}
|
||||||
|
const text = String(value);
|
||||||
|
return text ? text : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerGetter = (headers as { get?: (name: string) => any }).get;
|
||||||
|
if (typeof headerGetter === 'function') {
|
||||||
|
for (const key of keys) {
|
||||||
|
const match = normalizeValue(headerGetter.call(headers, key));
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries =
|
||||||
|
typeof (headers as { entries?: () => Iterable<[string, any]> }).entries === 'function'
|
||||||
|
? Array.from((headers as { entries: () => Iterable<[string, any]> }).entries())
|
||||||
|
: Object.entries(headers);
|
||||||
|
|
||||||
const normalized = Object.fromEntries(
|
const normalized = Object.fromEntries(
|
||||||
Object.entries(headers || {}).map(([key, value]) => [key.toLowerCase(), value as string | undefined])
|
entries.map(([key, value]) => [String(key).toLowerCase(), value])
|
||||||
);
|
);
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const match = normalized[key.toLowerCase()];
|
const match = normalizeValue(normalized[key.toLowerCase()]);
|
||||||
if (match) return match;
|
if (match) return match;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -68,8 +68,48 @@ export const configApi = {
|
|||||||
*/
|
*/
|
||||||
updateLoggingToFile: (enabled: boolean) => apiClient.put('/logging-to-file', { value: enabled }),
|
updateLoggingToFile: (enabled: boolean) => apiClient.put('/logging-to-file', { value: enabled }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取日志总大小上限(MB)
|
||||||
|
*/
|
||||||
|
async getLogsMaxTotalSizeMb(): Promise<number> {
|
||||||
|
const data = await apiClient.get('/logs-max-total-size-mb');
|
||||||
|
return data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新日志总大小上限(MB)
|
||||||
|
*/
|
||||||
|
updateLogsMaxTotalSizeMb: (value: number) =>
|
||||||
|
apiClient.put('/logs-max-total-size-mb', { value }),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket 鉴权开关
|
* WebSocket 鉴权开关
|
||||||
*/
|
*/
|
||||||
updateWsAuth: (enabled: boolean) => apiClient.put('/ws-auth', { value: enabled }),
|
updateWsAuth: (enabled: boolean) => apiClient.put('/ws-auth', { value: enabled }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取强制模型前缀开关
|
||||||
|
*/
|
||||||
|
async getForceModelPrefix(): Promise<boolean> {
|
||||||
|
const data = await apiClient.get('/force-model-prefix');
|
||||||
|
return data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新强制模型前缀开关
|
||||||
|
*/
|
||||||
|
updateForceModelPrefix: (enabled: boolean) => apiClient.put('/force-model-prefix', { value: enabled }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取路由策略
|
||||||
|
*/
|
||||||
|
async getRoutingStrategy(): Promise<string> {
|
||||||
|
const data = await apiClient.get('/routing/strategy');
|
||||||
|
return data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy ?? 'round-robin';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新路由策略
|
||||||
|
*/
|
||||||
|
updateRoutingStrategy: (strategy: string) => apiClient.put('/routing/strategy', { value: strategy }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './client';
|
export * from './client';
|
||||||
|
export * from './apiCall';
|
||||||
export * from './config';
|
export * from './config';
|
||||||
export * from './configFile';
|
export * from './configFile';
|
||||||
export * from './apiKeys';
|
export * from './apiKeys';
|
||||||
@@ -11,3 +12,4 @@ export * from './logs';
|
|||||||
export * from './version';
|
export * from './version';
|
||||||
export * from './models';
|
export * from './models';
|
||||||
export * from './transformers';
|
export * from './transformers';
|
||||||
|
export * from './vertex';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { normalizeModelList } from '@/utils/models';
|
import { normalizeModelList } from '@/utils/models';
|
||||||
|
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
|
||||||
|
|
||||||
const normalizeBaseUrl = (baseUrl: string): string => {
|
const normalizeBaseUrl = (baseUrl: string): string => {
|
||||||
let normalized = String(baseUrl || '').trim();
|
let normalized = String(baseUrl || '').trim();
|
||||||
@@ -39,5 +40,35 @@ export const modelsApi = {
|
|||||||
});
|
});
|
||||||
const payload = response.data?.data ?? response.data?.models ?? response.data;
|
const payload = response.data?.data ?? response.data?.models ?? response.data;
|
||||||
return normalizeModelList(payload, { dedupe: true });
|
return normalizeModelList(payload, { dedupe: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchModelsViaApiCall(
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey?: string,
|
||||||
|
headers: Record<string, string> = {}
|
||||||
|
) {
|
||||||
|
const endpoint = buildModelsEndpoint(baseUrl);
|
||||||
|
if (!endpoint) {
|
||||||
|
throw new Error('Invalid base url');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedHeaders = { ...headers };
|
||||||
|
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
|
||||||
|
if (apiKey && !hasAuthHeader) {
|
||||||
|
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiCallApi.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: endpoint,
|
||||||
|
header: Object.keys(resolvedHeaders).length ? resolvedHeaders : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
throw new Error(getApiCallErrorMessage(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = result.body ?? result.bodyText;
|
||||||
|
return normalizeModelList(payload, { dedupe: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ export type OAuthProvider =
|
|||||||
| 'anthropic'
|
| 'anthropic'
|
||||||
| 'antigravity'
|
| 'antigravity'
|
||||||
| 'gemini-cli'
|
| 'gemini-cli'
|
||||||
| 'qwen'
|
| 'qwen';
|
||||||
| 'iflow';
|
|
||||||
|
|
||||||
export interface OAuthStartResponse {
|
export interface OAuthStartResponse {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -30,7 +29,7 @@ export interface IFlowCookieAuthResponse {
|
|||||||
type?: string;
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow'];
|
const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
|
||||||
const CALLBACK_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = {
|
const CALLBACK_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = {
|
||||||
'gemini-cli': 'gemini'
|
'gemini-cli': 'gemini'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,6 +61,30 @@ const serializeProviderKey = (config: ProviderKeyConfig) => {
|
|||||||
return payload;
|
return payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const serializeVertexModelAliases = (models?: ModelAlias[]) =>
|
||||||
|
Array.isArray(models)
|
||||||
|
? models
|
||||||
|
.map((model) => {
|
||||||
|
const name = typeof model?.name === 'string' ? model.name.trim() : '';
|
||||||
|
const alias = typeof model?.alias === 'string' ? model.alias.trim() : '';
|
||||||
|
if (!name || !alias) return null;
|
||||||
|
return { name, alias };
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const serializeVertexKey = (config: ProviderKeyConfig) => {
|
||||||
|
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
||||||
|
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||||
|
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||||
|
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
||||||
|
const headers = serializeHeaders(config.headers);
|
||||||
|
if (headers) payload.headers = headers;
|
||||||
|
const models = serializeVertexModelAliases(config.models);
|
||||||
|
if (models && models.length) payload.models = models;
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
const serializeGeminiKey = (config: GeminiKeyConfig) => {
|
const serializeGeminiKey = (config: GeminiKeyConfig) => {
|
||||||
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
||||||
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||||
@@ -140,6 +164,22 @@ export const providersApi = {
|
|||||||
deleteClaudeConfig: (apiKey: string) =>
|
deleteClaudeConfig: (apiKey: string) =>
|
||||||
apiClient.delete(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`),
|
apiClient.delete(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`),
|
||||||
|
|
||||||
|
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
|
||||||
|
const data = await apiClient.get('/vertex-api-key');
|
||||||
|
const list = (data && (data['vertex-api-key'] ?? data.items ?? data)) as any;
|
||||||
|
if (!Array.isArray(list)) return [];
|
||||||
|
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
|
||||||
|
},
|
||||||
|
|
||||||
|
saveVertexConfigs: (configs: ProviderKeyConfig[]) =>
|
||||||
|
apiClient.put('/vertex-api-key', configs.map((item) => serializeVertexKey(item))),
|
||||||
|
|
||||||
|
updateVertexConfig: (index: number, value: ProviderKeyConfig) =>
|
||||||
|
apiClient.patch('/vertex-api-key', { index, value: serializeVertexKey(value) }),
|
||||||
|
|
||||||
|
deleteVertexConfig: (apiKey: string) =>
|
||||||
|
apiClient.delete(`/vertex-api-key?api-key=${encodeURIComponent(apiKey)}`),
|
||||||
|
|
||||||
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
|
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
|
||||||
const data = await apiClient.get('/openai-compatibility');
|
const data = await apiClient.get('/openai-compatibility');
|
||||||
const list = (data && (data['openai-compatibility'] ?? data.items ?? data)) as any;
|
const list = (data && (data['openai-compatibility'] ?? data.items ?? data)) as any;
|
||||||
|
|||||||
@@ -258,7 +258,15 @@ export const normalizeConfigResponse = (raw: any): Config => {
|
|||||||
config.usageStatisticsEnabled = raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled;
|
config.usageStatisticsEnabled = raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled;
|
||||||
config.requestLog = raw['request-log'] ?? raw.requestLog;
|
config.requestLog = raw['request-log'] ?? raw.requestLog;
|
||||||
config.loggingToFile = raw['logging-to-file'] ?? raw.loggingToFile;
|
config.loggingToFile = raw['logging-to-file'] ?? raw.loggingToFile;
|
||||||
|
config.logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb;
|
||||||
config.wsAuth = raw['ws-auth'] ?? raw.wsAuth;
|
config.wsAuth = raw['ws-auth'] ?? raw.wsAuth;
|
||||||
|
config.forceModelPrefix = raw['force-model-prefix'] ?? raw.forceModelPrefix;
|
||||||
|
const routing = raw.routing;
|
||||||
|
if (routing && typeof routing === 'object') {
|
||||||
|
config.routingStrategy = routing.strategy ?? routing['strategy'];
|
||||||
|
} else {
|
||||||
|
config.routingStrategy = raw['routing-strategy'] ?? raw.routingStrategy;
|
||||||
|
}
|
||||||
config.apiKeys = Array.isArray(raw['api-keys']) ? raw['api-keys'].slice() : raw.apiKeys;
|
config.apiKeys = Array.isArray(raw['api-keys']) ? raw['api-keys'].slice() : raw.apiKeys;
|
||||||
|
|
||||||
const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys;
|
const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys;
|
||||||
@@ -282,6 +290,13 @@ export const normalizeConfigResponse = (raw: any): Config => {
|
|||||||
.filter(Boolean) as ProviderKeyConfig[];
|
.filter(Boolean) as ProviderKeyConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const vertexList = raw['vertex-api-key'] ?? raw.vertexApiKey ?? raw.vertexApiKeys;
|
||||||
|
if (Array.isArray(vertexList)) {
|
||||||
|
config.vertexApiKeys = vertexList
|
||||||
|
.map((item: any) => normalizeProviderKeyConfig(item))
|
||||||
|
.filter(Boolean) as ProviderKeyConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility;
|
const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility;
|
||||||
if (Array.isArray(openaiList)) {
|
if (Array.isArray(openaiList)) {
|
||||||
config.openaiCompatibility = openaiList
|
config.openaiCompatibility = openaiList
|
||||||
|
|||||||
25
src/services/api/vertex.ts
Normal file
25
src/services/api/vertex.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Vertex credential import API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface VertexImportResponse {
|
||||||
|
status: 'ok';
|
||||||
|
project_id?: string;
|
||||||
|
email?: string;
|
||||||
|
location?: string;
|
||||||
|
'auth-file'?: string;
|
||||||
|
auth_file?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vertexApi = {
|
||||||
|
importCredential: (file: File, location?: string) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (location) {
|
||||||
|
formData.append('location', location);
|
||||||
|
}
|
||||||
|
return apiClient.postForm<VertexImportResponse>('/vertex/import', formData);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -8,3 +8,4 @@ export { useLanguageStore } from './useLanguageStore';
|
|||||||
export { useAuthStore } from './useAuthStore';
|
export { useAuthStore } from './useAuthStore';
|
||||||
export { useConfigStore } from './useConfigStore';
|
export { useConfigStore } from './useConfigStore';
|
||||||
export { useModelsStore } from './useModelsStore';
|
export { useModelsStore } from './useModelsStore';
|
||||||
|
export { useQuotaStore } from './useQuotaStore';
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
apiBase: '',
|
apiBase: '',
|
||||||
managementKey: '',
|
managementKey: '',
|
||||||
|
rememberPassword: false,
|
||||||
serverVersion: null,
|
serverVersion: null,
|
||||||
serverBuildDate: null,
|
serverBuildDate: null,
|
||||||
connectionStatus: 'disconnected',
|
connectionStatus: 'disconnected',
|
||||||
@@ -52,16 +53,25 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
secureStorage.getItem<string>('apiUrl', { encrypt: true });
|
secureStorage.getItem<string>('apiUrl', { encrypt: true });
|
||||||
const legacyKey = secureStorage.getItem<string>('managementKey');
|
const legacyKey = secureStorage.getItem<string>('managementKey');
|
||||||
|
|
||||||
const { apiBase, managementKey } = get();
|
const { apiBase, managementKey, rememberPassword } = get();
|
||||||
const resolvedBase = normalizeApiBase(apiBase || legacyBase || detectApiBaseFromLocation());
|
const resolvedBase = normalizeApiBase(apiBase || legacyBase || detectApiBaseFromLocation());
|
||||||
const resolvedKey = managementKey || legacyKey || '';
|
const resolvedKey = managementKey || legacyKey || '';
|
||||||
|
const resolvedRememberPassword = rememberPassword || Boolean(managementKey) || Boolean(legacyKey);
|
||||||
|
|
||||||
set({ apiBase: resolvedBase, managementKey: resolvedKey });
|
set({
|
||||||
|
apiBase: resolvedBase,
|
||||||
|
managementKey: resolvedKey,
|
||||||
|
rememberPassword: resolvedRememberPassword
|
||||||
|
});
|
||||||
apiClient.setConfig({ apiBase: resolvedBase, managementKey: resolvedKey });
|
apiClient.setConfig({ apiBase: resolvedBase, managementKey: resolvedKey });
|
||||||
|
|
||||||
if (wasLoggedIn && resolvedBase && resolvedKey) {
|
if (wasLoggedIn && resolvedBase && resolvedKey) {
|
||||||
try {
|
try {
|
||||||
await get().login({ apiBase: resolvedBase, managementKey: resolvedKey });
|
await get().login({
|
||||||
|
apiBase: resolvedBase,
|
||||||
|
managementKey: resolvedKey,
|
||||||
|
rememberPassword: resolvedRememberPassword
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Auto login failed:', error);
|
console.warn('Auto login failed:', error);
|
||||||
@@ -79,6 +89,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
login: async (credentials) => {
|
login: async (credentials) => {
|
||||||
const apiBase = normalizeApiBase(credentials.apiBase);
|
const apiBase = normalizeApiBase(credentials.apiBase);
|
||||||
const managementKey = credentials.managementKey.trim();
|
const managementKey = credentials.managementKey.trim();
|
||||||
|
const rememberPassword = credentials.rememberPassword ?? get().rememberPassword ?? false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
set({ connectionStatus: 'connecting' });
|
set({ connectionStatus: 'connecting' });
|
||||||
@@ -97,10 +108,15 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
apiBase,
|
apiBase,
|
||||||
managementKey,
|
managementKey,
|
||||||
|
rememberPassword,
|
||||||
connectionStatus: 'connected',
|
connectionStatus: 'connected',
|
||||||
connectionError: null
|
connectionError: null
|
||||||
});
|
});
|
||||||
localStorage.setItem('isLoggedIn', 'true');
|
if (rememberPassword) {
|
||||||
|
localStorage.setItem('isLoggedIn', 'true');
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('isLoggedIn');
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
set({
|
set({
|
||||||
connectionStatus: 'error',
|
connectionStatus: 'error',
|
||||||
@@ -185,7 +201,8 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
})),
|
})),
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
apiBase: state.apiBase,
|
apiBase: state.apiBase,
|
||||||
managementKey: state.managementKey,
|
...(state.rememberPassword ? { managementKey: state.managementKey } : {}),
|
||||||
|
rememberPassword: state.rememberPassword,
|
||||||
serverVersion: state.serverVersion,
|
serverVersion: state.serverVersion,
|
||||||
serverBuildDate: state.serverBuildDate
|
serverBuildDate: state.serverBuildDate
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,12 +38,16 @@ const SECTION_KEYS: RawConfigSection[] = [
|
|||||||
'usage-statistics-enabled',
|
'usage-statistics-enabled',
|
||||||
'request-log',
|
'request-log',
|
||||||
'logging-to-file',
|
'logging-to-file',
|
||||||
|
'logs-max-total-size-mb',
|
||||||
'ws-auth',
|
'ws-auth',
|
||||||
|
'force-model-prefix',
|
||||||
|
'routing/strategy',
|
||||||
'api-keys',
|
'api-keys',
|
||||||
'ampcode',
|
'ampcode',
|
||||||
'gemini-api-key',
|
'gemini-api-key',
|
||||||
'codex-api-key',
|
'codex-api-key',
|
||||||
'claude-api-key',
|
'claude-api-key',
|
||||||
|
'vertex-api-key',
|
||||||
'openai-compatibility',
|
'openai-compatibility',
|
||||||
'oauth-excluded-models'
|
'oauth-excluded-models'
|
||||||
];
|
];
|
||||||
@@ -65,8 +69,14 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection)
|
|||||||
return config.requestLog;
|
return config.requestLog;
|
||||||
case 'logging-to-file':
|
case 'logging-to-file':
|
||||||
return config.loggingToFile;
|
return config.loggingToFile;
|
||||||
|
case 'logs-max-total-size-mb':
|
||||||
|
return config.logsMaxTotalSizeMb;
|
||||||
case 'ws-auth':
|
case 'ws-auth':
|
||||||
return config.wsAuth;
|
return config.wsAuth;
|
||||||
|
case 'force-model-prefix':
|
||||||
|
return config.forceModelPrefix;
|
||||||
|
case 'routing/strategy':
|
||||||
|
return config.routingStrategy;
|
||||||
case 'api-keys':
|
case 'api-keys':
|
||||||
return config.apiKeys;
|
return config.apiKeys;
|
||||||
case 'ampcode':
|
case 'ampcode':
|
||||||
@@ -77,6 +87,8 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection)
|
|||||||
return config.codexApiKeys;
|
return config.codexApiKeys;
|
||||||
case 'claude-api-key':
|
case 'claude-api-key':
|
||||||
return config.claudeApiKeys;
|
return config.claudeApiKeys;
|
||||||
|
case 'vertex-api-key':
|
||||||
|
return config.vertexApiKeys;
|
||||||
case 'openai-compatibility':
|
case 'openai-compatibility':
|
||||||
return config.openaiCompatibility;
|
return config.openaiCompatibility;
|
||||||
case 'oauth-excluded-models':
|
case 'oauth-excluded-models':
|
||||||
@@ -194,9 +206,18 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
|||||||
case 'logging-to-file':
|
case 'logging-to-file':
|
||||||
nextConfig.loggingToFile = value;
|
nextConfig.loggingToFile = value;
|
||||||
break;
|
break;
|
||||||
|
case 'logs-max-total-size-mb':
|
||||||
|
nextConfig.logsMaxTotalSizeMb = value;
|
||||||
|
break;
|
||||||
case 'ws-auth':
|
case 'ws-auth':
|
||||||
nextConfig.wsAuth = value;
|
nextConfig.wsAuth = value;
|
||||||
break;
|
break;
|
||||||
|
case 'force-model-prefix':
|
||||||
|
nextConfig.forceModelPrefix = value;
|
||||||
|
break;
|
||||||
|
case 'routing/strategy':
|
||||||
|
nextConfig.routingStrategy = value;
|
||||||
|
break;
|
||||||
case 'api-keys':
|
case 'api-keys':
|
||||||
nextConfig.apiKeys = value;
|
nextConfig.apiKeys = value;
|
||||||
break;
|
break;
|
||||||
@@ -212,6 +233,9 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
|||||||
case 'claude-api-key':
|
case 'claude-api-key':
|
||||||
nextConfig.claudeApiKeys = value;
|
nextConfig.claudeApiKeys = value;
|
||||||
break;
|
break;
|
||||||
|
case 'vertex-api-key':
|
||||||
|
nextConfig.vertexApiKeys = value;
|
||||||
|
break;
|
||||||
case 'openai-compatibility':
|
case 'openai-compatibility':
|
||||||
nextConfig.openaiCompatibility = value;
|
nextConfig.openaiCompatibility = value;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { persist } from 'zustand/middleware';
|
|||||||
import type { Language } from '@/types';
|
import type { Language } from '@/types';
|
||||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
|
import { getInitialLanguage } from '@/utils/language';
|
||||||
|
|
||||||
interface LanguageState {
|
interface LanguageState {
|
||||||
language: Language;
|
language: Language;
|
||||||
@@ -18,7 +19,7 @@ interface LanguageState {
|
|||||||
export const useLanguageStore = create<LanguageState>()(
|
export const useLanguageStore = create<LanguageState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
language: 'zh-CN',
|
language: getInitialLanguage(),
|
||||||
|
|
||||||
setLanguage: (language) => {
|
setLanguage: (language) => {
|
||||||
// 切换 i18next 语言
|
// 切换 i18next 语言
|
||||||
|
|||||||
@@ -8,15 +8,38 @@ import type { Notification, NotificationType } from '@/types';
|
|||||||
import { generateId } from '@/utils/helpers';
|
import { generateId } from '@/utils/helpers';
|
||||||
import { NOTIFICATION_DURATION_MS } from '@/utils/constants';
|
import { NOTIFICATION_DURATION_MS } from '@/utils/constants';
|
||||||
|
|
||||||
|
interface ConfirmationOptions {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
variant?: 'danger' | 'primary' | 'secondary';
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface NotificationState {
|
interface NotificationState {
|
||||||
notifications: Notification[];
|
notifications: Notification[];
|
||||||
|
confirmation: {
|
||||||
|
isOpen: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
options: ConfirmationOptions | null;
|
||||||
|
};
|
||||||
showNotification: (message: string, type?: NotificationType, duration?: number) => void;
|
showNotification: (message: string, type?: NotificationType, duration?: number) => void;
|
||||||
removeNotification: (id: string) => void;
|
removeNotification: (id: string) => void;
|
||||||
clearAll: () => void;
|
clearAll: () => void;
|
||||||
|
showConfirmation: (options: ConfirmationOptions) => void;
|
||||||
|
hideConfirmation: () => void;
|
||||||
|
setConfirmationLoading: (loading: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useNotificationStore = create<NotificationState>((set) => ({
|
export const useNotificationStore = create<NotificationState>((set) => ({
|
||||||
notifications: [],
|
notifications: [],
|
||||||
|
confirmation: {
|
||||||
|
isOpen: false,
|
||||||
|
isLoading: false,
|
||||||
|
options: null
|
||||||
|
},
|
||||||
|
|
||||||
showNotification: (message, type = 'info', duration = NOTIFICATION_DURATION_MS) => {
|
showNotification: (message, type = 'info', duration = NOTIFICATION_DURATION_MS) => {
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
@@ -49,5 +72,34 @@ export const useNotificationStore = create<NotificationState>((set) => ({
|
|||||||
|
|
||||||
clearAll: () => {
|
clearAll: () => {
|
||||||
set({ notifications: [] });
|
set({ notifications: [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
showConfirmation: (options) => {
|
||||||
|
set({
|
||||||
|
confirmation: {
|
||||||
|
isOpen: true,
|
||||||
|
isLoading: false,
|
||||||
|
options
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
hideConfirmation: () => {
|
||||||
|
set((state) => ({
|
||||||
|
confirmation: {
|
||||||
|
...state.confirmation,
|
||||||
|
isOpen: false,
|
||||||
|
options: null // Cleanup
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
setConfirmationLoading: (loading) => {
|
||||||
|
set((state) => ({
|
||||||
|
confirmation: {
|
||||||
|
...state.confirmation,
|
||||||
|
isLoading: loading
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
49
src/stores/useQuotaStore.ts
Normal file
49
src/stores/useQuotaStore.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Quota cache that survives route switches.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { AntigravityQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
|
||||||
|
|
||||||
|
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||||
|
|
||||||
|
interface QuotaStoreState {
|
||||||
|
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||||
|
codexQuota: Record<string, CodexQuotaState>;
|
||||||
|
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||||
|
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||||
|
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||||
|
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||||
|
clearQuotaCache: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveUpdater = <T,>(updater: QuotaUpdater<T>, prev: T): T => {
|
||||||
|
if (typeof updater === 'function') {
|
||||||
|
return (updater as (value: T) => T)(prev);
|
||||||
|
}
|
||||||
|
return updater;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useQuotaStore = create<QuotaStoreState>((set) => ({
|
||||||
|
antigravityQuota: {},
|
||||||
|
codexQuota: {},
|
||||||
|
geminiCliQuota: {},
|
||||||
|
setAntigravityQuota: (updater) =>
|
||||||
|
set((state) => ({
|
||||||
|
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
|
||||||
|
})),
|
||||||
|
setCodexQuota: (updater) =>
|
||||||
|
set((state) => ({
|
||||||
|
codexQuota: resolveUpdater(updater, state.codexQuota)
|
||||||
|
})),
|
||||||
|
setGeminiCliQuota: (updater) =>
|
||||||
|
set((state) => ({
|
||||||
|
geminiCliQuota: resolveUpdater(updater, state.geminiCliQuota)
|
||||||
|
})),
|
||||||
|
clearQuotaCache: () =>
|
||||||
|
set({
|
||||||
|
antigravityQuota: {},
|
||||||
|
codexQuota: {},
|
||||||
|
geminiCliQuota: {}
|
||||||
|
})
|
||||||
|
}));
|
||||||
@@ -350,6 +350,32 @@ textarea {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: $z-modal;
|
z-index: $z-modal;
|
||||||
padding: $spacing-lg;
|
padding: $spacing-lg;
|
||||||
|
|
||||||
|
&.modal-overlay-entering {
|
||||||
|
animation: modal-overlay-fade-in 0.25s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.modal-overlay-closing {
|
||||||
|
animation: modal-overlay-fade-out 0.35s ease-in forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-overlay-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-overlay-fade-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
@@ -361,12 +387,89 @@ textarea {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
// 关闭按钮中心位置: right 12px + 16px = 28px, top 12px + 16px = 28px
|
||||||
|
transform-origin: calc(100% - 28px) 28px;
|
||||||
|
|
||||||
|
&.modal-entering {
|
||||||
|
animation: modal-scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.modal-closing {
|
||||||
|
animation: modal-collapse-to-close 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-scale-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.85) translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-collapse-to-close {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-floating {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled:hover {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
padding: $spacing-md $spacing-lg;
|
padding: $spacing-md $spacing-lg;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
@@ -375,30 +478,6 @@ textarea {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: $radius-md;
|
|
||||||
transition: color 0.15s ease, background-color 0.15s ease;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ body {
|
|||||||
transition: background-color $transition-normal, color $transition-normal;
|
transition: background-color $transition-normal, color $transition-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.modal-open,
|
||||||
|
body.modal-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.modal-open .content {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
// 滚动条样式
|
// 滚动条样式
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: visible;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-header {
|
.main-header {
|
||||||
@@ -28,6 +35,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@media (max-width: $breakpoint-mobile) {
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
padding: $spacing-sm $spacing-md;
|
padding: $spacing-sm $spacing-md;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
}
|
}
|
||||||
@@ -230,6 +240,17 @@
|
|||||||
@supports (height: 100dvh) {
|
@supports (height: 100dvh) {
|
||||||
height: calc(100dvh - var(--header-height));
|
height: calc(100dvh - var(--header-height));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100vh - var(--header-height));
|
||||||
|
overflow: visible;
|
||||||
|
padding-top: var(--header-height);
|
||||||
|
|
||||||
|
@supports (min-height: 100dvh) {
|
||||||
|
min-height: calc(100dvh - var(--header-height));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -327,7 +348,18 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
|
&.content-logs {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
overflow: visible;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
@@ -338,6 +370,18 @@
|
|||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
&.main-content-logs {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: $breakpoint-mobile) {
|
@media (max-width: $breakpoint-mobile) {
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
}
|
}
|
||||||
@@ -354,6 +398,13 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
|
|
||||||
|
.footer-version {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-page {
|
.login-page {
|
||||||
@@ -381,6 +432,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
@@ -393,6 +445,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-language-btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.connection-box {
|
.connection-box {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px dashed var(--border-color);
|
border: 1px dashed var(--border-color);
|
||||||
|
|||||||
@@ -32,6 +32,9 @@
|
|||||||
--failure-badge-text: #991b1b;
|
--failure-badge-text: #991b1b;
|
||||||
--failure-badge-border: #fca5a5;
|
--failure-badge-border: #fca5a5;
|
||||||
|
|
||||||
|
--count-badge-bg: rgba(59, 130, 246, 0.14);
|
||||||
|
--count-badge-text: var(--primary-active);
|
||||||
|
|
||||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||||
}
|
}
|
||||||
@@ -66,6 +69,9 @@
|
|||||||
--failure-badge-text: #fca5a5;
|
--failure-badge-text: #fca5a5;
|
||||||
--failure-badge-border: #dc2626;
|
--failure-badge-border: #dc2626;
|
||||||
|
|
||||||
|
--count-badge-bg: rgba(59, 130, 246, 0.25);
|
||||||
|
--count-badge-text: var(--primary-active);
|
||||||
|
|
||||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
|
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3);
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
export interface LoginCredentials {
|
export interface LoginCredentials {
|
||||||
apiBase: string;
|
apiBase: string;
|
||||||
managementKey: string;
|
managementKey: string;
|
||||||
|
rememberPassword?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 认证状态
|
// 认证状态
|
||||||
@@ -14,6 +15,7 @@ export interface AuthState {
|
|||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
apiBase: string;
|
apiBase: string;
|
||||||
managementKey: string;
|
managementKey: string;
|
||||||
|
rememberPassword: boolean;
|
||||||
serverVersion: string | null;
|
serverVersion: string | null;
|
||||||
serverBuildDate: string | null;
|
serverBuildDate: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,16 @@ export interface Config {
|
|||||||
usageStatisticsEnabled?: boolean;
|
usageStatisticsEnabled?: boolean;
|
||||||
requestLog?: boolean;
|
requestLog?: boolean;
|
||||||
loggingToFile?: boolean;
|
loggingToFile?: boolean;
|
||||||
|
logsMaxTotalSizeMb?: number;
|
||||||
wsAuth?: boolean;
|
wsAuth?: boolean;
|
||||||
|
forceModelPrefix?: boolean;
|
||||||
|
routingStrategy?: string;
|
||||||
apiKeys?: string[];
|
apiKeys?: string[];
|
||||||
ampcode?: AmpcodeConfig;
|
ampcode?: AmpcodeConfig;
|
||||||
geminiApiKeys?: GeminiKeyConfig[];
|
geminiApiKeys?: GeminiKeyConfig[];
|
||||||
codexApiKeys?: ProviderKeyConfig[];
|
codexApiKeys?: ProviderKeyConfig[];
|
||||||
claudeApiKeys?: ProviderKeyConfig[];
|
claudeApiKeys?: ProviderKeyConfig[];
|
||||||
|
vertexApiKeys?: ProviderKeyConfig[];
|
||||||
openaiCompatibility?: OpenAIProviderConfig[];
|
openaiCompatibility?: OpenAIProviderConfig[];
|
||||||
oauthExcludedModels?: Record<string, string[]>;
|
oauthExcludedModels?: Record<string, string[]>;
|
||||||
raw?: Record<string, any>;
|
raw?: Record<string, any>;
|
||||||
@@ -38,12 +42,16 @@ export type RawConfigSection =
|
|||||||
| 'usage-statistics-enabled'
|
| 'usage-statistics-enabled'
|
||||||
| 'request-log'
|
| 'request-log'
|
||||||
| 'logging-to-file'
|
| 'logging-to-file'
|
||||||
|
| 'logs-max-total-size-mb'
|
||||||
| 'ws-auth'
|
| 'ws-auth'
|
||||||
|
| 'force-model-prefix'
|
||||||
|
| 'routing/strategy'
|
||||||
| 'api-keys'
|
| 'api-keys'
|
||||||
| 'ampcode'
|
| 'ampcode'
|
||||||
| 'gemini-api-key'
|
| 'gemini-api-key'
|
||||||
| 'codex-api-key'
|
| 'codex-api-key'
|
||||||
| 'claude-api-key'
|
| 'claude-api-key'
|
||||||
|
| 'vertex-api-key'
|
||||||
| 'openai-compatibility'
|
| 'openai-compatibility'
|
||||||
| 'oauth-excluded-models';
|
| 'oauth-excluded-models';
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user