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
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e56d33bf0 | ||
|
|
80daf03fa6 | ||
|
|
883059b031 | ||
|
|
d077b5dd26 | ||
|
|
d79ccc480d | ||
|
|
7b0d6dc7e9 | ||
|
|
b8d7b8997c | ||
|
|
0bb34ca74b | ||
|
|
99c4fbc30d |
15
package-lock.json
generated
15
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"
|
||||||
},
|
},
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { Card } from '@/components/ui/Card';
|
|||||||
import iconVertex from '@/assets/icons/vertex.svg';
|
import iconVertex from '@/assets/icons/vertex.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';
|
||||||
@@ -51,11 +56,19 @@ export function VertexSection({
|
|||||||
|
|
||||||
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]);
|
||||||
|
|
||||||
@@ -86,10 +99,9 @@ export function VertexSection({
|
|||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
actionsDisabled={actionsDisabled}
|
actionsDisabled={actionsDisabled}
|
||||||
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 statusData =
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
|||||||
@@ -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 => ({
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -106,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>
|
||||||
|
|||||||
@@ -395,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",
|
||||||
|
|||||||
@@ -395,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 额度",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ 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);
|
||||||
@@ -29,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]);
|
||||||
|
|
||||||
@@ -115,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 = (
|
||||||
@@ -181,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>
|
||||||
|
|||||||
@@ -277,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 {
|
||||||
@@ -446,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);
|
||||||
@@ -546,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);
|
||||||
@@ -597,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;
|
||||||
|
|||||||
@@ -9,14 +9,28 @@ import { Input } from '@/components/ui/Input';
|
|||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
import { IconBot, IconDownload, IconInfo, IconTrash2, IconX } from '@/components/ui/icons';
|
import {
|
||||||
|
IconBot,
|
||||||
|
IconCode,
|
||||||
|
IconDownload,
|
||||||
|
IconInfo,
|
||||||
|
IconTrash2,
|
||||||
|
IconX,
|
||||||
|
} from '@/components/ui/icons';
|
||||||
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||||
import { authFilesApi, usageApi } from '@/services/api';
|
import { authFilesApi, usageApi } from '@/services/api';
|
||||||
import { apiClient } from '@/services/api/client';
|
import { apiClient } from '@/services/api/client';
|
||||||
import type { AuthFileItem, OAuthModelMappingEntry } from '@/types';
|
import type { AuthFileItem, OAuthModelMappingEntry } from '@/types';
|
||||||
import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
|
import {
|
||||||
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
|
calculateStatusBarData,
|
||||||
|
collectUsageDetails,
|
||||||
|
normalizeUsageSourceId,
|
||||||
|
type KeyStatBucket,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
import { formatFileSize } from '@/utils/format';
|
import { formatFileSize } from '@/utils/format';
|
||||||
|
import { generateId } from '@/utils/helpers';
|
||||||
import styles from './AuthFilesPage.module.scss';
|
import styles from './AuthFilesPage.module.scss';
|
||||||
|
|
||||||
type ThemeColors = { bg: string; text: string; border?: string };
|
type ThemeColors = { bg: string; text: string; border?: string };
|
||||||
@@ -27,44 +41,44 @@ type ResolvedTheme = 'light' | 'dark';
|
|||||||
const TYPE_COLORS: Record<string, TypeColorSet> = {
|
const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||||
qwen: {
|
qwen: {
|
||||||
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||||
dark: { bg: '#1b5e20', text: '#81c784' }
|
dark: { bg: '#1b5e20', text: '#81c784' },
|
||||||
},
|
},
|
||||||
gemini: {
|
gemini: {
|
||||||
light: { bg: '#e3f2fd', text: '#1565c0' },
|
light: { bg: '#e3f2fd', text: '#1565c0' },
|
||||||
dark: { bg: '#0d47a1', text: '#64b5f6' }
|
dark: { bg: '#0d47a1', text: '#64b5f6' },
|
||||||
},
|
},
|
||||||
'gemini-cli': {
|
'gemini-cli': {
|
||||||
light: { bg: '#e7efff', text: '#1e4fa3' },
|
light: { bg: '#e7efff', text: '#1e4fa3' },
|
||||||
dark: { bg: '#1c3f73', text: '#a8c7ff' }
|
dark: { bg: '#1c3f73', text: '#a8c7ff' },
|
||||||
},
|
},
|
||||||
aistudio: {
|
aistudio: {
|
||||||
light: { bg: '#f0f2f5', text: '#2f343c' },
|
light: { bg: '#f0f2f5', text: '#2f343c' },
|
||||||
dark: { bg: '#373c42', text: '#cfd3db' }
|
dark: { bg: '#373c42', text: '#cfd3db' },
|
||||||
},
|
},
|
||||||
claude: {
|
claude: {
|
||||||
light: { bg: '#fce4ec', text: '#c2185b' },
|
light: { bg: '#fce4ec', text: '#c2185b' },
|
||||||
dark: { bg: '#880e4f', text: '#f48fb1' }
|
dark: { bg: '#880e4f', text: '#f48fb1' },
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
light: { bg: '#fff3e0', text: '#ef6c00' },
|
light: { bg: '#fff3e0', text: '#ef6c00' },
|
||||||
dark: { bg: '#e65100', text: '#ffb74d' }
|
dark: { bg: '#e65100', text: '#ffb74d' },
|
||||||
},
|
},
|
||||||
antigravity: {
|
antigravity: {
|
||||||
light: { bg: '#e0f7fa', text: '#006064' },
|
light: { bg: '#e0f7fa', text: '#006064' },
|
||||||
dark: { bg: '#004d40', text: '#80deea' }
|
dark: { bg: '#004d40', text: '#80deea' },
|
||||||
},
|
},
|
||||||
iflow: {
|
iflow: {
|
||||||
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
||||||
dark: { bg: '#4a148c', text: '#ce93d8' }
|
dark: { bg: '#4a148c', text: '#ce93d8' },
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
light: { bg: '#f5f5f5', text: '#616161' },
|
light: { bg: '#f5f5f5', text: '#616161' },
|
||||||
dark: { bg: '#424242', text: '#bdbdbd' }
|
dark: { bg: '#424242', text: '#bdbdbd' },
|
||||||
},
|
},
|
||||||
unknown: {
|
unknown: {
|
||||||
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
|
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
|
||||||
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' }
|
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' },
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const OAUTH_PROVIDER_PRESETS = [
|
const OAUTH_PROVIDER_PRESETS = [
|
||||||
@@ -75,7 +89,7 @@ const OAUTH_PROVIDER_PRESETS = [
|
|||||||
'claude',
|
'claude',
|
||||||
'codex',
|
'codex',
|
||||||
'qwen',
|
'qwen',
|
||||||
'iflow'
|
'iflow',
|
||||||
];
|
];
|
||||||
|
|
||||||
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
|
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
|
||||||
@@ -91,15 +105,30 @@ interface ExcludedFormState {
|
|||||||
modelsText: string;
|
modelsText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OAuthModelMappingFormEntry = OAuthModelMappingEntry & { id: string };
|
||||||
|
|
||||||
interface ModelMappingsFormState {
|
interface ModelMappingsFormState {
|
||||||
provider: string;
|
provider: string;
|
||||||
mappings: OAuthModelMappingEntry[];
|
mappings: OAuthModelMappingFormEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildEmptyMappingEntry = (): OAuthModelMappingEntry => ({
|
interface PrefixProxyEditorState {
|
||||||
|
fileName: string;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
originalText: string;
|
||||||
|
rawText: string;
|
||||||
|
json: Record<string, unknown> | null;
|
||||||
|
prefix: string;
|
||||||
|
proxyUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({
|
||||||
|
id: generateId(),
|
||||||
name: '',
|
name: '',
|
||||||
alias: '',
|
alias: '',
|
||||||
fork: false
|
fork: false,
|
||||||
});
|
});
|
||||||
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
|
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
|
||||||
function normalizeAuthIndexValue(value: unknown): string | null {
|
function normalizeAuthIndexValue(value: unknown): string | null {
|
||||||
@@ -121,10 +150,7 @@ function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 解析认证文件的统计数据
|
// 解析认证文件的统计数据
|
||||||
function resolveAuthFileStats(
|
function resolveAuthFileStats(file: AuthFileItem, stats: KeyStats): KeyStatBucket {
|
||||||
file: AuthFileItem,
|
|
||||||
stats: KeyStats
|
|
||||||
): KeyStatBucket {
|
|
||||||
const defaultStats: KeyStatBucket = { success: 0, failure: 0 };
|
const defaultStats: KeyStatBucket = { success: 0, failure: 0 };
|
||||||
const rawFileName = file?.name || '';
|
const rawFileName = file?.name || '';
|
||||||
|
|
||||||
@@ -138,8 +164,9 @@ function resolveAuthFileStats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 尝试根据 source (文件名) 匹配
|
// 尝试根据 source (文件名) 匹配
|
||||||
if (rawFileName && stats.bySource?.[rawFileName]) {
|
const fileNameId = rawFileName ? normalizeUsageSourceId(rawFileName) : '';
|
||||||
const fromName = stats.bySource[rawFileName];
|
if (fileNameId && stats.bySource?.[fileNameId]) {
|
||||||
|
const fromName = stats.bySource[fileNameId];
|
||||||
if (fromName.success > 0 || fromName.failure > 0) {
|
if (fromName.success > 0 || fromName.failure > 0) {
|
||||||
return fromName;
|
return fromName;
|
||||||
}
|
}
|
||||||
@@ -149,8 +176,12 @@ function resolveAuthFileStats(
|
|||||||
if (rawFileName) {
|
if (rawFileName) {
|
||||||
const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, '');
|
const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, '');
|
||||||
if (nameWithoutExt && nameWithoutExt !== rawFileName) {
|
if (nameWithoutExt && nameWithoutExt !== rawFileName) {
|
||||||
const fromNameWithoutExt = stats.bySource?.[nameWithoutExt];
|
const nameWithoutExtId = normalizeUsageSourceId(nameWithoutExt);
|
||||||
if (fromNameWithoutExt && (fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0)) {
|
const fromNameWithoutExt = nameWithoutExtId ? stats.bySource?.[nameWithoutExtId] : undefined;
|
||||||
|
if (
|
||||||
|
fromNameWithoutExt &&
|
||||||
|
(fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0)
|
||||||
|
) {
|
||||||
return fromNameWithoutExt;
|
return fromNameWithoutExt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,6 +206,7 @@ export function AuthFilesPage() {
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
const [deletingAll, setDeletingAll] = useState(false);
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
|
const [statusUpdating, setStatusUpdating] = useState<Record<string, boolean>>({});
|
||||||
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
||||||
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||||
|
|
||||||
@@ -185,7 +217,9 @@ export function AuthFilesPage() {
|
|||||||
// 模型列表弹窗相关
|
// 模型列表弹窗相关
|
||||||
const [modelsModalOpen, setModelsModalOpen] = useState(false);
|
const [modelsModalOpen, setModelsModalOpen] = useState(false);
|
||||||
const [modelsLoading, setModelsLoading] = useState(false);
|
const [modelsLoading, setModelsLoading] = useState(false);
|
||||||
const [modelsList, setModelsList] = useState<{ id: string; display_name?: string; type?: string }[]>([]);
|
const [modelsList, setModelsList] = useState<
|
||||||
|
{ id: string; display_name?: string; type?: string }[]
|
||||||
|
>([]);
|
||||||
const [modelsFileName, setModelsFileName] = useState('');
|
const [modelsFileName, setModelsFileName] = useState('');
|
||||||
const [modelsFileType, setModelsFileType] = useState('');
|
const [modelsFileType, setModelsFileType] = useState('');
|
||||||
const [modelsError, setModelsError] = useState<'unsupported' | null>(null);
|
const [modelsError, setModelsError] = useState<'unsupported' | null>(null);
|
||||||
@@ -194,7 +228,10 @@ export function AuthFilesPage() {
|
|||||||
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
||||||
const [excludedError, setExcludedError] = useState<'unsupported' | null>(null);
|
const [excludedError, setExcludedError] = useState<'unsupported' | null>(null);
|
||||||
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
|
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
|
||||||
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' });
|
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({
|
||||||
|
provider: '',
|
||||||
|
modelsText: '',
|
||||||
|
});
|
||||||
const [savingExcluded, setSavingExcluded] = useState(false);
|
const [savingExcluded, setSavingExcluded] = useState(false);
|
||||||
|
|
||||||
// OAuth 模型映射相关
|
// OAuth 模型映射相关
|
||||||
@@ -203,10 +240,12 @@ export function AuthFilesPage() {
|
|||||||
const [mappingModalOpen, setMappingModalOpen] = useState(false);
|
const [mappingModalOpen, setMappingModalOpen] = useState(false);
|
||||||
const [mappingForm, setMappingForm] = useState<ModelMappingsFormState>({
|
const [mappingForm, setMappingForm] = useState<ModelMappingsFormState>({
|
||||||
provider: '',
|
provider: '',
|
||||||
mappings: [buildEmptyMappingEntry()]
|
mappings: [buildEmptyMappingEntry()],
|
||||||
});
|
});
|
||||||
const [savingMappings, setSavingMappings] = useState(false);
|
const [savingMappings, setSavingMappings] = useState(false);
|
||||||
|
|
||||||
|
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const loadingKeyStatsRef = useRef(false);
|
const loadingKeyStatsRef = useRef(false);
|
||||||
const excludedUnsupportedRef = useRef(false);
|
const excludedUnsupportedRef = useRef(false);
|
||||||
@@ -214,6 +253,29 @@ export function AuthFilesPage() {
|
|||||||
|
|
||||||
const disableControls = connectionStatus !== 'connected';
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
const prefixProxyUpdatedText = useMemo(() => {
|
||||||
|
if (!prefixProxyEditor?.json) return prefixProxyEditor?.rawText ?? '';
|
||||||
|
const next: Record<string, unknown> = { ...prefixProxyEditor.json };
|
||||||
|
if ('prefix' in next || prefixProxyEditor.prefix.trim()) {
|
||||||
|
next.prefix = prefixProxyEditor.prefix;
|
||||||
|
}
|
||||||
|
if ('proxy_url' in next || prefixProxyEditor.proxyUrl.trim()) {
|
||||||
|
next.proxy_url = prefixProxyEditor.proxyUrl;
|
||||||
|
}
|
||||||
|
return JSON.stringify(next);
|
||||||
|
}, [
|
||||||
|
prefixProxyEditor?.json,
|
||||||
|
prefixProxyEditor?.prefix,
|
||||||
|
prefixProxyEditor?.proxyUrl,
|
||||||
|
prefixProxyEditor?.rawText,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const prefixProxyDirty = useMemo(() => {
|
||||||
|
if (!prefixProxyEditor?.json) return false;
|
||||||
|
if (!prefixProxyEditor.originalText) return false;
|
||||||
|
return prefixProxyUpdatedText !== prefixProxyEditor.originalText;
|
||||||
|
}, [prefixProxyEditor?.json, prefixProxyEditor?.originalText, prefixProxyUpdatedText]);
|
||||||
|
|
||||||
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
const handlePageSizeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handlePageSizeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -349,7 +411,6 @@ export function AuthFilesPage() {
|
|||||||
return Array.from(types);
|
return Array.from(types);
|
||||||
}, [files]);
|
}, [files]);
|
||||||
|
|
||||||
|
|
||||||
const excludedProviderLookup = useMemo(() => {
|
const excludedProviderLookup = useMemo(() => {
|
||||||
const lookup = new Map<string, string>();
|
const lookup = new Map<string, string>();
|
||||||
Object.keys(excluded).forEach((provider) => {
|
Object.keys(excluded).forEach((provider) => {
|
||||||
@@ -480,7 +541,10 @@ export function AuthFilesPage() {
|
|||||||
|
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
|
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
|
||||||
showNotification(`${t('auth_files.upload_success')}${suffix}`, failed.length ? 'warning' : 'success');
|
showNotification(
|
||||||
|
`${t('auth_files.upload_success')}${suffix}`,
|
||||||
|
failed.length ? 'warning' : 'success'
|
||||||
|
);
|
||||||
await loadFiles();
|
await loadFiles();
|
||||||
await loadKeyStats();
|
await loadKeyStats();
|
||||||
}
|
}
|
||||||
@@ -529,9 +593,7 @@ export function AuthFilesPage() {
|
|||||||
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
|
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
|
||||||
} else {
|
} else {
|
||||||
// 删除筛选类型的文件
|
// 删除筛选类型的文件
|
||||||
const filesToDelete = files.filter(
|
const filesToDelete = files.filter((f) => f.type === filter && !isRuntimeOnlyAuthFile(f));
|
||||||
(f) => f.type === filter && !isRuntimeOnlyAuthFile(f)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (filesToDelete.length === 0) {
|
if (filesToDelete.length === 0) {
|
||||||
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
|
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
|
||||||
@@ -579,9 +641,12 @@ export function AuthFilesPage() {
|
|||||||
// 下载文件
|
// 下载文件
|
||||||
const handleDownload = async (name: string) => {
|
const handleDownload = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.getRaw(`/auth-files/download?name=${encodeURIComponent(name)}`, {
|
const response = await apiClient.getRaw(
|
||||||
responseType: 'blob'
|
`/auth-files/download?name=${encodeURIComponent(name)}`,
|
||||||
});
|
{
|
||||||
|
responseType: 'blob',
|
||||||
|
}
|
||||||
|
);
|
||||||
const blob = new Blob([response.data]);
|
const blob = new Blob([response.data]);
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -596,6 +661,167 @@ export function AuthFilesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openPrefixProxyEditor = async (name: string) => {
|
||||||
|
if (disableControls) return;
|
||||||
|
if (prefixProxyEditor?.fileName === name) {
|
||||||
|
setPrefixProxyEditor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrefixProxyEditor({
|
||||||
|
fileName: name,
|
||||||
|
loading: true,
|
||||||
|
saving: false,
|
||||||
|
error: null,
|
||||||
|
originalText: '',
|
||||||
|
rawText: '',
|
||||||
|
json: null,
|
||||||
|
prefix: '',
|
||||||
|
proxyUrl: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawText = await authFilesApi.downloadText(name);
|
||||||
|
const trimmed = rawText.trim();
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: t('auth_files.prefix_proxy_invalid_json'),
|
||||||
|
rawText: trimmed,
|
||||||
|
originalText: trimmed,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: t('auth_files.prefix_proxy_invalid_json'),
|
||||||
|
rawText: trimmed,
|
||||||
|
originalText: trimmed,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = parsed as Record<string, unknown>;
|
||||||
|
const originalText = JSON.stringify(json);
|
||||||
|
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
|
||||||
|
const proxyUrl = typeof json.proxy_url === 'string' ? json.proxy_url : '';
|
||||||
|
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
originalText,
|
||||||
|
rawText: originalText,
|
||||||
|
json,
|
||||||
|
prefix,
|
||||||
|
proxyUrl,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : t('notification.download_failed');
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return { ...prev, loading: false, error: errorMessage, rawText: '' };
|
||||||
|
});
|
||||||
|
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrefixProxyChange = (field: 'prefix' | 'proxyUrl', value: string) => {
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
if (field === 'prefix') return { ...prev, prefix: value };
|
||||||
|
return { ...prev, proxyUrl: value };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrefixProxySave = async () => {
|
||||||
|
if (!prefixProxyEditor?.json) return;
|
||||||
|
if (!prefixProxyDirty) return;
|
||||||
|
|
||||||
|
const name = prefixProxyEditor.fileName;
|
||||||
|
const payload = prefixProxyUpdatedText;
|
||||||
|
const fileSize = new Blob([payload]).size;
|
||||||
|
if (fileSize > MAX_AUTH_FILE_SIZE) {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return { ...prev, saving: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = new File([payload], name, { type: 'application/json' });
|
||||||
|
await authFilesApi.upload(file);
|
||||||
|
showNotification(t('auth_files.prefix_proxy_saved_success', { name }), 'success');
|
||||||
|
await loadFiles();
|
||||||
|
await loadKeyStats();
|
||||||
|
setPrefixProxyEditor(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error');
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return { ...prev, saving: false };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusToggle = async (item: AuthFileItem, enabled: boolean) => {
|
||||||
|
const name = item.name;
|
||||||
|
const nextDisabled = !enabled;
|
||||||
|
const previousDisabled = item.disabled === true;
|
||||||
|
|
||||||
|
setStatusUpdating((prev) => ({ ...prev, [name]: true }));
|
||||||
|
// Optimistic update for snappy UI.
|
||||||
|
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authFilesApi.setStatus(name, nextDisabled);
|
||||||
|
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f)));
|
||||||
|
showNotification(
|
||||||
|
enabled
|
||||||
|
? t('auth_files.status_enabled_success', { name })
|
||||||
|
: t('auth_files.status_disabled_success', { name }),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f))
|
||||||
|
);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${errorMessage}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setStatusUpdating((prev) => {
|
||||||
|
if (!prev[name]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[name];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 显示详情弹窗
|
// 显示详情弹窗
|
||||||
const showDetails = (file: AuthFileItem) => {
|
const showDetails = (file: AuthFileItem) => {
|
||||||
setSelectedFile(file);
|
setSelectedFile(file);
|
||||||
@@ -616,7 +842,11 @@ export function AuthFilesPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 检测是否是 API 不支持的错误 (404 或特定错误消息)
|
// 检测是否是 API 不支持的错误 (404 或特定错误消息)
|
||||||
const errorMessage = err instanceof Error ? err.message : '';
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
if (errorMessage.includes('404') || errorMessage.includes('not found') || errorMessage.includes('Not Found')) {
|
if (
|
||||||
|
errorMessage.includes('404') ||
|
||||||
|
errorMessage.includes('not found') ||
|
||||||
|
errorMessage.includes('Not Found')
|
||||||
|
) {
|
||||||
setModelsError('unsupported');
|
setModelsError('unsupported');
|
||||||
} else {
|
} else {
|
||||||
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
|
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
|
||||||
@@ -630,7 +860,7 @@ export function AuthFilesPage() {
|
|||||||
const isModelExcluded = (modelId: string, providerType: string): boolean => {
|
const isModelExcluded = (modelId: string, providerType: string): boolean => {
|
||||||
const providerKey = normalizeProviderKey(providerType);
|
const providerKey = normalizeProviderKey(providerType);
|
||||||
const excludedModels = excluded[providerKey] || excluded[providerType] || [];
|
const excludedModels = excluded[providerKey] || excluded[providerType] || [];
|
||||||
return excludedModels.some(pattern => {
|
return excludedModels.some((pattern) => {
|
||||||
if (pattern.includes('*')) {
|
if (pattern.includes('*')) {
|
||||||
// 支持通配符匹配
|
// 支持通配符匹配
|
||||||
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i');
|
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i');
|
||||||
@@ -664,7 +894,7 @@ export function AuthFilesPage() {
|
|||||||
const models = lookupKey ? excluded[lookupKey] : [];
|
const models = lookupKey ? excluded[lookupKey] : [];
|
||||||
setExcludedForm({
|
setExcludedForm({
|
||||||
provider: lookupKey || fallbackProvider,
|
provider: lookupKey || fallbackProvider,
|
||||||
modelsText: Array.isArray(models) ? models.join('\n') : ''
|
modelsText: Array.isArray(models) ? models.join('\n') : '',
|
||||||
});
|
});
|
||||||
setExcludedModalOpen(true);
|
setExcludedModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -721,18 +951,26 @@ export function AuthFilesPage() {
|
|||||||
await loadExcluded();
|
await loadExcluded();
|
||||||
showNotification(t('oauth_excluded.delete_success'), 'success');
|
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||||||
} catch (fallbackErr: unknown) {
|
} catch (fallbackErr: unknown) {
|
||||||
const errorMessage = fallbackErr instanceof Error ? fallbackErr.message : err instanceof Error ? err.message : '';
|
const errorMessage =
|
||||||
|
fallbackErr instanceof Error
|
||||||
|
? fallbackErr.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: '';
|
||||||
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
|
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// OAuth 模型映射相关方法
|
// OAuth 模型映射相关方法
|
||||||
const normalizeMappingEntries = (entries?: OAuthModelMappingEntry[]) => {
|
const normalizeMappingEntries = (
|
||||||
|
entries?: OAuthModelMappingEntry[]
|
||||||
|
): OAuthModelMappingFormEntry[] => {
|
||||||
if (!Array.isArray(entries) || entries.length === 0) {
|
if (!Array.isArray(entries) || entries.length === 0) {
|
||||||
return [buildEmptyMappingEntry()];
|
return [buildEmptyMappingEntry()];
|
||||||
}
|
}
|
||||||
return entries.map((entry) => ({
|
return entries.map((entry) => ({
|
||||||
|
id: generateId(),
|
||||||
name: entry.name ?? '',
|
name: entry.name ?? '',
|
||||||
alias: entry.alias ?? '',
|
alias: entry.alias ?? '',
|
||||||
fork: Boolean(entry.fork),
|
fork: Boolean(entry.fork),
|
||||||
@@ -753,7 +991,11 @@ export function AuthFilesPage() {
|
|||||||
setMappingModalOpen(true);
|
setMappingModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMappingEntry = (index: number, field: keyof OAuthModelMappingEntry, value: string | boolean) => {
|
const updateMappingEntry = (
|
||||||
|
index: number,
|
||||||
|
field: keyof OAuthModelMappingEntry,
|
||||||
|
value: string | boolean
|
||||||
|
) => {
|
||||||
setMappingForm((prev) => ({
|
setMappingForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
mappings: prev.mappings.map((entry, idx) =>
|
mappings: prev.mappings.map((entry, idx) =>
|
||||||
@@ -834,7 +1076,10 @@ export function AuthFilesPage() {
|
|||||||
<div className={styles.filterTags}>
|
<div className={styles.filterTags}>
|
||||||
{existingTypes.map((type) => {
|
{existingTypes.map((type) => {
|
||||||
const isActive = filter === type;
|
const isActive = filter === type;
|
||||||
const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type);
|
const color =
|
||||||
|
type === 'all'
|
||||||
|
? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' }
|
||||||
|
: getTypeColor(type);
|
||||||
const activeTextColor = resolvedTheme === 'dark' ? '#111827' : '#fff';
|
const activeTextColor = resolvedTheme === 'dark' ? '#111827' : '#fff';
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -843,7 +1088,7 @@ export function AuthFilesPage() {
|
|||||||
style={{
|
style={{
|
||||||
backgroundColor: isActive ? color.text : color.bg,
|
backgroundColor: isActive ? color.text : color.bg,
|
||||||
color: isActive ? activeTextColor : color.text,
|
color: isActive ? activeTextColor : color.text,
|
||||||
borderColor: color.text
|
borderColor: color.text,
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFilter(type);
|
setFilter(type);
|
||||||
@@ -884,7 +1129,8 @@ export function AuthFilesPage() {
|
|||||||
const rawAuthIndex = item['auth_index'] ?? item.authIndex;
|
const rawAuthIndex = item['auth_index'] ?? item.authIndex;
|
||||||
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
|
||||||
const statusData = (authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
const statusData =
|
||||||
|
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
||||||
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||||
const rateClass = !hasData
|
const rateClass = !hasData
|
||||||
? ''
|
? ''
|
||||||
@@ -932,7 +1178,7 @@ export function AuthFilesPage() {
|
|||||||
style={{
|
style={{
|
||||||
backgroundColor: typeColor.bg,
|
backgroundColor: typeColor.bg,
|
||||||
color: typeColor.text,
|
color: typeColor.text,
|
||||||
...(typeColor.border ? { border: typeColor.border } : {})
|
...(typeColor.border ? { border: typeColor.border } : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getTypeLabel(item.type || 'unknown')}
|
{getTypeLabel(item.type || 'unknown')}
|
||||||
@@ -941,8 +1187,12 @@ export function AuthFilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.cardMeta}>
|
<div className={styles.cardMeta}>
|
||||||
<span>{t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'}</span>
|
<span>
|
||||||
<span>{t('auth_files.file_modified')}: {formatModified(item)}</span>
|
{t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('auth_files.file_modified')}: {formatModified(item)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.cardStats}>
|
<div className={styles.cardStats}>
|
||||||
@@ -992,6 +1242,16 @@ export function AuthFilesPage() {
|
|||||||
>
|
>
|
||||||
<IconDownload className={styles.actionIcon} size={16} />
|
<IconDownload className={styles.actionIcon} size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void openPrefixProxyEditor(item.name)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.prefix_proxy_button')}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconCode className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1008,8 +1268,20 @@ export function AuthFilesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!isRuntimeOnly && (
|
||||||
|
<div className={styles.statusToggle}>
|
||||||
|
<ToggleSwitch
|
||||||
|
ariaLabel={t('auth_files.status_toggle_label')}
|
||||||
|
checked={!item.disabled}
|
||||||
|
disabled={disableControls || statusUpdating[item.name] === true}
|
||||||
|
onChange={(value) => void handleStatusToggle(item, value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isRuntimeOnly && (
|
{isRuntimeOnly && (
|
||||||
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
|
<div className={styles.virtualBadge}>
|
||||||
|
{t('auth_files.type_virtual') || '虚拟认证文件'}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1034,12 +1306,7 @@ export function AuthFilesPage() {
|
|||||||
title={titleNode}
|
title={titleNode}
|
||||||
extra={
|
extra={
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<Button
|
<Button variant="secondary" size="sm" onClick={handleHeaderRefresh} disabled={loading}>
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleHeaderRefresh}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{t('common.refresh')}
|
{t('common.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -1049,9 +1316,16 @@ export function AuthFilesPage() {
|
|||||||
disabled={disableControls || loading || deletingAll}
|
disabled={disableControls || loading || deletingAll}
|
||||||
loading={deletingAll}
|
loading={deletingAll}
|
||||||
>
|
>
|
||||||
{filter === 'all' ? t('auth_files.delete_all_button') : `${t('common.delete')} ${getTypeLabel(filter)}`}
|
{filter === 'all'
|
||||||
|
? t('auth_files.delete_all_button')
|
||||||
|
: `${t('common.delete')} ${getTypeLabel(filter)}`}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={handleUploadClick} disabled={disableControls || uploading} loading={uploading}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleUploadClick}
|
||||||
|
disabled={disableControls || uploading}
|
||||||
|
loading={uploading}
|
||||||
|
>
|
||||||
{t('auth_files.upload_button')}
|
{t('auth_files.upload_button')}
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
@@ -1102,11 +1376,12 @@ export function AuthFilesPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className={styles.hint}>{t('common.loading')}</div>
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
) : pageItems.length === 0 ? (
|
) : pageItems.length === 0 ? (
|
||||||
<EmptyState title={t('auth_files.search_empty_title')} description={t('auth_files.search_empty_desc')} />
|
<EmptyState
|
||||||
|
title={t('auth_files.search_empty_title')}
|
||||||
|
description={t('auth_files.search_empty_desc')}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.fileGrid}>
|
<div className={styles.fileGrid}>{pageItems.map(renderFileCard)}</div>
|
||||||
{pageItems.map(renderFileCard)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 分页 */}
|
{/* 分页 */}
|
||||||
@@ -1124,7 +1399,7 @@ export function AuthFilesPage() {
|
|||||||
{t('auth_files.pagination_info', {
|
{t('auth_files.pagination_info', {
|
||||||
current: currentPage,
|
current: currentPage,
|
||||||
total: totalPages,
|
total: totalPages,
|
||||||
count: filtered.length
|
count: filtered.length,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -1267,7 +1542,9 @@ export function AuthFilesPage() {
|
|||||||
<Modal
|
<Modal
|
||||||
open={modelsModalOpen}
|
open={modelsModalOpen}
|
||||||
onClose={() => setModelsModalOpen(false)}
|
onClose={() => setModelsModalOpen(false)}
|
||||||
title={t('auth_files.models_title', { defaultValue: '支持的模型' }) + ` - ${modelsFileName}`}
|
title={
|
||||||
|
t('auth_files.models_title', { defaultValue: '支持的模型' }) + ` - ${modelsFileName}`
|
||||||
|
}
|
||||||
footer={
|
footer={
|
||||||
<Button variant="secondary" onClick={() => setModelsModalOpen(false)}>
|
<Button variant="secondary" onClick={() => setModelsModalOpen(false)}>
|
||||||
{t('common.close')}
|
{t('common.close')}
|
||||||
@@ -1275,16 +1552,22 @@ export function AuthFilesPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{modelsLoading ? (
|
{modelsLoading ? (
|
||||||
<div className={styles.hint}>{t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })}</div>
|
<div className={styles.hint}>
|
||||||
|
{t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })}
|
||||||
|
</div>
|
||||||
) : modelsError === 'unsupported' ? (
|
) : modelsError === 'unsupported' ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={t('auth_files.models_unsupported', { defaultValue: '当前版本不支持此功能' })}
|
title={t('auth_files.models_unsupported', { defaultValue: '当前版本不支持此功能' })}
|
||||||
description={t('auth_files.models_unsupported_desc', { defaultValue: '请更新 CLI Proxy API 到最新版本后重试' })}
|
description={t('auth_files.models_unsupported_desc', {
|
||||||
|
defaultValue: '请更新 CLI Proxy API 到最新版本后重试',
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
) : modelsList.length === 0 ? (
|
) : modelsList.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={t('auth_files.models_empty', { defaultValue: '该凭证暂无可用模型' })}
|
title={t('auth_files.models_empty', { defaultValue: '该凭证暂无可用模型' })}
|
||||||
description={t('auth_files.models_empty_desc', { defaultValue: '该认证凭证可能尚未被服务器加载或没有绑定任何模型' })}
|
description={t('auth_files.models_empty_desc', {
|
||||||
|
defaultValue: '该认证凭证可能尚未被服务器加载或没有绑定任何模型',
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.modelsList}>
|
<div className={styles.modelsList}>
|
||||||
@@ -1296,19 +1579,28 @@ export function AuthFilesPage() {
|
|||||||
className={`${styles.modelItem} ${isExcluded ? styles.modelItemExcluded : ''}`}
|
className={`${styles.modelItem} ${isExcluded ? styles.modelItemExcluded : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(model.id);
|
navigator.clipboard.writeText(model.id);
|
||||||
showNotification(t('notification.link_copied', { defaultValue: '已复制到剪贴板' }), 'success');
|
showNotification(
|
||||||
|
t('notification.link_copied', { defaultValue: '已复制到剪贴板' }),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
title={isExcluded ? t('auth_files.models_excluded_hint', { defaultValue: '此模型已被 OAuth 排除' }) : t('common.copy', { defaultValue: '点击复制' })}
|
title={
|
||||||
|
isExcluded
|
||||||
|
? t('auth_files.models_excluded_hint', {
|
||||||
|
defaultValue: '此模型已被 OAuth 排除',
|
||||||
|
})
|
||||||
|
: t('common.copy', { defaultValue: '点击复制' })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className={styles.modelId}>{model.id}</span>
|
<span className={styles.modelId}>{model.id}</span>
|
||||||
{model.display_name && model.display_name !== model.id && (
|
{model.display_name && model.display_name !== model.id && (
|
||||||
<span className={styles.modelDisplayName}>{model.display_name}</span>
|
<span className={styles.modelDisplayName}>{model.display_name}</span>
|
||||||
)}
|
)}
|
||||||
{model.type && (
|
{model.type && <span className={styles.modelType}>{model.type}</span>}
|
||||||
<span className={styles.modelType}>{model.type}</span>
|
|
||||||
)}
|
|
||||||
{isExcluded && (
|
{isExcluded && (
|
||||||
<span className={styles.modelExcludedBadge}>{t('auth_files.models_excluded_badge', { defaultValue: '已排除' })}</span>
|
<span className={styles.modelExcludedBadge}>
|
||||||
|
{t('auth_files.models_excluded_badge', { defaultValue: '已排除' })}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1317,6 +1609,89 @@ export function AuthFilesPage() {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* prefix/proxy_url 编辑弹窗 */}
|
||||||
|
<Modal
|
||||||
|
open={Boolean(prefixProxyEditor)}
|
||||||
|
onClose={() => setPrefixProxyEditor(null)}
|
||||||
|
closeDisabled={prefixProxyEditor?.saving === true}
|
||||||
|
width={720}
|
||||||
|
title={
|
||||||
|
prefixProxyEditor?.fileName
|
||||||
|
? `${t('auth_files.prefix_proxy_button')} - ${prefixProxyEditor.fileName}`
|
||||||
|
: t('auth_files.prefix_proxy_button')
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setPrefixProxyEditor(null)}
|
||||||
|
disabled={prefixProxyEditor?.saving === true}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handlePrefixProxySave()}
|
||||||
|
loading={prefixProxyEditor?.saving === true}
|
||||||
|
disabled={
|
||||||
|
disableControls ||
|
||||||
|
prefixProxyEditor?.saving === true ||
|
||||||
|
!prefixProxyDirty ||
|
||||||
|
!prefixProxyEditor?.json
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{prefixProxyEditor && (
|
||||||
|
<div className={styles.prefixProxyEditor}>
|
||||||
|
{prefixProxyEditor.loading ? (
|
||||||
|
<div className={styles.prefixProxyLoading}>
|
||||||
|
<LoadingSpinner size={14} />
|
||||||
|
<span>{t('auth_files.prefix_proxy_loading')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{prefixProxyEditor.error && (
|
||||||
|
<div className={styles.prefixProxyError}>{prefixProxyEditor.error}</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.prefixProxyJsonWrapper}>
|
||||||
|
<label className={styles.prefixProxyLabel}>
|
||||||
|
{t('auth_files.prefix_proxy_source_label')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className={styles.prefixProxyTextarea}
|
||||||
|
rows={10}
|
||||||
|
readOnly
|
||||||
|
value={prefixProxyUpdatedText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.prefixProxyFields}>
|
||||||
|
<Input
|
||||||
|
label={t('auth_files.prefix_label')}
|
||||||
|
value={prefixProxyEditor.prefix}
|
||||||
|
disabled={
|
||||||
|
disableControls || prefixProxyEditor.saving || !prefixProxyEditor.json
|
||||||
|
}
|
||||||
|
onChange={(e) => handlePrefixProxyChange('prefix', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('auth_files.proxy_url_label')}
|
||||||
|
value={prefixProxyEditor.proxyUrl}
|
||||||
|
placeholder={t('auth_files.proxy_url_placeholder')}
|
||||||
|
disabled={
|
||||||
|
disableControls || prefixProxyEditor.saving || !prefixProxyEditor.json
|
||||||
|
}
|
||||||
|
onChange={(e) => handlePrefixProxyChange('proxyUrl', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* OAuth 排除弹窗 */}
|
{/* OAuth 排除弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
open={excludedModalOpen}
|
open={excludedModalOpen}
|
||||||
@@ -1324,7 +1699,11 @@ export function AuthFilesPage() {
|
|||||||
title={t('oauth_excluded.add_title')}
|
title={t('oauth_excluded.add_title')}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={() => setExcludedModalOpen(false)} disabled={savingExcluded}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setExcludedModalOpen(false)}
|
||||||
|
disabled={savingExcluded}
|
||||||
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={saveExcludedModels} loading={savingExcluded}>
|
<Button onClick={saveExcludedModels} loading={savingExcluded}>
|
||||||
@@ -1388,7 +1767,11 @@ export function AuthFilesPage() {
|
|||||||
title={t('oauth_model_mappings.add_title')}
|
title={t('oauth_model_mappings.add_title')}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={() => setMappingModalOpen(false)} disabled={savingMappings}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setMappingModalOpen(false)}
|
||||||
|
disabled={savingMappings}
|
||||||
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={saveModelMappings} loading={savingMappings}>
|
<Button onClick={saveModelMappings} loading={savingMappings}>
|
||||||
@@ -1399,15 +1782,15 @@ export function AuthFilesPage() {
|
|||||||
>
|
>
|
||||||
<div className={styles.providerField}>
|
<div className={styles.providerField}>
|
||||||
<Input
|
<Input
|
||||||
id="oauth-model-mappings-provider"
|
id="oauth-model-alias-provider"
|
||||||
list="oauth-model-mappings-provider-options"
|
list="oauth-model-alias-provider-options"
|
||||||
label={t('oauth_model_mappings.provider_label')}
|
label={t('oauth_model_mappings.provider_label')}
|
||||||
hint={t('oauth_model_mappings.provider_hint')}
|
hint={t('oauth_model_mappings.provider_hint')}
|
||||||
placeholder={t('oauth_model_mappings.provider_placeholder')}
|
placeholder={t('oauth_model_mappings.provider_placeholder')}
|
||||||
value={mappingForm.provider}
|
value={mappingForm.provider}
|
||||||
onChange={(e) => setMappingForm((prev) => ({ ...prev, provider: e.target.value }))}
|
onChange={(e) => setMappingForm((prev) => ({ ...prev, provider: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<datalist id="oauth-model-mappings-provider-options">
|
<datalist id="oauth-model-alias-provider-options">
|
||||||
{providerOptions.map((provider) => (
|
{providerOptions.map((provider) => (
|
||||||
<option key={provider} value={provider} />
|
<option key={provider} value={provider} />
|
||||||
))}
|
))}
|
||||||
@@ -1437,7 +1820,7 @@ export function AuthFilesPage() {
|
|||||||
<div className="header-input-list">
|
<div className="header-input-list">
|
||||||
{(mappingForm.mappings.length ? mappingForm.mappings : [buildEmptyMappingEntry()]).map(
|
{(mappingForm.mappings.length ? mappingForm.mappings : [buildEmptyMappingEntry()]).map(
|
||||||
(entry, index) => (
|
(entry, index) => (
|
||||||
<div key={`${entry.name}-${entry.alias}-${index}`} className={styles.mappingRow}>
|
<div key={entry.id} className={styles.mappingRow}>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
placeholder={t('oauth_model_mappings.mapping_name_placeholder')}
|
placeholder={t('oauth_model_mappings.mapping_name_placeholder')}
|
||||||
|
|||||||
@@ -6,10 +6,20 @@ import { apiClient } from './client';
|
|||||||
import type { AuthFilesResponse } from '@/types/authFile';
|
import type { AuthFilesResponse } from '@/types/authFile';
|
||||||
import type { OAuthModelMappingEntry } from '@/types';
|
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[]> => {
|
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
|
||||||
if (!payload || typeof payload !== 'object') return {};
|
if (!payload || typeof payload !== 'object') return {};
|
||||||
|
|
||||||
const source = (payload as any)['oauth-excluded-models'] ?? (payload as any).items ?? payload;
|
const record = payload as Record<string, unknown>;
|
||||||
|
const source = record['oauth-excluded-models'] ?? record.items ?? payload;
|
||||||
if (!source || typeof source !== 'object') return {};
|
if (!source || typeof source !== 'object') return {};
|
||||||
|
|
||||||
const result: Record<string, string[]> = {};
|
const result: Record<string, string[]> = {};
|
||||||
@@ -43,9 +53,63 @@ const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]
|
|||||||
return result;
|
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);
|
||||||
@@ -81,34 +145,63 @@ export const authFilesApi = {
|
|||||||
|
|
||||||
// OAuth 模型映射
|
// OAuth 模型映射
|
||||||
async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> {
|
async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> {
|
||||||
const data = await apiClient.get('/oauth-model-mappings');
|
try {
|
||||||
const payload = (data && (data['oauth-model-mappings'] ?? data.items ?? data)) as any;
|
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_ENDPOINT);
|
||||||
if (!payload || typeof payload !== 'object') return {};
|
return normalizeOauthModelMappings(data);
|
||||||
const result: Record<string, OAuthModelMappingEntry[]> = {};
|
} catch (err: unknown) {
|
||||||
Object.entries(payload).forEach(([channel, mappings]) => {
|
if (getStatusCode(err) !== 404) throw err;
|
||||||
if (!Array.isArray(mappings)) return;
|
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT);
|
||||||
const normalized = mappings
|
return normalizeOauthModelMappings(data);
|
||||||
.map((item) => {
|
}
|
||||||
if (!item || typeof item !== 'object') return null;
|
|
||||||
const name = String(item.name ?? item.id ?? item.model ?? '').trim();
|
|
||||||
const alias = String(item.alias ?? '').trim();
|
|
||||||
if (!name || !alias) return null;
|
|
||||||
const fork = item.fork === true;
|
|
||||||
return fork ? { name, alias, fork } : { name, alias };
|
|
||||||
})
|
|
||||||
.filter(Boolean) as OAuthModelMappingEntry[];
|
|
||||||
if (normalized.length) {
|
|
||||||
result[channel] = normalized;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
saveOauthModelMappings: (channel: string, mappings: OAuthModelMappingEntry[]) =>
|
saveOauthModelMappings: async (channel: string, mappings: OAuthModelMappingEntry[]) => {
|
||||||
apiClient.patch('/oauth-model-mappings', { channel, mappings }),
|
const normalizedChannel = String(channel ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const normalizedMappings = normalizeOauthModelMappings({ [normalizedChannel]: mappings })[normalizedChannel] ?? [];
|
||||||
|
|
||||||
deleteOauthModelMappings: (channel: string) =>
|
try {
|
||||||
apiClient.delete(`/oauth-model-mappings?channel=${encodeURIComponent(channel)}`),
|
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 }[]> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -7,14 +7,16 @@
|
|||||||
* 隐藏 API Key 中间部分,仅保留前后两位
|
* 隐藏 API Key 中间部分,仅保留前后两位
|
||||||
*/
|
*/
|
||||||
export function maskApiKey(key: string): string {
|
export function maskApiKey(key: string): string {
|
||||||
if (!key) {
|
const trimmed = String(key || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleChars = 2;
|
const MASKED_LENGTH = 10;
|
||||||
const start = key.slice(0, visibleChars);
|
const visibleChars = trimmed.length < 4 ? 1 : 2;
|
||||||
const end = key.slice(-visibleChars);
|
const start = trimmed.slice(0, visibleChars);
|
||||||
const maskedLength = Math.max(key.length - visibleChars * 2, 1);
|
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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -878,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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user