Compare commits

...

3 Commits

11 changed files with 211 additions and 67 deletions
@@ -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,19 @@ 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([]);
@@ -126,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}>
+18 -8
View File
@@ -675,11 +675,16 @@ const buildClaudeQuotaWindows = (
return windows;
};
const CLAUDE_PLAN_TYPE_MAP: Record<string, string> = {
default_claude_max_5x: 'plan_max5',
default_claude_max_20x: 'plan_max20',
default_claude_pro: 'plan_pro',
default_claude_ai: 'plan_free',
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 => {
@@ -702,10 +707,15 @@ const parseClaudeProfilePayload = (payload: unknown): ClaudeProfileResponse | nu
const resolveClaudePlanType = (profile: ClaudeProfileResponse | null): string | null => {
if (!profile) return null;
const tier = normalizeStringValue(profile.organization?.rate_limit_tier);
if (!tier) return null;
const hasClaudeMax = normalizeFlagValue(profile.account?.has_claude_max);
if (hasClaudeMax) return 'plan_max';
return CLAUDE_PLAN_TYPE_MAP[tier] ?? 'plan_unknown';
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 (
@@ -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;
}
+50
View File
@@ -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>
);
}
@@ -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,
@@ -118,18 +118,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}
+1
View File
@@ -604,6 +604,7 @@
"plan_unknown": "Unknown",
"plan_free": "Free",
"plan_pro": "Pro",
"plan_max": "Max",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
},
+1
View File
@@ -607,6 +607,7 @@
"plan_unknown": "Неизвестно",
"plan_free": "Free",
"plan_pro": "Pro",
"plan_max": "Max",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
},
+1
View File
@@ -604,6 +604,7 @@
"plan_unknown": "未知",
"plan_free": "免费版",
"plan_pro": "专业版",
"plan_max": "Max",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
},
+21 -5
View File
@@ -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 {
@@ -400,6 +415,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>
-39
View File
@@ -565,45 +565,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;
+7 -5
View File
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
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';
@@ -233,11 +233,12 @@ export function LoginPage() {
</div>
<div className={styles.toggleAdvanced}>
<ToggleSwitch
<SelectionCheckbox
checked={showCustomBase}
onChange={setShowCustomBase}
ariaLabel={t('login.custom_connection_label')}
label={<span className={styles.toggleLabel}>{t('login.custom_connection_label')}</span>}
label={t('login.custom_connection_label')}
labelClassName={styles.toggleLabel}
/>
</div>
@@ -281,11 +282,12 @@ export function LoginPage() {
/>
<div className={styles.toggleAdvanced}>
<ToggleSwitch
<SelectionCheckbox
checked={rememberPassword}
onChange={setRememberPassword}
ariaLabel={t('login.remember_password_label')}
label={<span className={styles.toggleLabel}>{t('login.remember_password_label')}</span>}
label={t('login.remember_password_label')}
labelClassName={styles.toggleLabel}
/>
</div>