mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 11:20:50 +08:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e56d33bf0 | ||
|
|
80daf03fa6 | ||
|
|
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 |
29
package-lock.json
generated
29
package-lock.json
generated
@@ -71,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",
|
||||||
@@ -465,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",
|
||||||
@@ -1930,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"
|
||||||
}
|
}
|
||||||
@@ -2017,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",
|
||||||
@@ -2334,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"
|
||||||
},
|
},
|
||||||
@@ -2545,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"
|
||||||
},
|
},
|
||||||
@@ -2809,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",
|
||||||
@@ -3285,6 +3292,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4"
|
"@babel/runtime": "^7.28.4"
|
||||||
},
|
},
|
||||||
@@ -3614,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"
|
||||||
},
|
},
|
||||||
@@ -3720,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"
|
||||||
}
|
}
|
||||||
@@ -3737,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"
|
||||||
},
|
},
|
||||||
@@ -3780,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",
|
||||||
@@ -3802,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"
|
||||||
@@ -3845,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"
|
||||||
},
|
},
|
||||||
@@ -4027,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"
|
||||||
@@ -4103,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",
|
||||||
@@ -4244,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { HashRouter, Route, Routes } from 'react-router-dom';
|
import { HashRouter, Route, Routes } from 'react-router-dom';
|
||||||
import { LoginPage } from '@/pages/LoginPage';
|
import { LoginPage } from '@/pages/LoginPage';
|
||||||
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||||
|
import { 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';
|
||||||
@@ -61,6 +62,7 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<NotificationContainer />
|
<NotificationContainer />
|
||||||
|
<ConfirmationModal />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@
|
|||||||
|
|
||||||
&--animating &__layer {
|
&--animating &__layer {
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transform-style: preserve-3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When both layers exist, current layer also needs positioning
|
// When both layers exist, current layer also needs positioning
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ interface PageTransitionProps {
|
|||||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRANSITION_DURATION = 0.65;
|
const TRANSITION_DURATION = 0.5;
|
||||||
|
const EXIT_DURATION = 0.45;
|
||||||
|
const ENTER_DELAY = 0.08;
|
||||||
|
|
||||||
type LayerStatus = 'current' | 'exiting';
|
type LayerStatus = 'current' | 'exiting';
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ export function PageTransition({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAnimating) return;
|
if (isAnimating) return;
|
||||||
if (location.key === currentLayerKey) return;
|
if (location.key === currentLayerKey) return;
|
||||||
|
if (currentLayerPathname === location.pathname) return;
|
||||||
const scrollContainer = resolveScrollContainer();
|
const scrollContainer = resolveScrollContainer();
|
||||||
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
|
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
|
||||||
const resolveOrderIndex = (pathname?: string) => {
|
const resolveOrderIndex = (pathname?: string) => {
|
||||||
@@ -67,17 +70,27 @@ export function PageTransition({
|
|||||||
: toIndex > fromIndex
|
: toIndex > fromIndex
|
||||||
? 'forward'
|
? 'forward'
|
||||||
: 'backward';
|
: 'backward';
|
||||||
setTransitionDirection(nextDirection);
|
|
||||||
setLayers((prev) => {
|
let cancelled = false;
|
||||||
const prevCurrent = prev[prev.length - 1];
|
|
||||||
return [
|
queueMicrotask(() => {
|
||||||
prevCurrent
|
if (cancelled) return;
|
||||||
? { ...prevCurrent, status: 'exiting' }
|
setTransitionDirection(nextDirection);
|
||||||
: { key: location.key, location, status: 'exiting' },
|
setLayers((prev) => {
|
||||||
{ key: location.key, location, status: 'current' },
|
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);
|
||||||
});
|
});
|
||||||
setIsAnimating(true);
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
isAnimating,
|
isAnimating,
|
||||||
location,
|
location,
|
||||||
@@ -99,6 +112,13 @@ export function PageTransition({
|
|||||||
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
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({
|
const tl = gsap.timeline({
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting'));
|
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting'));
|
||||||
@@ -108,15 +128,16 @@ export function PageTransition({
|
|||||||
|
|
||||||
// Exit animation: fly out to top (slow-to-fast)
|
// Exit animation: fly out to top (slow-to-fast)
|
||||||
if (exitingLayerRef.current) {
|
if (exitingLayerRef.current) {
|
||||||
gsap.set(exitingLayerRef.current, { y: scrollOffset ? -scrollOffset : 0 });
|
gsap.set(exitingLayerRef.current, { y: exitBaseY });
|
||||||
tl.fromTo(
|
tl.fromTo(
|
||||||
exitingLayerRef.current,
|
exitingLayerRef.current,
|
||||||
{ yPercent: 0, opacity: 1 },
|
{ y: exitBaseY, opacity: 1 },
|
||||||
{
|
{
|
||||||
yPercent: transitionDirection === 'forward' ? -100 : 100,
|
y: exitBaseY + exitToY,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
duration: TRANSITION_DURATION,
|
duration: EXIT_DURATION,
|
||||||
ease: 'power3.in', // slow start, fast end
|
ease: 'power2.in', // fast finish to clear screen
|
||||||
|
force3D: true,
|
||||||
},
|
},
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
@@ -125,15 +146,16 @@ export function PageTransition({
|
|||||||
// Enter animation: slide in from bottom (slow-to-fast)
|
// Enter animation: slide in from bottom (slow-to-fast)
|
||||||
tl.fromTo(
|
tl.fromTo(
|
||||||
currentLayerRef.current,
|
currentLayerRef.current,
|
||||||
{ yPercent: transitionDirection === 'forward' ? 100 : -100, opacity: 0 },
|
{ y: enterFromY, opacity: 0 },
|
||||||
{
|
{
|
||||||
yPercent: 0,
|
y: 0,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
duration: TRANSITION_DURATION,
|
duration: TRANSITION_DURATION,
|
||||||
ease: 'power2.in', // slow start, fast end
|
ease: 'power2.out', // smooth settle
|
||||||
clearProps: 'transform,opacity',
|
clearProps: 'transform,opacity',
|
||||||
|
force3D: true,
|
||||||
},
|
},
|
||||||
0
|
ENTER_DELAY
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -36,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} />,
|
||||||
@@ -384,12 +385,22 @@ export function MainLayout() {
|
|||||||
|
|
||||||
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 () => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/Input';
|
|||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
|
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
import { headersToEntries } from '@/utils/headers';
|
||||||
import { excludedModelsToText } from '../utils';
|
import { excludedModelsToText } from '../utils';
|
||||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
import type { ProviderFormState, ProviderModalProps } from '../types';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ const buildEmptyForm = (): ProviderFormState => ({
|
|||||||
prefix: '',
|
prefix: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
proxyUrl: '',
|
proxyUrl: '',
|
||||||
headers: {},
|
headers: [],
|
||||||
models: [],
|
models: [],
|
||||||
excludedModels: [],
|
excludedModels: [],
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
modelEntries: [{ name: '', alias: '' }],
|
||||||
@@ -43,7 +43,7 @@ export function ClaudeModal({
|
|||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setForm({
|
setForm({
|
||||||
...initialData,
|
...initialData,
|
||||||
headers: initialData.headers ?? {},
|
headers: headersToEntries(initialData.headers),
|
||||||
modelEntries: modelsToEntries(initialData.models),
|
modelEntries: modelsToEntries(initialData.models),
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||||
});
|
});
|
||||||
@@ -95,8 +95,8 @@ export function ClaudeModal({
|
|||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<HeaderInputList
|
<HeaderInputList
|
||||||
entries={headersToEntries(form.headers)}
|
entries={form.headers}
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
|
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||||
addLabel={t('common.custom_headers_add')}
|
addLabel={t('common.custom_headers_add')}
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
|||||||
import iconClaude from '@/assets/icons/claude.svg';
|
import iconClaude from '@/assets/icons/claude.svg';
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
import { maskApiKey } from '@/utils/format';
|
import { maskApiKey } from '@/utils/format';
|
||||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
import { ProviderList } from '../ProviderList';
|
import { ProviderList } from '../ProviderList';
|
||||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
@@ -55,11 +60,19 @@ export function ClaudeSection({
|
|||||||
|
|
||||||
const statusBarCache = useMemo(() => {
|
const statusBarCache = useMemo(() => {
|
||||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
const allApiKeys = new Set<string>();
|
|
||||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
configs.forEach((config) => {
|
||||||
allApiKeys.forEach((apiKey) => {
|
if (!config.apiKey) return;
|
||||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
const candidates = buildCandidateUsageSourceIds({
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
prefix: config.prefix,
|
||||||
|
});
|
||||||
|
if (!candidates.length) return;
|
||||||
|
const candidateSet = new Set(candidates);
|
||||||
|
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||||
|
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||||
});
|
});
|
||||||
|
|
||||||
return cache;
|
return cache;
|
||||||
}, [configs, usageDetails]);
|
}, [configs, usageDetails]);
|
||||||
|
|
||||||
@@ -99,12 +112,11 @@ export function ClaudeSection({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderContent={(item) => {
|
renderContent={(item) => {
|
||||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||||
const headerEntries = Object.entries(item.headers || {});
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||||
const excludedModels = item.excludedModels ?? [];
|
const excludedModels = item.excludedModels ?? [];
|
||||||
const statusData =
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|||||||
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 type { ProviderKeyConfig } from '@/types';
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
import { headersToEntries } from '@/utils/headers';
|
||||||
import { modelsToEntries } from '@/components/ui/ModelInputList';
|
import { modelsToEntries } from '@/components/ui/ModelInputList';
|
||||||
import { excludedModelsToText } from '../utils';
|
import { excludedModelsToText } from '../utils';
|
||||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
import type { ProviderFormState, ProviderModalProps } from '../types';
|
||||||
@@ -19,7 +19,7 @@ const buildEmptyForm = (): ProviderFormState => ({
|
|||||||
prefix: '',
|
prefix: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
proxyUrl: '',
|
proxyUrl: '',
|
||||||
headers: {},
|
headers: [],
|
||||||
models: [],
|
models: [],
|
||||||
excludedModels: [],
|
excludedModels: [],
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
modelEntries: [{ name: '', alias: '' }],
|
||||||
@@ -43,7 +43,7 @@ export function CodexModal({
|
|||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setForm({
|
setForm({
|
||||||
...initialData,
|
...initialData,
|
||||||
headers: initialData.headers ?? {},
|
headers: headersToEntries(initialData.headers),
|
||||||
modelEntries: modelsToEntries(initialData.models),
|
modelEntries: modelsToEntries(initialData.models),
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||||
});
|
});
|
||||||
@@ -95,8 +95,8 @@ export function CodexModal({
|
|||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<HeaderInputList
|
<HeaderInputList
|
||||||
entries={headersToEntries(form.headers)}
|
entries={form.headers}
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
|
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||||
addLabel={t('common.custom_headers_add')}
|
addLabel={t('common.custom_headers_add')}
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
|||||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
import { maskApiKey } from '@/utils/format';
|
import { maskApiKey } from '@/utils/format';
|
||||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
import { ProviderList } from '../ProviderList';
|
import { ProviderList } from '../ProviderList';
|
||||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
@@ -58,11 +63,19 @@ export function CodexSection({
|
|||||||
|
|
||||||
const statusBarCache = useMemo(() => {
|
const statusBarCache = useMemo(() => {
|
||||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
const allApiKeys = new Set<string>();
|
|
||||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
configs.forEach((config) => {
|
||||||
allApiKeys.forEach((apiKey) => {
|
if (!config.apiKey) return;
|
||||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
const candidates = buildCandidateUsageSourceIds({
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
prefix: config.prefix,
|
||||||
|
});
|
||||||
|
if (!candidates.length) return;
|
||||||
|
const candidateSet = new Set(candidates);
|
||||||
|
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||||
|
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||||
});
|
});
|
||||||
|
|
||||||
return cache;
|
return cache;
|
||||||
}, [configs, usageDetails]);
|
}, [configs, usageDetails]);
|
||||||
|
|
||||||
@@ -106,12 +119,11 @@ export function CodexSection({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderContent={(item) => {
|
renderContent={(item) => {
|
||||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||||
const headerEntries = Object.entries(item.headers || {});
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||||
const excludedModels = item.excludedModels ?? [];
|
const excludedModels = item.excludedModels ?? [];
|
||||||
const statusData =
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|||||||
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 type { GeminiKeyConfig } from '@/types';
|
import type { GeminiKeyConfig } from '@/types';
|
||||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
import { headersToEntries } from '@/utils/headers';
|
||||||
import { excludedModelsToText } from '../utils';
|
import { excludedModelsToText } from '../utils';
|
||||||
import type { GeminiFormState, ProviderModalProps } from '../types';
|
import type { GeminiFormState, ProviderModalProps } from '../types';
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ const buildEmptyForm = (): GeminiFormState => ({
|
|||||||
apiKey: '',
|
apiKey: '',
|
||||||
prefix: '',
|
prefix: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
headers: {},
|
headers: [],
|
||||||
excludedModels: [],
|
excludedModels: [],
|
||||||
excludedText: '',
|
excludedText: '',
|
||||||
});
|
});
|
||||||
@@ -39,7 +39,7 @@ export function GeminiModal({
|
|||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setForm({
|
setForm({
|
||||||
...initialData,
|
...initialData,
|
||||||
headers: initialData.headers ?? {},
|
headers: headersToEntries(initialData.headers),
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -91,8 +91,8 @@ export function GeminiModal({
|
|||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<HeaderInputList
|
<HeaderInputList
|
||||||
entries={headersToEntries(form.headers)}
|
entries={form.headers}
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
|
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||||
addLabel={t('common.custom_headers_add')}
|
addLabel={t('common.custom_headers_add')}
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
|||||||
import iconGemini from '@/assets/icons/gemini.svg';
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
import type { GeminiKeyConfig } from '@/types';
|
import type { GeminiKeyConfig } from '@/types';
|
||||||
import { maskApiKey } from '@/utils/format';
|
import { maskApiKey } from '@/utils/format';
|
||||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
import type { GeminiFormState } from '../types';
|
import type { GeminiFormState } from '../types';
|
||||||
import { ProviderList } from '../ProviderList';
|
import { ProviderList } from '../ProviderList';
|
||||||
@@ -55,11 +60,19 @@ export function GeminiSection({
|
|||||||
|
|
||||||
const statusBarCache = useMemo(() => {
|
const statusBarCache = useMemo(() => {
|
||||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
const allApiKeys = new Set<string>();
|
|
||||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
configs.forEach((config) => {
|
||||||
allApiKeys.forEach((apiKey) => {
|
if (!config.apiKey) return;
|
||||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
const candidates = buildCandidateUsageSourceIds({
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
prefix: config.prefix,
|
||||||
|
});
|
||||||
|
if (!candidates.length) return;
|
||||||
|
const candidateSet = new Set(candidates);
|
||||||
|
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||||
|
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||||
});
|
});
|
||||||
|
|
||||||
return cache;
|
return cache;
|
||||||
}, [configs, usageDetails]);
|
}, [configs, usageDetails]);
|
||||||
|
|
||||||
@@ -99,12 +112,11 @@ export function GeminiSection({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderContent={(item, index) => {
|
renderContent={(item, index) => {
|
||||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||||
const headerEntries = Object.entries(item.headers || {});
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||||
const excludedModels = item.excludedModels ?? [];
|
const excludedModels = item.excludedModels ?? [];
|
||||||
const statusData =
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
|||||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||||
import type { OpenAIProviderConfig } from '@/types';
|
import type { OpenAIProviderConfig } from '@/types';
|
||||||
import { maskApiKey } from '@/utils/format';
|
import { maskApiKey } from '@/utils/format';
|
||||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
import { ProviderList } from '../ProviderList';
|
import { ProviderList } from '../ProviderList';
|
||||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
@@ -57,8 +62,15 @@ export function OpenAISection({
|
|||||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
|
|
||||||
configs.forEach((provider) => {
|
configs.forEach((provider) => {
|
||||||
const allKeys = (provider.apiKeyEntries || []).map((entry) => entry.apiKey).filter(Boolean);
|
const sourceIds = new Set<string>();
|
||||||
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source));
|
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
|
||||||
|
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||||
|
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredDetails = sourceIds.size
|
||||||
|
? usageDetails.filter((detail) => sourceIds.has(detail.source))
|
||||||
|
: [];
|
||||||
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,7 +108,7 @@ export function OpenAISection({
|
|||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
actionsDisabled={actionsDisabled}
|
actionsDisabled={actionsDisabled}
|
||||||
renderContent={(item) => {
|
renderContent={(item) => {
|
||||||
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, maskApiKey);
|
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
|
||||||
const headerEntries = Object.entries(item.headers || {});
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
const apiKeyEntries = item.apiKeyEntries || [];
|
const apiKeyEntries = item.apiKeyEntries || [];
|
||||||
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
|
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
|
||||||
@@ -130,7 +142,7 @@ export function OpenAISection({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.apiKeyEntryList}>
|
<div className={styles.apiKeyEntryList}>
|
||||||
{apiKeyEntries.map((entry, entryIndex) => {
|
{apiKeyEntries.map((entry, entryIndex) => {
|
||||||
const entryStats = getStatsBySource(entry.apiKey, keyStats, maskApiKey);
|
const entryStats = getStatsBySource(entry.apiKey, keyStats);
|
||||||
return (
|
return (
|
||||||
<div key={entryIndex} className={styles.apiKeyEntryCard}>
|
<div key={entryIndex} className={styles.apiKeyEntryCard}>
|
||||||
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||||
|
|||||||
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';
|
||||||
@@ -3,6 +3,7 @@ export { ClaudeSection } from './ClaudeSection';
|
|||||||
export { CodexSection } from './CodexSection';
|
export { CodexSection } from './CodexSection';
|
||||||
export { GeminiSection } from './GeminiSection';
|
export { GeminiSection } from './GeminiSection';
|
||||||
export { OpenAISection } from './OpenAISection';
|
export { OpenAISection } from './OpenAISection';
|
||||||
|
export { VertexSection } from './VertexSection';
|
||||||
export { ProviderList } from './ProviderList';
|
export { ProviderList } from './ProviderList';
|
||||||
export { ProviderStatusBar } from './ProviderStatusBar';
|
export { ProviderStatusBar } from './ProviderStatusBar';
|
||||||
export * from './hooks/useProviderStats';
|
export * from './hooks/useProviderStats';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type ProviderModal =
|
|||||||
| { type: 'gemini'; index: number | null }
|
| { type: 'gemini'; index: number | null }
|
||||||
| { type: 'codex'; index: number | null }
|
| { type: 'codex'; index: number | null }
|
||||||
| { type: 'claude'; index: number | null }
|
| { type: 'claude'; index: number | null }
|
||||||
|
| { type: 'vertex'; index: number | null }
|
||||||
| { type: 'ampcode'; index: null }
|
| { type: 'ampcode'; index: null }
|
||||||
| { type: 'openai'; index: number | null };
|
| { type: 'openai'; index: number | null };
|
||||||
|
|
||||||
@@ -31,13 +32,22 @@ export interface AmpcodeFormState {
|
|||||||
mappingEntries: ModelEntry[];
|
mappingEntries: ModelEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GeminiFormState = GeminiKeyConfig & { excludedText: string };
|
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers'> & {
|
||||||
|
headers: HeaderEntry[];
|
||||||
|
excludedText: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProviderFormState = ProviderKeyConfig & {
|
export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
|
||||||
|
headers: HeaderEntry[];
|
||||||
modelEntries: ModelEntry[];
|
modelEntries: ModelEntry[];
|
||||||
excludedText: string;
|
excludedText: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
|
||||||
|
headers: HeaderEntry[];
|
||||||
|
modelEntries: ModelEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
export interface ProviderSectionProps<TConfig> {
|
export interface ProviderSectionProps<TConfig> {
|
||||||
configs: TConfig[];
|
configs: TConfig[];
|
||||||
keyStats: KeyStats;
|
keyStats: KeyStats;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
||||||
import type { KeyStatBucket, KeyStats } from '@/utils/usage';
|
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
|
||||||
import type { AmpcodeFormState, ModelEntry } from './types';
|
import type { AmpcodeFormState, ModelEntry } from './types';
|
||||||
|
|
||||||
export const DISABLE_ALL_MODELS_RULE = '*';
|
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||||
@@ -62,33 +62,50 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
|||||||
export const getStatsBySource = (
|
export const getStatsBySource = (
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
keyStats: KeyStats,
|
keyStats: KeyStats,
|
||||||
maskFn: (key: string) => string
|
prefix?: string
|
||||||
): KeyStatBucket => {
|
): KeyStatBucket => {
|
||||||
const bySource = keyStats.bySource ?? {};
|
const bySource = keyStats.bySource ?? {};
|
||||||
const masked = maskFn(apiKey);
|
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
|
||||||
return bySource[apiKey] || bySource[masked] || { success: 0, failure: 0 };
|
if (!candidates.length) {
|
||||||
|
return { success: 0, failure: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let success = 0;
|
||||||
|
let failure = 0;
|
||||||
|
candidates.forEach((candidate) => {
|
||||||
|
const stats = bySource[candidate];
|
||||||
|
if (!stats) return;
|
||||||
|
success += stats.success;
|
||||||
|
failure += stats.failure;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success, failure };
|
||||||
};
|
};
|
||||||
|
|
||||||
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||||
export const getOpenAIProviderStats = (
|
export const getOpenAIProviderStats = (
|
||||||
apiKeyEntries: ApiKeyEntry[] | undefined,
|
apiKeyEntries: ApiKeyEntry[] | undefined,
|
||||||
keyStats: KeyStats,
|
keyStats: KeyStats,
|
||||||
maskFn: (key: string) => string
|
providerPrefix?: string
|
||||||
): KeyStatBucket => {
|
): KeyStatBucket => {
|
||||||
const bySource = keyStats.bySource ?? {};
|
const bySource = keyStats.bySource ?? {};
|
||||||
let totalSuccess = 0;
|
|
||||||
let totalFailure = 0;
|
|
||||||
|
|
||||||
|
const sourceIds = new Set<string>();
|
||||||
|
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
|
||||||
(apiKeyEntries || []).forEach((entry) => {
|
(apiKeyEntries || []).forEach((entry) => {
|
||||||
const key = entry?.apiKey || '';
|
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
|
||||||
if (!key) return;
|
|
||||||
const masked = maskFn(key);
|
|
||||||
const stats = bySource[key] || bySource[masked] || { success: 0, failure: 0 };
|
|
||||||
totalSuccess += stats.success;
|
|
||||||
totalFailure += stats.failure;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: totalSuccess, failure: totalFailure };
|
let success = 0;
|
||||||
|
let failure = 0;
|
||||||
|
sourceIds.forEach((id) => {
|
||||||
|
const stats = bySource[id];
|
||||||
|
if (!stats) return;
|
||||||
|
success += stats.success;
|
||||||
|
failure += stats.failure;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success, failure };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
||||||
|
|||||||
@@ -2,28 +2,30 @@
|
|||||||
* Generic quota section component.
|
* Generic quota section component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } 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 { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
import { useQuotaStore, useThemeStore } from '@/stores';
|
import { useQuotaStore, useThemeStore } from '@/stores';
|
||||||
import type { AuthFileItem, ResolvedTheme } from '@/types';
|
import type { AuthFileItem, ResolvedTheme } from '@/types';
|
||||||
import { QuotaCard } from './QuotaCard';
|
import { QuotaCard } from './QuotaCard';
|
||||||
import type { QuotaStatusState } from './QuotaCard';
|
import type { QuotaStatusState } from './QuotaCard';
|
||||||
import { useQuotaLoader } from './useQuotaLoader';
|
import { useQuotaLoader } from './useQuotaLoader';
|
||||||
import type { QuotaConfig } from './quotaConfigs';
|
import type { QuotaConfig } from './quotaConfigs';
|
||||||
|
import { useGridColumns } from './useGridColumns';
|
||||||
|
import { IconRefreshCw } from '@/components/ui/icons';
|
||||||
import styles from '@/pages/QuotaPage.module.scss';
|
import styles from '@/pages/QuotaPage.module.scss';
|
||||||
|
|
||||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||||
|
|
||||||
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
||||||
|
|
||||||
const MIN_CARD_PAGE_SIZE = 3;
|
type ViewMode = 'paged' | 'all';
|
||||||
const MAX_CARD_PAGE_SIZE = 30;
|
|
||||||
|
|
||||||
const clampCardPageSize = (value: number) =>
|
const MAX_ITEMS_PER_PAGE = 14;
|
||||||
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
const MAX_SHOW_ALL_THRESHOLD = 30;
|
||||||
|
|
||||||
interface QuotaPaginationState<T> {
|
interface QuotaPaginationState<T> {
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
@@ -40,7 +42,7 @@ interface QuotaPaginationState<T> {
|
|||||||
|
|
||||||
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
|
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSizeState] = useState(() => clampCardPageSize(defaultPageSize));
|
const [pageSize, setPageSizeState] = useState(defaultPageSize);
|
||||||
const [loading, setLoadingState] = useState(false);
|
const [loading, setLoadingState] = useState(false);
|
||||||
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
|
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginatio
|
|||||||
}, [items, currentPage, pageSize]);
|
}, [items, currentPage, pageSize]);
|
||||||
|
|
||||||
const setPageSize = useCallback((size: number) => {
|
const setPageSize = useCallback((size: number) => {
|
||||||
setPageSizeState(clampCardPageSize(size));
|
setPageSizeState(size);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -107,10 +109,17 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
|||||||
Record<string, TState>
|
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)), [
|
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
|
||||||
files,
|
files,
|
||||||
config.filterFn
|
config
|
||||||
]);
|
]);
|
||||||
|
const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD;
|
||||||
|
const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -121,19 +130,59 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
|||||||
goToPrev,
|
goToPrev,
|
||||||
goToNext,
|
goToNext,
|
||||||
loading: sectionLoading,
|
loading: sectionLoading,
|
||||||
loadingScope,
|
|
||||||
setLoading
|
setLoading
|
||||||
} = useQuotaPagination(filteredFiles);
|
} = 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 { quota, loadQuota } = useQuotaLoader(config);
|
||||||
|
|
||||||
const handleRefreshPage = useCallback(() => {
|
const pendingQuotaRefreshRef = useRef(false);
|
||||||
loadQuota(pageItems, 'page', setLoading);
|
const prevFilesLoadingRef = useRef(loading);
|
||||||
}, [loadQuota, pageItems, setLoading]);
|
|
||||||
|
|
||||||
const handleRefreshAll = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
loadQuota(filteredFiles, 'all', setLoading);
|
pendingQuotaRefreshRef.current = true;
|
||||||
}, [loadQuota, filteredFiles, setLoading]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
@@ -153,28 +202,56 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
|||||||
});
|
});
|
||||||
}, [filteredFiles, loading, setQuota]);
|
}, [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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title={t(`${config.i18nPrefix}.title`)}
|
title={titleNode}
|
||||||
extra={
|
extra={
|
||||||
<div className={styles.headerActions}>
|
<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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleRefreshPage}
|
onClick={handleRefresh}
|
||||||
disabled={disabled || sectionLoading || pageItems.length === 0}
|
disabled={disabled || isRefreshing}
|
||||||
loading={sectionLoading && loadingScope === 'page'}
|
loading={isRefreshing}
|
||||||
|
title={t('quota_management.refresh_files_and_quota')}
|
||||||
|
aria-label={t('quota_management.refresh_files_and_quota')}
|
||||||
>
|
>
|
||||||
{t(`${config.i18nPrefix}.refresh_button`)}
|
{!isRefreshing && <IconRefreshCw size={16} />}
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefreshAll}
|
|
||||||
disabled={disabled || sectionLoading || filteredFiles.length === 0}
|
|
||||||
loading={sectionLoading && loadingScope === 'all'}
|
|
||||||
>
|
|
||||||
{t(`${config.i18nPrefix}.fetch_all`)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -186,31 +263,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={config.controlsClassName}>
|
<div ref={gridRef} className={config.gridClassName}>
|
||||||
<div className={config.controlClassName}>
|
|
||||||
<label>{t('auth_files.page_size_label')}</label>
|
|
||||||
<input
|
|
||||||
className={styles.pageSizeSelect}
|
|
||||||
type="number"
|
|
||||||
min={MIN_CARD_PAGE_SIZE}
|
|
||||||
max={MAX_CARD_PAGE_SIZE}
|
|
||||||
step={1}
|
|
||||||
value={pageSize}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.currentTarget.valueAsNumber;
|
|
||||||
if (!Number.isFinite(value)) return;
|
|
||||||
setPageSize(value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={config.controlClassName}>
|
|
||||||
<label>{t('common.info')}</label>
|
|
||||||
<div className={styles.statsInfo}>
|
|
||||||
{filteredFiles.length} {t('auth_files.files_count')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={config.gridClassName}>
|
|
||||||
{pageItems.map((item) => (
|
{pageItems.map((item) => (
|
||||||
<QuotaCard
|
<QuotaCard
|
||||||
key={item.name}
|
key={item.name}
|
||||||
@@ -224,7 +277,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{filteredFiles.length > pageSize && (
|
{filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
|
||||||
<div className={styles.pagination}>
|
<div className={styles.pagination}>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -253,6 +306,16 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type {
|
|||||||
GeminiCliQuotaBucketState,
|
GeminiCliQuotaBucketState,
|
||||||
GeminiCliQuotaState
|
GeminiCliQuotaState
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
|
||||||
import {
|
import {
|
||||||
ANTIGRAVITY_QUOTA_URLS,
|
ANTIGRAVITY_QUOTA_URLS,
|
||||||
ANTIGRAVITY_REQUEST_HEADERS,
|
ANTIGRAVITY_REQUEST_HEADERS,
|
||||||
@@ -55,6 +55,8 @@ type QuotaUpdater<T> = T | ((prev: T) => T);
|
|||||||
|
|
||||||
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
|
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
|
||||||
|
|
||||||
|
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
|
||||||
|
|
||||||
export interface QuotaStore {
|
export interface QuotaStore {
|
||||||
antigravityQuota: Record<string, AntigravityQuotaState>;
|
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||||
codexQuota: Record<string, CodexQuotaState>;
|
codexQuota: Record<string, CodexQuotaState>;
|
||||||
@@ -82,6 +84,43 @@ export interface QuotaConfig<TState, TData> {
|
|||||||
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveAntigravityProjectId = async (file: AuthFileItem): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const text = await authFilesApi.downloadText(file.name);
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
|
const topLevel = normalizeStringValue(parsed.project_id ?? parsed.projectId);
|
||||||
|
if (topLevel) return topLevel;
|
||||||
|
|
||||||
|
const installed =
|
||||||
|
parsed.installed && typeof parsed.installed === 'object' && parsed.installed !== null
|
||||||
|
? (parsed.installed as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const installedProjectId = installed
|
||||||
|
? normalizeStringValue(installed.project_id ?? installed.projectId)
|
||||||
|
: null;
|
||||||
|
if (installedProjectId) return installedProjectId;
|
||||||
|
|
||||||
|
const web =
|
||||||
|
parsed.web && typeof parsed.web === 'object' && parsed.web !== null
|
||||||
|
? (parsed.web as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const webProjectId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null;
|
||||||
|
if (webProjectId) return webProjectId;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAntigravityUnknownFieldError = (message: string): boolean => {
|
||||||
|
const normalized = message.toLowerCase();
|
||||||
|
return normalized.includes('unknown name') && normalized.includes('cannot find field');
|
||||||
|
};
|
||||||
|
|
||||||
const fetchAntigravityQuota = async (
|
const fetchAntigravityQuota = async (
|
||||||
file: AuthFileItem,
|
file: AuthFileItem,
|
||||||
t: TFunction
|
t: TFunction
|
||||||
@@ -92,52 +131,64 @@ const fetchAntigravityQuota = async (
|
|||||||
throw new Error(t('antigravity_quota.missing_auth_index'));
|
throw new Error(t('antigravity_quota.missing_auth_index'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectId = await resolveAntigravityProjectId(file);
|
||||||
|
const requestBodies = [JSON.stringify({ projectId }), JSON.stringify({ project: projectId })];
|
||||||
|
|
||||||
let lastError = '';
|
let lastError = '';
|
||||||
let lastStatus: number | undefined;
|
let lastStatus: number | undefined;
|
||||||
let priorityStatus: number | undefined;
|
let priorityStatus: number | undefined;
|
||||||
let hadSuccess = false;
|
let hadSuccess = false;
|
||||||
|
|
||||||
for (const url of ANTIGRAVITY_QUOTA_URLS) {
|
for (const url of ANTIGRAVITY_QUOTA_URLS) {
|
||||||
try {
|
for (let attempt = 0; attempt < requestBodies.length; attempt++) {
|
||||||
const result = await apiCallApi.request({
|
try {
|
||||||
authIndex,
|
const result = await apiCallApi.request({
|
||||||
method: 'POST',
|
authIndex,
|
||||||
url,
|
method: 'POST',
|
||||||
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
url,
|
||||||
data: '{}'
|
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
||||||
});
|
data: requestBodies[attempt]
|
||||||
|
});
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
lastError = getApiCallErrorMessage(result);
|
lastError = getApiCallErrorMessage(result);
|
||||||
lastStatus = result.statusCode;
|
lastStatus = result.statusCode;
|
||||||
if (result.statusCode === 403 || result.statusCode === 404) {
|
if (result.statusCode === 403 || result.statusCode === 404) {
|
||||||
priorityStatus ??= result.statusCode;
|
priorityStatus ??= result.statusCode;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result.statusCode === 400 &&
|
||||||
|
isAntigravityUnknownFieldError(lastError) &&
|
||||||
|
attempt < requestBodies.length - 1
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
hadSuccess = true;
|
hadSuccess = true;
|
||||||
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
|
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
|
||||||
const models = payload?.models;
|
const models = payload?.models;
|
||||||
if (!models || typeof models !== 'object' || Array.isArray(models)) {
|
if (!models || typeof models !== 'object' || Array.isArray(models)) {
|
||||||
lastError = t('antigravity_quota.empty_models');
|
lastError = t('antigravity_quota.empty_models');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
|
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
|
||||||
if (groups.length === 0) {
|
if (groups.length === 0) {
|
||||||
lastError = t('antigravity_quota.empty_models');
|
lastError = t('antigravity_quota.empty_models');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
lastError = err instanceof Error ? err.message : t('common.unknown_error');
|
lastError = err instanceof Error ? err.message : t('common.unknown_error');
|
||||||
const status = getStatusFromError(err);
|
const status = getStatusFromError(err);
|
||||||
if (status) {
|
if (status) {
|
||||||
lastStatus = status;
|
lastStatus = status;
|
||||||
if (status === 403 || status === 404) {
|
if (status === 403 || status === 404) {
|
||||||
priorityStatus ??= status;
|
priorityStatus ??= status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface ModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
width?: number | string;
|
width?: number | string;
|
||||||
|
closeDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLOSE_ANIMATION_DURATION = 350;
|
const CLOSE_ANIMATION_DURATION = 350;
|
||||||
@@ -32,7 +33,15 @@ const unlockScroll = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
export function Modal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
footer,
|
||||||
|
width = 520,
|
||||||
|
closeDisabled = false,
|
||||||
|
children
|
||||||
|
}: PropsWithChildren<ModalProps>) {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -54,19 +63,28 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
if (closeTimerRef.current !== null) {
|
if (closeTimerRef.current !== null) {
|
||||||
window.clearTimeout(closeTimerRef.current);
|
window.clearTimeout(closeTimerRef.current);
|
||||||
closeTimerRef.current = null;
|
closeTimerRef.current = null;
|
||||||
}
|
}
|
||||||
setIsVisible(true);
|
queueMicrotask(() => {
|
||||||
setIsClosing(false);
|
if (cancelled) return;
|
||||||
return;
|
setIsVisible(true);
|
||||||
|
setIsClosing(false);
|
||||||
|
});
|
||||||
|
} else if (isVisible) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
startClose(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVisible) {
|
return () => {
|
||||||
startClose(false);
|
cancelled = true;
|
||||||
}
|
};
|
||||||
}, [open, isVisible, startClose]);
|
}, [open, isVisible, startClose]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -97,7 +115,13 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
|
|||||||
const modalContent = (
|
const modalContent = (
|
||||||
<div className={overlayClass}>
|
<div className={overlayClass}>
|
||||||
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
||||||
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-close-floating"
|
||||||
|
onClick={closeDisabled ? undefined : handleClose}
|
||||||
|
aria-label="Close"
|
||||||
|
disabled={closeDisabled}
|
||||||
|
>
|
||||||
<IconX size={20} />
|
<IconX size={20} />
|
||||||
</button>
|
</button>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ interface ToggleSwitchProps {
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (value: boolean) => void;
|
onChange: (value: boolean) => void;
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
|
ariaLabel?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
labelPosition?: 'left' | 'right';
|
labelPosition?: 'left' | 'right';
|
||||||
}
|
}
|
||||||
@@ -12,6 +13,7 @@ export function ToggleSwitch({
|
|||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
|
ariaLabel,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
labelPosition = 'right'
|
labelPosition = 'right'
|
||||||
}: ToggleSwitchProps) {
|
}: ToggleSwitchProps) {
|
||||||
@@ -25,7 +27,13 @@ export function ToggleSwitch({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={className}>
|
<label className={className}>
|
||||||
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>
|
||||||
<span className="track">
|
<span className="track">
|
||||||
<span className="thumb" />
|
<span className="thumb" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
};
|
||||||
@@ -137,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",
|
||||||
@@ -221,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",
|
||||||
@@ -312,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",
|
||||||
@@ -327,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",
|
||||||
@@ -359,7 +395,19 @@
|
|||||||
"models_unsupported": "This feature is not supported in the current version",
|
"models_unsupported": "This feature is not supported in the current version",
|
||||||
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
|
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
|
||||||
"models_excluded_badge": "Excluded",
|
"models_excluded_badge": "Excluded",
|
||||||
"models_excluded_hint": "This model is excluded by OAuth"
|
"models_excluded_hint": "This model is excluded by OAuth",
|
||||||
|
"status_toggle_label": "Enabled",
|
||||||
|
"status_enabled_success": "\"{{name}}\" enabled",
|
||||||
|
"status_disabled_success": "\"{{name}}\" disabled",
|
||||||
|
"prefix_proxy_button": "Edit prefix/proxy_url",
|
||||||
|
"prefix_proxy_loading": "Loading credential...",
|
||||||
|
"prefix_proxy_source_label": "Credential JSON",
|
||||||
|
"prefix_label": "prefix",
|
||||||
|
"proxy_url_label": "proxy_url",
|
||||||
|
"prefix_placeholder": "",
|
||||||
|
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||||
|
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
|
||||||
|
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully"
|
||||||
},
|
},
|
||||||
"antigravity_quota": {
|
"antigravity_quota": {
|
||||||
"title": "Antigravity Quota",
|
"title": "Antigravity Quota",
|
||||||
@@ -464,6 +512,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",
|
||||||
@@ -548,7 +624,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",
|
||||||
@@ -709,7 +785,8 @@
|
|||||||
"quota_management": {
|
"quota_management": {
|
||||||
"title": "Quota Management",
|
"title": "Quota Management",
|
||||||
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
|
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
|
||||||
"refresh_files": "Refresh auth files"
|
"refresh_files": "Refresh auth files",
|
||||||
|
"refresh_files_and_quota": "Refresh files & quota"
|
||||||
},
|
},
|
||||||
"system_info": {
|
"system_info": {
|
||||||
"title": "Management Center Info",
|
"title": "Management Center Info",
|
||||||
@@ -761,12 +838,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",
|
"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",
|
||||||
@@ -780,6 +861,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",
|
||||||
|
|||||||
@@ -137,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 密钥管理",
|
||||||
@@ -221,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",
|
||||||
@@ -312,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": "文件删除成功",
|
||||||
@@ -327,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",
|
||||||
@@ -359,7 +395,19 @@
|
|||||||
"models_unsupported": "当前版本不支持此功能",
|
"models_unsupported": "当前版本不支持此功能",
|
||||||
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
|
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
|
||||||
"models_excluded_badge": "已排除",
|
"models_excluded_badge": "已排除",
|
||||||
"models_excluded_hint": "此模型已被 OAuth 排除"
|
"models_excluded_hint": "此模型已被 OAuth 排除",
|
||||||
|
"status_toggle_label": "启用",
|
||||||
|
"status_enabled_success": "已启用 \"{{name}}\"",
|
||||||
|
"status_disabled_success": "已停用 \"{{name}}\"",
|
||||||
|
"prefix_proxy_button": "配置 prefix/proxy_url",
|
||||||
|
"prefix_proxy_loading": "正在加载凭证文件...",
|
||||||
|
"prefix_proxy_source_label": "凭证 JSON",
|
||||||
|
"prefix_label": "prefix",
|
||||||
|
"proxy_url_label": "proxy_url",
|
||||||
|
"prefix_placeholder": "",
|
||||||
|
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||||
|
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
||||||
|
"prefix_proxy_saved_success": "已更新 \"{{name}}\""
|
||||||
},
|
},
|
||||||
"antigravity_quota": {
|
"antigravity_quota": {
|
||||||
"title": "Antigravity 额度",
|
"title": "Antigravity 额度",
|
||||||
@@ -464,6 +512,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 登录",
|
||||||
@@ -548,7 +624,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 登录",
|
||||||
@@ -709,7 +785,8 @@
|
|||||||
"quota_management": {
|
"quota_management": {
|
||||||
"title": "配额管理",
|
"title": "配额管理",
|
||||||
"description": "集中查看 OAuth 额度与剩余情况",
|
"description": "集中查看 OAuth 额度与剩余情况",
|
||||||
"refresh_files": "刷新认证文件"
|
"refresh_files": "刷新认证文件",
|
||||||
|
"refresh_files_and_quota": "刷新认证文件&额度"
|
||||||
},
|
},
|
||||||
"system_info": {
|
"system_info": {
|
||||||
"title": "管理中心信息",
|
"title": "管理中心信息",
|
||||||
@@ -761,12 +838,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": "本地登录信息已清理",
|
"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密钥删除成功",
|
||||||
@@ -780,6 +861,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": "必填字段不能为空",
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import {
|
|||||||
CodexSection,
|
CodexSection,
|
||||||
GeminiSection,
|
GeminiSection,
|
||||||
OpenAISection,
|
OpenAISection,
|
||||||
|
VertexSection,
|
||||||
useProviderStats,
|
useProviderStats,
|
||||||
type GeminiFormState,
|
type GeminiFormState,
|
||||||
type OpenAIFormState,
|
type OpenAIFormState,
|
||||||
type ProviderFormState,
|
type ProviderFormState,
|
||||||
type ProviderModal,
|
type ProviderModal,
|
||||||
|
type VertexFormState,
|
||||||
} from '@/components/providers';
|
} from '@/components/providers';
|
||||||
import {
|
import {
|
||||||
parseExcludedModels,
|
parseExcludedModels,
|
||||||
@@ -21,7 +23,7 @@ import {
|
|||||||
import { ampcodeApi, providersApi } from '@/services/api';
|
import { ampcodeApi, providersApi } from '@/services/api';
|
||||||
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||||
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
|
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
|
||||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
import { buildHeaderObject } from '@/utils/headers';
|
||||||
import styles from './AiProvidersPage.module.scss';
|
import styles from './AiProvidersPage.module.scss';
|
||||||
|
|
||||||
export function AiProvidersPage() {
|
export function AiProvidersPage() {
|
||||||
@@ -41,6 +43,7 @@ export function AiProvidersPage() {
|
|||||||
const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>([]);
|
const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>([]);
|
||||||
const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>([]);
|
const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||||
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
|
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||||
|
const [vertexConfigs, setVertexConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||||
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
|
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -63,17 +66,32 @@ export function AiProvidersPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = await fetchConfig();
|
const [configResult, vertexResult, ampcodeResult] = await Promise.allSettled([
|
||||||
|
fetchConfig(),
|
||||||
|
providersApi.getVertexConfigs(),
|
||||||
|
ampcodeApi.getAmpcode(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (configResult.status !== 'fulfilled') {
|
||||||
|
throw configResult.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = configResult.value;
|
||||||
setGeminiKeys(data?.geminiApiKeys || []);
|
setGeminiKeys(data?.geminiApiKeys || []);
|
||||||
setCodexConfigs(data?.codexApiKeys || []);
|
setCodexConfigs(data?.codexApiKeys || []);
|
||||||
setClaudeConfigs(data?.claudeApiKeys || []);
|
setClaudeConfigs(data?.claudeApiKeys || []);
|
||||||
|
setVertexConfigs(data?.vertexApiKeys || []);
|
||||||
setOpenaiProviders(data?.openaiCompatibility || []);
|
setOpenaiProviders(data?.openaiCompatibility || []);
|
||||||
try {
|
|
||||||
const ampcode = await ampcodeApi.getAmpcode();
|
if (vertexResult.status === 'fulfilled') {
|
||||||
updateConfigValue('ampcode', ampcode);
|
setVertexConfigs(vertexResult.value || []);
|
||||||
|
updateConfigValue('vertex-api-key', vertexResult.value || []);
|
||||||
|
clearCache('vertex-api-key');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ampcodeResult.status === 'fulfilled') {
|
||||||
|
updateConfigValue('ampcode', ampcodeResult.value);
|
||||||
clearCache('ampcode');
|
clearCache('ampcode');
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = getErrorMessage(err) || t('notification.refresh_failed');
|
const message = getErrorMessage(err) || t('notification.refresh_failed');
|
||||||
@@ -92,11 +110,13 @@ export function AiProvidersPage() {
|
|||||||
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
|
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
|
||||||
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
|
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
|
||||||
if (config?.claudeApiKeys) setClaudeConfigs(config.claudeApiKeys);
|
if (config?.claudeApiKeys) setClaudeConfigs(config.claudeApiKeys);
|
||||||
|
if (config?.vertexApiKeys) setVertexConfigs(config.vertexApiKeys);
|
||||||
if (config?.openaiCompatibility) setOpenaiProviders(config.openaiCompatibility);
|
if (config?.openaiCompatibility) setOpenaiProviders(config.openaiCompatibility);
|
||||||
}, [
|
}, [
|
||||||
config?.geminiApiKeys,
|
config?.geminiApiKeys,
|
||||||
config?.codexApiKeys,
|
config?.codexApiKeys,
|
||||||
config?.claudeApiKeys,
|
config?.claudeApiKeys,
|
||||||
|
config?.vertexApiKeys,
|
||||||
config?.openaiCompatibility,
|
config?.openaiCompatibility,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -112,6 +132,10 @@ export function AiProvidersPage() {
|
|||||||
setModal({ type, index });
|
setModal({ type, index });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openVertexModal = (index: number | null) => {
|
||||||
|
setModal({ type: 'vertex', index });
|
||||||
|
};
|
||||||
|
|
||||||
const openAmpcodeModal = () => {
|
const openAmpcodeModal = () => {
|
||||||
setModal({ type: 'ampcode', index: null });
|
setModal({ type: 'ampcode', index: null });
|
||||||
};
|
};
|
||||||
@@ -127,7 +151,7 @@ export function AiProvidersPage() {
|
|||||||
apiKey: form.apiKey.trim(),
|
apiKey: form.apiKey.trim(),
|
||||||
prefix: form.prefix?.trim() || undefined,
|
prefix: form.prefix?.trim() || undefined,
|
||||||
baseUrl: form.baseUrl?.trim() || undefined,
|
baseUrl: form.baseUrl?.trim() || undefined,
|
||||||
headers: buildHeaderObject(headersToEntries(form.headers)),
|
headers: buildHeaderObject(form.headers),
|
||||||
excludedModels: parseExcludedModels(form.excludedText),
|
excludedModels: parseExcludedModels(form.excludedText),
|
||||||
};
|
};
|
||||||
const nextList =
|
const nextList =
|
||||||
@@ -283,7 +307,7 @@ export function AiProvidersPage() {
|
|||||||
prefix: form.prefix?.trim() || undefined,
|
prefix: form.prefix?.trim() || undefined,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
proxyUrl: form.proxyUrl?.trim() || undefined,
|
proxyUrl: form.proxyUrl?.trim() || undefined,
|
||||||
headers: buildHeaderObject(headersToEntries(form.headers)),
|
headers: buildHeaderObject(form.headers),
|
||||||
models: entriesToModels(form.modelEntries),
|
models: entriesToModels(form.modelEntries),
|
||||||
excludedModels: parseExcludedModels(form.excludedText),
|
excludedModels: parseExcludedModels(form.excludedText),
|
||||||
};
|
};
|
||||||
@@ -351,6 +375,72 @@ export function AiProvidersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveVertex = async (form: VertexFormState, editIndex: number | null) => {
|
||||||
|
const trimmedBaseUrl = (form.baseUrl ?? '').trim();
|
||||||
|
const baseUrl = trimmedBaseUrl || undefined;
|
||||||
|
if (!baseUrl) {
|
||||||
|
showNotification(t('notification.vertex_base_url_required'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: ProviderKeyConfig = {
|
||||||
|
apiKey: form.apiKey.trim(),
|
||||||
|
prefix: form.prefix?.trim() || undefined,
|
||||||
|
baseUrl,
|
||||||
|
proxyUrl: form.proxyUrl?.trim() || undefined,
|
||||||
|
headers: buildHeaderObject(form.headers),
|
||||||
|
models: form.modelEntries
|
||||||
|
.map((entry) => {
|
||||||
|
const name = entry.name.trim();
|
||||||
|
const alias = entry.alias.trim();
|
||||||
|
if (!name || !alias) return null;
|
||||||
|
return { name, alias };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as ProviderKeyConfig['models'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextList =
|
||||||
|
editIndex !== null
|
||||||
|
? vertexConfigs.map((item, idx) => (idx === editIndex ? payload : item))
|
||||||
|
: [...vertexConfigs, payload];
|
||||||
|
|
||||||
|
await providersApi.saveVertexConfigs(nextList);
|
||||||
|
setVertexConfigs(nextList);
|
||||||
|
updateConfigValue('vertex-api-key', nextList);
|
||||||
|
clearCache('vertex-api-key');
|
||||||
|
const message =
|
||||||
|
editIndex !== null
|
||||||
|
? t('notification.vertex_config_updated')
|
||||||
|
: t('notification.vertex_config_added');
|
||||||
|
showNotification(message, 'success');
|
||||||
|
closeModal();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteVertex = async (index: number) => {
|
||||||
|
const entry = vertexConfigs[index];
|
||||||
|
if (!entry) return;
|
||||||
|
if (!window.confirm(t('ai_providers.vertex_delete_confirm'))) return;
|
||||||
|
try {
|
||||||
|
await providersApi.deleteVertexConfig(entry.apiKey);
|
||||||
|
const next = vertexConfigs.filter((_, idx) => idx !== index);
|
||||||
|
setVertexConfigs(next);
|
||||||
|
updateConfigValue('vertex-api-key', next);
|
||||||
|
clearCache('vertex-api-key');
|
||||||
|
showNotification(t('notification.vertex_config_deleted'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
|
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -412,6 +502,7 @@ export function AiProvidersPage() {
|
|||||||
const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null;
|
const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null;
|
||||||
const codexModalIndex = modal?.type === 'codex' ? modal.index : null;
|
const codexModalIndex = modal?.type === 'codex' ? modal.index : null;
|
||||||
const claudeModalIndex = modal?.type === 'claude' ? modal.index : null;
|
const claudeModalIndex = modal?.type === 'claude' ? modal.index : null;
|
||||||
|
const vertexModalIndex = modal?.type === 'vertex' ? modal.index : null;
|
||||||
const openaiModalIndex = modal?.type === 'openai' ? modal.index : null;
|
const openaiModalIndex = modal?.type === 'openai' ? modal.index : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -475,6 +566,23 @@ export function AiProvidersPage() {
|
|||||||
onSave={(form, editIndex) => saveProvider('claude', form, editIndex)}
|
onSave={(form, editIndex) => saveProvider('claude', form, editIndex)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<VertexSection
|
||||||
|
configs={vertexConfigs}
|
||||||
|
keyStats={keyStats}
|
||||||
|
usageDetails={usageDetails}
|
||||||
|
loading={loading}
|
||||||
|
disableControls={disableControls}
|
||||||
|
isSaving={saving}
|
||||||
|
isSwitching={isSwitching}
|
||||||
|
isModalOpen={modal?.type === 'vertex'}
|
||||||
|
modalIndex={vertexModalIndex}
|
||||||
|
onAdd={() => openVertexModal(null)}
|
||||||
|
onEdit={(index) => openVertexModal(index)}
|
||||||
|
onDelete={deleteVertex}
|
||||||
|
onCloseModal={closeModal}
|
||||||
|
onSave={saveVertex}
|
||||||
|
/>
|
||||||
|
|
||||||
<AmpcodeSection
|
<AmpcodeSection
|
||||||
config={config?.ampcode}
|
config={config?.ampcode}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -268,27 +277,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.antigravityCard {
|
.antigravityCard {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
|
||||||
180deg,
|
|
||||||
rgba(224, 247, 250, 0.12),
|
|
||||||
rgba(224, 247, 250, 0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.codexCard {
|
.codexCard {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(180deg, rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0));
|
||||||
180deg,
|
|
||||||
rgba(255, 243, 224, 0.18),
|
|
||||||
rgba(255, 243, 224, 0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.geminiCliCard {
|
.geminiCliCard {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(180deg, rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0));
|
||||||
180deg,
|
|
||||||
rgba(231, 239, 255, 0.2),
|
|
||||||
rgba(231, 239, 255, 0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quotaSection {
|
.quotaSection {
|
||||||
@@ -437,7 +434,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
|
transition:
|
||||||
|
transform $transition-fast,
|
||||||
|
box-shadow $transition-fast,
|
||||||
|
border-color $transition-fast;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
@@ -537,7 +537,9 @@
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
min-width: 6px;
|
min-width: 6px;
|
||||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
transition:
|
||||||
|
transform 0.15s ease,
|
||||||
|
opacity 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: scaleY(1.5);
|
transform: scaleY(1.5);
|
||||||
@@ -588,14 +590,90 @@
|
|||||||
background: var(--failure-badge-bg, #fee2e2);
|
background: var(--failure-badge-bg, #fee2e2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prefixProxyEditor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefixProxyLoading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: $spacing-sm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefixProxyError {
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
border: 1px solid var(--danger-color);
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--danger-color);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefixProxyJsonWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefixProxyLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefixProxyTextarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 120px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefixProxyFields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
|
||||||
|
:global(.form-group) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.cardActions {
|
.cardActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-xs;
|
gap: $spacing-xs;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding-top: $spacing-sm;
|
padding-top: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusToggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
.iconButton:global(.btn.btn-sm) {
|
.iconButton:global(.btn.btn-sm) {
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
@@ -742,6 +820,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -133,14 +133,18 @@
|
|||||||
|
|
||||||
.editorWrapper {
|
.editorWrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
min-height: 800px;
|
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;
|
||||||
@@ -219,8 +223,8 @@
|
|||||||
.configCard {
|
.configCard {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 1120px;
|
flex: 1;
|
||||||
flex-shrink: 0;
|
min-height: 0;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,11 +257,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.configCard {
|
.configCard {
|
||||||
height: 880px;
|
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorWrapper {
|
|
||||||
min-height: 600px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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';
|
||||||
@@ -50,11 +50,6 @@ export function LoginPage() {
|
|||||||
init();
|
init();
|
||||||
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
|
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
|
||||||
|
|
||||||
if (isAuthenticated) {
|
|
||||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
|
||||||
return <Navigate to={redirect} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!managementKey.trim()) {
|
if (!managementKey.trim()) {
|
||||||
setError(t('login.error_required'));
|
setError(t('login.error_required'));
|
||||||
@@ -81,6 +76,21 @@ 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">
|
||||||
@@ -129,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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -115,6 +115,13 @@
|
|||||||
margin-top: $spacing-sm;
|
margin-top: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.geminiProjectField {
|
||||||
|
:global(.form-group) {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.filePicker {
|
.filePicker {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -327,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}`}>
|
||||||
|
|||||||
@@ -30,6 +30,37 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
flex-wrap: wrap;
|
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 {
|
.errorBox {
|
||||||
@@ -76,11 +107,7 @@
|
|||||||
.geminiCliGrid {
|
.geminiCliGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||||
|
|
||||||
@include tablet {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -112,28 +139,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viewModeToggle {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.antigravityCard {
|
.antigravityCard {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(180deg,
|
||||||
180deg,
|
rgba(224, 247, 250, 0.12),
|
||||||
rgba(224, 247, 250, 0.12),
|
rgba(224, 247, 250, 0));
|
||||||
rgba(224, 247, 250, 0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.codexCard {
|
.codexCard {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(180deg,
|
||||||
180deg,
|
rgba(255, 243, 224, 0.18),
|
||||||
rgba(255, 243, 224, 0.18),
|
rgba(255, 243, 224, 0));
|
||||||
rgba(255, 243, 224, 0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.geminiCliCard {
|
.geminiCliCard {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(180deg,
|
||||||
180deg,
|
rgba(231, 239, 255, 0.2),
|
||||||
rgba(231, 239, 255, 0.2),
|
rgba(231, 239, 255, 0));
|
||||||
rgba(231, 239, 255, 0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quotaSection {
|
.quotaSection {
|
||||||
@@ -331,3 +358,32 @@
|
|||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
border-radius: $radius-md;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
import { useAuthStore } from '@/stores';
|
import { useAuthStore } from '@/stores';
|
||||||
import { authFilesApi } from '@/services/api';
|
import { authFilesApi, configFileApi } from '@/services/api';
|
||||||
import {
|
import {
|
||||||
QuotaSection,
|
QuotaSection,
|
||||||
ANTIGRAVITY_CONFIG,
|
ANTIGRAVITY_CONFIG,
|
||||||
@@ -26,6 +26,15 @@ export function QuotaPage() {
|
|||||||
|
|
||||||
const disableControls = connectionStatus !== 'connected';
|
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 () => {
|
const loadFiles = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -40,20 +49,22 @@ export function QuotaPage() {
|
|||||||
}
|
}
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
|
const handleHeaderRefresh = useCallback(async () => {
|
||||||
|
await Promise.all([loadConfig(), loadFiles()]);
|
||||||
|
}, [loadConfig, loadFiles]);
|
||||||
|
|
||||||
|
useHeaderRefresh(handleHeaderRefresh);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFiles();
|
loadFiles();
|
||||||
}, [loadFiles]);
|
loadConfig();
|
||||||
|
}, [loadFiles, loadConfig]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<h1 className={styles.pageTitle}>{t('quota_management.title')}</h1>
|
<h1 className={styles.pageTitle}>{t('quota_management.title')}</h1>
|
||||||
<p className={styles.description}>{t('quota_management.description')}</p>
|
<p className={styles.description}>{t('quota_management.description')}</p>
|
||||||
<div className={styles.headerActions}>
|
|
||||||
<Button variant="secondary" size="sm" onClick={loadFiles} disabled={loading}>
|
|
||||||
{t('quota_management.refresh_files')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className={styles.errorBox}>{error}</div>}
|
{error && <div className={styles.errorBox}>{error}</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
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
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 { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
|
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
import { useThemeStore } from '@/stores';
|
import { useThemeStore } from '@/stores';
|
||||||
import {
|
import {
|
||||||
StatCards,
|
StatCards,
|
||||||
@@ -63,6 +64,8 @@ export function UsagePage() {
|
|||||||
importing
|
importing
|
||||||
} = useUsageData();
|
} = useUsageData();
|
||||||
|
|
||||||
|
useHeaderRefresh(loadUsage);
|
||||||
|
|
||||||
// Chart lines state
|
// Chart lines state
|
||||||
const [chartLines, setChartLines] = useState<string[]>(['all']);
|
const [chartLines, setChartLines] = useState<string[]>(['all']);
|
||||||
const MAX_CHART_LINES = 9;
|
const MAX_CHART_LINES = 9;
|
||||||
|
|||||||
@@ -4,10 +4,112 @@
|
|||||||
|
|
||||||
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 };
|
||||||
|
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||||
|
|
||||||
|
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 record = payload as Record<string, unknown>;
|
||||||
|
const source = record['oauth-excluded-models'] ?? record.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 record = payload as Record<string, unknown>;
|
||||||
|
const source =
|
||||||
|
record['oauth-model-mappings'] ??
|
||||||
|
record['oauth-model-alias'] ??
|
||||||
|
record.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 entry = item as Record<string, unknown>;
|
||||||
|
const name = String(entry.name ?? entry.id ?? entry.model ?? '').trim();
|
||||||
|
const alias = String(entry.alias ?? '').trim();
|
||||||
|
if (!name || !alias) return null;
|
||||||
|
const fork = entry.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'),
|
||||||
|
|
||||||
|
setStatus: (name: string, disabled: boolean) =>
|
||||||
|
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
|
||||||
|
|
||||||
upload: (file: File) => {
|
upload: (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file, file.name);
|
formData.append('file', file, file.name);
|
||||||
@@ -18,11 +120,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 +140,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)}`);
|
||||||
|
|||||||
@@ -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 }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -453,6 +453,18 @@ textarea {
|
|||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.95);
|
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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -33,3 +33,12 @@ export interface OAuthConfig {
|
|||||||
export interface OAuthExcludedModels {
|
export interface OAuthExcludedModels {
|
||||||
models: string[];
|
models: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuth 模型映射
|
||||||
|
export interface OAuthModelMappingEntry {
|
||||||
|
name: string;
|
||||||
|
alias: string;
|
||||||
|
fork?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OAuthModelMappings = Record<string, OAuthModelMappingEntry[]>;
|
||||||
|
|||||||
@@ -4,16 +4,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 隐藏 API Key 中间部分
|
* 隐藏 API Key 中间部分,仅保留前后两位
|
||||||
*/
|
*/
|
||||||
export function maskApiKey(key: string, visibleChars: number = 4): string {
|
export function maskApiKey(key: string): string {
|
||||||
if (!key || key.length <= visibleChars * 2) {
|
const trimmed = String(key || '').trim();
|
||||||
return key;
|
if (!trimmed) {
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = key.slice(0, visibleChars);
|
const MASKED_LENGTH = 10;
|
||||||
const end = key.slice(-visibleChars);
|
const visibleChars = trimmed.length < 4 ? 1 : 2;
|
||||||
const maskedLength = Math.min(key.length - visibleChars * 2, 20);
|
const start = trimmed.slice(0, visibleChars);
|
||||||
|
const end = trimmed.slice(-visibleChars);
|
||||||
|
const maskedLength = Math.max(MASKED_LENGTH - visibleChars * 2, 1);
|
||||||
const masked = '*'.repeat(maskedLength);
|
const masked = '*'.repeat(maskedLength);
|
||||||
|
|
||||||
return `${start}${masked}${end}`;
|
return `${start}${masked}${end}`;
|
||||||
|
|||||||
@@ -73,6 +73,124 @@ const normalizeAuthIndex = (value: any) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const USAGE_SOURCE_PREFIX_KEY = 'k:';
|
||||||
|
const USAGE_SOURCE_PREFIX_MASKED = 'm:';
|
||||||
|
const USAGE_SOURCE_PREFIX_TEXT = 't:';
|
||||||
|
|
||||||
|
const KEY_LIKE_TOKEN_REGEX =
|
||||||
|
/(sk-[A-Za-z0-9-_]{6,}|sk-ant-[A-Za-z0-9-_]{6,}|AIza[0-9A-Za-z-_]{8,}|AI[a-zA-Z0-9_-]{6,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/;
|
||||||
|
const MASKED_TOKEN_HINT_REGEX = /^[^\s]{1,24}(\*{2,}|\.{3}|…)[^\s]{1,24}$/;
|
||||||
|
|
||||||
|
const keyFingerprintCache = new Map<string, string>();
|
||||||
|
|
||||||
|
const fnv1a64Hex = (value: string): string => {
|
||||||
|
const cached = keyFingerprintCache.get(value);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const FNV_OFFSET_BASIS = 0xcbf29ce484222325n;
|
||||||
|
const FNV_PRIME = 0x100000001b3n;
|
||||||
|
|
||||||
|
let hash = FNV_OFFSET_BASIS;
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
hash ^= BigInt(value.charCodeAt(i));
|
||||||
|
hash = (hash * FNV_PRIME) & 0xffffffffffffffffn;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hex = hash.toString(16).padStart(16, '0');
|
||||||
|
keyFingerprintCache.set(value, hex);
|
||||||
|
return hex;
|
||||||
|
};
|
||||||
|
|
||||||
|
const looksLikeRawSecret = (text: string): boolean => {
|
||||||
|
if (!text || /\s/.test(text)) return false;
|
||||||
|
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
if (lower.endsWith('.json')) return false;
|
||||||
|
if (lower.startsWith('http://') || lower.startsWith('https://')) return false;
|
||||||
|
if (/[\\/]/.test(text)) return false;
|
||||||
|
|
||||||
|
if (KEY_LIKE_TOKEN_REGEX.test(text)) return true;
|
||||||
|
|
||||||
|
if (text.length >= 32 && text.length <= 512) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length >= 16 && text.length < 32 && /^[A-Za-z0-9._=-]+$/.test(text)) {
|
||||||
|
return /[A-Za-z]/.test(text) && /\d/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractRawSecretFromText = (text: string): string | null => {
|
||||||
|
if (!text) return null;
|
||||||
|
if (looksLikeRawSecret(text)) return text;
|
||||||
|
|
||||||
|
const keyLikeMatch = text.match(KEY_LIKE_TOKEN_REGEX);
|
||||||
|
if (keyLikeMatch?.[0]) return keyLikeMatch[0];
|
||||||
|
|
||||||
|
const queryMatch = text.match(
|
||||||
|
/(?:[?&])(api[-_]?key|key|token|access_token|authorization)=([^&#\s]+)/i
|
||||||
|
);
|
||||||
|
const queryValue = queryMatch?.[2];
|
||||||
|
if (queryValue && looksLikeRawSecret(queryValue)) {
|
||||||
|
return queryValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerMatch = text.match(
|
||||||
|
/(api[-_]?key|key|token|access[-_]?token|authorization)\s*[:=]\s*([A-Za-z0-9._=-]+)/i
|
||||||
|
);
|
||||||
|
const headerValue = headerMatch?.[2];
|
||||||
|
if (headerValue && looksLikeRawSecret(headerValue)) {
|
||||||
|
return headerValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bearerMatch = text.match(/\bBearer\s+([A-Za-z0-9._=-]{6,})/i);
|
||||||
|
const bearerValue = bearerMatch?.[1];
|
||||||
|
if (bearerValue && looksLikeRawSecret(bearerValue)) {
|
||||||
|
return bearerValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeUsageSourceId(
|
||||||
|
value: unknown,
|
||||||
|
masker: (val: string) => string = maskApiKey
|
||||||
|
): string {
|
||||||
|
const raw = typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
|
||||||
|
const extracted = extractRawSecretFromText(trimmed);
|
||||||
|
if (extracted) {
|
||||||
|
return `${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(extracted)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MASKED_TOKEN_HINT_REGEX.test(trimmed)) {
|
||||||
|
return `${USAGE_SOURCE_PREFIX_MASKED}${masker(trimmed)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${USAGE_SOURCE_PREFIX_TEXT}${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?: string }): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
const prefix = input.prefix?.trim();
|
||||||
|
if (prefix) {
|
||||||
|
result.push(`${USAGE_SOURCE_PREFIX_TEXT}${prefix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = input.apiKey?.trim();
|
||||||
|
if (apiKey) {
|
||||||
|
result.push(`${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(apiKey)}`);
|
||||||
|
result.push(`${USAGE_SOURCE_PREFIX_MASKED}${maskApiKey(apiKey)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set(result));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对使用数据中的敏感字段进行遮罩
|
* 对使用数据中的敏感字段进行遮罩
|
||||||
*/
|
*/
|
||||||
@@ -200,6 +318,7 @@ export function collectUsageDetails(usageData: any): UsageDetail[] {
|
|||||||
if (detail && detail.timestamp) {
|
if (detail && detail.timestamp) {
|
||||||
details.push({
|
details.push({
|
||||||
...detail,
|
...detail,
|
||||||
|
source: normalizeUsageSourceId(detail.source),
|
||||||
__modelName: modelName
|
__modelName: modelName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -638,6 +757,8 @@ export interface ChartDataset {
|
|||||||
data: number[];
|
data: number[];
|
||||||
borderColor: string;
|
borderColor: string;
|
||||||
backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient);
|
backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient);
|
||||||
|
pointBackgroundColor?: string;
|
||||||
|
pointBorderColor?: string;
|
||||||
fill: boolean;
|
fill: boolean;
|
||||||
tension: number;
|
tension: number;
|
||||||
}
|
}
|
||||||
@@ -743,6 +864,8 @@ export function buildChartData(
|
|||||||
backgroundColor: shouldFill
|
backgroundColor: shouldFill
|
||||||
? (ctx) => buildAreaGradient(ctx, style.borderColor, style.backgroundColor)
|
? (ctx) => buildAreaGradient(ctx, style.borderColor, style.backgroundColor)
|
||||||
: style.backgroundColor,
|
: style.backgroundColor,
|
||||||
|
pointBackgroundColor: style.borderColor,
|
||||||
|
pointBorderColor: style.borderColor,
|
||||||
fill: shouldFill,
|
fill: shouldFill,
|
||||||
tension: 0.35
|
tension: 0.35
|
||||||
};
|
};
|
||||||
@@ -874,7 +997,7 @@ export function computeKeyStats(usageData: any, masker: (val: string) => string
|
|||||||
const details = modelEntry?.details || [];
|
const details = modelEntry?.details || [];
|
||||||
|
|
||||||
details.forEach((detail: any) => {
|
details.forEach((detail: any) => {
|
||||||
const source = maskUsageSensitiveValue(detail?.source, masker);
|
const source = normalizeUsageSourceId(detail?.source, masker);
|
||||||
const authIndexKey = normalizeAuthIndex(detail?.auth_index);
|
const authIndexKey = normalizeAuthIndex(detail?.auth_index);
|
||||||
const isFailed = detail?.failed === true;
|
const isFailed = detail?.failed === true;
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ export function isValidApiKey(key: string): boolean {
|
|||||||
return !/\s/.test(key);
|
return !/\s/.test(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 API Key 字符集(仅允许 ASCII 可见字符)
|
||||||
|
*/
|
||||||
|
export function isValidApiKeyCharset(key: string): boolean {
|
||||||
|
if (!key) return false;
|
||||||
|
return /^[\x21-\x7E]+$/.test(key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 JSON 格式
|
* 验证 JSON 格式
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user