mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
35 Commits
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-fill)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-fill" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)"
|
||||
fill="#FFFFFF" stroke="none">
|
||||
<path d="M1107 2290 c-316 -57 -615 -283 -748 -565 -68 -144 -91 -241 -96
|
||||
-406 -6 -156 7 -249 49 -374 87 -254 291 -478 542 -596 146 -68 226 -84 426
|
||||
-84 152 0 186 3 260 23 182 50 327 136 465 277 147 150 245 334 282 529 23
|
||||
123 14 344 -20 456 -35 116 -69 190 -134 290 -131 200 -340 354 -578 426 -78
|
||||
23 -111 27 -245 30 -85 1 -177 -1 -203 -6z m362 -216 c91 -21 224 -86 310
|
||||
-152 133 -101 249 -275 293 -439 16 -60 21 -108 21 -203 0 -152 -21 -240 -88
|
||||
-368 -130 -253 -350 -407 -634 -443 -393 -50 -777 214 -882 607 -30 110 -30
|
||||
296 0 408 72 270 282 489 552 576 130 41 287 47 428 14z"/>
|
||||
<path d="M849 1637 c-31 -24 -52 -67 -46 -95 3 -15 35 -78 71 -139 36 -61 66
|
||||
-115 66 -119 0 -5 -30 -58 -66 -119 -36 -60 -68 -123 -70 -140 -7 -42 26 -90
|
||||
70 -105 31 -10 42 -9 72 7 31 15 51 43 125 173 93 162 101 188 73 243 -50 97
|
||||
-169 289 -185 297 -25 14 -91 12 -110 -3z"/>
|
||||
<path d="M1353 1139 c-42 -12 -73 -53 -73 -96 0 -27 8 -43 35 -70 l34 -34 216
|
||||
3 217 3 30 34 c26 29 29 40 25 73 -7 49 -29 75 -76 88 -45 12 -364 12 -408 -1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M1107 2290 c-316 -57 -615 -283 -748 -565 -68 -144 -91 -241 -96
|
||||
-406 -6 -156 7 -249 49 -374 87 -254 291 -478 542 -596 146 -68 226 -84 426
|
||||
-84 152 0 186 3 260 23 182 50 327 136 465 277 147 150 245 334 282 529 23
|
||||
123 14 344 -20 456 -35 116 -69 190 -134 290 -131 200 -340 354 -578 426 -78
|
||||
23 -111 27 -245 30 -85 1 -177 -1 -203 -6z m362 -216 c91 -21 224 -86 310
|
||||
-152 133 -101 249 -275 293 -439 16 -60 21 -108 21 -203 0 -152 -21 -240 -88
|
||||
-368 -130 -253 -350 -407 -634 -443 -393 -50 -777 214 -882 607 -30 110 -30
|
||||
296 0 408 72 270 282 489 552 576 130 41 287 47 428 14z"/>
|
||||
<path d="M849 1637 c-31 -24 -52 -67 -46 -95 3 -15 35 -78 71 -139 36 -61 66
|
||||
-115 66 -119 0 -5 -30 -58 -66 -119 -36 -60 -68 -123 -70 -140 -7 -42 26 -90
|
||||
70 -105 31 -10 42 -9 72 7 31 15 51 43 125 173 93 162 101 188 73 243 -50 97
|
||||
-169 289 -185 297 -25 14 -91 12 -110 -3z"/>
|
||||
<path d="M1353 1139 c-42 -12 -73 -53 -73 -96 0 -27 8 -43 35 -70 l34 -34 216
|
||||
3 217 3 30 34 c26 29 29 40 25 73 -7 49 -29 75 -76 88 -45 12 -364 12 -408 -1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -50,7 +50,7 @@ function computeUnifiedDiff(original: string, modified: string): DiffResult {
|
||||
let totalAdditions = 0;
|
||||
let totalDeletions = 0;
|
||||
|
||||
const hunks: Hunk[] = chunks.map((chunk) => {
|
||||
const hunks: Hunk[] = chunks.map((chunk: Chunk) => {
|
||||
const lines: UnifiedLine[] = [];
|
||||
|
||||
const hasDel = chunk.fromA < chunk.toA;
|
||||
|
||||
@@ -13,15 +13,15 @@ import { Button } from '@/components/ui/Button';
|
||||
import { PageTransition } from '@/components/common/PageTransition';
|
||||
import { MainRoutes } from '@/router/MainRoutes';
|
||||
import {
|
||||
IconBot,
|
||||
IconChartLine,
|
||||
IconFileText,
|
||||
IconInfo,
|
||||
IconLayoutDashboard,
|
||||
IconScrollText,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconTimer,
|
||||
IconSidebarAuthFiles,
|
||||
IconSidebarConfig,
|
||||
IconSidebarDashboard,
|
||||
IconSidebarLogs,
|
||||
IconSidebarOauth,
|
||||
IconSidebarProviders,
|
||||
IconSidebarQuota,
|
||||
IconSidebarSystem,
|
||||
IconSidebarUsage,
|
||||
} from '@/components/ui/icons';
|
||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||
import {
|
||||
@@ -31,21 +31,21 @@ import {
|
||||
useNotificationStore,
|
||||
useThemeStore,
|
||||
} from '@/stores';
|
||||
import { versionApi } from '@/services/api';
|
||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
|
||||
import { isSupportedLanguage } from '@/utils/language';
|
||||
import type { Theme } from '@/types';
|
||||
|
||||
const sidebarIcons: Record<string, ReactNode> = {
|
||||
dashboard: <IconLayoutDashboard size={18} />,
|
||||
aiProviders: <IconBot size={18} />,
|
||||
authFiles: <IconFileText size={18} />,
|
||||
oauth: <IconShield size={18} />,
|
||||
quota: <IconTimer size={18} />,
|
||||
usage: <IconChartLine size={18} />,
|
||||
config: <IconSettings size={18} />,
|
||||
logs: <IconScrollText size={18} />,
|
||||
system: <IconInfo size={18} />,
|
||||
dashboard: <IconSidebarDashboard size={18} />,
|
||||
aiProviders: <IconSidebarProviders size={18} />,
|
||||
authFiles: <IconSidebarAuthFiles size={18} />,
|
||||
oauth: <IconSidebarOauth size={18} />,
|
||||
quota: <IconSidebarQuota size={18} />,
|
||||
usage: <IconSidebarUsage size={18} />,
|
||||
config: <IconSidebarConfig size={18} />,
|
||||
logs: <IconSidebarLogs size={18} />,
|
||||
system: <IconSidebarSystem size={18} />,
|
||||
};
|
||||
|
||||
// Header action icons - smaller size for header buttons
|
||||
@@ -69,12 +69,6 @@ const headerIcons = {
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
),
|
||||
update: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="M12 19V5" />
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
</svg>
|
||||
),
|
||||
menu: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="M4 7h16" />
|
||||
@@ -117,6 +111,12 @@ const headerIcons = {
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
|
||||
</svg>
|
||||
),
|
||||
whiteTheme: (
|
||||
<svg {...headerIconProps}>
|
||||
<circle cx="12" cy="12" r="7" />
|
||||
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
),
|
||||
autoTheme: (
|
||||
<svg {...headerIconProps}>
|
||||
<defs>
|
||||
@@ -125,7 +125,13 @@ const headerIcons = {
|
||||
</clipPath>
|
||||
</defs>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<circle cx="12" cy="12" r="4" clipPath="url(#mainLayoutAutoThemeSunLeftHalf)" fill="currentColor" />
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="4"
|
||||
clipPath="url(#mainLayoutAutoThemeSunLeftHalf)"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="M4.93 4.93l1.41 1.41" />
|
||||
@@ -145,31 +151,56 @@ const headerIcons = {
|
||||
),
|
||||
};
|
||||
|
||||
const parseVersionSegments = (version?: string | null) => {
|
||||
if (!version) return null;
|
||||
const cleaned = version.trim().replace(/^v/i, '');
|
||||
if (!cleaned) return null;
|
||||
const parts = cleaned
|
||||
.split(/[^0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => Number.parseInt(segment, 10))
|
||||
.filter(Number.isFinite);
|
||||
return parts.length ? parts : null;
|
||||
};
|
||||
|
||||
const compareVersions = (latest?: string | null, current?: string | null) => {
|
||||
const latestParts = parseVersionSegments(latest);
|
||||
const currentParts = parseVersionSegments(current);
|
||||
if (!latestParts || !currentParts) return null;
|
||||
const length = Math.max(latestParts.length, currentParts.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const l = latestParts[i] || 0;
|
||||
const c = currentParts[i] || 0;
|
||||
if (l > c) return 1;
|
||||
if (l < c) return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
const THEME_CARDS: Array<{
|
||||
key: Theme;
|
||||
labelKey: string;
|
||||
colors: { bg: string; card: string; border: string; text: string; textMuted: string };
|
||||
}> = [
|
||||
{
|
||||
key: 'auto',
|
||||
labelKey: 'theme.auto',
|
||||
colors: {
|
||||
bg: 'linear-gradient(135deg, #faf9f5 0 50%, #151412 50% 100%)',
|
||||
card: 'linear-gradient(135deg, #f0eee8 0 50%, #1d1b18 50% 100%)',
|
||||
border: '#bdb6ae',
|
||||
text: '#2d2a26',
|
||||
textMuted: 'linear-gradient(135deg, #a29c95 0 50%, #9c958d 50% 100%)',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'white',
|
||||
labelKey: 'theme.white',
|
||||
colors: {
|
||||
bg: '#ffffff',
|
||||
card: '#ffffff',
|
||||
border: '#e5e5e5',
|
||||
text: '#2d2a26',
|
||||
textMuted: '#a29c95',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'light',
|
||||
labelKey: 'theme.light',
|
||||
colors: {
|
||||
bg: '#faf9f5',
|
||||
card: '#f0eee8',
|
||||
border: '#e3e1db',
|
||||
text: '#2d2a26',
|
||||
textMuted: '#a29c95',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
labelKey: 'theme.dark',
|
||||
colors: {
|
||||
bg: '#151412',
|
||||
card: '#1d1b18',
|
||||
border: '#3a3530',
|
||||
text: '#f6f4f1',
|
||||
textMuted: '#9c958d',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function MainLayout() {
|
||||
const { t } = useTranslation();
|
||||
@@ -177,7 +208,6 @@ export function MainLayout() {
|
||||
const location = useLocation();
|
||||
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
@@ -186,17 +216,18 @@ export function MainLayout() {
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
|
||||
const [brandExpanded, setBrandExpanded] = useState(true);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const languageMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const themeMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const headerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
@@ -304,6 +335,32 @@ export function MainLayout() {
|
||||
};
|
||||
}, [languageMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeMenuOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!themeMenuRef.current?.contains(event.target as Node)) {
|
||||
setThemeMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setThemeMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [themeMenuOpen]);
|
||||
|
||||
const handleBrandClick = useCallback(() => {
|
||||
if (!brandExpanded) {
|
||||
setBrandExpanded(true);
|
||||
@@ -319,8 +376,22 @@ export function MainLayout() {
|
||||
|
||||
const toggleLanguageMenu = useCallback(() => {
|
||||
setLanguageMenuOpen((prev) => !prev);
|
||||
setThemeMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const toggleThemeMenu = useCallback(() => {
|
||||
setThemeMenuOpen((prev) => !prev);
|
||||
setLanguageMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleThemeSelect = useCallback(
|
||||
(nextTheme: Theme) => {
|
||||
setTheme(nextTheme);
|
||||
setThemeMenuOpen(false);
|
||||
},
|
||||
[setTheme]
|
||||
);
|
||||
|
||||
const handleLanguageSelect = useCallback(
|
||||
(nextLanguage: string) => {
|
||||
if (!isSupportedLanguage(nextLanguage)) {
|
||||
@@ -338,7 +409,6 @@ export function MainLayout() {
|
||||
});
|
||||
}, [fetchConfig]);
|
||||
|
||||
|
||||
const statusClass =
|
||||
connectionStatus === 'connected'
|
||||
? 'success'
|
||||
@@ -421,7 +491,7 @@ export function MainLayout() {
|
||||
clearCache();
|
||||
const results = await Promise.allSettled([
|
||||
fetchConfig(undefined, true),
|
||||
triggerHeaderRefresh()
|
||||
triggerHeaderRefresh(),
|
||||
]);
|
||||
const rejected = results.find((result) => result.status === 'rejected');
|
||||
if (rejected && rejected.status === 'rejected') {
|
||||
@@ -437,39 +507,6 @@ export function MainLayout() {
|
||||
showNotification(t('notification.data_refreshed'), 'success');
|
||||
};
|
||||
|
||||
const handleVersionCheck = async () => {
|
||||
setCheckingVersion(true);
|
||||
try {
|
||||
const data = await versionApi.checkLatest();
|
||||
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
||||
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
|
||||
const comparison = compareVersions(latest, serverVersion);
|
||||
|
||||
if (!latest) {
|
||||
showNotification(t('system_info.version_check_error'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison === null) {
|
||||
showNotification(t('system_info.version_current_missing'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison > 0) {
|
||||
showNotification(t('system_info.version_update_available', { version: latest }), 'warning');
|
||||
} else {
|
||||
showNotification(t('system_info.version_is_latest'), 'success');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
||||
const suffix = message ? `: ${message}` : '';
|
||||
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
|
||||
} finally {
|
||||
setCheckingVersion(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="main-header" ref={headerRef}>
|
||||
@@ -527,16 +564,10 @@ export function MainLayout() {
|
||||
>
|
||||
{headerIcons.refresh}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleVersionCheck}
|
||||
loading={checkingVersion}
|
||||
title={t('system_info.version_check_button')}
|
||||
<div
|
||||
className={`language-menu ${languageMenuOpen ? 'open' : ''}`}
|
||||
ref={languageMenuRef}
|
||||
>
|
||||
{headerIcons.update}
|
||||
</Button>
|
||||
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -549,7 +580,11 @@ export function MainLayout() {
|
||||
{headerIcons.language}
|
||||
</Button>
|
||||
{languageMenuOpen && (
|
||||
<div className="notification entering language-menu-popover" role="menu" aria-label={t('language.switch')}>
|
||||
<div
|
||||
className="notification entering language-menu-popover"
|
||||
role="menu"
|
||||
aria-label={t('language.switch')}
|
||||
>
|
||||
{LANGUAGE_ORDER.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
@@ -566,13 +601,79 @@ export function MainLayout() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
|
||||
{theme === 'auto'
|
||||
? headerIcons.autoTheme
|
||||
: theme === 'dark'
|
||||
? headerIcons.moon
|
||||
: headerIcons.sun}
|
||||
</Button>
|
||||
<div className={`theme-menu ${themeMenuOpen ? 'open' : ''}`} ref={themeMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleThemeMenu}
|
||||
title={t('theme.switch')}
|
||||
aria-label={t('theme.switch')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={themeMenuOpen}
|
||||
>
|
||||
{theme === 'auto'
|
||||
? headerIcons.autoTheme
|
||||
: theme === 'dark'
|
||||
? headerIcons.moon
|
||||
: theme === 'white'
|
||||
? headerIcons.whiteTheme
|
||||
: headerIcons.sun}
|
||||
</Button>
|
||||
{themeMenuOpen && (
|
||||
<div
|
||||
className="notification entering theme-menu-popover"
|
||||
role="menu"
|
||||
aria-label={t('theme.switch')}
|
||||
>
|
||||
{THEME_CARDS.map((tc) => (
|
||||
<button
|
||||
key={tc.key}
|
||||
type="button"
|
||||
className={`theme-card ${theme === tc.key ? 'active' : ''}`}
|
||||
onClick={() => handleThemeSelect(tc.key)}
|
||||
role="menuitemradio"
|
||||
aria-checked={theme === tc.key}
|
||||
>
|
||||
<div
|
||||
className="theme-card-preview"
|
||||
style={{
|
||||
background: tc.colors.bg,
|
||||
border: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="theme-card-header"
|
||||
style={{
|
||||
background: tc.colors.card,
|
||||
borderBottom: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
/>
|
||||
<div className="theme-card-body">
|
||||
<div
|
||||
className="theme-card-sidebar"
|
||||
style={{
|
||||
background: tc.colors.card,
|
||||
borderRight: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
/>
|
||||
<div className="theme-card-content" style={{ background: tc.colors.bg }}>
|
||||
<div
|
||||
className="theme-card-line"
|
||||
style={{ background: tc.colors.textMuted }}
|
||||
/>
|
||||
<div
|
||||
className="theme-card-line short"
|
||||
style={{ background: tc.colors.textMuted }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="theme-card-label">{t(tc.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
||||
{headerIcons.logout}
|
||||
</Button>
|
||||
@@ -581,6 +682,15 @@ export function MainLayout() {
|
||||
</header>
|
||||
|
||||
<div className="main-body">
|
||||
<button
|
||||
type="button"
|
||||
className={`sidebar-backdrop ${sidebarOpen ? 'visible' : ''}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-label={t('common.close')}
|
||||
aria-hidden={!sidebarOpen}
|
||||
tabIndex={sidebarOpen ? 0 : -1}
|
||||
/>
|
||||
|
||||
<aside
|
||||
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
|
||||
>
|
||||
|
||||
@@ -71,6 +71,10 @@ export function AmpcodeSection({
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_model_mappings_count')}:</span>
|
||||
<span className={styles.fieldValue}>{config?.modelMappings?.length || 0}</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_upstream_api_keys_count')}:</span>
|
||||
<span className={styles.fieldValue}>{config?.upstreamApiKeys?.length || 0}</span>
|
||||
</div>
|
||||
{config?.modelMappings?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
{config.modelMappings.slice(0, 5).map((mapping) => (
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||
import iconCodex from '@/assets/icons/codex.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
@@ -25,7 +24,6 @@ interface CodexSectionProps {
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
resolvedTheme: string;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
@@ -39,7 +37,6 @@ export function CodexSection({
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
resolvedTheme,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -72,11 +69,7 @@ export function CodexSection({
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? iconCodexDark : iconCodexLight}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
/>
|
||||
<img src={iconCodex} alt="" className={styles.cardTitleIcon} />
|
||||
{t('ai_providers.codex_title')}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ import { useThemeStore } from '@/stores';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||
import iconCodex from '@/assets/icons/codex.svg';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import iconAmp from '@/assets/icons/amp.svg';
|
||||
@@ -23,7 +22,7 @@ interface ProviderNavItem {
|
||||
|
||||
const PROVIDERS: ProviderNavItem[] = [
|
||||
{ id: 'gemini', label: 'Gemini', getIcon: () => iconGemini },
|
||||
{ id: 'codex', label: 'Codex', getIcon: (theme) => (theme === 'dark' ? iconCodexDark : iconCodexLight) },
|
||||
{ id: 'codex', label: 'Codex', getIcon: () => iconCodex },
|
||||
{ id: 'claude', label: 'Claude', getIcon: () => iconClaude },
|
||||
{ id: 'vertex', label: 'Vertex', getIcon: () => iconVertex },
|
||||
{ id: 'ampcode', label: 'Ampcode', getIcon: () => iconAmp },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Fragment, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource } from '../utils';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
|
||||
interface VertexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
@@ -26,6 +27,7 @@ interface VertexSectionProps {
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggle: (index: number, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function VertexSection({
|
||||
@@ -38,9 +40,11 @@ export function VertexSection({
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
}: VertexSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
@@ -84,9 +88,20 @@ export function VertexSection({
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.config_toggle_label')}
|
||||
checked={!hasDisableAllModelsRule(item.excludedModels)}
|
||||
disabled={toggleDisabled}
|
||||
onChange={(value) => void onToggle(index, value)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
@@ -125,6 +140,11 @@ export function VertexSection({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{configDisabled && (
|
||||
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
{t('ai_providers.config_disabled_badge')}
|
||||
</div>
|
||||
)}
|
||||
{item.models?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
<span className={styles.modelCountLabel}>
|
||||
@@ -140,6 +160,20 @@ export function VertexSection({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{excludedModels.length ? (
|
||||
<div className={styles.excludedModelsSection}>
|
||||
<div className={styles.excludedModelsLabel}>
|
||||
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||
</div>
|
||||
<div className={styles.modelTagList}>
|
||||
{excludedModels.map((model) => (
|
||||
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.cardStats}>
|
||||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
{t('stats.success')}: {stats.success}
|
||||
|
||||
@@ -18,11 +18,17 @@ export interface OpenAIFormState {
|
||||
apiKeyEntries: ApiKeyEntry[];
|
||||
}
|
||||
|
||||
export interface AmpcodeUpstreamApiKeyEntry {
|
||||
upstreamApiKey: string;
|
||||
clientApiKeysText: string;
|
||||
}
|
||||
|
||||
export interface AmpcodeFormState {
|
||||
upstreamUrl: string;
|
||||
upstreamApiKey: string;
|
||||
forceModelMappings: boolean;
|
||||
mappingEntries: ModelEntry[];
|
||||
upstreamApiKeyEntries: AmpcodeUpstreamApiKeyEntry[];
|
||||
}
|
||||
|
||||
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers' | 'models'> & {
|
||||
@@ -37,9 +43,10 @@ export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
|
||||
excludedText: string;
|
||||
};
|
||||
|
||||
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
|
||||
export type VertexFormState = Omit<ProviderKeyConfig, 'headers'> & {
|
||||
headers: HeaderEntry[];
|
||||
modelEntries: ModelEntry[];
|
||||
excludedText: string;
|
||||
};
|
||||
|
||||
export interface ProviderSectionProps<TConfig> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping, ApiKeyEntry } from '@/types';
|
||||
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
|
||||
import type { AmpcodeFormState, ModelEntry } from './types';
|
||||
import type { AmpcodeFormState, AmpcodeUpstreamApiKeyEntry, ModelEntry } from './types';
|
||||
|
||||
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||
|
||||
@@ -168,9 +168,43 @@ export const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMap
|
||||
return mappings;
|
||||
};
|
||||
|
||||
export const ampcodeUpstreamApiKeysToEntries = (
|
||||
mappings?: AmpcodeUpstreamApiKeyMapping[]
|
||||
): AmpcodeUpstreamApiKeyEntry[] => {
|
||||
if (!Array.isArray(mappings) || mappings.length === 0) {
|
||||
return [{ upstreamApiKey: '', clientApiKeysText: '' }];
|
||||
}
|
||||
|
||||
return mappings.map((mapping) => ({
|
||||
upstreamApiKey: mapping.upstreamApiKey ?? '',
|
||||
clientApiKeysText: Array.isArray(mapping.apiKeys) ? mapping.apiKeys.join('\n') : '',
|
||||
}));
|
||||
};
|
||||
|
||||
export const entriesToAmpcodeUpstreamApiKeys = (
|
||||
entries: AmpcodeUpstreamApiKeyEntry[]
|
||||
): AmpcodeUpstreamApiKeyMapping[] => {
|
||||
const seen = new Set<string>();
|
||||
const mappings: AmpcodeUpstreamApiKeyMapping[] = [];
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const upstreamApiKey = String(entry?.upstreamApiKey ?? '').trim();
|
||||
if (!upstreamApiKey || seen.has(upstreamApiKey)) return;
|
||||
|
||||
const apiKeys = Array.from(new Set(parseTextList(String(entry?.clientApiKeysText ?? ''))));
|
||||
if (!apiKeys.length) return;
|
||||
|
||||
seen.add(upstreamApiKey);
|
||||
mappings.push({ upstreamApiKey, apiKeys });
|
||||
});
|
||||
|
||||
return mappings;
|
||||
};
|
||||
|
||||
export const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
|
||||
upstreamUrl: ampcode?.upstreamUrl ?? '',
|
||||
upstreamApiKey: '',
|
||||
forceModelMappings: ampcode?.forceModelMappings ?? false,
|
||||
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings),
|
||||
upstreamApiKeyEntries: ampcodeUpstreamApiKeysToEntries(ampcode?.upstreamApiKeys),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
AntigravityQuotaState,
|
||||
AuthFileItem,
|
||||
ClaudeExtraUsage,
|
||||
ClaudeProfileResponse,
|
||||
ClaudeQuotaState,
|
||||
ClaudeQuotaWindow,
|
||||
ClaudeUsagePayload,
|
||||
@@ -19,22 +20,28 @@ import type {
|
||||
CodexUsageWindow,
|
||||
CodexQuotaWindow,
|
||||
CodexUsagePayload,
|
||||
GeminiCliCodeAssistPayload,
|
||||
GeminiCliCredits,
|
||||
GeminiCliParsedBucket,
|
||||
GeminiCliQuotaBucketState,
|
||||
GeminiCliQuotaState,
|
||||
GeminiCliUserTier,
|
||||
KimiQuotaRow,
|
||||
KimiQuotaState,
|
||||
} from '@/types';
|
||||
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import { useQuotaStore } from '@/stores';
|
||||
import {
|
||||
ANTIGRAVITY_QUOTA_URLS,
|
||||
ANTIGRAVITY_REQUEST_HEADERS,
|
||||
CLAUDE_PROFILE_URL,
|
||||
CLAUDE_USAGE_URL,
|
||||
CLAUDE_REQUEST_HEADERS,
|
||||
CLAUDE_USAGE_WINDOW_KEYS,
|
||||
CODEX_USAGE_URL,
|
||||
CODEX_REQUEST_HEADERS,
|
||||
GEMINI_CLI_QUOTA_URL,
|
||||
GEMINI_CLI_CODE_ASSIST_URL,
|
||||
GEMINI_CLI_REQUEST_HEADERS,
|
||||
KIMI_USAGE_URL,
|
||||
KIMI_REQUEST_HEADERS,
|
||||
@@ -47,6 +54,7 @@ import {
|
||||
parseClaudeUsagePayload,
|
||||
parseCodexUsagePayload,
|
||||
parseGeminiCliQuotaPayload,
|
||||
parseGeminiCliCodeAssistPayload,
|
||||
parseKimiUsagePayload,
|
||||
resolveCodexChatgptAccountId,
|
||||
resolveCodexPlanType,
|
||||
@@ -76,6 +84,11 @@ type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
|
||||
|
||||
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
|
||||
const geminiCliSupplementaryRequestIds = new Map<string, number>();
|
||||
const geminiCliSupplementaryCache = new Map<
|
||||
string,
|
||||
{ requestId: number; tierLabel: string | null; tierId: string | null; creditBalance: number | null }
|
||||
>();
|
||||
|
||||
export interface QuotaStore {
|
||||
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||
@@ -425,10 +438,181 @@ const fetchCodexQuota = async (
|
||||
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
|
||||
};
|
||||
|
||||
const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI';
|
||||
|
||||
const GEMINI_CLI_TIER_LABELS: Record<string, string> = {
|
||||
'free-tier': 'tier_free',
|
||||
'legacy-tier': 'tier_legacy',
|
||||
'standard-tier': 'tier_standard',
|
||||
'g1-pro-tier': 'tier_pro',
|
||||
'g1-ultra-tier': 'tier_ultra',
|
||||
};
|
||||
|
||||
const resolveGeminiCliTierLabel = (
|
||||
payload: GeminiCliCodeAssistPayload | null,
|
||||
t: TFunction
|
||||
): string | null => {
|
||||
if (!payload) return null;
|
||||
const currentTier: GeminiCliUserTier | null | undefined =
|
||||
payload.currentTier ?? payload.current_tier;
|
||||
const paidTier: GeminiCliUserTier | null | undefined =
|
||||
payload.paidTier ?? payload.paid_tier;
|
||||
const rawId = normalizeStringValue(paidTier?.id) ?? normalizeStringValue(currentTier?.id);
|
||||
if (!rawId) return null;
|
||||
const tierId = rawId.toLowerCase();
|
||||
const labelKey = GEMINI_CLI_TIER_LABELS[tierId];
|
||||
return labelKey ? t(`gemini_cli_quota.${labelKey}`) : rawId;
|
||||
};
|
||||
|
||||
const resolveGeminiCliTierId = (
|
||||
payload: GeminiCliCodeAssistPayload | null
|
||||
): string | null => {
|
||||
if (!payload) return null;
|
||||
const currentTier: GeminiCliUserTier | null | undefined =
|
||||
payload.currentTier ?? payload.current_tier;
|
||||
const paidTier: GeminiCliUserTier | null | undefined =
|
||||
payload.paidTier ?? payload.paid_tier;
|
||||
const rawId = normalizeStringValue(paidTier?.id) ?? normalizeStringValue(currentTier?.id);
|
||||
return rawId ? rawId.toLowerCase() : null;
|
||||
};
|
||||
|
||||
const resolveGeminiCliCreditBalance = (
|
||||
payload: GeminiCliCodeAssistPayload | null
|
||||
): number | null => {
|
||||
if (!payload) return null;
|
||||
const paidTier: GeminiCliUserTier | null | undefined =
|
||||
payload.paidTier ?? payload.paid_tier;
|
||||
const currentTier: GeminiCliUserTier | null | undefined =
|
||||
payload.currentTier ?? payload.current_tier;
|
||||
const tier = paidTier ?? currentTier;
|
||||
if (!tier) return null;
|
||||
const credits: GeminiCliCredits[] =
|
||||
tier.availableCredits ?? tier.available_credits ?? [];
|
||||
let total = 0;
|
||||
let found = false;
|
||||
for (const credit of credits) {
|
||||
const creditType = normalizeStringValue(credit.creditType ?? credit.credit_type);
|
||||
if (creditType !== GEMINI_CLI_G1_CREDIT_TYPE) continue;
|
||||
const amount = normalizeNumberValue(credit.creditAmount ?? credit.credit_amount);
|
||||
if (amount !== null) {
|
||||
total += amount;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
return found ? total : null;
|
||||
};
|
||||
|
||||
const fetchGeminiCliCodeAssist = async (
|
||||
authIndex: string,
|
||||
projectId: string,
|
||||
t: TFunction
|
||||
): Promise<{ tierLabel: string | null; tierId: string | null; creditBalance: number | null }> => {
|
||||
try {
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'POST',
|
||||
url: GEMINI_CLI_CODE_ASSIST_URL,
|
||||
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
||||
data: JSON.stringify({
|
||||
cloudaicompanionProject: projectId,
|
||||
metadata: {
|
||||
ideType: 'IDE_UNSPECIFIED',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
duetProject: projectId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
return { tierLabel: null, tierId: null, creditBalance: null };
|
||||
}
|
||||
|
||||
const payload = parseGeminiCliCodeAssistPayload(result.body ?? result.bodyText);
|
||||
return {
|
||||
tierLabel: resolveGeminiCliTierLabel(payload, t),
|
||||
tierId: resolveGeminiCliTierId(payload),
|
||||
creditBalance: resolveGeminiCliCreditBalance(payload),
|
||||
};
|
||||
} catch {
|
||||
return { tierLabel: null, tierId: null, creditBalance: null };
|
||||
}
|
||||
};
|
||||
|
||||
const readGeminiCliSupplementarySnapshot = (
|
||||
fileName: string,
|
||||
requestId: number
|
||||
): { tierLabel: string | null; tierId: string | null; creditBalance: number | null } => {
|
||||
const cached = geminiCliSupplementaryCache.get(fileName);
|
||||
if (!cached || cached.requestId !== requestId) {
|
||||
return { tierLabel: null, tierId: null, creditBalance: null };
|
||||
}
|
||||
|
||||
return {
|
||||
tierLabel: cached.tierLabel,
|
||||
tierId: cached.tierId,
|
||||
creditBalance: cached.creditBalance,
|
||||
};
|
||||
};
|
||||
|
||||
const scheduleGeminiCliSupplementaryRefresh = (
|
||||
fileName: string,
|
||||
authIndex: string,
|
||||
projectId: string,
|
||||
t: TFunction
|
||||
): number => {
|
||||
const requestId = (geminiCliSupplementaryRequestIds.get(fileName) ?? 0) + 1;
|
||||
geminiCliSupplementaryRequestIds.set(fileName, requestId);
|
||||
geminiCliSupplementaryCache.delete(fileName);
|
||||
|
||||
void (async () => {
|
||||
const supplementary = await fetchGeminiCliCodeAssist(authIndex, projectId, t);
|
||||
if (geminiCliSupplementaryRequestIds.get(fileName) !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
geminiCliSupplementaryCache.set(fileName, { requestId, ...supplementary });
|
||||
|
||||
useQuotaStore.getState().setGeminiCliQuota((prev) => {
|
||||
const current = prev[fileName];
|
||||
if (!current || current.status !== 'success') {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (
|
||||
current.tierLabel === supplementary.tierLabel &&
|
||||
current.tierId === supplementary.tierId &&
|
||||
current.creditBalance === supplementary.creditBalance
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[fileName]: {
|
||||
...current,
|
||||
tierLabel: supplementary.tierLabel,
|
||||
tierId: supplementary.tierId,
|
||||
creditBalance: supplementary.creditBalance,
|
||||
},
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
return requestId;
|
||||
};
|
||||
|
||||
const fetchGeminiCliQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<GeminiCliQuotaBucketState[]> => {
|
||||
): Promise<{
|
||||
fileName: string;
|
||||
supplementaryRequestId: number;
|
||||
buckets: GeminiCliQuotaBucketState[];
|
||||
tierLabel: string | null;
|
||||
tierId: string | null;
|
||||
creditBalance: number | null;
|
||||
}> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndex(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
@@ -440,21 +624,19 @@ const fetchGeminiCliQuota = async (
|
||||
throw new Error(t('gemini_cli_quota.missing_project_id'));
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
const quotaResponse = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'POST',
|
||||
url: GEMINI_CLI_QUOTA_URL,
|
||||
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
||||
data: JSON.stringify({ project: projectId }),
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||
if (quotaResponse.statusCode < 200 || quotaResponse.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(quotaResponse), quotaResponse.statusCode);
|
||||
}
|
||||
|
||||
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
|
||||
const payload = parseGeminiCliQuotaPayload(quotaResponse.body ?? quotaResponse.bodyText);
|
||||
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
|
||||
if (buckets.length === 0) return [];
|
||||
|
||||
const parsedBuckets = buckets
|
||||
.map((bucket) => {
|
||||
@@ -485,7 +667,26 @@ const fetchGeminiCliQuota = async (
|
||||
})
|
||||
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
||||
|
||||
return buildGeminiCliQuotaBuckets(parsedBuckets);
|
||||
const builtBuckets = buildGeminiCliQuotaBuckets(parsedBuckets);
|
||||
const supplementaryRequestId = scheduleGeminiCliSupplementaryRefresh(
|
||||
file.name,
|
||||
authIndex,
|
||||
projectId,
|
||||
t
|
||||
);
|
||||
const supplementarySnapshot = readGeminiCliSupplementarySnapshot(
|
||||
file.name,
|
||||
supplementaryRequestId
|
||||
);
|
||||
|
||||
return {
|
||||
fileName: file.name,
|
||||
supplementaryRequestId,
|
||||
buckets: builtBuckets,
|
||||
tierLabel: supplementarySnapshot.tierLabel,
|
||||
tierId: supplementarySnapshot.tierId,
|
||||
creditBalance: supplementarySnapshot.creditBalance,
|
||||
};
|
||||
};
|
||||
|
||||
const renderAntigravityItems = (
|
||||
@@ -525,6 +726,8 @@ const renderAntigravityItems = (
|
||||
});
|
||||
};
|
||||
|
||||
const PREMIUM_GEMINI_CLI_TIER_IDS = new Set(['g1-ultra-tier']);
|
||||
|
||||
const renderCodexItems = (
|
||||
quota: CodexQuotaState,
|
||||
t: TFunction,
|
||||
@@ -538,6 +741,7 @@ const renderCodexItems = (
|
||||
const getPlanLabel = (pt?: string | null): string | null => {
|
||||
const normalized = normalizePlanType(pt);
|
||||
if (!normalized) return null;
|
||||
if (normalized === 'pro') return t('codex_quota.plan_pro');
|
||||
if (normalized === 'plus') return t('codex_quota.plan_plus');
|
||||
if (normalized === 'team') return t('codex_quota.plan_team');
|
||||
if (normalized === 'free') return t('codex_quota.plan_free');
|
||||
@@ -545,15 +749,17 @@ const renderCodexItems = (
|
||||
};
|
||||
|
||||
const planLabel = getPlanLabel(planType);
|
||||
const isPremiumPlan = normalizePlanType(planType) === 'pro';
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (planLabel) {
|
||||
const valueClass = isPremiumPlan ? styleMap.premiumPlanValue : styleMap.codexPlanValue;
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'plan', className: styleMap.codexPlan },
|
||||
h('span', { className: styleMap.codexPlanLabel }, t('codex_quota.plan_label')),
|
||||
h('span', { className: styleMap.codexPlanValue }, planLabel)
|
||||
h('span', { className: valueClass }, planLabel)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -603,50 +809,89 @@ const renderGeminiCliItems = (
|
||||
helpers: QuotaRenderHelpers
|
||||
): ReactNode => {
|
||||
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||
const { createElement: h } = React;
|
||||
const { createElement: h, Fragment } = React;
|
||||
const buckets = quota.buckets ?? [];
|
||||
const tierLabel = quota.tierLabel ?? null;
|
||||
const tierId = quota.tierId ?? null;
|
||||
const creditBalance = quota.creditBalance ?? null;
|
||||
const isPremiumTier = tierId !== null && PREMIUM_GEMINI_CLI_TIER_IDS.has(tierId);
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (buckets.length === 0) {
|
||||
return h('div', { className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'));
|
||||
}
|
||||
|
||||
return buckets.map((bucket) => {
|
||||
const fraction = bucket.remainingFraction;
|
||||
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
|
||||
const percent = clamped === null ? null : Math.round(clamped * 100);
|
||||
const percentLabel = percent === null ? '--' : `${percent}%`;
|
||||
const remainingAmountLabel =
|
||||
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
||||
? null
|
||||
: t('gemini_cli_quota.remaining_amount', {
|
||||
count: bucket.remainingAmount,
|
||||
});
|
||||
const titleBase =
|
||||
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
|
||||
const title = bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase;
|
||||
|
||||
const resetLabel = formatQuotaResetTime(bucket.resetTime);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ key: bucket.id, className: styleMap.quotaRow },
|
||||
if (tierLabel) {
|
||||
const valueClass = isPremiumTier ? styleMap.premiumPlanValue : styleMap.codexPlanValue;
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel, title }, bucket.label),
|
||||
{ key: 'tier', className: styleMap.codexPlan },
|
||||
h('span', { className: styleMap.codexPlanLabel }, t('gemini_cli_quota.tier_label')),
|
||||
h('span', { className: valueClass }, tierLabel)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (creditBalance !== null) {
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'credits', className: styleMap.codexPlan },
|
||||
h('span', { className: styleMap.codexPlanLabel }, t('gemini_cli_quota.credit_label')),
|
||||
h(
|
||||
'span',
|
||||
{ className: styleMap.codexPlanValue },
|
||||
t('gemini_cli_quota.credit_amount', { count: creditBalance })
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (buckets.length === 0) {
|
||||
nodes.push(
|
||||
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'))
|
||||
);
|
||||
return h(Fragment, null, ...nodes);
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
...buckets.map((bucket) => {
|
||||
const fraction = bucket.remainingFraction;
|
||||
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
|
||||
const percent = clamped === null ? null : Math.round(clamped * 100);
|
||||
const percentLabel = percent === null ? '--' : `${percent}%`;
|
||||
const remainingAmountLabel =
|
||||
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
||||
? null
|
||||
: t('gemini_cli_quota.remaining_amount', {
|
||||
count: bucket.remainingAmount,
|
||||
});
|
||||
const titleBase =
|
||||
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
|
||||
const title = bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase;
|
||||
|
||||
const resetLabel = formatQuotaResetTime(bucket.resetTime);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ key: bucket.id, className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, percentLabel),
|
||||
remainingAmountLabel
|
||||
? h('span', { className: styleMap.quotaAmount }, remainingAmountLabel)
|
||||
: null,
|
||||
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||
);
|
||||
});
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel, title }, bucket.label),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, percentLabel),
|
||||
remainingAmountLabel
|
||||
? h('span', { className: styleMap.quotaAmount }, remainingAmountLabel)
|
||||
: null,
|
||||
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return h(Fragment, null, ...nodes);
|
||||
};
|
||||
|
||||
const buildClaudeQuotaWindows = (
|
||||
@@ -673,22 +918,79 @@ const buildClaudeQuotaWindows = (
|
||||
return windows;
|
||||
};
|
||||
|
||||
const normalizeFlagValue = (value: unknown): boolean | undefined => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (['true', '1', 'yes', 'y', 'on'].includes(trimmed)) return true;
|
||||
if (['false', '0', 'no', 'n', 'off'].includes(trimmed)) return false;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parseClaudeProfilePayload = (payload: unknown): ClaudeProfileResponse | null => {
|
||||
if (payload === undefined || payload === null) return null;
|
||||
if (typeof payload === 'string') {
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as ClaudeProfileResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof payload === 'object') {
|
||||
return payload as ClaudeProfileResponse;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveClaudePlanType = (profile: ClaudeProfileResponse | null): string | null => {
|
||||
if (!profile) return null;
|
||||
|
||||
const hasClaudeMax = normalizeFlagValue(profile.account?.has_claude_max);
|
||||
if (hasClaudeMax) return 'plan_max';
|
||||
|
||||
const hasClaudePro = normalizeFlagValue(profile.account?.has_claude_pro);
|
||||
if (hasClaudePro) return 'plan_pro';
|
||||
|
||||
if (hasClaudeMax === false && hasClaudePro === false) return 'plan_free';
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const fetchClaudeQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => {
|
||||
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null; planType?: string | null }> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndex(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('claude_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: CLAUDE_USAGE_URL,
|
||||
header: { ...CLAUDE_REQUEST_HEADERS },
|
||||
});
|
||||
const [usageResult, profileResult] = await Promise.allSettled([
|
||||
apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: CLAUDE_USAGE_URL,
|
||||
header: { ...CLAUDE_REQUEST_HEADERS },
|
||||
}),
|
||||
apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: CLAUDE_PROFILE_URL,
|
||||
header: { ...CLAUDE_REQUEST_HEADERS },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (usageResult.status === 'rejected') {
|
||||
throw usageResult.reason;
|
||||
}
|
||||
|
||||
const result = usageResult.value;
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||
@@ -700,7 +1002,16 @@ const fetchClaudeQuota = async (
|
||||
}
|
||||
|
||||
const windows = buildClaudeQuotaWindows(payload, t);
|
||||
return { windows, extraUsage: payload.extra_usage };
|
||||
const planType =
|
||||
profileResult.status === 'fulfilled' &&
|
||||
profileResult.value.statusCode >= 200 &&
|
||||
profileResult.value.statusCode < 300
|
||||
? resolveClaudePlanType(
|
||||
parseClaudeProfilePayload(profileResult.value.body ?? profileResult.value.bodyText)
|
||||
)
|
||||
: null;
|
||||
|
||||
return { windows, extraUsage: payload.extra_usage, planType };
|
||||
};
|
||||
|
||||
const renderClaudeItems = (
|
||||
@@ -712,8 +1023,20 @@ const renderClaudeItems = (
|
||||
const { createElement: h, Fragment } = React;
|
||||
const windows = quota.windows ?? [];
|
||||
const extraUsage = quota.extraUsage ?? null;
|
||||
const planType = quota.planType ?? null;
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (planType) {
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'plan', className: styleMap.codexPlan },
|
||||
h('span', { className: styleMap.codexPlanLabel }, t('claude_quota.plan_label')),
|
||||
h('span', { className: styleMap.codexPlanValue }, t(`claude_quota.${planType}`))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (extraUsage && extraUsage.is_enabled) {
|
||||
const usedLabel = `$${(extraUsage.used_credits / 100).toFixed(2)} / $${(extraUsage.monthly_limit / 100).toFixed(2)}`;
|
||||
nodes.push(
|
||||
@@ -765,7 +1088,7 @@ const renderClaudeItems = (
|
||||
|
||||
export const CLAUDE_CONFIG: QuotaConfig<
|
||||
ClaudeQuotaState,
|
||||
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }
|
||||
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null; planType?: string | null }
|
||||
> = {
|
||||
type: 'claude',
|
||||
i18nPrefix: 'claude_quota',
|
||||
@@ -779,6 +1102,7 @@ export const CLAUDE_CONFIG: QuotaConfig<
|
||||
status: 'success',
|
||||
windows: data.windows,
|
||||
extraUsage: data.extraUsage,
|
||||
planType: data.planType,
|
||||
}),
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
@@ -846,7 +1170,17 @@ export const CODEX_CONFIG: QuotaConfig<
|
||||
renderQuotaItems: renderCodexItems,
|
||||
};
|
||||
|
||||
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
||||
export const GEMINI_CLI_CONFIG: QuotaConfig<
|
||||
GeminiCliQuotaState,
|
||||
{
|
||||
fileName: string;
|
||||
supplementaryRequestId: number;
|
||||
buckets: GeminiCliQuotaBucketState[];
|
||||
tierLabel: string | null;
|
||||
tierId: string | null;
|
||||
creditBalance: number | null;
|
||||
}
|
||||
> = {
|
||||
type: 'gemini-cli',
|
||||
i18nPrefix: 'gemini_cli_quota',
|
||||
cardIdleMessageKey: 'quota_management.card_idle_hint',
|
||||
@@ -855,8 +1189,21 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaB
|
||||
fetchQuota: fetchGeminiCliQuota,
|
||||
storeSelector: (state) => state.geminiCliQuota,
|
||||
storeSetter: 'setGeminiCliQuota',
|
||||
buildLoadingState: () => ({ status: 'loading', buckets: [] }),
|
||||
buildSuccessState: (buckets) => ({ status: 'success', buckets }),
|
||||
buildLoadingState: () => ({ status: 'loading', buckets: [], tierLabel: null, tierId: null, creditBalance: null }),
|
||||
buildSuccessState: (data) => {
|
||||
const supplementarySnapshot = readGeminiCliSupplementarySnapshot(
|
||||
data.fileName,
|
||||
data.supplementaryRequestId
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
buckets: data.buckets,
|
||||
tierLabel: supplementarySnapshot.tierLabel ?? data.tierLabel,
|
||||
tierId: supplementarySnapshot.tierId ?? data.tierId,
|
||||
creditBalance: supplementarySnapshot.creditBalance ?? data.creditBalance,
|
||||
};
|
||||
},
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
buckets: [],
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.box {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
|
||||
color: var(--primary-contrast, #fff);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
border-color $transition-fast,
|
||||
background-color $transition-fast,
|
||||
box-shadow $transition-fast,
|
||||
transform $transition-fast;
|
||||
}
|
||||
|
||||
.root:hover .box {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent);
|
||||
}
|
||||
|
||||
.root:active .box {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.disabled:hover .box {
|
||||
border-color: var(--border-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.disabled:active .box {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.input:focus-visible + .box {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent),
|
||||
0 0 0 1px color-mix(in srgb, var(--primary-color) 50%, transparent);
|
||||
}
|
||||
|
||||
.boxChecked {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.boxChecked svg {
|
||||
display: block;
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ChangeEvent, ReactNode } from 'react';
|
||||
import { IconCheck } from './icons';
|
||||
import styles from './SelectionCheckbox.module.scss';
|
||||
|
||||
interface SelectionCheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label?: ReactNode;
|
||||
ariaLabel?: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
export function SelectionCheckbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
ariaLabel,
|
||||
title,
|
||||
disabled = false,
|
||||
className,
|
||||
labelClassName,
|
||||
}: SelectionCheckboxProps) {
|
||||
const rootClassName = [styles.root, disabled ? styles.disabled : '', className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const boxClassName = [styles.box, checked ? styles.boxChecked : ''].filter(Boolean).join(' ');
|
||||
const textClassName = [styles.label, labelClassName].filter(Boolean).join(' ');
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(event.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={rootClassName} title={title}>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={boxClassName}>{checked ? <IconCheck size={12} /> : null}</span>
|
||||
{label ? <span className={textClassName}>{label}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
+118
-1
@@ -16,7 +16,15 @@ const baseSvgProps: SVGProps<SVGSVGElement> = {
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': 'true',
|
||||
focusable: 'false'
|
||||
focusable: 'false',
|
||||
};
|
||||
|
||||
const sidebarSvgProps: SVGProps<SVGSVGElement> = {
|
||||
...baseSvgProps,
|
||||
strokeWidth: 1.72,
|
||||
strokeLinecap: 'square',
|
||||
strokeLinejoin: 'miter',
|
||||
strokeMiterlimit: 10,
|
||||
};
|
||||
|
||||
export function IconSlidersHorizontal({ size = 20, ...props }: IconProps) {
|
||||
@@ -322,3 +330,112 @@ export function IconLayoutDashboard({ size = 20, ...props }: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarDashboard({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<rect x="3" y="3" width="7.5" height="8" rx="1.5" />
|
||||
<rect x="13.5" y="3" width="7.5" height="5" rx="1.5" fill="currentColor" fillOpacity="0.12" />
|
||||
<rect x="3" y="14" width="7.5" height="7" rx="1.5" fill="currentColor" fillOpacity="0.12" />
|
||||
<rect x="13.5" y="11" width="7.5" height="10" rx="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarConfig({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M4 8h16" />
|
||||
<path d="M4 16h16" />
|
||||
<circle cx="9.5" cy="8" r="2.8" fill="currentColor" fillOpacity="0.12" />
|
||||
<circle cx="15" cy="16" r="2.8" fill="currentColor" fillOpacity="0.12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarProviders({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<circle cx="12" cy="5.5" r="2.8" fill="currentColor" fillOpacity="0.12" />
|
||||
<circle cx="5.5" cy="18.5" r="2.8" />
|
||||
<circle cx="18.5" cy="18.5" r="2.8" />
|
||||
<path d="M10.2 7.8 7 16.2" />
|
||||
<path d="M13.8 7.8 17 16.2" />
|
||||
<path d="M8.3 18.5h7.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarAuthFiles({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M7 3h7l4 4v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" />
|
||||
<path d="M14 3v4h4" fill="currentColor" fillOpacity="0.12" />
|
||||
<path d="M9 13l2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarOauth({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<path
|
||||
d="M12 3l8 4v5c0 5.25-3.4 8.25-8 10-4.6-1.75-8-4.75-8-10V7Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.08"
|
||||
/>
|
||||
<circle cx="12" cy="11" r="1.5" fill="currentColor" stroke="none" />
|
||||
<path d="M12 12.5v2.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarQuota({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<path d="M12 12V4a8 8 0 0 1 8 8Z" fill="currentColor" fillOpacity="0.12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarUsage({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M3.5 20h17" />
|
||||
<rect x="5" y="13" width="3.5" height="7" rx="0.5" />
|
||||
<rect x="10.25" y="7" width="3.5" height="13" rx="0.5" fill="currentColor" fillOpacity="0.12" />
|
||||
<rect x="15.5" y="10" width="3.5" height="10" rx="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarLogs({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M3 8.5h18" />
|
||||
<circle cx="5.5" cy="6.2" r="0.8" fill="currentColor" stroke="none" />
|
||||
<circle cx="7.8" cy="6.2" r="0.8" fill="currentColor" fillOpacity="0.4" stroke="none" />
|
||||
<path d="M7 12l3 2.5-3 2.5" />
|
||||
<path d="M13 17h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarSystem({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" fillOpacity="0.12" />
|
||||
<path d="M6 10H3" />
|
||||
<path d="M6 14H3" />
|
||||
<path d="M21 10h-3" />
|
||||
<path d="M21 14h-3" />
|
||||
<path d="M10 6V3" />
|
||||
<path d="M14 6V3" />
|
||||
<path d="M10 21v-3" />
|
||||
<path d="M14 21v-3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import {
|
||||
IconBot,
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconDownload,
|
||||
IconInfo,
|
||||
@@ -18,9 +18,11 @@ import { formatFileSize } from '@/utils/format';
|
||||
import {
|
||||
QUOTA_PROVIDER_TYPES,
|
||||
formatModified,
|
||||
getAuthFileStatusMessage,
|
||||
getTypeColor,
|
||||
getTypeLabel,
|
||||
isRuntimeOnlyAuthFile,
|
||||
parsePriorityValue,
|
||||
resolveAuthFileStats,
|
||||
type QuotaProviderType,
|
||||
type ResolvedTheme,
|
||||
@@ -91,6 +93,8 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const providerCardClass =
|
||||
quotaType === 'antigravity'
|
||||
? styles.antigravityCard
|
||||
: quotaType === 'claude'
|
||||
? styles.claudeCard
|
||||
: quotaType === 'codex'
|
||||
? styles.codexCard
|
||||
: quotaType === 'gemini-cli'
|
||||
@@ -103,10 +107,13 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
|
||||
const statusData =
|
||||
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
||||
const rawStatusMessage = String(file['status_message'] ?? file.statusMessage ?? '').trim();
|
||||
const rawStatusMessage = getAuthFileStatusMessage(file);
|
||||
const hasStatusWarning =
|
||||
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(rawStatusMessage.toLowerCase());
|
||||
|
||||
const priorityValue = parsePriorityValue(file.priority ?? file['priority']);
|
||||
const noteValue = typeof file.note === 'string' ? file.note.trim() : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.fileCard} ${providerCardClass} ${selected ? styles.fileCardSelected : ''} ${file.disabled ? styles.fileCardDisabled : ''}`}
|
||||
@@ -115,18 +122,14 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
<div className={styles.fileCardMain}>
|
||||
<div className={styles.cardHeader}>
|
||||
{!isRuntimeOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.selectionToggle} ${selected ? styles.selectionToggleActive : ''}`}
|
||||
onClick={() => onToggleSelect(file.name)}
|
||||
<SelectionCheckbox
|
||||
checked={selected}
|
||||
onChange={() => onToggleSelect(file.name)}
|
||||
aria-label={
|
||||
selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')
|
||||
}
|
||||
aria-pressed={selected}
|
||||
title={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
|
||||
>
|
||||
{selected && <IconCheck size={12} />}
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={styles.typeBadge}
|
||||
@@ -148,8 +151,20 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
<span>
|
||||
{t('auth_files.file_modified')}: {formatModified(file)}
|
||||
</span>
|
||||
{priorityValue !== undefined && (
|
||||
<span className={styles.priorityBadge}>
|
||||
{t('auth_files.priority_display')}: <span className={styles.priorityValue}>{priorityValue}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{noteValue && (
|
||||
<div className={styles.noteText} title={noteValue}>
|
||||
<span className={styles.noteLabel}>{t('auth_files.note_display')}: </span>
|
||||
{noteValue}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rawStatusMessage && hasStatusWarning && (
|
||||
<div className={styles.healthStatusMessage} title={rawStatusMessage}>
|
||||
{rawStatusMessage}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useCallback, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG, KIMI_CONFIG } from '@/components/quota';
|
||||
import {
|
||||
ANTIGRAVITY_CONFIG,
|
||||
CLAUDE_CONFIG,
|
||||
CODEX_CONFIG,
|
||||
GEMINI_CLI_CONFIG,
|
||||
KIMI_CONFIG
|
||||
} from '@/components/quota';
|
||||
import { useNotificationStore, useQuotaStore } from '@/stores';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { getStatusFromError } from '@/utils/quota';
|
||||
@@ -17,6 +23,7 @@ type QuotaState = { status?: string; error?: string; errorStatus?: number } | un
|
||||
|
||||
const getQuotaConfig = (type: QuotaProviderType) => {
|
||||
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
|
||||
if (type === 'claude') return CLAUDE_CONFIG;
|
||||
if (type === 'codex') return CODEX_CONFIG;
|
||||
if (type === 'kimi') return KIMI_CONFIG;
|
||||
return GEMINI_CLI_CONFIG;
|
||||
@@ -35,6 +42,7 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
|
||||
|
||||
const quota = useQuotaStore((state) => {
|
||||
if (quotaType === 'antigravity') return state.antigravityQuota[file.name] as QuotaState;
|
||||
if (quotaType === 'claude') return state.claudeQuota[file.name] as QuotaState;
|
||||
if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaState;
|
||||
if (quotaType === 'kimi') return state.kimiQuota[file.name] as QuotaState;
|
||||
return state.geminiCliQuota[file.name] as QuotaState;
|
||||
@@ -42,6 +50,7 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
|
||||
|
||||
const updateQuotaState = useQuotaStore((state) => {
|
||||
if (quotaType === 'antigravity') return state.setAntigravityQuota as unknown as (updater: unknown) => void;
|
||||
if (quotaType === 'claude') return state.setClaudeQuota as unknown as (updater: unknown) => void;
|
||||
if (quotaType === 'codex') return state.setCodexQuota as unknown as (updater: unknown) => void;
|
||||
if (quotaType === 'kimi') return state.setKimiQuota as unknown as (updater: unknown) => void;
|
||||
return state.setGeminiCliQuota as unknown as (updater: unknown) => void;
|
||||
|
||||
@@ -114,6 +114,14 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
onChange={(e) => onChange('disableCooling', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('auth_files.note_label')}
|
||||
value={editor.note}
|
||||
placeholder={t('auth_files.note_placeholder')}
|
||||
hint={t('auth_files.note_hint')}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
onChange={(e) => onChange('note', e.target.value)}
|
||||
/>
|
||||
{editor.isCodexFile && (
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.codex_websockets_label')}</label>
|
||||
|
||||
@@ -12,9 +12,15 @@ export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
export type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
|
||||
|
||||
export type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli' | 'kimi';
|
||||
export type QuotaProviderType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
|
||||
|
||||
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli', 'kimi']);
|
||||
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>([
|
||||
'antigravity',
|
||||
'claude',
|
||||
'codex',
|
||||
'gemini-cli',
|
||||
'kimi'
|
||||
]);
|
||||
|
||||
export const MIN_CARD_PAGE_SIZE = 3;
|
||||
export const MAX_CARD_PAGE_SIZE = 30;
|
||||
@@ -87,6 +93,16 @@ export const resolveQuotaErrorMessage = (
|
||||
|
||||
export const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
export const getAuthFileStatusMessage = (file: AuthFileItem): string => {
|
||||
const raw = file['status_message'] ?? file.statusMessage;
|
||||
if (typeof raw === 'string') return raw.trim();
|
||||
if (raw == null) return '';
|
||||
return String(raw).trim();
|
||||
};
|
||||
|
||||
export const hasAuthFileStatusMessage = (file: AuthFileItem): boolean =>
|
||||
getAuthFileStatusMessage(file).length > 0;
|
||||
|
||||
export const getTypeLabel = (t: TFunction, type: string): string => {
|
||||
const key = `auth_files.filter_${type}`;
|
||||
const translated = t(key);
|
||||
|
||||
@@ -7,11 +7,17 @@ import type { AuthFileItem } from '@/types';
|
||||
import { formatFileSize } from '@/utils/format';
|
||||
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
|
||||
import { downloadBlob } from '@/utils/download';
|
||||
import { getTypeLabel, isRuntimeOnlyAuthFile } from '@/features/authFiles/constants';
|
||||
import {
|
||||
getTypeLabel,
|
||||
hasAuthFileStatusMessage,
|
||||
isRuntimeOnlyAuthFile,
|
||||
} from '@/features/authFiles/constants';
|
||||
|
||||
type DeleteAllOptions = {
|
||||
filter: string;
|
||||
problemOnly: boolean;
|
||||
onResetFilterToAll: () => void;
|
||||
onResetProblemOnly: () => void;
|
||||
};
|
||||
|
||||
export type UseAuthFilesDataResult = {
|
||||
@@ -59,7 +65,6 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const selectionCount = selectedFiles.size;
|
||||
|
||||
const toggleSelect = useCallback((name: string) => {
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -223,12 +228,17 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
|
||||
const handleDeleteAll = useCallback(
|
||||
(deleteAllOptions: DeleteAllOptions) => {
|
||||
const { filter, onResetFilterToAll } = deleteAllOptions;
|
||||
const { filter, problemOnly, onResetFilterToAll, onResetProblemOnly } = deleteAllOptions;
|
||||
const isFiltered = filter !== 'all';
|
||||
const isProblemOnly = problemOnly === true;
|
||||
const typeLabel = isFiltered ? getTypeLabel(t, filter) : t('auth_files.filter_all');
|
||||
const confirmMessage = isFiltered
|
||||
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
|
||||
: t('auth_files.delete_all_confirm');
|
||||
const confirmMessage = isProblemOnly
|
||||
? isFiltered
|
||||
? t('auth_files.delete_problem_filtered_confirm', { type: typeLabel })
|
||||
: t('auth_files.delete_problem_confirm')
|
||||
: isFiltered
|
||||
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
|
||||
: t('auth_files.delete_all_confirm');
|
||||
|
||||
showConfirmation({
|
||||
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
|
||||
@@ -238,18 +248,26 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
onConfirm: async () => {
|
||||
setDeletingAll(true);
|
||||
try {
|
||||
if (!isFiltered) {
|
||||
if (!isFiltered && !isProblemOnly) {
|
||||
await authFilesApi.deleteAll();
|
||||
showNotification(t('auth_files.delete_all_success'), 'success');
|
||||
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
|
||||
deselectAll();
|
||||
} else {
|
||||
const filesToDelete = files.filter(
|
||||
(f) => f.type === filter && !isRuntimeOnlyAuthFile(f)
|
||||
);
|
||||
const filesToDelete = files.filter((file) => {
|
||||
if (isRuntimeOnlyAuthFile(file)) return false;
|
||||
if (isFiltered && file.type !== filter) return false;
|
||||
if (isProblemOnly && !hasAuthFileStatusMessage(file)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (filesToDelete.length === 0) {
|
||||
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
|
||||
const emptyMessage = isProblemOnly
|
||||
? isFiltered
|
||||
? t('auth_files.delete_problem_filtered_none', { type: typeLabel })
|
||||
: t('auth_files.delete_problem_none')
|
||||
: t('auth_files.delete_filtered_none', { type: typeLabel });
|
||||
showNotification(emptyMessage, 'info');
|
||||
setDeletingAll(false);
|
||||
return;
|
||||
}
|
||||
@@ -284,18 +302,45 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
if (failed === 0) {
|
||||
if (failed === 0 && isProblemOnly) {
|
||||
showNotification(
|
||||
isFiltered
|
||||
? t('auth_files.delete_problem_filtered_success', {
|
||||
count: success,
|
||||
type: typeLabel,
|
||||
})
|
||||
: t('auth_files.delete_problem_success', { count: success }),
|
||||
'success'
|
||||
);
|
||||
} else if (failed === 0) {
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
|
||||
'success'
|
||||
);
|
||||
} else if (isProblemOnly) {
|
||||
showNotification(
|
||||
isFiltered
|
||||
? t('auth_files.delete_problem_filtered_partial', {
|
||||
success,
|
||||
failed,
|
||||
type: typeLabel,
|
||||
})
|
||||
: t('auth_files.delete_problem_partial', { success, failed }),
|
||||
'warning'
|
||||
);
|
||||
} else {
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
onResetFilterToAll();
|
||||
|
||||
if (isFiltered) {
|
||||
onResetFilterToAll();
|
||||
}
|
||||
if (isProblemOnly) {
|
||||
onResetProblemOnly();
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
|
||||
@@ -18,7 +18,8 @@ export type PrefixProxyEditorField =
|
||||
| 'priority'
|
||||
| 'excludedModelsText'
|
||||
| 'disableCooling'
|
||||
| 'websocket';
|
||||
| 'websocket'
|
||||
| 'note';
|
||||
|
||||
export type PrefixProxyEditorFieldValue = string | boolean;
|
||||
|
||||
@@ -37,6 +38,8 @@ export type PrefixProxyEditorState = {
|
||||
excludedModelsText: string;
|
||||
disableCooling: string;
|
||||
websocket: boolean;
|
||||
note: string;
|
||||
noteTouched: boolean;
|
||||
};
|
||||
|
||||
export type UseAuthFilesPrefixProxyEditorOptions = {
|
||||
@@ -93,6 +96,15 @@ const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): str
|
||||
next.websocket = editor.websocket;
|
||||
}
|
||||
|
||||
if (editor.noteTouched) {
|
||||
const noteValue = editor.note.trim();
|
||||
if (noteValue) {
|
||||
next.note = editor.note;
|
||||
} else if ('note' in next) {
|
||||
delete next.note;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(next);
|
||||
};
|
||||
|
||||
@@ -146,6 +158,8 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
excludedModelsText: '',
|
||||
disableCooling: '',
|
||||
websocket: false,
|
||||
note: '',
|
||||
noteTouched: false,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -195,6 +209,7 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
const excludedModels = normalizeExcludedModels(json.excluded_models);
|
||||
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
|
||||
const websocketValue = parseDisableCoolingValue(json.websocket);
|
||||
const note = typeof json.note === 'string' ? json.note : '';
|
||||
|
||||
setPrefixProxyEditor((prev) => {
|
||||
if (!prev || prev.fileName !== name) return prev;
|
||||
@@ -211,6 +226,8 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
disableCooling:
|
||||
disableCoolingValue === undefined ? '' : disableCoolingValue ? 'true' : 'false',
|
||||
websocket: websocketValue ?? false,
|
||||
note,
|
||||
noteTouched: false,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
@@ -235,6 +252,7 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
if (field === 'priority') return { ...prev, priority: String(value) };
|
||||
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: String(value) };
|
||||
if (field === 'disableCooling') return { ...prev, disableCooling: String(value) };
|
||||
if (field === 'note') return { ...prev, note: String(value), noteTouched: true };
|
||||
return { ...prev, websocket: Boolean(value) };
|
||||
});
|
||||
};
|
||||
@@ -260,8 +278,7 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
});
|
||||
|
||||
try {
|
||||
const file = new File([payload], name, { type: 'application/json' });
|
||||
await authFilesApi.upload(file);
|
||||
await authFilesApi.saveText(name, payload);
|
||||
showNotification(t('auth_files.prefix_proxy_saved_success', { name }), 'success');
|
||||
await loadFiles();
|
||||
await loadKeyStats();
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
export const AUTH_FILES_SORT_MODES = ['default', 'az', 'priority'] as const;
|
||||
|
||||
export type AuthFilesSortMode = (typeof AUTH_FILES_SORT_MODES)[number];
|
||||
|
||||
export type AuthFilesUiState = {
|
||||
filter?: string;
|
||||
problemOnly?: boolean;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortMode?: AuthFilesSortMode;
|
||||
};
|
||||
|
||||
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
|
||||
const AUTH_FILES_SORT_MODE_SET = new Set<AuthFilesSortMode>(AUTH_FILES_SORT_MODES);
|
||||
|
||||
export const isAuthFilesSortMode = (value: unknown): value is AuthFilesSortMode =>
|
||||
typeof value === 'string' && AUTH_FILES_SORT_MODE_SET.has(value as AuthFilesSortMode);
|
||||
|
||||
export const readAuthFilesUiState = (): AuthFilesUiState | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
@@ -27,4 +37,3 @@ export const writeAuthFilesUiState = (state: AuthFilesUiState) => {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -342,18 +342,17 @@
|
||||
"vertex_add_modal_title": "Add Vertex API Configuration",
|
||||
"vertex_add_modal_key_label": "API Key:",
|
||||
"vertex_add_modal_key_placeholder": "Please enter Vertex API key",
|
||||
"vertex_add_modal_url_label": "Base URL (Required):",
|
||||
"vertex_add_modal_url_label": "Base URL:",
|
||||
"vertex_add_modal_url_placeholder": "e.g.: https://example.com/api",
|
||||
"vertex_add_modal_proxy_label": "Proxy URL (Optional):",
|
||||
"vertex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
|
||||
"vertex_edit_modal_title": "Edit Vertex API Configuration",
|
||||
"vertex_edit_modal_key_label": "API Key:",
|
||||
"vertex_edit_modal_url_label": "Base URL (Required):",
|
||||
"vertex_edit_modal_url_label": "Base URL:",
|
||||
"vertex_edit_modal_proxy_label": "Proxy URL (Optional):",
|
||||
"vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?",
|
||||
"vertex_models_label": "Model aliases (alias required):",
|
||||
"vertex_models_label": "Model aliases:",
|
||||
"vertex_models_add_btn": "Add Mapping",
|
||||
"vertex_models_hint": "Each alias needs both the original model and the alias.",
|
||||
"vertex_models_count": "Alias count",
|
||||
"ampcode_title": "Amp CLI Integration (ampcode)",
|
||||
"ampcode_modal_title": "Configure Ampcode",
|
||||
@@ -366,6 +365,13 @@
|
||||
"ampcode_upstream_api_key_current": "Current Amp official key: {{key}}",
|
||||
"ampcode_clear_upstream_api_key": "Clear official key",
|
||||
"ampcode_clear_upstream_api_key_confirm": "Are you sure you want to clear the Ampcode upstream API key (Amp official)?",
|
||||
"ampcode_upstream_api_keys_label": "Multi-upstream API key routing",
|
||||
"ampcode_upstream_api_keys_hint": "Bind different Amp upstream API keys to specific client API keys. Client keys can be separated by commas or new lines.",
|
||||
"ampcode_upstream_api_keys_add_btn": "Add upstream mapping",
|
||||
"ampcode_upstream_api_keys_upstream_placeholder": "Upstream API key (sk-amp-...)",
|
||||
"ampcode_upstream_api_keys_clients_placeholder": "Client API keys, separated by commas or new lines",
|
||||
"ampcode_upstream_api_keys_item_title": "Upstream mapping #{{index}}",
|
||||
"ampcode_upstream_api_keys_count": "Upstream mappings",
|
||||
"ampcode_force_model_mappings_label": "Force model mappings",
|
||||
"ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.",
|
||||
"ampcode_model_mappings_label": "Model mappings (from → to)",
|
||||
@@ -374,6 +380,8 @@
|
||||
"ampcode_model_mappings_from_placeholder": "from model (source)",
|
||||
"ampcode_model_mappings_to_placeholder": "to model (target)",
|
||||
"ampcode_model_mappings_count": "Mappings Count",
|
||||
"ampcode_lists_overwrite_title": "Overwrite list settings",
|
||||
"ampcode_lists_overwrite_confirm": "Existing multi-upstream/model mapping lists could not be loaded. Continuing may overwrite or clear them. Continue?",
|
||||
"ampcode_mappings_overwrite_confirm": "Existing mappings could not be loaded. Continuing may overwrite or clear them. Continue?",
|
||||
"openai_title": "OpenAI Compatible Providers",
|
||||
"openai_add_button": "Add Provider",
|
||||
@@ -460,6 +468,10 @@
|
||||
"delete_confirm": "Are you sure you want to delete file",
|
||||
"delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!",
|
||||
"delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!",
|
||||
"delete_problem_button": "Delete Problem Files",
|
||||
"delete_problem_button_with_type": "Delete Problematic {{type}} Files",
|
||||
"delete_problem_confirm": "Are you sure you want to delete all problematic auth files? This operation cannot be undone!",
|
||||
"delete_problem_filtered_confirm": "Are you sure you want to delete all problematic {{type}} auth files? This operation cannot be undone!",
|
||||
"upload_error_json": "Only JSON files are allowed",
|
||||
"upload_error_size": "File size cannot exceed {{maxSize}}",
|
||||
"upload_success": "File uploaded successfully",
|
||||
@@ -469,12 +481,25 @@
|
||||
"delete_filtered_success": "Deleted {{count}} {{type}} auth files successfully",
|
||||
"delete_filtered_partial": "{{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed",
|
||||
"delete_filtered_none": "No deletable auth files under the current filter ({{type}})",
|
||||
"delete_problem_success": "Deleted {{count}} problematic auth files successfully",
|
||||
"delete_problem_filtered_success": "Deleted {{count}} problematic {{type}} auth files successfully",
|
||||
"delete_problem_partial": "Problematic auth files deletion finished: {{success}} succeeded, {{failed}} failed",
|
||||
"delete_problem_filtered_partial": "Problematic {{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed",
|
||||
"delete_problem_none": "No deletable problematic auth files under the current filter",
|
||||
"delete_problem_filtered_none": "No deletable problematic {{type}} auth files under the current filter",
|
||||
"files_count": "files",
|
||||
"pagination_prev": "Previous",
|
||||
"pagination_next": "Next",
|
||||
"pagination_info": "Page {{current}} / {{total}} · {{count}} files",
|
||||
"search_label": "Search configs",
|
||||
"search_placeholder": "Filter by name, type, or provider",
|
||||
"problem_filter_label": "Problem Filter",
|
||||
"problem_filter_only": "Only show problematic credentials",
|
||||
"sort_label": "Sort",
|
||||
"sort_default": "Default",
|
||||
"sort_az": "A-Z Name",
|
||||
"sort_priority": "Priority",
|
||||
"priority_display": "Priority",
|
||||
"page_size_label": "Per page",
|
||||
"page_size_unit": "items",
|
||||
"view_mode_paged": "Paged",
|
||||
@@ -544,6 +569,10 @@
|
||||
"disable_cooling_label": "Disable cooling (disable_cooling)",
|
||||
"disable_cooling_placeholder": "e.g. true / false / 1 / 0",
|
||||
"disable_cooling_hint": "Supports booleans, numeric 0/non-0, and strings like true/false/1/0; unparseable values are ignored.",
|
||||
"note_label": "Note",
|
||||
"note_placeholder": "Enter a note, e.g.: John's account",
|
||||
"note_hint": "Optional. Used to describe the purpose or owner of this credential; leave empty to omit.",
|
||||
"note_display": "Note",
|
||||
"prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.",
|
||||
"prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully",
|
||||
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
|
||||
@@ -579,7 +608,14 @@
|
||||
"seven_day_sonnet": "7-day Sonnet",
|
||||
"seven_day_cowork": "7-day Cowork",
|
||||
"iguana_necktie": "Iguana Necktie",
|
||||
"extra_usage_label": "Extra Usage"
|
||||
"extra_usage_label": "Extra Usage",
|
||||
"plan_label": "Plan",
|
||||
"plan_unknown": "Unknown",
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro",
|
||||
"plan_max": "Max",
|
||||
"plan_max5": "Max 5x",
|
||||
"plan_max20": "Max 20x"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Codex Quota",
|
||||
@@ -603,7 +639,8 @@
|
||||
"plan_label": "Plan",
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
"plan_free": "Free"
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro"
|
||||
},
|
||||
"gemini_cli_quota": {
|
||||
"title": "Gemini CLI Quota",
|
||||
@@ -617,7 +654,15 @@
|
||||
"empty_buckets": "No quota data available",
|
||||
"refresh_button": "Refresh Quota",
|
||||
"fetch_all": "Fetch All",
|
||||
"remaining_amount": "Remaining {{count}}"
|
||||
"remaining_amount": "Remaining {{count}}",
|
||||
"tier_label": "Tier",
|
||||
"tier_free": "Free",
|
||||
"tier_legacy": "Legacy",
|
||||
"tier_standard": "Standard",
|
||||
"tier_pro": "Pro",
|
||||
"tier_ultra": "Ultra",
|
||||
"credit_label": "Google One AI Credits",
|
||||
"credit_amount": "{{count}} credits"
|
||||
},
|
||||
"kimi_quota": {
|
||||
"title": "Kimi Quota",
|
||||
@@ -1016,6 +1061,10 @@
|
||||
"show_raw_logs": "Show Raw Logs",
|
||||
"show_raw_logs_hint": "Show original log text for easier multi-line copy",
|
||||
"search_placeholder": "Search logs by content or keyword",
|
||||
"filter_panel_title": "Structured Filters",
|
||||
"filter_panel_expand": "Expand structured filters",
|
||||
"filter_panel_collapse": "Collapse structured filters",
|
||||
"filter_panel_active_count": "{{count}} active",
|
||||
"filter_method": "Method",
|
||||
"filter_status": "Status",
|
||||
"filter_path": "Path",
|
||||
@@ -1342,7 +1391,6 @@
|
||||
"vertex_config_added": "Vertex configuration added successfully",
|
||||
"vertex_config_updated": "Vertex configuration updated successfully",
|
||||
"vertex_config_deleted": "Vertex configuration deleted successfully",
|
||||
"vertex_base_url_required": "Please enter the Vertex Base URL",
|
||||
"config_enabled": "Configuration enabled",
|
||||
"config_disabled": "Configuration disabled",
|
||||
"field_required": "Required fields cannot be empty",
|
||||
@@ -1385,6 +1433,7 @@
|
||||
"theme": {
|
||||
"switch": "Theme",
|
||||
"light": "Light",
|
||||
"white": "Pure White",
|
||||
"dark": "Dark",
|
||||
"switch_to_light": "Switch to light mode",
|
||||
"switch_to_dark": "Switch to dark mode",
|
||||
|
||||
@@ -342,18 +342,17 @@
|
||||
"vertex_add_modal_title": "Добавление конфигурации Vertex API",
|
||||
"vertex_add_modal_key_label": "API-ключ:",
|
||||
"vertex_add_modal_key_placeholder": "Введите API-ключ Vertex",
|
||||
"vertex_add_modal_url_label": "Базовый URL (обязательно):",
|
||||
"vertex_add_modal_url_label": "Базовый URL:",
|
||||
"vertex_add_modal_url_placeholder": "например: https://example.com/api",
|
||||
"vertex_add_modal_proxy_label": "URL прокси (необязательно):",
|
||||
"vertex_add_modal_proxy_placeholder": "например: socks5://proxy.example.com:1080",
|
||||
"vertex_edit_modal_title": "Редактирование конфигурации Vertex API",
|
||||
"vertex_edit_modal_key_label": "API-ключ:",
|
||||
"vertex_edit_modal_url_label": "Базовый URL (обязательно):",
|
||||
"vertex_edit_modal_url_label": "Базовый URL:",
|
||||
"vertex_edit_modal_proxy_label": "URL прокси (необязательно):",
|
||||
"vertex_delete_confirm": "Удалить эту конфигурацию Vertex?",
|
||||
"vertex_models_label": "Псевдонимы моделей (требуется псевдоним):",
|
||||
"vertex_models_label": "Псевдонимы моделей:",
|
||||
"vertex_models_add_btn": "Добавить сопоставление",
|
||||
"vertex_models_hint": "Каждому псевдониму требуются исходная модель и псевдоним.",
|
||||
"vertex_models_count": "Количество псевдонимов",
|
||||
"ampcode_title": "Интеграция Amp CLI (ampcode)",
|
||||
"ampcode_modal_title": "Настройка Ampcode",
|
||||
@@ -366,6 +365,13 @@
|
||||
"ampcode_upstream_api_key_current": "Текущий официальный ключ Amp: {{key}}",
|
||||
"ampcode_clear_upstream_api_key": "Очистить официальный ключ",
|
||||
"ampcode_clear_upstream_api_key_confirm": "Очистить upstream API-ключ Ampcode (официальный Amp)?",
|
||||
"ampcode_upstream_api_keys_label": "Маршрутизация нескольких upstream API-ключей",
|
||||
"ampcode_upstream_api_keys_hint": "Привяжите разные upstream API-ключи Amp к указанным клиентским API-ключам. Клиентские ключи можно разделять запятыми или переводами строки.",
|
||||
"ampcode_upstream_api_keys_add_btn": "Добавить upstream-сопоставление",
|
||||
"ampcode_upstream_api_keys_upstream_placeholder": "Upstream API-ключ (sk-amp-...)",
|
||||
"ampcode_upstream_api_keys_clients_placeholder": "Клиентские API-ключи, через запятую или с новой строки",
|
||||
"ampcode_upstream_api_keys_item_title": "Upstream-сопоставление #{{index}}",
|
||||
"ampcode_upstream_api_keys_count": "Количество upstream-сопоставлений",
|
||||
"ampcode_force_model_mappings_label": "Принудительно применять сопоставления моделей",
|
||||
"ampcode_force_model_mappings_hint": "При включении сопоставления переопределяют локальные проверки доступности API-ключей.",
|
||||
"ampcode_model_mappings_label": "Сопоставления моделей (из → в)",
|
||||
@@ -374,6 +380,8 @@
|
||||
"ampcode_model_mappings_from_placeholder": "исходная модель",
|
||||
"ampcode_model_mappings_to_placeholder": "целевая модель",
|
||||
"ampcode_model_mappings_count": "Количество сопоставлений",
|
||||
"ampcode_lists_overwrite_title": "Перезаписать списки",
|
||||
"ampcode_lists_overwrite_confirm": "Существующие списки multi-upstream/сопоставлений моделей не удалось загрузить. Продолжение может перезаписать или очистить их. Продолжить?",
|
||||
"ampcode_mappings_overwrite_confirm": "Не удалось загрузить существующие сопоставления. Продолжение может перезаписать или очистить их. Продолжить?",
|
||||
"openai_title": "Совместимые с OpenAI провайдеры",
|
||||
"openai_add_button": "Добавить провайдера",
|
||||
@@ -460,6 +468,10 @@
|
||||
"delete_confirm": "Удалить файл",
|
||||
"delete_all_confirm": "Удалить все файлы авторизации? Это действие нельзя отменить!",
|
||||
"delete_filtered_confirm": "Удалить все файлы авторизации {{type}}? Это действие нельзя отменить!",
|
||||
"delete_problem_button": "Удалить проблемные",
|
||||
"delete_problem_button_with_type": "Удалить проблемные файлы {{type}}",
|
||||
"delete_problem_confirm": "Удалить все проблемные файлы авторизации? Это действие нельзя отменить!",
|
||||
"delete_problem_filtered_confirm": "Удалить все проблемные файлы авторизации {{type}}? Это действие нельзя отменить!",
|
||||
"upload_error_json": "Допустимы только файлы JSON",
|
||||
"upload_error_size": "Размер файла не может превышать {{maxSize}}",
|
||||
"upload_success": "Файл успешно загружен",
|
||||
@@ -469,12 +481,25 @@
|
||||
"delete_filtered_success": "Удалено файлов {{type}}: {{count}}",
|
||||
"delete_filtered_partial": "Удаление файлов {{type}} завершено: успешных {{success}}, ошибок {{failed}}",
|
||||
"delete_filtered_none": "Нет файлов {{type}} для удаления при текущем фильтре",
|
||||
"delete_problem_success": "Удалено проблемных файлов авторизации: {{count}}",
|
||||
"delete_problem_filtered_success": "Удалено проблемных файлов авторизации {{type}}: {{count}}",
|
||||
"delete_problem_partial": "Удаление проблемных файлов авторизации завершено: успешных {{success}}, ошибок {{failed}}",
|
||||
"delete_problem_filtered_partial": "Удаление проблемных файлов авторизации {{type}} завершено: успешных {{success}}, ошибок {{failed}}",
|
||||
"delete_problem_none": "Нет проблемных файлов авторизации для удаления при текущем фильтре",
|
||||
"delete_problem_filtered_none": "Нет проблемных файлов авторизации {{type}} для удаления при текущем фильтре",
|
||||
"files_count": "файлов",
|
||||
"pagination_prev": "Предыдущая",
|
||||
"pagination_next": "Следующая",
|
||||
"pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов",
|
||||
"search_label": "Поиск конфигов",
|
||||
"search_placeholder": "Фильтр по имени, типу или провайдеру",
|
||||
"problem_filter_label": "Фильтр проблем",
|
||||
"problem_filter_only": "Показывать только проблемные учётные данные",
|
||||
"sort_label": "Сортировка",
|
||||
"sort_default": "По умолчанию",
|
||||
"sort_az": "A-Z Имя",
|
||||
"sort_priority": "Приоритет",
|
||||
"priority_display": "Приоритет",
|
||||
"page_size_label": "На странице",
|
||||
"page_size_unit": "элементов",
|
||||
"view_mode_paged": "Постранично",
|
||||
@@ -544,6 +569,10 @@
|
||||
"disable_cooling_label": "Отключение охлаждения (disable_cooling)",
|
||||
"disable_cooling_placeholder": "например: true / false / 1 / 0",
|
||||
"disable_cooling_hint": "Поддерживает boolean, числа 0/не 0 и строки true/false/1/0; непарсируемые значения игнорируются.",
|
||||
"note_label": "Заметка (note)",
|
||||
"note_placeholder": "Введите заметку, например: аккаунт Ивана",
|
||||
"note_hint": "Необязательно. Используется для описания назначения или владельца учётных данных; оставьте пустым, чтобы не записывать.",
|
||||
"note_display": "Заметка",
|
||||
"prefix_proxy_invalid_json": "Этот файл авторизации не является JSON-объектом, поэтому поля нельзя редактировать.",
|
||||
"prefix_proxy_saved_success": "Файл авторизации \"{{name}}\" успешно обновлён",
|
||||
"card_tools_title": "Инструменты",
|
||||
@@ -582,7 +611,14 @@
|
||||
"seven_day_sonnet": "7 дней Sonnet",
|
||||
"seven_day_cowork": "7 дней Cowork",
|
||||
"iguana_necktie": "Iguana Necktie",
|
||||
"extra_usage_label": "Дополнительное использование"
|
||||
"extra_usage_label": "Дополнительное использование",
|
||||
"plan_label": "План",
|
||||
"plan_unknown": "Неизвестно",
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro",
|
||||
"plan_max": "Max",
|
||||
"plan_max5": "Max 5x",
|
||||
"plan_max20": "Max 20x"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Квота Codex",
|
||||
@@ -606,7 +642,8 @@
|
||||
"plan_label": "Тариф",
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
"plan_free": "Free"
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro"
|
||||
},
|
||||
"gemini_cli_quota": {
|
||||
"title": "Квота Gemini CLI",
|
||||
@@ -620,7 +657,15 @@
|
||||
"empty_buckets": "Данные по квоте отсутствуют",
|
||||
"refresh_button": "Обновить квоту",
|
||||
"fetch_all": "Получить все",
|
||||
"remaining_amount": "Осталось {{count}}"
|
||||
"remaining_amount": "Осталось {{count}}",
|
||||
"tier_label": "Уровень",
|
||||
"tier_free": "Бесплатный",
|
||||
"tier_legacy": "Устаревший",
|
||||
"tier_standard": "Стандартный",
|
||||
"tier_pro": "Pro",
|
||||
"tier_ultra": "Ultra",
|
||||
"credit_label": "Google One AI кредиты",
|
||||
"credit_amount": "{{count}} кредитов"
|
||||
},
|
||||
"kimi_quota": {
|
||||
"title": "Квота Kimi",
|
||||
@@ -1019,6 +1064,10 @@
|
||||
"show_raw_logs": "Показать исходные журналы",
|
||||
"show_raw_logs_hint": "Показать текст журнала без обработки для удобного копирования в несколько строк",
|
||||
"search_placeholder": "Искать по содержимому или ключевым словам",
|
||||
"filter_panel_title": "Структурные фильтры",
|
||||
"filter_panel_expand": "Развернуть структурные фильтры",
|
||||
"filter_panel_collapse": "Свернуть структурные фильтры",
|
||||
"filter_panel_active_count": "Активно: {{count}}",
|
||||
"filter_method": "Метод",
|
||||
"filter_status": "Статус",
|
||||
"filter_path": "Путь",
|
||||
@@ -1347,7 +1396,6 @@
|
||||
"vertex_config_added": "Конфигурация Vertex успешно добавлена",
|
||||
"vertex_config_updated": "Конфигурация Vertex успешно обновлена",
|
||||
"vertex_config_deleted": "Конфигурация Vertex успешно удалена",
|
||||
"vertex_base_url_required": "Введите базовый URL Vertex",
|
||||
"config_enabled": "Конфигурация включена",
|
||||
"config_disabled": "Конфигурация выключена",
|
||||
"field_required": "Обязательные поля не могут быть пустыми",
|
||||
@@ -1390,6 +1438,7 @@
|
||||
"theme": {
|
||||
"switch": "Тема",
|
||||
"light": "Светлая",
|
||||
"white": "Чисто-белая",
|
||||
"dark": "Тёмная",
|
||||
"switch_to_light": "Переключиться на светлую тему",
|
||||
"switch_to_dark": "Переключиться на тёмную тему",
|
||||
|
||||
@@ -342,18 +342,17 @@
|
||||
"vertex_add_modal_title": "添加Vertex API配置",
|
||||
"vertex_add_modal_key_label": "API密钥:",
|
||||
"vertex_add_modal_key_placeholder": "请输入Vertex API密钥",
|
||||
"vertex_add_modal_url_label": "Base URL (必填):",
|
||||
"vertex_add_modal_url_label": "Base URL:",
|
||||
"vertex_add_modal_url_placeholder": "例如: https://example.com/api",
|
||||
"vertex_add_modal_proxy_label": "代理 URL (可选):",
|
||||
"vertex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
|
||||
"vertex_edit_modal_title": "编辑Vertex API配置",
|
||||
"vertex_edit_modal_key_label": "API密钥:",
|
||||
"vertex_edit_modal_url_label": "Base URL (必填):",
|
||||
"vertex_edit_modal_url_label": "Base URL:",
|
||||
"vertex_edit_modal_proxy_label": "代理 URL (可选):",
|
||||
"vertex_delete_confirm": "确定要删除这个Vertex配置吗?",
|
||||
"vertex_models_label": "模型别名 (别名必填):",
|
||||
"vertex_models_label": "模型别名:",
|
||||
"vertex_models_add_btn": "添加映射",
|
||||
"vertex_models_hint": "每条别名需要填写原模型与别名。",
|
||||
"vertex_models_count": "别名数量",
|
||||
"ampcode_title": "Amp CLI 集成 (ampcode)",
|
||||
"ampcode_modal_title": "配置 Ampcode",
|
||||
@@ -366,6 +365,13 @@
|
||||
"ampcode_upstream_api_key_current": "当前Amp官方密钥: {{key}}",
|
||||
"ampcode_clear_upstream_api_key": "清除官方密钥",
|
||||
"ampcode_clear_upstream_api_key_confirm": "确定要清除 Ampcode 的 upstream API key(Amp官方)吗?",
|
||||
"ampcode_upstream_api_keys_label": "多上游 API Key 路由",
|
||||
"ampcode_upstream_api_keys_hint": "为指定客户端 API Key 绑定不同的 Amp 上游 API Key;客户端 key 可用逗号或换行分隔。",
|
||||
"ampcode_upstream_api_keys_add_btn": "添加多上游映射",
|
||||
"ampcode_upstream_api_keys_upstream_placeholder": "上游 API Key(sk-amp-...)",
|
||||
"ampcode_upstream_api_keys_clients_placeholder": "客户端 API Keys,用逗号或换行分隔",
|
||||
"ampcode_upstream_api_keys_item_title": "上游映射 #{{index}}",
|
||||
"ampcode_upstream_api_keys_count": "多上游映射",
|
||||
"ampcode_force_model_mappings_label": "强制应用模型映射",
|
||||
"ampcode_force_model_mappings_hint": "开启后,模型映射将覆盖本地 API Key 可用性判断。",
|
||||
"ampcode_model_mappings_label": "模型映射 (from → to)",
|
||||
@@ -374,6 +380,8 @@
|
||||
"ampcode_model_mappings_from_placeholder": "from 模型(原始)",
|
||||
"ampcode_model_mappings_to_placeholder": "to 模型(目标)",
|
||||
"ampcode_model_mappings_count": "映射数量",
|
||||
"ampcode_lists_overwrite_title": "覆盖列表配置",
|
||||
"ampcode_lists_overwrite_confirm": "当前未成功加载服务器已有多上游/模型映射配置,继续保存可能覆盖或清空这些列表,是否继续?",
|
||||
"ampcode_mappings_overwrite_confirm": "当前未成功加载服务器已有映射,继续保存可能覆盖或清空已有映射,是否继续?",
|
||||
"openai_title": "OpenAI 兼容提供商",
|
||||
"openai_add_button": "添加提供商",
|
||||
@@ -460,6 +468,10 @@
|
||||
"delete_confirm": "确定要删除文件",
|
||||
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
|
||||
"delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!",
|
||||
"delete_problem_button": "删除问题凭证",
|
||||
"delete_problem_button_with_type": "删除 {{type}} 问题凭证",
|
||||
"delete_problem_confirm": "确定要删除所有有问题的认证文件吗?此操作不可恢复!",
|
||||
"delete_problem_filtered_confirm": "确定要删除筛选出的有问题的 {{type}} 认证文件吗?此操作不可恢复!",
|
||||
"upload_error_json": "只能上传JSON文件",
|
||||
"upload_error_size": "文件大小不能超过 {{maxSize}}",
|
||||
"upload_success": "文件上传成功",
|
||||
@@ -469,12 +481,25 @@
|
||||
"delete_filtered_success": "成功删除 {{count}} 个 {{type}} 认证文件",
|
||||
"delete_filtered_partial": "{{type}} 认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个",
|
||||
"delete_filtered_none": "当前筛选类型 ({{type}}) 下没有可删除的认证文件",
|
||||
"delete_problem_success": "成功删除 {{count}} 个有问题的认证文件",
|
||||
"delete_problem_filtered_success": "成功删除 {{count}} 个有问题的 {{type}} 认证文件",
|
||||
"delete_problem_partial": "有问题认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个",
|
||||
"delete_problem_filtered_partial": "有问题的 {{type}} 认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个",
|
||||
"delete_problem_none": "当前没有可删除的有问题认证文件",
|
||||
"delete_problem_filtered_none": "当前筛选类型 ({{type}}) 下没有可删除的有问题认证文件",
|
||||
"files_count": "个文件",
|
||||
"pagination_prev": "上一页",
|
||||
"pagination_next": "下一页",
|
||||
"pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件",
|
||||
"search_label": "搜索配置文件",
|
||||
"search_placeholder": "输入名称、类型或提供方关键字",
|
||||
"problem_filter_label": "问题筛选",
|
||||
"problem_filter_only": "仅显示有问题凭证",
|
||||
"sort_label": "排序",
|
||||
"sort_default": "默认",
|
||||
"sort_az": "A-Z 名称",
|
||||
"sort_priority": "优先级",
|
||||
"priority_display": "优先级",
|
||||
"page_size_label": "单页数量",
|
||||
"page_size_unit": "个/页",
|
||||
"view_mode_paged": "按页显示",
|
||||
@@ -544,6 +569,10 @@
|
||||
"disable_cooling_label": "禁用冷却(disable_cooling)",
|
||||
"disable_cooling_placeholder": "例如: true / false / 1 / 0",
|
||||
"disable_cooling_hint": "支持布尔值、0/非0 数字或字符串 true/false/1/0;无法解析时忽略。",
|
||||
"note_label": "备注(note)",
|
||||
"note_placeholder": "输入备注信息,例如:张三的账号",
|
||||
"note_hint": "可选,用于标记凭证用途或归属;留空则不写入。",
|
||||
"note_display": "备注",
|
||||
"prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。",
|
||||
"prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"",
|
||||
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
|
||||
@@ -579,7 +608,14 @@
|
||||
"seven_day_sonnet": "7 天 Sonnet",
|
||||
"seven_day_cowork": "7 天 Cowork",
|
||||
"iguana_necktie": "Iguana Necktie",
|
||||
"extra_usage_label": "额外用量"
|
||||
"extra_usage_label": "额外用量",
|
||||
"plan_label": "套餐",
|
||||
"plan_unknown": "未知",
|
||||
"plan_free": "免费版",
|
||||
"plan_pro": "专业版",
|
||||
"plan_max": "Max",
|
||||
"plan_max5": "Max 5x",
|
||||
"plan_max20": "Max 20x"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Codex 额度",
|
||||
@@ -603,7 +639,8 @@
|
||||
"plan_label": "套餐",
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
"plan_free": "Free"
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro"
|
||||
},
|
||||
"gemini_cli_quota": {
|
||||
"title": "Gemini CLI 额度",
|
||||
@@ -617,7 +654,15 @@
|
||||
"empty_buckets": "暂无额度数据",
|
||||
"refresh_button": "刷新额度",
|
||||
"fetch_all": "获取全部",
|
||||
"remaining_amount": "剩余 {{count}}"
|
||||
"remaining_amount": "剩余 {{count}}",
|
||||
"tier_label": "层级",
|
||||
"tier_free": "免费版",
|
||||
"tier_legacy": "旧版",
|
||||
"tier_standard": "标准版",
|
||||
"tier_pro": "Pro",
|
||||
"tier_ultra": "Ultra",
|
||||
"credit_label": "Google One AI 积分",
|
||||
"credit_amount": "{{count}} 积分"
|
||||
},
|
||||
"kimi_quota": {
|
||||
"title": "Kimi 额度",
|
||||
@@ -1016,6 +1061,10 @@
|
||||
"show_raw_logs": "显示原始日志",
|
||||
"show_raw_logs_hint": "直接显示原始日志文本,方便多行复制",
|
||||
"search_placeholder": "搜索日志内容或关键字",
|
||||
"filter_panel_title": "结构化筛选",
|
||||
"filter_panel_expand": "展开结构化筛选",
|
||||
"filter_panel_collapse": "收起结构化筛选",
|
||||
"filter_panel_active_count": "已选 {{count}} 项",
|
||||
"filter_method": "请求方法",
|
||||
"filter_status": "状态码",
|
||||
"filter_path": "路径",
|
||||
@@ -1342,7 +1391,6 @@
|
||||
"vertex_config_added": "Vertex配置添加成功",
|
||||
"vertex_config_updated": "Vertex配置更新成功",
|
||||
"vertex_config_deleted": "Vertex配置删除成功",
|
||||
"vertex_base_url_required": "请填写Vertex Base URL",
|
||||
"config_enabled": "配置已启用",
|
||||
"config_disabled": "配置已停用",
|
||||
"field_required": "必填字段不能为空",
|
||||
@@ -1385,6 +1433,7 @@
|
||||
"theme": {
|
||||
"switch": "主题",
|
||||
"light": "亮色",
|
||||
"white": "纯白",
|
||||
"dark": "暗色",
|
||||
"switch_to_light": "切换到亮色模式",
|
||||
"switch_to_dark": "切换到暗色模式",
|
||||
|
||||
@@ -13,7 +13,11 @@ import { ampcodeApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { AmpcodeConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '@/components/providers/utils';
|
||||
import {
|
||||
buildAmpcodeFormState,
|
||||
entriesToAmpcodeMappings,
|
||||
entriesToAmpcodeUpstreamApiKeys,
|
||||
} from '@/components/providers/utils';
|
||||
import type { AmpcodeFormState } from '@/components/providers';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
|
||||
@@ -34,11 +38,18 @@ const normalizeMappingEntries = (entries: Array<{ name: string; alias: string }>
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const normalizeUpstreamApiKeyEntries = (form: AmpcodeFormState) =>
|
||||
entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries).map((entry) => ({
|
||||
upstreamApiKey: entry.upstreamApiKey,
|
||||
apiKeys: entry.apiKeys,
|
||||
}));
|
||||
|
||||
const buildAmpcodeSignature = (form: AmpcodeFormState) =>
|
||||
JSON.stringify({
|
||||
upstreamUrl: String(form.upstreamUrl ?? '').trim(),
|
||||
upstreamApiKey: String(form.upstreamApiKey ?? '').trim(),
|
||||
forceModelMappings: Boolean(form.forceModelMappings),
|
||||
upstreamApiKeys: normalizeUpstreamApiKeyEntries(form),
|
||||
modelMappings: normalizeMappingEntries(form.mappingEntries),
|
||||
});
|
||||
|
||||
@@ -57,7 +68,8 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [mappingsDirty, setMappingsDirty] = useState(false);
|
||||
const [modelMappingsDirty, setModelMappingsDirty] = useState(false);
|
||||
const [upstreamApiKeysDirty, setUpstreamApiKeysDirty] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [baselineSignature, setBaselineSignature] = useState(() =>
|
||||
@@ -102,7 +114,8 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
|
||||
setLoading(true);
|
||||
setLoaded(false);
|
||||
setMappingsDirty(false);
|
||||
setModelMappingsDirty(false);
|
||||
setUpstreamApiKeysDirty(false);
|
||||
setError('');
|
||||
const initialForm = buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null);
|
||||
setForm(initialForm);
|
||||
@@ -183,6 +196,7 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
try {
|
||||
const upstreamUrl = form.upstreamUrl.trim();
|
||||
const overrideKey = form.upstreamApiKey.trim();
|
||||
const upstreamApiKeys = entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries);
|
||||
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
|
||||
|
||||
if (upstreamUrl) {
|
||||
@@ -193,7 +207,15 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
|
||||
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
|
||||
|
||||
if (loaded || mappingsDirty) {
|
||||
if (loaded || upstreamApiKeysDirty) {
|
||||
if (upstreamApiKeys.length) {
|
||||
await ampcodeApi.saveUpstreamApiKeys(upstreamApiKeys);
|
||||
} else {
|
||||
await ampcodeApi.deleteUpstreamApiKeys([]);
|
||||
}
|
||||
}
|
||||
|
||||
if (loaded || modelMappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
await ampcodeApi.saveModelMappings(modelMappings);
|
||||
} else {
|
||||
@@ -207,23 +229,29 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = {
|
||||
upstreamUrl: upstreamUrl || undefined,
|
||||
...previous,
|
||||
forceModelMappings: form.forceModelMappings,
|
||||
};
|
||||
|
||||
if (previous.upstreamApiKey) {
|
||||
next.upstreamApiKey = previous.upstreamApiKey;
|
||||
}
|
||||
|
||||
if (Array.isArray(previous.modelMappings)) {
|
||||
next.modelMappings = previous.modelMappings;
|
||||
if (upstreamUrl) {
|
||||
next.upstreamUrl = upstreamUrl;
|
||||
} else {
|
||||
delete next.upstreamUrl;
|
||||
}
|
||||
|
||||
if (overrideKey) {
|
||||
next.upstreamApiKey = overrideKey;
|
||||
}
|
||||
|
||||
if (loaded || mappingsDirty) {
|
||||
if (loaded || upstreamApiKeysDirty) {
|
||||
if (upstreamApiKeys.length) {
|
||||
next.upstreamApiKeys = upstreamApiKeys;
|
||||
} else {
|
||||
delete next.upstreamApiKeys;
|
||||
}
|
||||
}
|
||||
|
||||
if (loaded || modelMappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
next.modelMappings = modelMappings;
|
||||
} else {
|
||||
@@ -247,10 +275,10 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
};
|
||||
|
||||
const saveAmpcode = async () => {
|
||||
if (!loaded && mappingsDirty) {
|
||||
if (!loaded && (modelMappingsDirty || upstreamApiKeysDirty)) {
|
||||
showConfirmation({
|
||||
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
|
||||
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
|
||||
title: t('ai_providers.ampcode_lists_overwrite_title'),
|
||||
message: t('ai_providers.ampcode_lists_overwrite_confirm'),
|
||||
variant: 'secondary',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: performSaveAmpcode,
|
||||
@@ -334,6 +362,98 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className={layoutStyles.ampcodeUpstreamMappingsHeader}>
|
||||
<label>{t('ai_providers.ampcode_upstream_api_keys_label')}</label>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUpstreamApiKeysDirty(true);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
upstreamApiKeyEntries: [
|
||||
...prev.upstreamApiKeyEntries,
|
||||
{ upstreamApiKey: '', clientApiKeysText: '' },
|
||||
],
|
||||
}));
|
||||
}}
|
||||
disabled={loading || saving || disableControls}
|
||||
>
|
||||
{t('ai_providers.ampcode_upstream_api_keys_add_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={layoutStyles.ampcodeUpstreamMappingsList}>
|
||||
{(form.upstreamApiKeyEntries.length
|
||||
? form.upstreamApiKeyEntries
|
||||
: [{ upstreamApiKey: '', clientApiKeysText: '' }]
|
||||
).map((entry, index, entries) => (
|
||||
<div key={index} className={layoutStyles.ampcodeUpstreamMappingCard}>
|
||||
<div className={layoutStyles.ampcodeUpstreamMappingCardTop}>
|
||||
<span className={layoutStyles.ampcodeUpstreamMappingTitle}>
|
||||
{t('ai_providers.ampcode_upstream_api_keys_item_title', { index: index + 1 })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUpstreamApiKeysDirty(true);
|
||||
setForm((prev) => {
|
||||
const nextEntries = prev.upstreamApiKeyEntries.filter((_, entryIndex) => entryIndex !== index);
|
||||
return {
|
||||
...prev,
|
||||
upstreamApiKeyEntries: nextEntries.length
|
||||
? nextEntries
|
||||
: [{ upstreamApiKey: '', clientApiKeysText: '' }],
|
||||
};
|
||||
});
|
||||
}}
|
||||
disabled={loading || saving || disableControls || entries.length <= 1}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('ai_providers.ampcode_upstream_api_keys_upstream_placeholder')}
|
||||
aria-label={t('ai_providers.ampcode_upstream_api_keys_upstream_placeholder')}
|
||||
value={entry.upstreamApiKey}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setUpstreamApiKeysDirty(true);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
upstreamApiKeyEntries: prev.upstreamApiKeyEntries.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, upstreamApiKey: value } : item
|
||||
),
|
||||
}));
|
||||
}}
|
||||
disabled={loading || saving || disableControls}
|
||||
/>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.ampcode_upstream_api_keys_clients_placeholder')}
|
||||
aria-label={t('ai_providers.ampcode_upstream_api_keys_clients_placeholder')}
|
||||
value={entry.clientApiKeysText}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setUpstreamApiKeysDirty(true);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
upstreamApiKeyEntries: prev.upstreamApiKeyEntries.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, clientApiKeysText: value } : item
|
||||
),
|
||||
}));
|
||||
}}
|
||||
rows={3}
|
||||
disabled={loading || saving || disableControls}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hint">{t('ai_providers.ampcode_upstream_api_keys_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
||||
@@ -349,7 +469,7 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
<ModelInputList
|
||||
entries={form.mappingEntries}
|
||||
onChange={(entries) => {
|
||||
setMappingsDirty(true);
|
||||
setModelMappingsDirty(true);
|
||||
setForm((prev) => ({ ...prev, mappingEntries: entries }));
|
||||
}}
|
||||
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
||||
|
||||
@@ -31,3 +31,45 @@
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
label {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingCard {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingCardTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ export function AiProvidersPage() {
|
||||
};
|
||||
|
||||
const setConfigEnabled = async (
|
||||
provider: 'gemini' | 'codex' | 'claude',
|
||||
provider: 'gemini' | 'codex' | 'claude' | 'vertex',
|
||||
index: number,
|
||||
enabled: boolean
|
||||
) => {
|
||||
@@ -204,7 +204,12 @@ export function AiProvidersPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = provider === 'codex' ? codexConfigs : claudeConfigs;
|
||||
const source =
|
||||
provider === 'codex'
|
||||
? codexConfigs
|
||||
: provider === 'claude'
|
||||
? claudeConfigs
|
||||
: vertexConfigs;
|
||||
const current = source[index];
|
||||
if (!current) return;
|
||||
|
||||
@@ -222,17 +227,23 @@ export function AiProvidersPage() {
|
||||
setCodexConfigs(nextList);
|
||||
updateConfigValue('codex-api-key', nextList);
|
||||
clearCache('codex-api-key');
|
||||
} else {
|
||||
} else if (provider === 'claude') {
|
||||
setClaudeConfigs(nextList);
|
||||
updateConfigValue('claude-api-key', nextList);
|
||||
clearCache('claude-api-key');
|
||||
} else {
|
||||
setVertexConfigs(nextList);
|
||||
updateConfigValue('vertex-api-key', nextList);
|
||||
clearCache('vertex-api-key');
|
||||
}
|
||||
|
||||
try {
|
||||
if (provider === 'codex') {
|
||||
await providersApi.saveCodexConfigs(nextList);
|
||||
} else {
|
||||
} else if (provider === 'claude') {
|
||||
await providersApi.saveClaudeConfigs(nextList);
|
||||
} else {
|
||||
await providersApi.saveVertexConfigs(nextList);
|
||||
}
|
||||
showNotification(
|
||||
enabled ? t('notification.config_enabled') : t('notification.config_disabled'),
|
||||
@@ -244,10 +255,14 @@ export function AiProvidersPage() {
|
||||
setCodexConfigs(previousList);
|
||||
updateConfigValue('codex-api-key', previousList);
|
||||
clearCache('codex-api-key');
|
||||
} else {
|
||||
} else if (provider === 'claude') {
|
||||
setClaudeConfigs(previousList);
|
||||
updateConfigValue('claude-api-key', previousList);
|
||||
clearCache('claude-api-key');
|
||||
} else {
|
||||
setVertexConfigs(previousList);
|
||||
updateConfigValue('vertex-api-key', previousList);
|
||||
clearCache('vertex-api-key');
|
||||
}
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
@@ -366,7 +381,6 @@ export function AiProvidersPage() {
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
resolvedTheme={resolvedTheme}
|
||||
onAdd={() => openEditor('/ai-providers/codex/new')}
|
||||
onEdit={(index) => openEditor(`/ai-providers/codex/${index}`)}
|
||||
onDelete={(index) => void deleteProviderEntry('codex', index)}
|
||||
@@ -400,6 +414,7 @@ export function AiProvidersPage() {
|
||||
onAdd={() => openEditor('/ai-providers/vertex/new')}
|
||||
onEdit={(index) => openEditor(`/ai-providers/vertex/${index}`)}
|
||||
onDelete={deleteVertex}
|
||||
onToggle={(index, enabled) => void setConfigEnabled('vertex', index, enabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
|
||||
import type { VertexFormState } from '@/components/providers';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
@@ -26,7 +27,9 @@ const buildEmptyForm = (): VertexFormState => ({
|
||||
proxyUrl: '',
|
||||
headers: [],
|
||||
models: [],
|
||||
excludedModels: [],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
const parseIndexParam = (value: string | undefined) => {
|
||||
@@ -54,6 +57,7 @@ const buildVertexSignature = (form: VertexFormState) =>
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
headers: normalizeHeaderEntries(form.headers),
|
||||
models: normalizeModelEntries(form.modelEntries),
|
||||
excludedModels: parseExcludedModels(form.excludedText ?? ''),
|
||||
});
|
||||
|
||||
export function AiProvidersVertexEditPage() {
|
||||
@@ -153,6 +157,7 @@ export function AiProvidersVertexEditPage() {
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
modelEntries: modelsToEntries(initialData.models),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
};
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildVertexSignature(nextForm));
|
||||
@@ -187,10 +192,6 @@ export function AiProvidersVertexEditPage() {
|
||||
|
||||
const trimmedBaseUrl = (form.baseUrl ?? '').trim();
|
||||
const baseUrl = trimmedBaseUrl || undefined;
|
||||
if (!baseUrl) {
|
||||
showNotification(t('notification.vertex_base_url_required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
@@ -213,6 +214,7 @@ export function AiProvidersVertexEditPage() {
|
||||
return { name, alias };
|
||||
})
|
||||
.filter(Boolean) as ProviderKeyConfig['models'],
|
||||
excludedModels: parseExcludedModels(form.excludedText),
|
||||
};
|
||||
|
||||
const nextList =
|
||||
@@ -341,7 +343,18 @@ export function AiProvidersVertexEditPage() {
|
||||
removeButtonAriaLabel={t('common.delete')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={form.excludedText}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -170,19 +170,19 @@
|
||||
}
|
||||
|
||||
.modelItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-radius: $radius-sm;
|
||||
transition: background-color $transition-fast;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
&:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modelId {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { IconInfo } from '@/components/ui/icons';
|
||||
@@ -400,20 +401,22 @@ export function AuthFilesOAuthExcludedEditPage() {
|
||||
{modelsList.map((model) => {
|
||||
const checked = selectedModels.has(model.id);
|
||||
return (
|
||||
<label key={model.id} className={styles.modelItem}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disableControls || saving}
|
||||
onChange={(event) => toggleModel(model.id, event.target.checked)}
|
||||
/>
|
||||
<span className={styles.modelText}>
|
||||
<span className={styles.modelId}>{model.id}</span>
|
||||
{model.display_name && model.display_name !== model.id && (
|
||||
<span className={styles.modelDisplayName}>{model.display_name}</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<SelectionCheckbox
|
||||
key={model.id}
|
||||
checked={checked}
|
||||
disabled={disableControls || saving}
|
||||
onChange={(value) => toggleModel(model.id, value)}
|
||||
className={styles.modelItem}
|
||||
labelClassName={styles.modelText}
|
||||
label={
|
||||
<>
|
||||
<span className={styles.modelId}>{model.id}</span>
|
||||
{model.display_name && model.display_name !== model.id && (
|
||||
<span className={styles.modelDisplayName}>{model.display_name}</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
|
||||
.filterTag {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
@@ -104,13 +104,21 @@
|
||||
|
||||
.filterTagLabel {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterTagIcon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: 0 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.filterTagCount {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 2ch;
|
||||
font-size: 12px;
|
||||
@@ -161,6 +169,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
.filterToggleItem {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.filterToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.filterToggleLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pageSizeSelect {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -314,6 +341,10 @@
|
||||
background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
|
||||
}
|
||||
|
||||
.claudeCard {
|
||||
background-image: linear-gradient(180deg, rgba(252, 228, 236, 0.18), rgba(252, 228, 236, 0));
|
||||
}
|
||||
|
||||
.codexCard {
|
||||
background-image: linear-gradient(180deg, rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0));
|
||||
}
|
||||
@@ -481,6 +512,45 @@
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.premiumPlanValue {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #fdf3d7, #f0d060);
|
||||
border: 1px solid #e0b830;
|
||||
box-shadow: 0 1px 4px rgba(200, 160, 0, 0.18);
|
||||
color: #7a5c00;
|
||||
text-transform: capitalize;
|
||||
|
||||
@media (dynamic-range: high) {
|
||||
background: linear-gradient(135deg,
|
||||
color(display-p3 0.99 0.95 0.82),
|
||||
color(display-p3 0.97 0.82 0.28));
|
||||
border-color: color(display-p3 0.90 0.73 0.12);
|
||||
box-shadow: 0 1px 8px color(display-p3 1.0 0.84 0.0 / 0.25);
|
||||
color: color(display-p3 0.50 0.38 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .premiumPlanValue {
|
||||
background: linear-gradient(135deg, #3d3210, #5a4a18);
|
||||
border-color: #8b7030;
|
||||
box-shadow: 0 1px 8px rgba(180, 140, 0, 0.2);
|
||||
color: #e8c84c;
|
||||
|
||||
@media (dynamic-range: high) {
|
||||
background: linear-gradient(135deg,
|
||||
color(display-p3 0.24 0.20 0.06),
|
||||
color(display-p3 0.38 0.30 0.08));
|
||||
border-color: color(display-p3 0.58 0.46 0.15);
|
||||
box-shadow: 0 1px 12px color(display-p3 0.80 0.65 0.0 / 0.2);
|
||||
color: color(display-p3 0.95 0.82 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
// 单个认证文件卡片
|
||||
.fileCard {
|
||||
background-color: var(--bg-primary);
|
||||
@@ -542,45 +612,6 @@
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.selectionToggle {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
|
||||
color: var(--primary-contrast, #fff);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color $transition-fast,
|
||||
background-color $transition-fast,
|
||||
box-shadow $transition-fast,
|
||||
transform $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.selectionToggleActive {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.selectionToggleActive svg {
|
||||
display: block;
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
|
||||
.typeBadge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
@@ -609,6 +640,39 @@
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.priorityBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.priorityValue {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.noteText {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-word;
|
||||
|
||||
.noteLabel {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.sortSelect {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.healthStatusMessage {
|
||||
font-size: 12px;
|
||||
color: var(--warning-text);
|
||||
|
||||
+155
-17
@@ -18,7 +18,9 @@ import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer'
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import {
|
||||
MAX_CARD_PAGE_SIZE,
|
||||
@@ -27,8 +29,10 @@ import {
|
||||
clampCardPageSize,
|
||||
getTypeColor,
|
||||
getTypeLabel,
|
||||
hasAuthFileStatusMessage,
|
||||
isRuntimeOnlyAuthFile,
|
||||
normalizeProviderKey,
|
||||
parsePriorityValue,
|
||||
type QuotaProviderType,
|
||||
type ResolvedTheme,
|
||||
} from '@/features/authFiles/constants';
|
||||
@@ -38,13 +42,27 @@ import { AuthFileModelsModal } from '@/features/authFiles/components/AuthFileMod
|
||||
import { AuthFilesPrefixProxyEditorModal } from '@/features/authFiles/components/AuthFilesPrefixProxyEditorModal';
|
||||
import { OAuthExcludedCard } from '@/features/authFiles/components/OAuthExcludedCard';
|
||||
import { OAuthModelAliasCard } from '@/features/authFiles/components/OAuthModelAliasCard';
|
||||
import iconAntigravity from '@/assets/icons/antigravity.svg';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import iconCodex from '@/assets/icons/codex.svg';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconIflow from '@/assets/icons/iflow.svg';
|
||||
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
|
||||
import iconKimiLight from '@/assets/icons/kimi-light.svg';
|
||||
import iconQwen from '@/assets/icons/qwen.svg';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import { useAuthFilesData } from '@/features/authFiles/hooks/useAuthFilesData';
|
||||
import { useAuthFilesModels } from '@/features/authFiles/hooks/useAuthFilesModels';
|
||||
import { useAuthFilesOauth } from '@/features/authFiles/hooks/useAuthFilesOauth';
|
||||
import { useAuthFilesPrefixProxyEditor } from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
|
||||
import { useAuthFilesStats } from '@/features/authFiles/hooks/useAuthFilesStats';
|
||||
import { useAuthFilesStatusBarCache } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
|
||||
import { readAuthFilesUiState, writeAuthFilesUiState } from '@/features/authFiles/uiState';
|
||||
import {
|
||||
isAuthFilesSortMode,
|
||||
readAuthFilesUiState,
|
||||
writeAuthFilesUiState,
|
||||
type AuthFilesSortMode,
|
||||
} from '@/features/authFiles/uiState';
|
||||
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import styles from './AuthFilesPage.module.scss';
|
||||
@@ -53,6 +71,28 @@ const easePower3Out = (progress: number) => 1 - (1 - progress) ** 4;
|
||||
const easePower2In = (progress: number) => progress ** 3;
|
||||
const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)';
|
||||
const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
|
||||
const AUTH_FILE_FILTER_ICONS: Record<string, string | { light: string; dark: string }> = {
|
||||
antigravity: iconAntigravity,
|
||||
aistudio: iconGemini,
|
||||
claude: iconClaude,
|
||||
codex: iconCodex,
|
||||
gemini: iconGemini,
|
||||
'gemini-cli': iconGemini,
|
||||
iflow: iconIflow,
|
||||
kimi: { light: iconKimiLight, dark: iconKimiDark },
|
||||
qwen: iconQwen,
|
||||
vertex: iconVertex,
|
||||
};
|
||||
|
||||
const getFilterTagIcon = (type: string, resolvedTheme: ResolvedTheme): string | null => {
|
||||
const iconEntry = AUTH_FILE_FILTER_ICONS[normalizeProviderKey(type)];
|
||||
if (!iconEntry) return null;
|
||||
return typeof iconEntry === 'string'
|
||||
? iconEntry
|
||||
: resolvedTheme === 'dark'
|
||||
? iconEntry.dark
|
||||
: iconEntry.light;
|
||||
};
|
||||
|
||||
export function AuthFilesPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -64,6 +104,7 @@ export function AuthFilesPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [filter, setFilter] = useState<'all' | string>('all');
|
||||
const [problemOnly, setProblemOnly] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(9);
|
||||
@@ -71,6 +112,7 @@ export function AuthFilesPage() {
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<AuthFileItem | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
|
||||
const [sortMode, setSortMode] = useState<AuthFilesSortMode>('default');
|
||||
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
|
||||
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
|
||||
const batchActionAnimationRef = useRef<AnimationPlaybackControlsWithThen | null>(null);
|
||||
@@ -162,6 +204,9 @@ export function AuthFilesPage() {
|
||||
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
|
||||
setFilter(persisted.filter);
|
||||
}
|
||||
if (typeof persisted.problemOnly === 'boolean') {
|
||||
setProblemOnly(persisted.problemOnly);
|
||||
}
|
||||
if (typeof persisted.search === 'string') {
|
||||
setSearch(persisted.search);
|
||||
}
|
||||
@@ -171,11 +216,14 @@ export function AuthFilesPage() {
|
||||
if (typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)) {
|
||||
setPageSize(clampCardPageSize(persisted.pageSize));
|
||||
}
|
||||
if (isAuthFilesSortMode(persisted.sortMode)) {
|
||||
setSortMode(persisted.sortMode);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
writeAuthFilesUiState({ filter, search, page, pageSize });
|
||||
}, [filter, search, page, pageSize]);
|
||||
writeAuthFilesUiState({ filter, problemOnly, search, page, pageSize, sortMode });
|
||||
}, [filter, problemOnly, search, page, pageSize, sortMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageSizeInput(String(pageSize));
|
||||
@@ -217,6 +265,16 @@ export function AuthFilesPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSortModeChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!isAuthFilesSortMode(value) || value === sortMode) return;
|
||||
setSortMode(value);
|
||||
setPage(1);
|
||||
void loadFiles().catch(() => {});
|
||||
},
|
||||
[loadFiles, sortMode]
|
||||
);
|
||||
|
||||
const handleHeaderRefresh = useCallback(async () => {
|
||||
await Promise.all([loadFiles(), refreshKeyStats(), loadExcluded(), loadModelAlias()]);
|
||||
}, [loadFiles, refreshKeyStats, loadExcluded, loadModelAlias]);
|
||||
@@ -248,17 +306,31 @@ export function AuthFilesPage() {
|
||||
return Array.from(types);
|
||||
}, [files]);
|
||||
|
||||
const filesMatchingProblemFilter = useMemo(
|
||||
() => (problemOnly ? files.filter(hasAuthFileStatusMessage) : files),
|
||||
[files, problemOnly]
|
||||
);
|
||||
|
||||
const sortOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'default', label: t('auth_files.sort_default') },
|
||||
{ value: 'az', label: t('auth_files.sort_az') },
|
||||
{ value: 'priority', label: t('auth_files.sort_priority') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = { all: files.length };
|
||||
files.forEach((file) => {
|
||||
const counts: Record<string, number> = { all: filesMatchingProblemFilter.length };
|
||||
filesMatchingProblemFilter.forEach((file) => {
|
||||
if (!file.type) return;
|
||||
counts[file.type] = (counts[file.type] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [files]);
|
||||
}, [filesMatchingProblemFilter]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return files.filter((item) => {
|
||||
return filesMatchingProblemFilter.filter((item) => {
|
||||
const matchType = filter === 'all' || item.type === filter;
|
||||
const term = search.trim().toLowerCase();
|
||||
const matchSearch =
|
||||
@@ -268,12 +340,34 @@ export function AuthFilesPage() {
|
||||
(item.provider || '').toString().toLowerCase().includes(term);
|
||||
return matchType && matchSearch;
|
||||
});
|
||||
}, [files, filter, search]);
|
||||
}, [filesMatchingProblemFilter, filter, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...filtered];
|
||||
if (sortMode === 'default') {
|
||||
copy.sort((a, b) => {
|
||||
const providerA = normalizeProviderKey(String(a.provider ?? a.type ?? 'unknown'));
|
||||
const providerB = normalizeProviderKey(String(b.provider ?? b.type ?? 'unknown'));
|
||||
const providerCompare = providerA.localeCompare(providerB);
|
||||
if (providerCompare !== 0) return providerCompare;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
} else if (sortMode === 'az') {
|
||||
copy.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} else if (sortMode === 'priority') {
|
||||
copy.sort((a, b) => {
|
||||
const pa = parsePriorityValue(a.priority ?? a['priority']) ?? 0;
|
||||
const pb = parsePriorityValue(b.priority ?? b['priority']) ?? 0;
|
||||
return pb - pa; // 高优先级排前面
|
||||
});
|
||||
}
|
||||
return copy;
|
||||
}, [filtered, sortMode]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
const pageItems = filtered.slice(start, start + pageSize);
|
||||
const pageItems = sorted.slice(start, start + pageSize);
|
||||
const selectablePageItems = useMemo(
|
||||
() => pageItems.filter((file) => !isRuntimeOnlyAuthFile(file)),
|
||||
[pageItems]
|
||||
@@ -422,6 +516,7 @@ export function AuthFilesPage() {
|
||||
<div className={styles.filterTags}>
|
||||
{existingTypes.map((type) => {
|
||||
const isActive = filter === type;
|
||||
const iconSrc = getFilterTagIcon(type, resolvedTheme);
|
||||
const color =
|
||||
type === 'all'
|
||||
? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' }
|
||||
@@ -441,7 +536,10 @@ export function AuthFilesPage() {
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<span className={styles.filterTagLabel}>{getTypeLabel(t, type)}</span>
|
||||
<span className={styles.filterTagLabel}>
|
||||
{iconSrc && <img src={iconSrc} alt="" className={styles.filterTagIcon} />}
|
||||
<span>{getTypeLabel(t, type)}</span>
|
||||
</span>
|
||||
<span className={styles.filterTagCount}>{typeCounts[type] ?? 0}</span>
|
||||
</button>
|
||||
);
|
||||
@@ -456,6 +554,14 @@ export function AuthFilesPage() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const deleteAllButtonLabel = problemOnly
|
||||
? filter === 'all'
|
||||
? t('auth_files.delete_problem_button')
|
||||
: t('auth_files.delete_problem_button_with_type', { type: getTypeLabel(t, filter) })
|
||||
: filter === 'all'
|
||||
? t('auth_files.delete_all_button')
|
||||
: `${t('common.delete')} ${getTypeLabel(t, filter)}`;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.pageHeader}>
|
||||
@@ -482,14 +588,17 @@ export function AuthFilesPage() {
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleDeleteAll({ filter, onResetFilterToAll: () => setFilter('all') })
|
||||
handleDeleteAll({
|
||||
filter,
|
||||
problemOnly,
|
||||
onResetFilterToAll: () => setFilter('all'),
|
||||
onResetProblemOnly: () => setProblemOnly(false),
|
||||
})
|
||||
}
|
||||
disabled={disableControls || loading || deletingAll}
|
||||
loading={deletingAll}
|
||||
>
|
||||
{filter === 'all'
|
||||
? t('auth_files.delete_all_button')
|
||||
: `${t('common.delete')} ${getTypeLabel(t, filter)}`}
|
||||
{deleteAllButtonLabel}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -537,6 +646,35 @@ export function AuthFilesPage() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<label>{t('auth_files.sort_label')}</label>
|
||||
<Select
|
||||
className={styles.sortSelect}
|
||||
value={sortMode}
|
||||
options={sortOptions}
|
||||
onChange={handleSortModeChange}
|
||||
ariaLabel={t('auth_files.sort_label')}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.filterItem} ${styles.filterToggleItem}`}>
|
||||
<label>{t('auth_files.problem_filter_label')}</label>
|
||||
<div className={styles.filterToggle}>
|
||||
<ToggleSwitch
|
||||
checked={problemOnly}
|
||||
onChange={(value) => {
|
||||
setProblemOnly(value);
|
||||
setPage(1);
|
||||
}}
|
||||
ariaLabel={t('auth_files.problem_filter_only')}
|
||||
label={
|
||||
<span className={styles.filterToggleLabel}>
|
||||
{t('auth_files.problem_filter_only')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -575,7 +713,7 @@ export function AuthFilesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filtered.length > pageSize && (
|
||||
{!loading && sorted.length > pageSize && (
|
||||
<div className={styles.pagination}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -589,7 +727,7 @@ export function AuthFilesPage() {
|
||||
{t('auth_files.pagination_info', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
count: filtered.length,
|
||||
count: sorted.length,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -169,22 +169,8 @@
|
||||
|
||||
// 语言下拉选择
|
||||
.languageSelect {
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
height: 40px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba($primary-color, 0.18);
|
||||
}
|
||||
min-width: 108px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
// 连接信息框
|
||||
@@ -218,19 +204,13 @@
|
||||
.toggleAdvanced {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
label {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 错误提示框
|
||||
|
||||
+26
-22
@@ -3,6 +3,8 @@ import { Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
||||
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||
@@ -89,9 +91,16 @@ export function LoginPage() {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
||||
const languageOptions = useMemo(
|
||||
() =>
|
||||
LANGUAGE_ORDER.map((lang) => ({
|
||||
value: lang,
|
||||
label: t(LANGUAGE_LABEL_KEYS[lang])
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const handleLanguageChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedLanguage = event.target.value;
|
||||
(selectedLanguage: string) => {
|
||||
if (!isSupportedLanguage(selectedLanguage)) {
|
||||
return;
|
||||
}
|
||||
@@ -205,19 +214,14 @@ export function LoginPage() {
|
||||
<div className={styles.loginHeader}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.title}>{t('title.login')}</div>
|
||||
<select
|
||||
<Select
|
||||
className={styles.languageSelect}
|
||||
value={language}
|
||||
options={languageOptions}
|
||||
onChange={handleLanguageChange}
|
||||
title={t('language.switch')}
|
||||
aria-label={t('language.switch')}
|
||||
>
|
||||
{LANGUAGE_ORDER.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{t(LANGUAGE_LABEL_KEYS[lang])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
fullWidth={false}
|
||||
ariaLabel={t('language.switch')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.subtitle}>{t('login.subtitle')}</div>
|
||||
</div>
|
||||
@@ -229,13 +233,13 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.toggleAdvanced}>
|
||||
<input
|
||||
id="custom-connection-toggle"
|
||||
type="checkbox"
|
||||
<SelectionCheckbox
|
||||
checked={showCustomBase}
|
||||
onChange={(e) => setShowCustomBase(e.target.checked)}
|
||||
onChange={setShowCustomBase}
|
||||
ariaLabel={t('login.custom_connection_label')}
|
||||
label={t('login.custom_connection_label')}
|
||||
labelClassName={styles.toggleLabel}
|
||||
/>
|
||||
<label htmlFor="custom-connection-toggle">{t('login.custom_connection_label')}</label>
|
||||
</div>
|
||||
|
||||
{showCustomBase && (
|
||||
@@ -278,13 +282,13 @@ export function LoginPage() {
|
||||
/>
|
||||
|
||||
<div className={styles.toggleAdvanced}>
|
||||
<input
|
||||
id="remember-password-toggle"
|
||||
type="checkbox"
|
||||
<SelectionCheckbox
|
||||
checked={rememberPassword}
|
||||
onChange={(e) => setRememberPassword(e.target.checked)}
|
||||
onChange={setRememberPassword}
|
||||
ariaLabel={t('login.remember_password_label')}
|
||||
label={t('login.remember_password_label')}
|
||||
labelClassName={styles.toggleLabel}
|
||||
/>
|
||||
<label htmlFor="remember-password-toggle">{t('login.remember_password_label')}</label>
|
||||
</div>
|
||||
|
||||
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
||||
|
||||
@@ -121,6 +121,33 @@
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.filterPanelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.filterPanelToggle {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterPanelButtonContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filterPanelCount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-full;
|
||||
background: rgba($primary-color, 0.12);
|
||||
color: var(--primary-color);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.structuredFilters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -200,6 +227,20 @@
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.filterPanelHeader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filterPanelToggle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filterPanelButtonContent {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterChipGroup {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
+120
-75
@@ -8,16 +8,20 @@ import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import {
|
||||
IconDownload,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconCode,
|
||||
IconDownload,
|
||||
IconEyeOff,
|
||||
IconRefreshCw,
|
||||
IconSearch,
|
||||
IconSlidersHorizontal,
|
||||
IconTimer,
|
||||
IconTrash2,
|
||||
IconX,
|
||||
} from '@/components/ui/icons';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { logsApi } from '@/services/api/logs';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
@@ -79,6 +83,10 @@ export function LogsPage() {
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [hideManagementLogs, setHideManagementLogs] = useState(true);
|
||||
const [showRawLogs, setShowRawLogs] = useState(false);
|
||||
const [structuredFiltersExpanded, setStructuredFiltersExpanded] = useLocalStorage(
|
||||
'logsPage.structuredFiltersExpanded',
|
||||
true
|
||||
);
|
||||
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
||||
const [loadingErrors, setLoadingErrors] = useState(false);
|
||||
const [errorLogsError, setErrorLogsError] = useState('');
|
||||
@@ -305,6 +313,9 @@ export function LogsPage() {
|
||||
}, [baseLines, hideManagementLogs, trimmedSearchQuery]);
|
||||
|
||||
const filters = useLogFilters({ parsedLines: parsedSearchLines });
|
||||
const structuredFiltersPanelId = 'logs-structured-filters';
|
||||
const structuredFilterCount =
|
||||
filters.methodFilters.length + filters.statusFilters.length + filters.pathFilters.length;
|
||||
|
||||
const { filteredParsedLines, filteredLines, removedCount } = useMemo(() => {
|
||||
const filteredParsed = parsedSearchLines.filter((line) => {
|
||||
@@ -498,86 +509,120 @@ export function LogsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.structuredFilters}>
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_method')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{HTTP_METHODS.map((method) => {
|
||||
const active = filters.methodFilters.includes(method);
|
||||
const count = filters.methodCounts[method] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => filters.toggleMethodFilter(method)}
|
||||
disabled={count === 0 && !active}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{method} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_status')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{STATUS_GROUPS.map((statusGroup) => {
|
||||
const active = filters.statusFilters.includes(statusGroup);
|
||||
const count = filters.statusCounts[statusGroup] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={statusGroup}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => filters.toggleStatusFilter(statusGroup)}
|
||||
disabled={count === 0 && !active}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{t(`logs.filter_status_${statusGroup}`)} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_path')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{filters.pathOptions.length === 0 ? (
|
||||
<span className={styles.filterChipHint}>{t('logs.filter_path_empty')}</span>
|
||||
) : (
|
||||
filters.pathOptions.map(({ path, count }) => {
|
||||
const active = filters.pathFilters.includes(path);
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => filters.togglePathFilter(path)}
|
||||
aria-pressed={active}
|
||||
title={path}
|
||||
>
|
||||
{path} ({count})
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterPanelHeader}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={filters.clearStructuredFilters}
|
||||
disabled={!filters.hasStructuredFilters}
|
||||
className={styles.filterPanelToggle}
|
||||
onClick={() => setStructuredFiltersExpanded((prev) => !prev)}
|
||||
aria-expanded={structuredFiltersExpanded}
|
||||
aria-controls={structuredFiltersPanelId}
|
||||
title={
|
||||
structuredFiltersExpanded
|
||||
? t('logs.filter_panel_collapse')
|
||||
: t('logs.filter_panel_expand')
|
||||
}
|
||||
>
|
||||
{t('logs.clear_filters')}
|
||||
<span className={styles.filterPanelButtonContent}>
|
||||
<IconSlidersHorizontal size={16} />
|
||||
<span>{t('logs.filter_panel_title')}</span>
|
||||
{structuredFilterCount > 0 && (
|
||||
<span className={styles.filterPanelCount}>
|
||||
{t('logs.filter_panel_active_count', { count: structuredFilterCount })}
|
||||
</span>
|
||||
)}
|
||||
{structuredFiltersExpanded ? (
|
||||
<IconChevronUp size={16} />
|
||||
) : (
|
||||
<IconChevronDown size={16} />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{structuredFiltersExpanded && (
|
||||
<div id={structuredFiltersPanelId} className={styles.structuredFilters}>
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_method')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{HTTP_METHODS.map((method) => {
|
||||
const active = filters.methodFilters.includes(method);
|
||||
const count = filters.methodCounts[method] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => filters.toggleMethodFilter(method)}
|
||||
disabled={count === 0 && !active}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{method} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_status')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{STATUS_GROUPS.map((statusGroup) => {
|
||||
const active = filters.statusFilters.includes(statusGroup);
|
||||
const count = filters.statusCounts[statusGroup] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={statusGroup}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => filters.toggleStatusFilter(statusGroup)}
|
||||
disabled={count === 0 && !active}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{t(`logs.filter_status_${statusGroup}`)} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_path')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{filters.pathOptions.length === 0 ? (
|
||||
<span className={styles.filterChipHint}>{t('logs.filter_path_empty')}</span>
|
||||
) : (
|
||||
filters.pathOptions.map(({ path, count }) => {
|
||||
const active = filters.pathFilters.includes(path);
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => filters.togglePathFilter(path)}
|
||||
aria-pressed={active}
|
||||
title={path}
|
||||
>
|
||||
{path} ({count})
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={filters.clearStructuredFilters}
|
||||
disabled={!filters.hasStructuredFilters}
|
||||
>
|
||||
{t('logs.clear_filters')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToggleSwitch
|
||||
checked={hideManagementLogs}
|
||||
onChange={setHideManagementLogs}
|
||||
|
||||
@@ -8,8 +8,7 @@ import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/se
|
||||
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import styles from './OAuthPage.module.scss';
|
||||
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||
import iconCodex from '@/assets/icons/codex.svg';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import iconAntigravity from '@/assets/icons/antigravity.svg';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
@@ -73,7 +72,7 @@ function getErrorStatus(error: unknown): number | undefined {
|
||||
}
|
||||
|
||||
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
|
||||
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconCodexLight, dark: iconCodexDark } },
|
||||
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: iconCodex },
|
||||
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
|
||||
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
|
||||
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
|
||||
|
||||
@@ -318,6 +318,45 @@
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.premiumPlanValue {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #fdf3d7, #f0d060);
|
||||
border: 1px solid #e0b830;
|
||||
box-shadow: 0 1px 4px rgba(200, 160, 0, 0.18);
|
||||
color: #7a5c00;
|
||||
text-transform: capitalize;
|
||||
|
||||
@media (dynamic-range: high) {
|
||||
background: linear-gradient(135deg,
|
||||
color(display-p3 0.99 0.95 0.82),
|
||||
color(display-p3 0.97 0.82 0.28));
|
||||
border-color: color(display-p3 0.90 0.73 0.12);
|
||||
box-shadow: 0 1px 8px color(display-p3 1.0 0.84 0.0 / 0.25);
|
||||
color: color(display-p3 0.50 0.38 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .premiumPlanValue {
|
||||
background: linear-gradient(135deg, #3d3210, #5a4a18);
|
||||
border-color: #8b7030;
|
||||
box-shadow: 0 1px 8px rgba(180, 140, 0, 0.2);
|
||||
color: #e8c84c;
|
||||
|
||||
@media (dynamic-range: high) {
|
||||
background: linear-gradient(135deg,
|
||||
color(display-p3 0.24 0.20 0.06),
|
||||
color(display-p3 0.38 0.30 0.08));
|
||||
border-color: color(display-p3 0.58 0.46 0.15);
|
||||
box-shadow: 0 1px 12px color(display-p3 0.80 0.65 0.0 / 0.2);
|
||||
color: color(display-p3 0.95 0.82 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.fileCard {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
@@ -78,7 +78,10 @@
|
||||
color: inherit;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
@@ -97,6 +100,19 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tileHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.tileAction {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
margin: -4px -8px 0 0;
|
||||
}
|
||||
|
||||
.tileValue {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
@@ -111,12 +127,6 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.aboutActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: $spacing-lg;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -212,7 +222,9 @@
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
.modelName {
|
||||
|
||||
+241
-154
@@ -5,8 +5,14 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore, useThemeStore } from '@/stores';
|
||||
import { configApi } from '@/services/api';
|
||||
import {
|
||||
useAuthStore,
|
||||
useConfigStore,
|
||||
useNotificationStore,
|
||||
useModelsStore,
|
||||
useThemeStore,
|
||||
} from '@/stores';
|
||||
import { configApi, versionApi } from '@/services/api';
|
||||
import { apiKeysApi } from '@/services/api/apiKeys';
|
||||
import { classifyModels } from '@/utils/models';
|
||||
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
||||
@@ -36,6 +42,32 @@ const MODEL_CATEGORY_ICONS: Record<string, string | { light: string; dark: strin
|
||||
minimax: iconMinimax,
|
||||
};
|
||||
|
||||
const parseVersionSegments = (version?: string | null) => {
|
||||
if (!version) return null;
|
||||
const cleaned = version.trim().replace(/^v/i, '');
|
||||
if (!cleaned) return null;
|
||||
const parts = cleaned
|
||||
.split(/[^0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => Number.parseInt(segment, 10))
|
||||
.filter(Number.isFinite);
|
||||
return parts.length ? parts : null;
|
||||
};
|
||||
|
||||
const compareVersions = (latest?: string | null, current?: string | null) => {
|
||||
const latestParts = parseVersionSegments(latest);
|
||||
const currentParts = parseVersionSegments(current);
|
||||
if (!latestParts || !currentParts) return null;
|
||||
const length = Math.max(latestParts.length, currentParts.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const l = latestParts[i] || 0;
|
||||
const c = currentParts[i] || 0;
|
||||
if (l > c) return 1;
|
||||
if (l < c) return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export function SystemPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
@@ -51,11 +83,15 @@ export function SystemPage() {
|
||||
const modelsError = useModelsStore((state) => state.error);
|
||||
const fetchModelsFromStore = useModelsStore((state) => state.fetchModels);
|
||||
|
||||
const [modelStatus, setModelStatus] = useState<{ type: 'success' | 'warning' | 'error' | 'muted'; message: string }>();
|
||||
const [modelStatus, setModelStatus] = useState<{
|
||||
type: 'success' | 'warning' | 'error' | 'muted';
|
||||
message: string;
|
||||
}>();
|
||||
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
|
||||
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
||||
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
||||
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||
|
||||
const apiKeysCache = useRef<string[]>([]);
|
||||
const versionTapCount = useRef(0);
|
||||
@@ -136,7 +172,7 @@ export function SystemPage() {
|
||||
if (auth.connectionStatus !== 'connected') {
|
||||
setModelStatus({
|
||||
type: 'warning',
|
||||
message: t('notification.connection_required')
|
||||
message: t('notification.connection_required'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -158,11 +194,12 @@ export function SystemPage() {
|
||||
const hasModels = list.length > 0;
|
||||
setModelStatus({
|
||||
type: hasModels ? 'success' : 'warning',
|
||||
message: hasModels ? t('system_info.models_count', { count: list.length }) : t('system_info.models_empty')
|
||||
message: hasModels
|
||||
? t('system_info.models_count', { count: list.length })
|
||||
: t('system_info.models_empty'),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
||||
const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
||||
const suffix = message ? `: ${message}` : '';
|
||||
const text = `${t('system_info.models_error')}${suffix}`;
|
||||
setModelStatus({ type: 'error', message: text });
|
||||
@@ -244,6 +281,39 @@ export function SystemPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleVersionCheck = useCallback(async () => {
|
||||
setCheckingVersion(true);
|
||||
try {
|
||||
const data = await versionApi.checkLatest();
|
||||
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
||||
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
|
||||
const comparison = compareVersions(latest, auth.serverVersion);
|
||||
|
||||
if (!latest) {
|
||||
showNotification(t('system_info.version_check_error'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison === null) {
|
||||
showNotification(t('system_info.version_current_missing'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison > 0) {
|
||||
showNotification(t('system_info.version_update_available', { version: latest }), 'warning');
|
||||
} else {
|
||||
showNotification(t('system_info.version_is_latest'), 'success');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
||||
const suffix = message ? `: ${message}` : '';
|
||||
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
|
||||
} finally {
|
||||
setCheckingVersion(false);
|
||||
}
|
||||
}, [auth.serverVersion, showNotification, t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig().catch(() => {
|
||||
// ignore
|
||||
@@ -273,160 +343,177 @@ export function SystemPage() {
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('system_info.title')}</h1>
|
||||
<div className={styles.content}>
|
||||
<Card className={styles.aboutCard}>
|
||||
<div className={styles.aboutHeader}>
|
||||
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.aboutLogo} />
|
||||
<div className={styles.aboutTitle}>{t('system_info.about_title')}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.aboutInfoGrid}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.infoTile} ${styles.tapTile}`}
|
||||
onClick={handleInfoVersionTap}
|
||||
>
|
||||
<div className={styles.tileLabel}>{t('footer.version')}</div>
|
||||
<div className={styles.tileValue}>{appVersion}</div>
|
||||
</button>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileLabel}>{t('footer.api_version')}</div>
|
||||
<div className={styles.tileValue}>{apiVersion}</div>
|
||||
<Card className={styles.aboutCard}>
|
||||
<div className={styles.aboutHeader}>
|
||||
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.aboutLogo} />
|
||||
<div className={styles.aboutTitle}>{t('system_info.about_title')}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileLabel}>{t('footer.build_date')}</div>
|
||||
<div className={styles.tileValue}>{buildTime}</div>
|
||||
<div className={styles.aboutInfoGrid}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.infoTile} ${styles.tapTile}`}
|
||||
onClick={handleInfoVersionTap}
|
||||
>
|
||||
<div className={styles.tileLabel}>{t('footer.version')}</div>
|
||||
<div className={styles.tileValue}>{appVersion}</div>
|
||||
</button>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileHeader}>
|
||||
<div className={styles.tileLabel}>{t('footer.api_version')}</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.tileAction}
|
||||
onClick={() => void handleVersionCheck()}
|
||||
loading={checkingVersion}
|
||||
title={t('system_info.version_check_button')}
|
||||
aria-label={t('system_info.version_check_button')}
|
||||
>
|
||||
{t('system_info.version_check_button')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.tileValue}>{apiVersion}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileLabel}>{t('footer.build_date')}</div>
|
||||
<div className={styles.tileValue}>{buildTime}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileLabel}>{t('connection.status')}</div>
|
||||
<div className={styles.tileValue}>{t(`common.${auth.connectionStatus}_status`)}</div>
|
||||
<div className={styles.tileSub}>{auth.apiBase || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileLabel}>{t('connection.status')}</div>
|
||||
<div className={styles.tileValue}>{t(`common.${auth.connectionStatus}_status`)}</div>
|
||||
<div className={styles.tileSub}>{auth.apiBase || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.aboutActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => fetchConfig(undefined, true)}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('system_info.quick_links_title')}>
|
||||
<p className={styles.sectionDescription}>{t('system_info.quick_links_desc')}</p>
|
||||
<div className={styles.quickLinks}>
|
||||
<a
|
||||
href="https://github.com/router-for-me/CLIProxyAPI"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.linkCard}
|
||||
>
|
||||
<div className={`${styles.linkIcon} ${styles.github}`}>
|
||||
<IconGithub size={22} />
|
||||
</div>
|
||||
<div className={styles.linkContent}>
|
||||
<div className={styles.linkTitle}>
|
||||
{t('system_info.link_main_repo')}
|
||||
<IconExternalLink size={14} />
|
||||
<Card title={t('system_info.quick_links_title')}>
|
||||
<p className={styles.sectionDescription}>{t('system_info.quick_links_desc')}</p>
|
||||
<div className={styles.quickLinks}>
|
||||
<a
|
||||
href="https://github.com/router-for-me/CLIProxyAPI"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.linkCard}
|
||||
>
|
||||
<div className={`${styles.linkIcon} ${styles.github}`}>
|
||||
<IconGithub size={22} />
|
||||
</div>
|
||||
<div className={styles.linkDesc}>{t('system_info.link_main_repo_desc')}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.linkCard}
|
||||
>
|
||||
<div className={`${styles.linkIcon} ${styles.github}`}>
|
||||
<IconCode size={22} />
|
||||
</div>
|
||||
<div className={styles.linkContent}>
|
||||
<div className={styles.linkTitle}>
|
||||
{t('system_info.link_webui_repo')}
|
||||
<IconExternalLink size={14} />
|
||||
</div>
|
||||
<div className={styles.linkDesc}>{t('system_info.link_webui_repo_desc')}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://help.router-for.me/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.linkCard}
|
||||
>
|
||||
<div className={`${styles.linkIcon} ${styles.docs}`}>
|
||||
<IconBookOpen size={22} />
|
||||
</div>
|
||||
<div className={styles.linkContent}>
|
||||
<div className={styles.linkTitle}>
|
||||
{t('system_info.link_docs')}
|
||||
<IconExternalLink size={14} />
|
||||
</div>
|
||||
<div className={styles.linkDesc}>{t('system_info.link_docs_desc')}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t('system_info.models_title')}
|
||||
extra={
|
||||
<Button variant="secondary" size="sm" onClick={() => fetchModels({ forceRefresh: true })} loading={modelsLoading}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className={styles.sectionDescription}>{t('system_info.models_desc')}</p>
|
||||
{modelStatus && <div className={`status-badge ${modelStatus.type}`}>{modelStatus.message}</div>}
|
||||
{modelsError && <div className="error-box">{modelsError}</div>}
|
||||
{modelsLoading ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="hint">{t('system_info.models_empty')}</div>
|
||||
) : (
|
||||
<div className="item-list">
|
||||
{groupedModels.map((group) => {
|
||||
const iconSrc = getIconForCategory(group.id);
|
||||
return (
|
||||
<div key={group.id} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className={styles.groupTitle}>
|
||||
{iconSrc && <img src={iconSrc} alt="" className={styles.groupIcon} />}
|
||||
<span className="item-title">{group.label}</span>
|
||||
</div>
|
||||
<div className="item-subtitle">{t('system_info.models_count', { count: group.items.length })}</div>
|
||||
</div>
|
||||
<div className={styles.modelTags}>
|
||||
{group.items.map((model) => (
|
||||
<span
|
||||
key={`${model.name}-${model.alias ?? 'default'}`}
|
||||
className={styles.modelTag}
|
||||
title={model.description || ''}
|
||||
>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && <span className={styles.modelAlias}>{model.alias}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.linkContent}>
|
||||
<div className={styles.linkTitle}>
|
||||
{t('system_info.link_main_repo')}
|
||||
<IconExternalLink size={14} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<div className={styles.linkDesc}>{t('system_info.link_main_repo_desc')}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<Card title={t('system_info.clear_login_title')}>
|
||||
<p className={styles.sectionDescription}>{t('system_info.clear_login_desc')}</p>
|
||||
<div className={styles.clearLoginActions}>
|
||||
<Button variant="danger" onClick={handleClearLoginStorage}>
|
||||
{t('system_info.clear_login_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<a
|
||||
href="https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.linkCard}
|
||||
>
|
||||
<div className={`${styles.linkIcon} ${styles.github}`}>
|
||||
<IconCode size={22} />
|
||||
</div>
|
||||
<div className={styles.linkContent}>
|
||||
<div className={styles.linkTitle}>
|
||||
{t('system_info.link_webui_repo')}
|
||||
<IconExternalLink size={14} />
|
||||
</div>
|
||||
<div className={styles.linkDesc}>{t('system_info.link_webui_repo_desc')}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://help.router-for.me/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.linkCard}
|
||||
>
|
||||
<div className={`${styles.linkIcon} ${styles.docs}`}>
|
||||
<IconBookOpen size={22} />
|
||||
</div>
|
||||
<div className={styles.linkContent}>
|
||||
<div className={styles.linkTitle}>
|
||||
{t('system_info.link_docs')}
|
||||
<IconExternalLink size={14} />
|
||||
</div>
|
||||
<div className={styles.linkDesc}>{t('system_info.link_docs_desc')}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t('system_info.models_title')}
|
||||
extra={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fetchModels({ forceRefresh: true })}
|
||||
loading={modelsLoading}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className={styles.sectionDescription}>{t('system_info.models_desc')}</p>
|
||||
{modelStatus && (
|
||||
<div className={`status-badge ${modelStatus.type}`}>{modelStatus.message}</div>
|
||||
)}
|
||||
{modelsError && <div className="error-box">{modelsError}</div>}
|
||||
{modelsLoading ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="hint">{t('system_info.models_empty')}</div>
|
||||
) : (
|
||||
<div className="item-list">
|
||||
{groupedModels.map((group) => {
|
||||
const iconSrc = getIconForCategory(group.id);
|
||||
return (
|
||||
<div key={group.id} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className={styles.groupTitle}>
|
||||
{iconSrc && <img src={iconSrc} alt="" className={styles.groupIcon} />}
|
||||
<span className="item-title">{group.label}</span>
|
||||
</div>
|
||||
<div className="item-subtitle">
|
||||
{t('system_info.models_count', { count: group.items.length })}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modelTags}>
|
||||
{group.items.map((model) => (
|
||||
<span
|
||||
key={`${model.name}-${model.alias ?? 'default'}`}
|
||||
className={styles.modelTag}
|
||||
title={model.description || ''}
|
||||
>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && <span className={styles.modelAlias}>{model.alias}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title={t('system_info.clear_login_title')}>
|
||||
<p className={styles.sectionDescription}>{t('system_info.clear_login_desc')}</p>
|
||||
<div className={styles.clearLoginActions}>
|
||||
<Button variant="danger" onClick={handleClearLoginStorage}>
|
||||
{t('system_info.clear_login_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -3,8 +3,18 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import { normalizeAmpcodeConfig, normalizeAmpcodeModelMappings } from './transformers';
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping } from '@/types';
|
||||
import {
|
||||
normalizeAmpcodeConfig,
|
||||
normalizeAmpcodeModelMappings,
|
||||
normalizeAmpcodeUpstreamApiKeys,
|
||||
} from './transformers';
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping } from '@/types';
|
||||
|
||||
const serializeUpstreamApiKeyMappings = (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
|
||||
mappings.map((mapping) => ({
|
||||
'upstream-api-key': mapping.upstreamApiKey,
|
||||
'api-keys': mapping.apiKeys,
|
||||
}));
|
||||
|
||||
export const ampcodeApi = {
|
||||
async getAmpcode(): Promise<AmpcodeConfig> {
|
||||
@@ -18,6 +28,19 @@ export const ampcodeApi = {
|
||||
updateUpstreamApiKey: (apiKey: string) => apiClient.put('/ampcode/upstream-api-key', { value: apiKey }),
|
||||
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
|
||||
|
||||
async getUpstreamApiKeys(): Promise<AmpcodeUpstreamApiKeyMapping[]> {
|
||||
const data = await apiClient.get<Record<string, unknown>>('/ampcode/upstream-api-keys');
|
||||
const list = data?.['upstream-api-keys'] ?? data?.upstreamApiKeys ?? data?.items ?? data;
|
||||
return normalizeAmpcodeUpstreamApiKeys(list);
|
||||
},
|
||||
|
||||
saveUpstreamApiKeys: (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
|
||||
apiClient.put('/ampcode/upstream-api-keys', { value: serializeUpstreamApiKeyMappings(mappings) }),
|
||||
patchUpstreamApiKeys: (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
|
||||
apiClient.patch('/ampcode/upstream-api-keys', { value: serializeUpstreamApiKeyMappings(mappings) }),
|
||||
deleteUpstreamApiKeys: (upstreamApiKeys: string[]) =>
|
||||
apiClient.delete('/ampcode/upstream-api-keys', { data: { value: upstreamApiKeys } }),
|
||||
|
||||
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
|
||||
const data = await apiClient.get<Record<string, unknown>>('/ampcode/model-mappings');
|
||||
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
|
||||
@@ -34,4 +57,3 @@ export const ampcodeApi = {
|
||||
|
||||
updateForceModelMappings: (enabled: boolean) => apiClient.put('/ampcode/force-model-mappings', { value: enabled })
|
||||
};
|
||||
|
||||
|
||||
@@ -9,12 +9,39 @@ import type { OAuthModelAliasEntry } from '@/types';
|
||||
type StatusError = { status?: number };
|
||||
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||
|
||||
export const AUTH_FILE_INVALID_JSON_OBJECT_ERROR = 'AUTH_FILE_INVALID_JSON_OBJECT';
|
||||
|
||||
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 parseAuthFileJsonObject = (rawText: string): Record<string, unknown> => {
|
||||
const trimmed = rawText.trim();
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
throw new Error(AUTH_FILE_INVALID_JSON_OBJECT_ERROR);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(AUTH_FILE_INVALID_JSON_OBJECT_ERROR);
|
||||
}
|
||||
|
||||
return { ...(parsed as Record<string, unknown>) };
|
||||
};
|
||||
|
||||
const saveAuthFileText = async (name: string, text: string) => {
|
||||
const file = new File([text], name, { type: 'application/json' });
|
||||
await authFilesApi.upload(file);
|
||||
};
|
||||
|
||||
export const isAuthFileInvalidJsonObjectError = (err: unknown): boolean =>
|
||||
err instanceof Error && err.message === AUTH_FILE_INVALID_JSON_OBJECT_ERROR;
|
||||
|
||||
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
|
||||
if (!payload || typeof payload !== 'object') return {};
|
||||
|
||||
@@ -126,6 +153,16 @@ export const authFilesApi = {
|
||||
return blob.text();
|
||||
},
|
||||
|
||||
async downloadJsonObject(name: string): Promise<Record<string, unknown>> {
|
||||
const rawText = await authFilesApi.downloadText(name);
|
||||
return parseAuthFileJsonObject(rawText);
|
||||
},
|
||||
|
||||
saveText: (name: string, text: string) => saveAuthFileText(name, text),
|
||||
|
||||
saveJsonObject: (name: string, json: Record<string, unknown>) =>
|
||||
saveAuthFileText(name, JSON.stringify(json)),
|
||||
|
||||
// OAuth 排除模型
|
||||
async getOauthExcludedModels(): Promise<Record<string, string[]>> {
|
||||
const data = await apiClient.get('/oauth-excluded-models');
|
||||
|
||||
@@ -107,6 +107,9 @@ const serializeVertexKey = (config: ProviderKeyConfig) => {
|
||||
if (headers) payload.headers = headers;
|
||||
const models = serializeVertexModelAliases(config.models);
|
||||
if (models && models.length) payload.models = models;
|
||||
if (config.excludedModels && config.excludedModels.length) {
|
||||
payload['excluded-models'] = config.excludedModels;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
OpenAIProviderConfig,
|
||||
ProviderKeyConfig,
|
||||
AmpcodeConfig,
|
||||
AmpcodeModelMapping
|
||||
AmpcodeModelMapping,
|
||||
AmpcodeUpstreamApiKeyMapping
|
||||
} from '@/types';
|
||||
import type { Config } from '@/types/config';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
@@ -276,6 +277,33 @@ const normalizeAmpcodeModelMappings = (input: unknown): AmpcodeModelMapping[] =>
|
||||
return mappings;
|
||||
};
|
||||
|
||||
const normalizeAmpcodeUpstreamApiKeys = (input: unknown): AmpcodeUpstreamApiKeyMapping[] => {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const mappings: AmpcodeUpstreamApiKeyMapping[] = [];
|
||||
|
||||
input.forEach((entry) => {
|
||||
if (!isRecord(entry)) return;
|
||||
|
||||
const upstreamApiKey = String(
|
||||
entry['upstream-api-key'] ?? entry.upstreamApiKey ?? entry['upstream_api_key'] ?? ''
|
||||
).trim();
|
||||
if (!upstreamApiKey || seen.has(upstreamApiKey)) return;
|
||||
|
||||
const rawApiKeys = entry['api-keys'] ?? entry.apiKeys ?? entry['api_keys'] ?? [];
|
||||
const apiKeys = Array.isArray(rawApiKeys)
|
||||
? Array.from(new Set(rawApiKeys.map((item) => String(item ?? '').trim()).filter(Boolean)))
|
||||
: [];
|
||||
if (!apiKeys.length) return;
|
||||
|
||||
seen.add(upstreamApiKey);
|
||||
mappings.push({ upstreamApiKey, apiKeys });
|
||||
});
|
||||
|
||||
return mappings;
|
||||
};
|
||||
|
||||
const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined => {
|
||||
const sourceRaw = isRecord(payload) ? (payload.ampcode ?? payload) : payload;
|
||||
if (!isRecord(sourceRaw)) return undefined;
|
||||
@@ -287,6 +315,13 @@ const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined =>
|
||||
const upstreamApiKey = source['upstream-api-key'] ?? source.upstreamApiKey ?? source['upstream_api_key'];
|
||||
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey);
|
||||
|
||||
const upstreamApiKeys = normalizeAmpcodeUpstreamApiKeys(
|
||||
source['upstream-api-keys'] ?? source.upstreamApiKeys ?? source['upstream_api_keys']
|
||||
);
|
||||
if (upstreamApiKeys.length) {
|
||||
config.upstreamApiKeys = upstreamApiKeys;
|
||||
}
|
||||
|
||||
const forceModelMappings = normalizeBoolean(
|
||||
source['force-model-mappings'] ?? source.forceModelMappings ?? source['force_model_mappings']
|
||||
);
|
||||
@@ -420,5 +455,6 @@ export {
|
||||
normalizeHeaders,
|
||||
normalizeExcludedModels,
|
||||
normalizeAmpcodeConfig,
|
||||
normalizeAmpcodeModelMappings
|
||||
normalizeAmpcodeModelMappings,
|
||||
normalizeAmpcodeUpstreamApiKeys
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
||||
import { secureStorage } from '@/services/storage/secureStorage';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import { useConfigStore } from './useConfigStore';
|
||||
import { useUsageStatsStore } from './useUsageStatsStore';
|
||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||
|
||||
interface AuthStoreState extends AuthState {
|
||||
@@ -136,6 +137,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
logout: () => {
|
||||
restoreSessionPromise = null;
|
||||
useConfigStore.getState().clearCache();
|
||||
useUsageStatsStore.getState().clearUsageStats();
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
apiBase: '',
|
||||
|
||||
@@ -25,12 +25,28 @@ const getSystemTheme = (): ResolvedTheme => {
|
||||
return 'light';
|
||||
};
|
||||
|
||||
const applyTheme = (resolved: ResolvedTheme) => {
|
||||
const resolveTheme = (theme: Theme): ResolvedTheme | 'white' => {
|
||||
if (theme === 'auto') {
|
||||
return getSystemTheme();
|
||||
}
|
||||
if (theme === 'white') {
|
||||
return 'white';
|
||||
}
|
||||
return theme;
|
||||
};
|
||||
|
||||
const applyTheme = (resolved: ResolvedTheme | 'white') => {
|
||||
if (resolved === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolved === 'white') {
|
||||
document.documentElement.setAttribute('data-theme', 'white');
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
};
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
@@ -40,14 +56,17 @@ export const useThemeStore = create<ThemeState>()(
|
||||
resolvedTheme: 'light',
|
||||
|
||||
setTheme: (theme) => {
|
||||
const resolved: ResolvedTheme = theme === 'auto' ? getSystemTheme() : theme;
|
||||
const resolved = resolveTheme(theme);
|
||||
applyTheme(resolved);
|
||||
set({ theme, resolvedTheme: resolved });
|
||||
set({
|
||||
theme,
|
||||
resolvedTheme: resolved === 'white' ? 'light' : resolved,
|
||||
});
|
||||
},
|
||||
|
||||
cycleTheme: () => {
|
||||
const { theme, setTheme } = get();
|
||||
const order: Theme[] = ['light', 'dark', 'auto'];
|
||||
const order: Theme[] = ['light', 'white', 'dark', 'auto'];
|
||||
const currentIndex = order.indexOf(theme);
|
||||
const nextTheme = order[(currentIndex + 1) % order.length];
|
||||
setTheme(nextTheme);
|
||||
|
||||
+185
-9
@@ -81,7 +81,9 @@
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
transition:
|
||||
background $transition-fast,
|
||||
color $transition-fast;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -118,7 +120,10 @@
|
||||
max-width: 320px;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition: max-width 0.4s ease, opacity 0.4s ease, transform 0.4s ease;
|
||||
transition:
|
||||
max-width 0.4s ease,
|
||||
opacity 0.4s ease,
|
||||
transform 0.4s ease;
|
||||
}
|
||||
|
||||
.brand-abbr {
|
||||
@@ -126,7 +131,9 @@
|
||||
transform: translateX(12px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
transition:
|
||||
opacity 0.4s ease,
|
||||
transform 0.4s ease;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
@@ -220,7 +227,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color $transition-fast, color $transition-fast;
|
||||
transition:
|
||||
background-color $transition-fast,
|
||||
color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
@@ -251,6 +260,125 @@
|
||||
}
|
||||
}
|
||||
|
||||
.theme-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
.theme-menu-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: $z-dropdown;
|
||||
padding: $spacing-sm $spacing-sm $spacing-xs;
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
border: 2px solid transparent;
|
||||
border-radius: $radius-md;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 6px 6px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition:
|
||||
border-color $transition-fast,
|
||||
background-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba($primary-color, 0.22);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-card-preview {
|
||||
width: 72px;
|
||||
height: 52px;
|
||||
border-radius: $radius-sm;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-card-header {
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.theme-card-sidebar {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-card-content {
|
||||
flex: 1;
|
||||
padding: 5px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.theme-card-line {
|
||||
height: 3px;
|
||||
border-radius: 1px;
|
||||
|
||||
&.short {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-card-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.theme-menu-popover {
|
||||
right: 0;
|
||||
left: auto;
|
||||
transform: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
justify-content: stretch;
|
||||
width: min(188px, calc(100vw - 16px));
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.theme-card-label {
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
@@ -314,6 +442,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: rgb(15 23 42 / 0.18);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: var(--header-height) 0 0;
|
||||
z-index: $z-dropdown - 1;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--bg-primary);
|
||||
@@ -322,7 +473,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
transition: width $transition-normal, transform $transition-normal;
|
||||
transition:
|
||||
width $transition-normal,
|
||||
transform $transition-normal;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
@@ -347,22 +500,33 @@
|
||||
.nav-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
transition:
|
||||
background $transition-fast,
|
||||
color $transition-fast;
|
||||
|
||||
.nav-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.9;
|
||||
opacity: 0.96;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 0.18), rgb(255 255 255 / 0))
|
||||
var(--bg-secondary);
|
||||
box-shadow: inset 0 0 0 1px var(--border-primary);
|
||||
transition:
|
||||
background $transition-fast,
|
||||
box-shadow $transition-fast,
|
||||
color $transition-fast;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
@@ -379,12 +543,24 @@
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
|
||||
.nav-icon {
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 0.24), rgb(255 255 255 / 0))
|
||||
var(--bg-primary);
|
||||
box-shadow: inset 0 0 0 1px var(--border-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba($primary-color, 0.14);
|
||||
color: var(--primary-color);
|
||||
border: 1px solid rgba($primary-color, 0.35);
|
||||
|
||||
.nav-icon {
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 0.22), rgb(255 255 255 / 0))
|
||||
rgba($primary-color, 0.1);
|
||||
box-shadow: inset 0 0 0 1px rgba($primary-color, 0.26);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,59 @@
|
||||
--accent-tertiary: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
// 纯白主题
|
||||
[data-theme='white'] {
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-tertiary: #f6f6f6;
|
||||
--bg-hover: var(--bg-tertiary);
|
||||
--bg-quinary: #ffffff;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.08);
|
||||
|
||||
--text-primary: #2d2a26;
|
||||
--text-secondary: #6d6760;
|
||||
--text-tertiary: #a29c95;
|
||||
--text-quaternary: #c0bab3;
|
||||
--text-muted: var(--text-tertiary);
|
||||
|
||||
--border-color: #e5e5e5;
|
||||
--border-secondary: var(--border-color);
|
||||
--border-primary: #d9d9d9;
|
||||
--border-hover: #cccccc;
|
||||
|
||||
--primary-color: #8b8680;
|
||||
--primary-hover: #7f7a74;
|
||||
--primary-active: #726d67;
|
||||
--primary-contrast: #ffffff;
|
||||
|
||||
--success-color: #10b981;
|
||||
--warning-color: #c65746;
|
||||
--error-color: #c65746;
|
||||
--danger-color: var(--error-color);
|
||||
--info-color: var(--primary-color);
|
||||
|
||||
--warning-bg: rgba(198, 87, 70, 0.12);
|
||||
--warning-border: rgba(198, 87, 70, 0.35);
|
||||
--warning-text: var(--warning-color);
|
||||
|
||||
--success-badge-bg: #d1fae5;
|
||||
--success-badge-text: #065f46;
|
||||
--success-badge-border: #6ee7b7;
|
||||
|
||||
--failure-badge-bg: rgba(198, 87, 70, 0.14);
|
||||
--failure-badge-text: #8a3a30;
|
||||
--failure-badge-border: rgba(198, 87, 70, 0.35);
|
||||
|
||||
--count-badge-bg: rgba(139, 134, 128, 0.18);
|
||||
--count-badge-text: var(--primary-active);
|
||||
|
||||
--shadow: 0 1px 2px 0 rgb(0 0 0 / 0.08);
|
||||
--shadow-lg: 0 10px 18px -3px rgb(0 0 0 / 0.1);
|
||||
|
||||
--radius-md: 8px;
|
||||
--accent-tertiary: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
// 深色主题(#191919)
|
||||
[data-theme='dark'] {
|
||||
// 极简暖灰:深色模式(提升对比度与层级)
|
||||
|
||||
@@ -7,10 +7,15 @@ export interface AmpcodeModelMapping {
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface AmpcodeUpstreamApiKeyMapping {
|
||||
upstreamApiKey: string;
|
||||
apiKeys: string[];
|
||||
}
|
||||
|
||||
export interface AmpcodeConfig {
|
||||
upstreamUrl?: string;
|
||||
upstreamApiKey?: string;
|
||||
upstreamApiKeys?: AmpcodeUpstreamApiKeyMapping[];
|
||||
modelMappings?: AmpcodeModelMapping[];
|
||||
forceModelMappings?: boolean;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
* 通用类型定义
|
||||
*/
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'auto';
|
||||
export type Theme = 'light' | 'white' | 'dark' | 'auto';
|
||||
|
||||
export type Language = 'zh-CN' | 'en' | 'ru';
|
||||
|
||||
|
||||
@@ -25,6 +25,28 @@ export interface GeminiCliQuotaPayload {
|
||||
buckets?: GeminiCliQuotaBucket[];
|
||||
}
|
||||
|
||||
export interface GeminiCliCredits {
|
||||
creditType?: string;
|
||||
credit_type?: string;
|
||||
creditAmount?: string | number;
|
||||
credit_amount?: string | number;
|
||||
}
|
||||
|
||||
export interface GeminiCliUserTier {
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
availableCredits?: GeminiCliCredits[];
|
||||
available_credits?: GeminiCliCredits[];
|
||||
}
|
||||
|
||||
export interface GeminiCliCodeAssistPayload {
|
||||
currentTier?: GeminiCliUserTier | null;
|
||||
current_tier?: GeminiCliUserTier | null;
|
||||
paidTier?: GeminiCliUserTier | null;
|
||||
paid_tier?: GeminiCliUserTier | null;
|
||||
}
|
||||
|
||||
export interface AntigravityQuotaInfo {
|
||||
displayName?: string;
|
||||
quotaInfo?: {
|
||||
@@ -132,6 +154,28 @@ export interface ClaudeUsagePayload {
|
||||
extra_usage?: ClaudeExtraUsage | null;
|
||||
}
|
||||
|
||||
export interface ClaudeProfileResponse {
|
||||
account?: {
|
||||
uuid?: string;
|
||||
full_name?: string;
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
has_claude_max?: boolean;
|
||||
has_claude_pro?: boolean;
|
||||
created_at?: string;
|
||||
};
|
||||
organization?: {
|
||||
uuid?: string;
|
||||
name?: string;
|
||||
organization_type?: string;
|
||||
billing_type?: string;
|
||||
rate_limit_tier?: string;
|
||||
has_extra_usage_enabled?: boolean;
|
||||
subscription_status?: string;
|
||||
subscription_created_at?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClaudeQuotaWindow {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -144,6 +188,7 @@ export interface ClaudeQuotaState {
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
windows: ClaudeQuotaWindow[];
|
||||
extraUsage?: ClaudeExtraUsage | null;
|
||||
planType?: string | null;
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
@@ -177,6 +222,9 @@ export interface GeminiCliQuotaBucketState {
|
||||
export interface GeminiCliQuotaState {
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
buckets: GeminiCliQuotaBucketState[];
|
||||
tierLabel?: string | null;
|
||||
tierId?: string | null;
|
||||
creditBalance?: number | null;
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
|
||||
@@ -117,6 +117,9 @@ export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
|
||||
export const GEMINI_CLI_QUOTA_URL =
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
|
||||
|
||||
export const GEMINI_CLI_CODE_ASSIST_URL =
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
|
||||
|
||||
export const GEMINI_CLI_REQUEST_HEADERS = {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
@@ -156,6 +159,8 @@ export const GEMINI_CLI_GROUP_LOOKUP = new Map(
|
||||
export const GEMINI_CLI_IGNORED_MODEL_PREFIXES = ['gemini-2.0-flash'];
|
||||
|
||||
// Claude API configuration
|
||||
export const CLAUDE_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
|
||||
|
||||
export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
||||
|
||||
export const CLAUDE_REQUEST_HEADERS = {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Normalization and parsing functions for quota data.
|
||||
*/
|
||||
|
||||
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload, KimiUsagePayload } from '@/types';
|
||||
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliCodeAssistPayload, GeminiCliQuotaPayload, KimiUsagePayload } from '@/types';
|
||||
import { normalizeAuthIndex } from '@/utils/usage';
|
||||
|
||||
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
|
||||
@@ -191,6 +191,23 @@ export function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayl
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseGeminiCliCodeAssistPayload(payload: unknown): GeminiCliCodeAssistPayload | null {
|
||||
if (payload === undefined || payload === null) return null;
|
||||
if (typeof payload === 'string') {
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as GeminiCliCodeAssistPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof payload === 'object') {
|
||||
return payload as GeminiCliCodeAssistPayload;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseKimiUsagePayload(payload: unknown): KimiUsagePayload | null {
|
||||
if (payload === undefined || payload === null) return null;
|
||||
if (typeof payload === 'string') {
|
||||
|
||||
Reference in New Issue
Block a user