mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 03:00:49 +08:00
feat: add vertex provider, oauth model mappings, and routing/log settings
This commit is contained in:
@@ -4,15 +4,16 @@ import { useInterval } from '@/hooks/useInterval';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { IconBot, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
|
||||
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconBot, IconDownload, IconInfo, IconTrash2, IconX } from '@/components/ui/icons';
|
||||
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { authFilesApi, usageApi } from '@/services/api';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import type { AuthFileItem, OAuthModelMappingEntry } from '@/types';
|
||||
import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
|
||||
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
|
||||
import { formatFileSize } from '@/utils/format';
|
||||
@@ -90,6 +91,17 @@ interface ExcludedFormState {
|
||||
provider: string;
|
||||
modelsText: string;
|
||||
}
|
||||
|
||||
interface ModelMappingsFormState {
|
||||
provider: string;
|
||||
mappings: OAuthModelMappingEntry[];
|
||||
}
|
||||
|
||||
const buildEmptyMappingEntry = (): OAuthModelMappingEntry => ({
|
||||
name: '',
|
||||
alias: '',
|
||||
fork: false
|
||||
});
|
||||
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
|
||||
function normalizeAuthIndexValue(value: unknown): string | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -181,14 +193,25 @@ export function AuthFilesPage() {
|
||||
|
||||
// OAuth 排除模型相关
|
||||
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
||||
const [excludedError, setExcludedError] = useState<'unsupported' | null>(null);
|
||||
const [excludedError, setExcludedError] = useState<'unsupported' | null>(null);
|
||||
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
|
||||
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' });
|
||||
const [savingExcluded, setSavingExcluded] = useState(false);
|
||||
|
||||
// OAuth 模型映射相关
|
||||
const [modelMappings, setModelMappings] = useState<Record<string, OAuthModelMappingEntry[]>>({});
|
||||
const [modelMappingsError, setModelMappingsError] = useState<'unsupported' | null>(null);
|
||||
const [mappingModalOpen, setMappingModalOpen] = useState(false);
|
||||
const [mappingForm, setMappingForm] = useState<ModelMappingsFormState>({
|
||||
provider: '',
|
||||
mappings: [buildEmptyMappingEntry()]
|
||||
});
|
||||
const [savingMappings, setSavingMappings] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const loadingKeyStatsRef = useRef(false);
|
||||
const excludedUnsupportedRef = useRef(false);
|
||||
const mappingsUnsupportedRef = useRef(false);
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
@@ -252,7 +275,7 @@ export function AuthFilesPage() {
|
||||
const res = await authFilesApi.getOauthExcludedModels();
|
||||
excludedUnsupportedRef.current = false;
|
||||
setExcluded(res || {});
|
||||
setExcludedError(null);
|
||||
setExcludedError(null);
|
||||
} catch (err: unknown) {
|
||||
const status =
|
||||
typeof err === 'object' && err !== null && 'status' in err
|
||||
@@ -272,9 +295,35 @@ export function AuthFilesPage() {
|
||||
}
|
||||
}, [showNotification, t]);
|
||||
|
||||
// 加载 OAuth 模型映射
|
||||
const loadModelMappings = useCallback(async () => {
|
||||
try {
|
||||
const res = await authFilesApi.getOauthModelMappings();
|
||||
mappingsUnsupportedRef.current = false;
|
||||
setModelMappings(res || {});
|
||||
setModelMappingsError(null);
|
||||
} catch (err: unknown) {
|
||||
const status =
|
||||
typeof err === 'object' && err !== null && 'status' in err
|
||||
? (err as { status?: unknown }).status
|
||||
: undefined;
|
||||
|
||||
if (status === 404) {
|
||||
setModelMappings({});
|
||||
setModelMappingsError('unsupported');
|
||||
if (!mappingsUnsupportedRef.current) {
|
||||
mappingsUnsupportedRef.current = true;
|
||||
showNotification(t('oauth_model_mappings.upgrade_required'), 'warning');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 静默失败
|
||||
}
|
||||
}, [showNotification, t]);
|
||||
|
||||
const handleHeaderRefresh = useCallback(async () => {
|
||||
await Promise.all([loadFiles(), loadKeyStats(), loadExcluded()]);
|
||||
}, [loadFiles, loadKeyStats, loadExcluded]);
|
||||
await Promise.all([loadFiles(), loadKeyStats(), loadExcluded(), loadModelMappings()]);
|
||||
}, [loadFiles, loadKeyStats, loadExcluded, loadModelMappings]);
|
||||
|
||||
useHeaderRefresh(handleHeaderRefresh);
|
||||
|
||||
@@ -282,7 +331,8 @@ export function AuthFilesPage() {
|
||||
loadFiles();
|
||||
loadKeyStats();
|
||||
loadExcluded();
|
||||
}, [loadFiles, loadKeyStats, loadExcluded]);
|
||||
loadModelMappings();
|
||||
}, [loadFiles, loadKeyStats, loadExcluded, loadModelMappings]);
|
||||
|
||||
// 定时刷新状态数据(每240秒)
|
||||
useInterval(loadKeyStats, 240_000);
|
||||
@@ -310,12 +360,26 @@ export function AuthFilesPage() {
|
||||
return lookup;
|
||||
}, [excluded]);
|
||||
|
||||
const mappingProviderLookup = useMemo(() => {
|
||||
const lookup = new Map<string, string>();
|
||||
Object.keys(modelMappings).forEach((provider) => {
|
||||
const key = provider.trim().toLowerCase();
|
||||
if (key && !lookup.has(key)) {
|
||||
lookup.set(key, provider);
|
||||
}
|
||||
});
|
||||
return lookup;
|
||||
}, [modelMappings]);
|
||||
|
||||
const providerOptions = useMemo(() => {
|
||||
const extraProviders = new Set<string>();
|
||||
|
||||
Object.keys(excluded).forEach((provider) => {
|
||||
extraProviders.add(provider);
|
||||
});
|
||||
Object.keys(modelMappings).forEach((provider) => {
|
||||
extraProviders.add(provider);
|
||||
});
|
||||
files.forEach((file) => {
|
||||
if (typeof file.type === 'string') {
|
||||
extraProviders.add(file.type);
|
||||
@@ -335,7 +399,7 @@ export function AuthFilesPage() {
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
|
||||
}, [excluded, files]);
|
||||
}, [excluded, files, modelMappings]);
|
||||
|
||||
// 过滤和搜索
|
||||
const filtered = useMemo(() => {
|
||||
@@ -604,12 +668,12 @@ export function AuthFilesPage() {
|
||||
setExcludedModalOpen(true);
|
||||
};
|
||||
|
||||
const saveExcludedModels = async () => {
|
||||
const provider = excludedForm.provider.trim();
|
||||
if (!provider) {
|
||||
showNotification(t('oauth_excluded.provider_required'), 'error');
|
||||
return;
|
||||
}
|
||||
const saveExcludedModels = async () => {
|
||||
const provider = excludedForm.provider.trim();
|
||||
if (!provider) {
|
||||
showNotification(t('oauth_excluded.provider_required'), 'error');
|
||||
return;
|
||||
}
|
||||
const models = excludedForm.modelsText
|
||||
.split(/[\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
@@ -628,11 +692,11 @@ export function AuthFilesPage() {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('oauth_excluded.save_failed')}: ${errorMessage}`, 'error');
|
||||
} finally {
|
||||
setSavingExcluded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteExcluded = async (provider: string) => {
|
||||
setSavingExcluded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteExcluded = async (provider: string) => {
|
||||
if (!window.confirm(t('oauth_excluded.delete_confirm', { provider }))) return;
|
||||
try {
|
||||
await authFilesApi.deleteOauthExcludedEntry(provider);
|
||||
@@ -641,8 +705,110 @@ export function AuthFilesPage() {
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// OAuth 模型映射相关方法
|
||||
const normalizeMappingEntries = (entries?: OAuthModelMappingEntry[]) => {
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
return [buildEmptyMappingEntry()];
|
||||
}
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name ?? '',
|
||||
alias: entry.alias ?? '',
|
||||
fork: Boolean(entry.fork),
|
||||
}));
|
||||
};
|
||||
|
||||
const openMappingsModal = (provider?: string) => {
|
||||
const normalizedProvider = (provider || '').trim();
|
||||
const fallbackProvider = normalizedProvider || (filter !== 'all' ? String(filter) : '');
|
||||
const lookupKey = fallbackProvider
|
||||
? mappingProviderLookup.get(fallbackProvider.toLowerCase())
|
||||
: undefined;
|
||||
const mappings = lookupKey ? modelMappings[lookupKey] : [];
|
||||
setMappingForm({
|
||||
provider: lookupKey || fallbackProvider,
|
||||
mappings: normalizeMappingEntries(mappings),
|
||||
});
|
||||
setMappingModalOpen(true);
|
||||
};
|
||||
|
||||
const updateMappingEntry = (index: number, field: keyof OAuthModelMappingEntry, value: string | boolean) => {
|
||||
setMappingForm((prev) => ({
|
||||
...prev,
|
||||
mappings: prev.mappings.map((entry, idx) =>
|
||||
idx === index ? { ...entry, [field]: value } : entry
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addMappingEntry = () => {
|
||||
setMappingForm((prev) => ({
|
||||
...prev,
|
||||
mappings: [...prev.mappings, buildEmptyMappingEntry()],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeMappingEntry = (index: number) => {
|
||||
setMappingForm((prev) => {
|
||||
const next = prev.mappings.filter((_, idx) => idx !== index);
|
||||
return {
|
||||
...prev,
|
||||
mappings: next.length ? next : [buildEmptyMappingEntry()],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const saveModelMappings = async () => {
|
||||
const provider = mappingForm.provider.trim();
|
||||
if (!provider) {
|
||||
showNotification(t('oauth_model_mappings.provider_required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const mappings = mappingForm.mappings
|
||||
.map((entry) => {
|
||||
const name = String(entry.name ?? '').trim();
|
||||
const alias = String(entry.alias ?? '').trim();
|
||||
if (!name || !alias) return null;
|
||||
const key = `${name.toLowerCase()}::${alias.toLowerCase()}::${entry.fork ? '1' : '0'}`;
|
||||
if (seen.has(key)) return null;
|
||||
seen.add(key);
|
||||
return entry.fork ? { name, alias, fork: true } : { name, alias };
|
||||
})
|
||||
.filter(Boolean) as OAuthModelMappingEntry[];
|
||||
|
||||
setSavingMappings(true);
|
||||
try {
|
||||
if (mappings.length) {
|
||||
await authFilesApi.saveOauthModelMappings(provider, mappings);
|
||||
} else {
|
||||
await authFilesApi.deleteOauthModelMappings(provider);
|
||||
}
|
||||
await loadModelMappings();
|
||||
showNotification(t('oauth_model_mappings.save_success'), 'success');
|
||||
setMappingModalOpen(false);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('oauth_model_mappings.save_failed')}: ${errorMessage}`, 'error');
|
||||
} finally {
|
||||
setSavingMappings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteModelMappings = async (provider: string) => {
|
||||
if (!window.confirm(t('oauth_model_mappings.delete_confirm', { provider }))) return;
|
||||
try {
|
||||
await authFilesApi.deleteOauthModelMappings(provider);
|
||||
await loadModelMappings();
|
||||
showNotification(t('oauth_model_mappings.delete_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('oauth_model_mappings.delete_failed')}: ${errorMessage}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染标签筛选器
|
||||
const renderFilterTags = () => (
|
||||
@@ -959,14 +1125,14 @@ export function AuthFilesPage() {
|
||||
title={t('oauth_excluded.title')}
|
||||
extra={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => openExcludedModal()}
|
||||
disabled={disableControls || excludedError === 'unsupported'}
|
||||
>
|
||||
{t('oauth_excluded.add')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
size="sm"
|
||||
onClick={() => openExcludedModal()}
|
||||
disabled={disableControls || excludedError === 'unsupported'}
|
||||
>
|
||||
{t('oauth_excluded.add')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{excludedError === 'unsupported' ? (
|
||||
<EmptyState
|
||||
title={t('oauth_excluded.upgrade_required_title')}
|
||||
@@ -997,12 +1163,58 @@ export function AuthFilesPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
open={detailModalOpen}
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* OAuth 模型映射卡片 */}
|
||||
<Card
|
||||
title={t('oauth_model_mappings.title')}
|
||||
extra={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => openMappingsModal()}
|
||||
disabled={disableControls || modelMappingsError === 'unsupported'}
|
||||
>
|
||||
{t('oauth_model_mappings.add')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{modelMappingsError === 'unsupported' ? (
|
||||
<EmptyState
|
||||
title={t('oauth_model_mappings.upgrade_required_title')}
|
||||
description={t('oauth_model_mappings.upgrade_required_desc')}
|
||||
/>
|
||||
) : Object.keys(modelMappings).length === 0 ? (
|
||||
<EmptyState title={t('oauth_model_mappings.list_empty_all')} />
|
||||
) : (
|
||||
<div className={styles.excludedList}>
|
||||
{Object.entries(modelMappings).map(([provider, mappings]) => (
|
||||
<div key={provider} className={styles.excludedItem}>
|
||||
<div className={styles.excludedInfo}>
|
||||
<div className={styles.excludedProvider}>{provider}</div>
|
||||
<div className={styles.excludedModels}>
|
||||
{mappings?.length
|
||||
? t('oauth_model_mappings.model_count', { count: mappings.length })
|
||||
: t('oauth_model_mappings.no_models')}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.excludedActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => openMappingsModal(provider)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => deleteModelMappings(provider)}>
|
||||
{t('oauth_model_mappings.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
open={detailModalOpen}
|
||||
onClose={() => setDetailModalOpen(false)}
|
||||
title={selectedFile?.name || t('auth_files.title_section')}
|
||||
footer={
|
||||
@@ -1146,9 +1358,117 @@ export function AuthFilesPage() {
|
||||
value={excludedForm.modelsText}
|
||||
onChange={(e) => setExcludedForm((prev) => ({ ...prev, modelsText: e.target.value }))}
|
||||
/>
|
||||
<div className={styles.hint}>{t('oauth_excluded.models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div className={styles.hint}>{t('oauth_excluded.models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* OAuth 模型映射弹窗 */}
|
||||
<Modal
|
||||
open={mappingModalOpen}
|
||||
onClose={() => setMappingModalOpen(false)}
|
||||
title={t('oauth_model_mappings.add_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setMappingModalOpen(false)} disabled={savingMappings}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={saveModelMappings} loading={savingMappings}>
|
||||
{t('oauth_model_mappings.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.providerField}>
|
||||
<Input
|
||||
id="oauth-model-mappings-provider"
|
||||
list="oauth-model-mappings-provider-options"
|
||||
label={t('oauth_model_mappings.provider_label')}
|
||||
hint={t('oauth_model_mappings.provider_hint')}
|
||||
placeholder={t('oauth_model_mappings.provider_placeholder')}
|
||||
value={mappingForm.provider}
|
||||
onChange={(e) => setMappingForm((prev) => ({ ...prev, provider: e.target.value }))}
|
||||
/>
|
||||
<datalist id="oauth-model-mappings-provider-options">
|
||||
{providerOptions.map((provider) => (
|
||||
<option key={provider} value={provider} />
|
||||
))}
|
||||
</datalist>
|
||||
{providerOptions.length > 0 && (
|
||||
<div className={styles.providerTagList}>
|
||||
{providerOptions.map((provider) => {
|
||||
const isActive =
|
||||
mappingForm.provider.trim().toLowerCase() === provider.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
className={`${styles.providerTag} ${isActive ? styles.providerTagActive : ''}`}
|
||||
onClick={() => setMappingForm((prev) => ({ ...prev, provider }))}
|
||||
disabled={savingMappings}
|
||||
>
|
||||
{getTypeLabel(provider)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>{t('oauth_model_mappings.mappings_label')}</label>
|
||||
<div className="header-input-list">
|
||||
{(mappingForm.mappings.length ? mappingForm.mappings : [buildEmptyMappingEntry()]).map(
|
||||
(entry, index) => (
|
||||
<div key={`${entry.name}-${entry.alias}-${index}`} className={styles.mappingRow}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('oauth_model_mappings.mapping_name_placeholder')}
|
||||
value={entry.name}
|
||||
onChange={(e) => updateMappingEntry(index, 'name', e.target.value)}
|
||||
disabled={savingMappings}
|
||||
/>
|
||||
<span className={styles.mappingSeparator}>→</span>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('oauth_model_mappings.mapping_alias_placeholder')}
|
||||
value={entry.alias}
|
||||
onChange={(e) => updateMappingEntry(index, 'alias', e.target.value)}
|
||||
disabled={savingMappings}
|
||||
/>
|
||||
<div className={styles.mappingFork}>
|
||||
<ToggleSwitch
|
||||
label={t('oauth_model_mappings.mapping_fork_label')}
|
||||
labelPosition="left"
|
||||
checked={Boolean(entry.fork)}
|
||||
onChange={(value) => updateMappingEntry(index, 'fork', value)}
|
||||
disabled={savingMappings}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeMappingEntry(index)}
|
||||
disabled={savingMappings || mappingForm.mappings.length <= 1}
|
||||
title={t('common.delete')}
|
||||
aria-label={t('common.delete')}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={addMappingEntry}
|
||||
disabled={savingMappings}
|
||||
className="align-start"
|
||||
>
|
||||
{t('oauth_model_mappings.add_mapping')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.hint}>{t('oauth_model_mappings.mappings_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user