Compare commits

...

4 Commits

10 changed files with 243 additions and 25 deletions
+22 -1
View File
@@ -5,6 +5,8 @@
import { useTranslation } from 'react-i18next';
import type { ReactElement, ReactNode } from 'react';
import type { TFunction } from 'i18next';
import { Button } from '@/components/ui/Button';
import { IconRefreshCw } from '@/components/ui/icons';
import type { AuthFileItem, ResolvedTheme, ThemeColors } from '@/types';
import { TYPE_COLORS } from '@/utils/quota';
import styles from '@/pages/QuotaPage.module.scss';
@@ -89,6 +91,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
const quotaStatus = quota?.status ?? 'idle';
const quotaLoading = quotaStatus === 'loading';
const quotaErrorMessage = resolveQuotaErrorMessage(
t,
quota?.errorStatus,
@@ -121,7 +124,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
</div>
<div className={styles.quotaSection}>
{quotaStatus === 'loading' ? (
{quotaLoading ? (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
) : quotaStatus === 'idle' ? (
onRefresh ? (
@@ -148,6 +151,24 @@ export function QuotaCard<TState extends QuotaStatusState>({
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
)}
</div>
{onRefresh && quotaStatus !== 'idle' && (
<div className={styles.quotaCardActions}>
<Button
type="button"
variant="secondary"
size="sm"
className={styles.quotaRefreshButton}
onClick={onRefresh}
disabled={!canRefresh || quotaLoading}
loading={quotaLoading}
title={t('auth_files.quota_refresh_hint')}
>
{!quotaLoading && <IconRefreshCw size={14} />}
{t('auth_files.quota_refresh_single')}
</Button>
</div>
)}
</div>
);
}
@@ -15,13 +15,16 @@ import { ProviderHeaderCard } from './components/ProviderHeaderCard';
import { ProviderCategoryList } from './components/ProviderCategoryList';
import { ProviderResourcePanel } from './components/ProviderResourcePanel';
import type { ProviderPanelControls } from './components/ProviderResourcePanel';
import type {
ProviderSortBy,
SortDir,
} from './components/ProviderResourceToolbar';
import { ProviderSheet, type ProviderSheetHandle } from './sheets/ProviderSheet';
import { useProviderWorkbench } from './useProviderWorkbench';
import type { ProviderBrand, ProviderResource } from './types';
import {
getProviderFilterState,
readProvidersWorkbenchUiState,
writeProvidersWorkbenchUiState,
type ProviderFilterState,
type ProvidersWorkbenchUiState,
} from './uiState';
import type { ProviderBrand, ProviderResource, ProviderSortBy, SortDir } from './types';
import styles from './ProvidersWorkbenchPage.module.scss';
type SheetMode = 'detail' | 'create' | 'edit';
@@ -129,11 +132,9 @@ export function ProvidersWorkbenchPage() {
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const workbench = useProviderWorkbench();
const [activeBrand, setActiveBrand] = useState<ProviderBrand>('gemini');
const [filter, setFilter] = useState('');
const [providerSortBy, setProviderSortBy] = useState<ProviderSortBy>('name');
const [providerSortDir, setProviderSortDir] = useState<SortDir>('asc');
const [selectedModels, setSelectedModels] = useState<Set<string>>(() => new Set());
const [uiState, setUiState] = useState<ProvidersWorkbenchUiState>(
readProvidersWorkbenchUiState
);
const [sheetState, setSheetState] = useState<SheetState>({
open: false,
brand: 'gemini',
@@ -158,7 +159,52 @@ export function ProvidersWorkbenchPage() {
const disableMutations = connectionStatus !== 'connected' || workbench.mutating;
const persistUiState = useCallback(
(updater: (prev: ProvidersWorkbenchUiState) => ProvidersWorkbenchUiState) => {
setUiState((prev) => {
const next = updater(prev);
writeProvidersWorkbenchUiState(next);
return next;
});
},
[]
);
const setActiveBrand = useCallback(
(brand: ProviderBrand) => {
persistUiState((prev) =>
prev.activeBrand === brand ? prev : { ...prev, activeBrand: brand }
);
},
[persistUiState]
);
const updateActiveFilterState = useCallback(
(patch: Partial<ProviderFilterState>) => {
persistUiState((prev) => {
const brand = prev.activeBrand;
const current = getProviderFilterState(prev, brand);
return {
...prev,
filtersByBrand: {
...prev.filtersByBrand,
[brand]: {
...current,
...patch,
},
},
};
});
},
[persistUiState]
);
const groups = useMemo(() => workbench.snapshot?.groups ?? [], [workbench.snapshot]);
const activeBrand = uiState.activeBrand;
const activeFilterState = getProviderFilterState(uiState, activeBrand);
const filter = activeFilterState.filter;
const providerSortBy = activeFilterState.sortBy;
const providerSortDir = activeFilterState.sortDir;
const activeGroup =
groups.find((g) => g.id === activeBrand) ?? groups[0] ?? null;
@@ -177,6 +223,14 @@ export function ProvidersWorkbenchPage() {
return Array.from(seen).sort();
}, [activeGroup]);
const selectedModels = useMemo(() => {
if (availableModels.length === 0) return new Set<string>();
const availableModelSet = new Set(availableModels);
return new Set(
activeFilterState.selectedModels.filter((name) => availableModelSet.has(name))
);
}, [activeFilterState.selectedModels, availableModels]);
const visibleResources = useMemo(() => {
let arr = filteredResources;
if (selectedModels.size > 0) {
@@ -219,11 +273,14 @@ export function ProvidersWorkbenchPage() {
return {
sortBy: providerSortBy,
sortDir: providerSortDir,
onSortBy: setProviderSortBy,
onSortDir: setProviderSortDir,
onSortBy: (value: ProviderSortBy) => updateActiveFilterState({ sortBy: value }),
onSortDir: (value: SortDir) => updateActiveFilterState({ sortDir: value }),
availableModels,
selectedModels,
onSelectedModelsChange: setSelectedModels,
onSelectedModelsChange: (next) =>
updateActiveFilterState({
selectedModels: Array.from(next).sort((a, b) => a.localeCompare(b)),
}),
};
}, [
activeGroup,
@@ -231,6 +288,7 @@ export function ProvidersWorkbenchPage() {
providerSortBy,
providerSortDir,
selectedModels,
updateActiveFilterState,
]);
const totalResources = useMemo(
@@ -423,8 +481,6 @@ export function ProvidersWorkbenchPage() {
void proceed.then((ok) => {
if (!ok) return;
setActiveBrand(brand);
setFilter('');
setSelectedModels(new Set());
if (isSwitching) {
closeSheet();
}
@@ -434,7 +490,7 @@ export function ProvidersWorkbenchPage() {
<ProviderResourcePanel
group={activeGroup}
filter={filter}
onFilterChange={setFilter}
onFilterChange={(value) => updateActiveFilterState({ filter: value })}
filteredResources={visibleResources}
selectedId={sheetState.open ? sheetState.resource?.id ?? null : null}
disableMutations={disableMutations}
@@ -9,11 +9,8 @@ import { IconPlus, IconSearch } from '@/components/ui/icons';
import type { ProviderRecentUsageMap } from '@/components/providers/utils';
import type { ProviderBrand, ProviderGroup, ProviderResource } from '../types';
import { ProviderResourceTable } from './ProviderResourceTable';
import {
ProviderResourceToolbar,
type ProviderSortBy,
type SortDir,
} from './ProviderResourceToolbar';
import { ProviderResourceToolbar } from './ProviderResourceToolbar';
import type { ProviderSortBy, SortDir } from '../types';
import styles from './ProviderResourcePanel.module.scss';
const LOGOS: Record<ProviderBrand, { src: string; invertOnDark?: boolean }> = {
@@ -7,11 +7,9 @@ import {
} from '@/components/ui/icons';
import { Select } from '@/components/ui/Select';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import type { ProviderSortBy, SortDir } from '../types';
import styles from './ProviderResourceToolbar.module.scss';
export type ProviderSortBy = 'name' | 'priority' | 'recent-success';
export type SortDir = 'asc' | 'desc';
interface ProviderResourceToolbarProps {
sortBy: ProviderSortBy;
sortDir: SortDir;
+6
View File
@@ -10,6 +10,12 @@ export type ProviderBrand =
| 'openaiCompatibility'
| 'ampcode';
export const PROVIDER_SORT_BY_VALUES = ['name', 'priority', 'recent-success'] as const;
export type ProviderSortBy = (typeof PROVIDER_SORT_BY_VALUES)[number];
export const SORT_DIR_VALUES = ['asc', 'desc'] as const;
export type SortDir = (typeof SORT_DIR_VALUES)[number];
export type ProviderResourceSelector =
| { brand: 'gemini'; apiKey: string; baseUrl?: string; index: number }
| { brand: 'codex'; apiKey: string; baseUrl?: string; index: number }
+116
View File
@@ -0,0 +1,116 @@
import { PROVIDER_BRAND_ORDER } from './descriptors';
import {
PROVIDER_SORT_BY_VALUES,
SORT_DIR_VALUES,
type ProviderBrand,
type ProviderSortBy,
type SortDir,
} from './types';
const PROVIDERS_UI_STATE_KEY = 'providersPage.uiState';
const DEFAULT_ACTIVE_BRAND: ProviderBrand = 'gemini';
const DEFAULT_PROVIDER_FILTER_STATE: ProviderFilterState = {
filter: '',
sortBy: 'name',
sortDir: 'asc',
selectedModels: [],
};
const PROVIDER_BRAND_SET = new Set<ProviderBrand>(PROVIDER_BRAND_ORDER);
const PROVIDER_SORT_BY_SET = new Set<ProviderSortBy>(PROVIDER_SORT_BY_VALUES);
const SORT_DIR_SET = new Set<SortDir>(SORT_DIR_VALUES);
export interface ProviderFilterState {
filter: string;
sortBy: ProviderSortBy;
sortDir: SortDir;
selectedModels: string[];
}
export interface ProvidersWorkbenchUiState {
activeBrand: ProviderBrand;
filtersByBrand: Partial<Record<ProviderBrand, ProviderFilterState>>;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
const isProviderBrand = (value: unknown): value is ProviderBrand =>
typeof value === 'string' && PROVIDER_BRAND_SET.has(value as ProviderBrand);
const isProviderSortBy = (value: unknown): value is ProviderSortBy =>
typeof value === 'string' && PROVIDER_SORT_BY_SET.has(value as ProviderSortBy);
const isSortDir = (value: unknown): value is SortDir =>
typeof value === 'string' && SORT_DIR_SET.has(value as SortDir);
const normalizeSelectedModels = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
value.forEach((item) => {
if (typeof item !== 'string') return;
const name = item.trim();
if (!name) return;
seen.add(name);
});
return Array.from(seen);
};
const normalizeProviderFilterState = (value: unknown): ProviderFilterState => {
if (!isRecord(value)) return { ...DEFAULT_PROVIDER_FILTER_STATE };
return {
filter: typeof value.filter === 'string' ? value.filter : '',
sortBy: isProviderSortBy(value.sortBy) ? value.sortBy : 'name',
sortDir: isSortDir(value.sortDir) ? value.sortDir : 'asc',
selectedModels: normalizeSelectedModels(value.selectedModels),
};
};
const createDefaultProvidersWorkbenchUiState = (): ProvidersWorkbenchUiState => ({
activeBrand: DEFAULT_ACTIVE_BRAND,
filtersByBrand: {},
});
export const getProviderFilterState = (
state: ProvidersWorkbenchUiState,
brand: ProviderBrand
): ProviderFilterState => state.filtersByBrand[brand] ?? DEFAULT_PROVIDER_FILTER_STATE;
export const readProvidersWorkbenchUiState = (): ProvidersWorkbenchUiState => {
if (typeof window === 'undefined') return createDefaultProvidersWorkbenchUiState();
try {
const raw = window.localStorage.getItem(PROVIDERS_UI_STATE_KEY);
if (!raw) return createDefaultProvidersWorkbenchUiState();
const parsed = JSON.parse(raw);
if (!isRecord(parsed)) return createDefaultProvidersWorkbenchUiState();
const source = isRecord(parsed.filtersByBrand) ? parsed.filtersByBrand : {};
const filtersByBrand: ProvidersWorkbenchUiState['filtersByBrand'] = {};
PROVIDER_BRAND_ORDER.forEach((brand) => {
const filterState = source[brand];
if (filterState !== undefined) {
filtersByBrand[brand] = normalizeProviderFilterState(filterState);
}
});
return {
activeBrand: isProviderBrand(parsed.activeBrand)
? parsed.activeBrand
: DEFAULT_ACTIVE_BRAND,
filtersByBrand,
};
} catch {
return createDefaultProvidersWorkbenchUiState();
}
};
export const writeProvidersWorkbenchUiState = (state: ProvidersWorkbenchUiState) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(PROVIDERS_UI_STATE_KEY, JSON.stringify(state));
} catch {
// ignore storage failures
}
};
+2
View File
@@ -368,6 +368,8 @@
"prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.",
"prefix_proxy_html_challenge": "Downloaded content is an HTML challenge page, not an auth JSON object. Re-authenticate or replace this auth file before editing fields.",
"prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully",
"quota_refresh_single": "Refresh quota",
"quota_refresh_hint": "Refresh quota only for this credential",
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
},
+2
View File
@@ -368,6 +368,8 @@
"prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。",
"prefix_proxy_html_challenge": "下载到的是 HTML 验证页面,不是认证 JSON 对象。请重新认证或替换该认证文件后再编辑字段。",
"prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"",
"quota_refresh_single": "刷新额度",
"quota_refresh_hint": "仅刷新该认证文件的额度",
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
},
+2
View File
@@ -368,6 +368,8 @@
"prefix_proxy_invalid_json": "該驗證檔案不是 JSON 物件,無法編輯欄位。",
"prefix_proxy_html_challenge": "下載到的是 HTML 驗證頁面,不是驗證 JSON 物件。請重新驗證或替換該驗證檔案後再編輯欄位。",
"prefix_proxy_saved_success": "已更新驗證檔案「{{name}}」",
"quota_refresh_single": "重新整理配額",
"quota_refresh_hint": "僅重新整理該驗證檔案的配額",
"quota_refresh_success": "已重新整理「{{name}}」的配額",
"quota_refresh_failed": "重新整理「{{name}}」的配額失敗:{{message}}"
},
+18
View File
@@ -392,6 +392,24 @@
}
}
.quotaCardActions {
display: flex;
justify-content: flex-end;
padding-top: $spacing-xs;
}
.quotaRefreshButton:global(.btn.btn-sm) {
border-radius: 999px;
padding-inline: 12px;
> span {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
}
.quotaError {
font-size: 12px;
color: var(--danger-color);