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
4 Commits
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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}}"
|
||||
},
|
||||
|
||||
@@ -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}}"
|
||||
},
|
||||
|
||||
@@ -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}}"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user