mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 11:10:49 +08:00
refactor(auth-files): split AuthFilesPage
This commit is contained in:
28
src/features/authFiles/clipboard.ts
Normal file
28
src/features/authFiles/clipboard.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export const copyToClipboard = async (text: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback below
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
textarea.style.left = '-9999px';
|
||||||
|
textarea.style.top = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
const copied = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
return copied;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
240
src/features/authFiles/components/AuthFileCard.tsx
Normal file
240
src/features/authFiles/components/AuthFileCard.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import { IconBot, IconCode, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { resolveAuthProvider } from '@/utils/quota';
|
||||||
|
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||||
|
import { formatFileSize } from '@/utils/format';
|
||||||
|
import {
|
||||||
|
QUOTA_PROVIDER_TYPES,
|
||||||
|
formatModified,
|
||||||
|
getTypeColor,
|
||||||
|
getTypeLabel,
|
||||||
|
isRuntimeOnlyAuthFile,
|
||||||
|
normalizeAuthIndexValue,
|
||||||
|
resolveAuthFileStats,
|
||||||
|
type QuotaProviderType,
|
||||||
|
type ResolvedTheme
|
||||||
|
} from '@/features/authFiles/constants';
|
||||||
|
import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
|
||||||
|
import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type AuthFileCardProps = {
|
||||||
|
file: AuthFileItem;
|
||||||
|
resolvedTheme: ResolvedTheme;
|
||||||
|
disableControls: boolean;
|
||||||
|
deleting: string | null;
|
||||||
|
statusUpdating: Record<string, boolean>;
|
||||||
|
quotaFilterType: QuotaProviderType | null;
|
||||||
|
keyStats: KeyStats;
|
||||||
|
statusBarCache: Map<string, AuthFileStatusBarData>;
|
||||||
|
onShowModels: (file: AuthFileItem) => void;
|
||||||
|
onShowDetails: (file: AuthFileItem) => void;
|
||||||
|
onDownload: (name: string) => void;
|
||||||
|
onOpenPrefixProxyEditor: (name: string) => void;
|
||||||
|
onDelete: (name: string) => void;
|
||||||
|
onToggleStatus: (file: AuthFileItem, enabled: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
|
||||||
|
const provider = resolveAuthProvider(file);
|
||||||
|
if (!QUOTA_PROVIDER_TYPES.has(provider as QuotaProviderType)) return null;
|
||||||
|
return provider as QuotaProviderType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFileCard(props: AuthFileCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
file,
|
||||||
|
resolvedTheme,
|
||||||
|
disableControls,
|
||||||
|
deleting,
|
||||||
|
statusUpdating,
|
||||||
|
quotaFilterType,
|
||||||
|
keyStats,
|
||||||
|
statusBarCache,
|
||||||
|
onShowModels,
|
||||||
|
onShowDetails,
|
||||||
|
onDownload,
|
||||||
|
onOpenPrefixProxyEditor,
|
||||||
|
onDelete,
|
||||||
|
onToggleStatus
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const fileStats = resolveAuthFileStats(file, keyStats);
|
||||||
|
const isRuntimeOnly = isRuntimeOnlyAuthFile(file);
|
||||||
|
const isAistudio = (file.type || '').toLowerCase() === 'aistudio';
|
||||||
|
const showModelsButton = !isRuntimeOnly || isAistudio;
|
||||||
|
const typeColor = getTypeColor(file.type || 'unknown', resolvedTheme);
|
||||||
|
|
||||||
|
const quotaType =
|
||||||
|
quotaFilterType && resolveQuotaType(file) === quotaFilterType ? quotaFilterType : null;
|
||||||
|
|
||||||
|
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
|
||||||
|
|
||||||
|
const providerCardClass =
|
||||||
|
quotaType === 'antigravity'
|
||||||
|
? styles.antigravityCard
|
||||||
|
: quotaType === 'codex'
|
||||||
|
? styles.codexCard
|
||||||
|
: quotaType === 'gemini-cli'
|
||||||
|
? styles.geminiCliCard
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
const statusData =
|
||||||
|
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
||||||
|
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||||
|
const rateClass = !hasData
|
||||||
|
? ''
|
||||||
|
: statusData.successRate >= 90
|
||||||
|
? styles.statusRateHigh
|
||||||
|
: statusData.successRate >= 50
|
||||||
|
? styles.statusRateMedium
|
||||||
|
: styles.statusRateLow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.fileCard} ${providerCardClass} ${file.disabled ? styles.fileCardDisabled : ''}`}
|
||||||
|
>
|
||||||
|
<div className={styles.fileCardLayout}>
|
||||||
|
<div className={styles.fileCardMain}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<span
|
||||||
|
className={styles.typeBadge}
|
||||||
|
style={{
|
||||||
|
backgroundColor: typeColor.bg,
|
||||||
|
color: typeColor.text,
|
||||||
|
...(typeColor.border ? { border: typeColor.border } : {})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getTypeLabel(t, file.type || 'unknown')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.fileName}>{file.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cardMeta}>
|
||||||
|
<span>
|
||||||
|
{t('auth_files.file_size')}: {file.size ? formatFileSize(file.size) : '-'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('auth_files.file_modified')}: {formatModified(file)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cardStats}>
|
||||||
|
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||||
|
{t('stats.success')}: {fileStats.success}
|
||||||
|
</span>
|
||||||
|
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||||
|
{t('stats.failure')}: {fileStats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.statusBar}>
|
||||||
|
<div className={styles.statusBlocks}>
|
||||||
|
{statusData.blocks.map((state, idx) => {
|
||||||
|
const blockClass =
|
||||||
|
state === 'success'
|
||||||
|
? styles.statusBlockSuccess
|
||||||
|
: state === 'failure'
|
||||||
|
? styles.statusBlockFailure
|
||||||
|
: state === 'mixed'
|
||||||
|
? styles.statusBlockMixed
|
||||||
|
: styles.statusBlockIdle;
|
||||||
|
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className={`${styles.statusRate} ${rateClass}`}>
|
||||||
|
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showQuotaLayout && quotaType && (
|
||||||
|
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.cardActions}>
|
||||||
|
{showModelsButton && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onShowModels(file)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.models_button', { defaultValue: '模型' })}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconBot className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isRuntimeOnly && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onShowDetails(file)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('common.info', { defaultValue: '关于' })}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconInfo className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDownload(file.name)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.download_button')}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconDownload className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onOpenPrefixProxyEditor(file.name)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.prefix_proxy_button')}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconCode className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(file.name)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.delete_button')}
|
||||||
|
disabled={disableControls || deleting === file.name}
|
||||||
|
>
|
||||||
|
{deleting === file.name ? (
|
||||||
|
<LoadingSpinner size={14} />
|
||||||
|
) : (
|
||||||
|
<IconTrash2 className={styles.actionIcon} size={16} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isRuntimeOnly && (
|
||||||
|
<div className={styles.statusToggle}>
|
||||||
|
<ToggleSwitch
|
||||||
|
ariaLabel={t('auth_files.status_toggle_label')}
|
||||||
|
checked={!file.disabled}
|
||||||
|
disabled={disableControls || statusUpdating[file.name] === true}
|
||||||
|
onChange={(value) => onToggleStatus(file, value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isRuntimeOnly && (
|
||||||
|
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/features/authFiles/components/AuthFileDetailModal.tsx
Normal file
47
src/features/authFiles/components/AuthFileDetailModal.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type AuthFileDetailModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
file: AuthFileItem | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onCopyText: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFileDetailModal({ open, file, onClose, onCopyText }: AuthFileDetailModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={file?.name || t('auth_files.title_section')}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!file) return;
|
||||||
|
const text = JSON.stringify(file, null, 2);
|
||||||
|
onCopyText(text);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.copy')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{file && (
|
||||||
|
<div className={styles.detailContent}>
|
||||||
|
<pre className={styles.jsonContent}>{JSON.stringify(file, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
91
src/features/authFiles/components/AuthFileModelsModal.tsx
Normal file
91
src/features/authFiles/components/AuthFileModelsModal.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||||
|
import { isModelExcluded } from '@/features/authFiles/constants';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type AuthFileModelsModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
fileName: string;
|
||||||
|
fileType: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: 'unsupported' | null;
|
||||||
|
models: AuthFileModelItem[];
|
||||||
|
excluded: Record<string, string[]>;
|
||||||
|
onClose: () => void;
|
||||||
|
onCopyText: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFileModelsModal(props: AuthFileModelsModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { open, fileName, fileType, loading, error, models, excluded, onClose, onCopyText } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('auth_files.models_title', { defaultValue: '支持的模型' }) + ` - ${fileName}`}
|
||||||
|
footer={
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>
|
||||||
|
{t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })}
|
||||||
|
</div>
|
||||||
|
) : error === 'unsupported' ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('auth_files.models_unsupported', { defaultValue: '当前版本不支持此功能' })}
|
||||||
|
description={t('auth_files.models_unsupported_desc', {
|
||||||
|
defaultValue: '请更新 CLI Proxy API 到最新版本后重试'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : models.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('auth_files.models_empty', { defaultValue: '该凭证暂无可用模型' })}
|
||||||
|
description={t('auth_files.models_empty_desc', {
|
||||||
|
defaultValue: '该认证凭证可能尚未被服务器加载或没有绑定任何模型'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={styles.modelsList}>
|
||||||
|
{models.map((model) => {
|
||||||
|
const excludedModel = isModelExcluded(model.id, fileType, excluded);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={model.id}
|
||||||
|
className={`${styles.modelItem} ${excludedModel ? styles.modelItemExcluded : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
onCopyText(model.id);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
excludedModel
|
||||||
|
? t('auth_files.models_excluded_hint', {
|
||||||
|
defaultValue: '此 OAuth 模型已被禁用'
|
||||||
|
})
|
||||||
|
: t('common.copy', { defaultValue: '点击复制' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={styles.modelId}>{model.id}</span>
|
||||||
|
{model.display_name && model.display_name !== model.id && (
|
||||||
|
<span className={styles.modelDisplayName}>{model.display_name}</span>
|
||||||
|
)}
|
||||||
|
{model.type && <span className={styles.modelType}>{model.type}</span>}
|
||||||
|
{excludedModel && (
|
||||||
|
<span className={styles.modelExcludedBadge}>
|
||||||
|
{t('auth_files.models_excluded_badge', { defaultValue: '已禁用' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
124
src/features/authFiles/components/AuthFileQuotaSection.tsx
Normal file
124
src/features/authFiles/components/AuthFileQuotaSection.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useCallback, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota';
|
||||||
|
import { useNotificationStore, useQuotaStore } from '@/stores';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { getStatusFromError } from '@/utils/quota';
|
||||||
|
import {
|
||||||
|
isRuntimeOnlyAuthFile,
|
||||||
|
resolveQuotaErrorMessage,
|
||||||
|
type QuotaProviderType
|
||||||
|
} from '@/features/authFiles/constants';
|
||||||
|
import { QuotaProgressBar } from '@/features/authFiles/components/QuotaProgressBar';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
type QuotaState = { status?: string; error?: string; errorStatus?: number } | undefined;
|
||||||
|
|
||||||
|
const getQuotaConfig = (type: QuotaProviderType) => {
|
||||||
|
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
|
||||||
|
if (type === 'codex') return CODEX_CONFIG;
|
||||||
|
return GEMINI_CLI_CONFIG;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthFileQuotaSectionProps = {
|
||||||
|
file: AuthFileItem;
|
||||||
|
quotaType: QuotaProviderType;
|
||||||
|
disableControls: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
|
||||||
|
const { file, quotaType, disableControls } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||||
|
|
||||||
|
const quota = useQuotaStore((state) => {
|
||||||
|
if (quotaType === 'antigravity') return state.antigravityQuota[file.name] as QuotaState;
|
||||||
|
if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaState;
|
||||||
|
return state.geminiCliQuota[file.name] as QuotaState;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateQuotaState = useQuotaStore((state) => {
|
||||||
|
if (quotaType === 'antigravity') return state.setAntigravityQuota as unknown as (updater: unknown) => void;
|
||||||
|
if (quotaType === 'codex') return state.setCodexQuota as unknown as (updater: unknown) => void;
|
||||||
|
return state.setGeminiCliQuota as unknown as (updater: unknown) => void;
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshQuotaForFile = useCallback(async () => {
|
||||||
|
if (disableControls) return;
|
||||||
|
if (isRuntimeOnlyAuthFile(file)) return;
|
||||||
|
if (file.disabled) return;
|
||||||
|
if (quota?.status === 'loading') return;
|
||||||
|
|
||||||
|
const config = getQuotaConfig(quotaType) as unknown as {
|
||||||
|
i18nPrefix: string;
|
||||||
|
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<unknown>;
|
||||||
|
buildLoadingState: () => unknown;
|
||||||
|
buildSuccessState: (data: unknown) => unknown;
|
||||||
|
buildErrorState: (message: string, status?: number) => unknown;
|
||||||
|
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
updateQuotaState((prev: Record<string, unknown>) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: config.buildLoadingState()
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await config.fetchQuota(file, t);
|
||||||
|
updateQuotaState((prev: Record<string, unknown>) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: config.buildSuccessState(data)
|
||||||
|
}));
|
||||||
|
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
||||||
|
const status = getStatusFromError(err);
|
||||||
|
updateQuotaState((prev: Record<string, unknown>) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: config.buildErrorState(message, status)
|
||||||
|
}));
|
||||||
|
showNotification(t('auth_files.quota_refresh_failed', { name: file.name, message }), 'error');
|
||||||
|
}
|
||||||
|
}, [disableControls, file, quota?.status, quotaType, showNotification, t, updateQuotaState]);
|
||||||
|
|
||||||
|
const config = getQuotaConfig(quotaType) as unknown as {
|
||||||
|
i18nPrefix: string;
|
||||||
|
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const quotaStatus = quota?.status ?? 'idle';
|
||||||
|
const canRefreshQuota = !disableControls && !file.disabled;
|
||||||
|
const quotaErrorMessage = resolveQuotaErrorMessage(
|
||||||
|
t,
|
||||||
|
quota?.errorStatus,
|
||||||
|
quota?.error || t('common.unknown_error')
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.quotaSection}>
|
||||||
|
{quotaStatus === 'loading' ? (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.loading`)}</div>
|
||||||
|
) : quotaStatus === 'idle' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.quotaMessage} ${styles.quotaMessageAction}`}
|
||||||
|
onClick={() => void refreshQuotaForFile()}
|
||||||
|
disabled={!canRefreshQuota}
|
||||||
|
>
|
||||||
|
{t(`${config.i18nPrefix}.idle`)}
|
||||||
|
</button>
|
||||||
|
) : quotaStatus === 'error' ? (
|
||||||
|
<div className={styles.quotaError}>
|
||||||
|
{t(`${config.i18nPrefix}.load_failed`, {
|
||||||
|
message: quotaErrorMessage
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : quota ? (
|
||||||
|
(config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode)
|
||||||
|
) : (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import type {
|
||||||
|
PrefixProxyEditorField,
|
||||||
|
PrefixProxyEditorState
|
||||||
|
} from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type AuthFilesPrefixProxyEditorModalProps = {
|
||||||
|
disableControls: boolean;
|
||||||
|
editor: PrefixProxyEditorState | null;
|
||||||
|
updatedText: string;
|
||||||
|
dirty: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onChange: (field: PrefixProxyEditorField, value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEditorModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { disableControls, editor, updatedText, dirty, onClose, onSave, onChange } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={Boolean(editor)}
|
||||||
|
onClose={onClose}
|
||||||
|
closeDisabled={editor?.saving === true}
|
||||||
|
width={720}
|
||||||
|
title={
|
||||||
|
editor?.fileName
|
||||||
|
? t('auth_files.auth_field_editor_title', { name: editor.fileName })
|
||||||
|
: t('auth_files.prefix_proxy_button')
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={editor?.saving === true}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
loading={editor?.saving === true}
|
||||||
|
disabled={
|
||||||
|
disableControls || editor?.saving === true || !dirty || !editor?.json
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{editor && (
|
||||||
|
<div className={styles.prefixProxyEditor}>
|
||||||
|
{editor.loading ? (
|
||||||
|
<div className={styles.prefixProxyLoading}>
|
||||||
|
<LoadingSpinner size={14} />
|
||||||
|
<span>{t('auth_files.prefix_proxy_loading')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{editor.error && <div className={styles.prefixProxyError}>{editor.error}</div>}
|
||||||
|
<div className={styles.prefixProxyJsonWrapper}>
|
||||||
|
<label className={styles.prefixProxyLabel}>
|
||||||
|
{t('auth_files.prefix_proxy_source_label')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className={styles.prefixProxyTextarea}
|
||||||
|
rows={10}
|
||||||
|
readOnly
|
||||||
|
value={updatedText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.prefixProxyFields}>
|
||||||
|
<Input
|
||||||
|
label={t('auth_files.prefix_label')}
|
||||||
|
value={editor.prefix}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('prefix', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('auth_files.proxy_url_label')}
|
||||||
|
value={editor.proxyUrl}
|
||||||
|
placeholder={t('auth_files.proxy_url_placeholder')}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('proxyUrl', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('auth_files.priority_label')}
|
||||||
|
value={editor.priority}
|
||||||
|
placeholder={t('auth_files.priority_placeholder')}
|
||||||
|
hint={t('auth_files.priority_hint')}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('priority', e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('auth_files.excluded_models_label')}</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
value={editor.excludedModelsText}
|
||||||
|
placeholder={t('auth_files.excluded_models_placeholder')}
|
||||||
|
rows={4}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('excludedModelsText', e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label={t('auth_files.disable_cooling_label')}
|
||||||
|
value={editor.disableCooling}
|
||||||
|
placeholder={t('auth_files.disable_cooling_placeholder')}
|
||||||
|
hint={t('auth_files.disable_cooling_hint')}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('disableCooling', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
65
src/features/authFiles/components/OAuthExcludedCard.tsx
Normal file
65
src/features/authFiles/components/OAuthExcludedCard.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
type UnsupportedError = 'unsupported' | null;
|
||||||
|
|
||||||
|
export type OAuthExcludedCardProps = {
|
||||||
|
disableControls: boolean;
|
||||||
|
excludedError: UnsupportedError;
|
||||||
|
excluded: Record<string, string[]>;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (provider: string) => void;
|
||||||
|
onDelete: (provider: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OAuthExcludedCard(props: OAuthExcludedCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { disableControls, excludedError, excluded, onAdd, onEdit, onDelete } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t('oauth_excluded.title')}
|
||||||
|
extra={
|
||||||
|
<Button size="sm" onClick={onAdd} disabled={disableControls || excludedError === 'unsupported'}>
|
||||||
|
{t('oauth_excluded.add')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{excludedError === 'unsupported' ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('oauth_excluded.upgrade_required_title')}
|
||||||
|
description={t('oauth_excluded.upgrade_required_desc')}
|
||||||
|
/>
|
||||||
|
) : Object.keys(excluded).length === 0 ? (
|
||||||
|
<EmptyState title={t('oauth_excluded.list_empty_all')} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.excludedList}>
|
||||||
|
{Object.entries(excluded).map(([provider, models]) => (
|
||||||
|
<div key={provider} className={styles.excludedItem}>
|
||||||
|
<div className={styles.excludedInfo}>
|
||||||
|
<div className={styles.excludedProvider}>{provider}</div>
|
||||||
|
<div className={styles.excludedModels}>
|
||||||
|
{models?.length
|
||||||
|
? t('oauth_excluded.model_count', { count: models.length })
|
||||||
|
: t('oauth_excluded.no_models')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.excludedActions}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onEdit(provider)}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => onDelete(provider)}>
|
||||||
|
{t('oauth_excluded.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
152
src/features/authFiles/components/OAuthModelAliasCard.tsx
Normal file
152
src/features/authFiles/components/OAuthModelAliasCard.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { ModelMappingDiagram, type ModelMappingDiagramRef } from '@/components/modelAlias';
|
||||||
|
import { IconChevronUp } from '@/components/ui/icons';
|
||||||
|
import type { OAuthModelAliasEntry } from '@/types';
|
||||||
|
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
type UnsupportedError = 'unsupported' | null;
|
||||||
|
type ViewMode = 'diagram' | 'list';
|
||||||
|
|
||||||
|
export type OAuthModelAliasCardProps = {
|
||||||
|
disableControls: boolean;
|
||||||
|
viewMode: ViewMode;
|
||||||
|
onViewModeChange: (mode: ViewMode) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEditProvider: (provider?: string) => void;
|
||||||
|
onDeleteProvider: (provider: string) => void;
|
||||||
|
modelAliasError: UnsupportedError;
|
||||||
|
modelAlias: Record<string, OAuthModelAliasEntry[]>;
|
||||||
|
allProviderModels: Record<string, AuthFileModelItem[]>;
|
||||||
|
onUpdate: (provider: string, sourceModel: string, newAlias: string) => Promise<void>;
|
||||||
|
onDeleteLink: (provider: string, sourceModel: string, alias: string) => void;
|
||||||
|
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => Promise<void>;
|
||||||
|
onRenameAlias: (oldAlias: string, newAlias: string) => Promise<void>;
|
||||||
|
onDeleteAlias: (aliasName: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OAuthModelAliasCard(props: OAuthModelAliasCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const diagramRef = useRef<ModelMappingDiagramRef | null>(null);
|
||||||
|
const {
|
||||||
|
disableControls,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
onAdd,
|
||||||
|
onEditProvider,
|
||||||
|
onDeleteProvider,
|
||||||
|
modelAliasError,
|
||||||
|
modelAlias,
|
||||||
|
allProviderModels,
|
||||||
|
onUpdate,
|
||||||
|
onDeleteLink,
|
||||||
|
onToggleFork,
|
||||||
|
onRenameAlias,
|
||||||
|
onDeleteAlias
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t('oauth_model_alias.title')}
|
||||||
|
extra={
|
||||||
|
<div className={styles.cardExtraButtons}>
|
||||||
|
<div className={styles.viewModeSwitch}>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewModeChange('list')}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
>
|
||||||
|
{t('oauth_model_alias.view_mode_list')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'diagram' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewModeChange('diagram')}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
>
|
||||||
|
{t('oauth_model_alias.view_mode_diagram')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onAdd}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
>
|
||||||
|
{t('oauth_model_alias.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{modelAliasError === 'unsupported' ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('oauth_model_alias.upgrade_required_title')}
|
||||||
|
description={t('oauth_model_alias.upgrade_required_desc')}
|
||||||
|
/>
|
||||||
|
) : viewMode === 'diagram' ? (
|
||||||
|
Object.keys(modelAlias).length === 0 ? (
|
||||||
|
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.aliasChartSection}>
|
||||||
|
<div className={styles.aliasChartHeader}>
|
||||||
|
<h4 className={styles.aliasChartTitle}>{t('oauth_model_alias.chart_title')}</h4>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => diagramRef.current?.collapseAll()}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
title={t('oauth_model_alias.diagram_collapse')}
|
||||||
|
aria-label={t('oauth_model_alias.diagram_collapse')}
|
||||||
|
>
|
||||||
|
<IconChevronUp size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ModelMappingDiagram
|
||||||
|
ref={diagramRef}
|
||||||
|
modelAlias={modelAlias}
|
||||||
|
allProviderModels={allProviderModels}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onDeleteLink={onDeleteLink}
|
||||||
|
onToggleFork={onToggleFork}
|
||||||
|
onRenameAlias={onRenameAlias}
|
||||||
|
onDeleteAlias={onDeleteAlias}
|
||||||
|
onEditProvider={onEditProvider}
|
||||||
|
onDeleteProvider={onDeleteProvider}
|
||||||
|
className={styles.aliasChart}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : Object.keys(modelAlias).length === 0 ? (
|
||||||
|
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.excludedList}>
|
||||||
|
{Object.entries(modelAlias).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_alias.model_count', { count: mappings.length })
|
||||||
|
: t('oauth_model_alias.no_models')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.excludedActions}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onEditProvider(provider)}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => onDeleteProvider(provider)}>
|
||||||
|
{t('oauth_model_alias.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
28
src/features/authFiles/components/QuotaProgressBar.tsx
Normal file
28
src/features/authFiles/components/QuotaProgressBar.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type QuotaProgressBarProps = {
|
||||||
|
percent: number | null;
|
||||||
|
highThreshold: number;
|
||||||
|
mediumThreshold: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QuotaProgressBar({ percent, highThreshold, mediumThreshold }: QuotaProgressBarProps) {
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||||
|
const normalized = percent === null ? null : clamp(percent, 0, 100);
|
||||||
|
const fillClass =
|
||||||
|
normalized === null
|
||||||
|
? styles.quotaBarFillMedium
|
||||||
|
: normalized >= highThreshold
|
||||||
|
? styles.quotaBarFillHigh
|
||||||
|
: normalized >= mediumThreshold
|
||||||
|
? styles.quotaBarFillMedium
|
||||||
|
: styles.quotaBarFillLow;
|
||||||
|
const widthPercent = Math.round(normalized ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.quotaBar}>
|
||||||
|
<div className={`${styles.quotaBarFill} ${fillClass}`} style={{ width: `${widthPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
236
src/features/authFiles/constants.ts
Normal file
236
src/features/authFiles/constants.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import {
|
||||||
|
normalizeUsageSourceId,
|
||||||
|
type KeyStatBucket,
|
||||||
|
type KeyStats
|
||||||
|
} from '@/utils/usage';
|
||||||
|
|
||||||
|
export type ThemeColors = { bg: string; text: string; border?: string };
|
||||||
|
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';
|
||||||
|
|
||||||
|
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli']);
|
||||||
|
|
||||||
|
export const MIN_CARD_PAGE_SIZE = 3;
|
||||||
|
export const MAX_CARD_PAGE_SIZE = 30;
|
||||||
|
export const MAX_AUTH_FILE_SIZE = 50 * 1024;
|
||||||
|
|
||||||
|
export const INTEGER_STRING_PATTERN = /^[+-]?\d+$/;
|
||||||
|
export const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']);
|
||||||
|
export const FALSY_TEXT_VALUES = new Set(['false', '0', 'no', 'n', 'off']);
|
||||||
|
|
||||||
|
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
|
||||||
|
export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||||
|
qwen: {
|
||||||
|
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||||
|
dark: { bg: '#1b5e20', text: '#81c784' }
|
||||||
|
},
|
||||||
|
kimi: {
|
||||||
|
light: { bg: '#fff4e5', text: '#ad6800' },
|
||||||
|
dark: { bg: '#7c4a03', text: '#ffd591' }
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
light: { bg: '#e3f2fd', text: '#1565c0' },
|
||||||
|
dark: { bg: '#0d47a1', text: '#64b5f6' }
|
||||||
|
},
|
||||||
|
'gemini-cli': {
|
||||||
|
light: { bg: '#e7efff', text: '#1e4fa3' },
|
||||||
|
dark: { bg: '#1c3f73', text: '#a8c7ff' }
|
||||||
|
},
|
||||||
|
aistudio: {
|
||||||
|
light: { bg: '#f0f2f5', text: '#2f343c' },
|
||||||
|
dark: { bg: '#373c42', text: '#cfd3db' }
|
||||||
|
},
|
||||||
|
claude: {
|
||||||
|
light: { bg: '#fce4ec', text: '#c2185b' },
|
||||||
|
dark: { bg: '#880e4f', text: '#f48fb1' }
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
light: { bg: '#fff3e0', text: '#ef6c00' },
|
||||||
|
dark: { bg: '#e65100', text: '#ffb74d' }
|
||||||
|
},
|
||||||
|
antigravity: {
|
||||||
|
light: { bg: '#e0f7fa', text: '#006064' },
|
||||||
|
dark: { bg: '#004d40', text: '#80deea' }
|
||||||
|
},
|
||||||
|
iflow: {
|
||||||
|
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
||||||
|
dark: { bg: '#4a148c', text: '#ce93d8' }
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
light: { bg: '#f5f5f5', text: '#616161' },
|
||||||
|
dark: { bg: '#424242', text: '#bdbdbd' }
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
|
||||||
|
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clampCardPageSize = (value: number) =>
|
||||||
|
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
||||||
|
|
||||||
|
export const resolveQuotaErrorMessage = (
|
||||||
|
t: TFunction,
|
||||||
|
status: number | undefined,
|
||||||
|
fallback: string
|
||||||
|
): string => {
|
||||||
|
if (status === 404) return t('common.quota_update_required');
|
||||||
|
if (status === 403) return t('common.quota_check_credential');
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
|
export const getTypeLabel = (t: TFunction, type: string): string => {
|
||||||
|
const key = `auth_files.filter_${type}`;
|
||||||
|
const translated = t(key);
|
||||||
|
if (translated !== key) return translated;
|
||||||
|
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
||||||
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTypeColor = (type: string, resolvedTheme: ResolvedTheme): ThemeColors => {
|
||||||
|
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
|
||||||
|
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parsePriorityValue = (value: unknown): number | undefined => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isInteger(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || !INTEGER_STRING_PATTERN.test(trimmed)) return undefined;
|
||||||
|
const parsed = Number.parseInt(trimmed, 10);
|
||||||
|
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeExcludedModels = (value: unknown): string[] => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const normalized: string[] = [];
|
||||||
|
value.forEach((entry) => {
|
||||||
|
const model = String(entry ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!model || seen.has(model)) return;
|
||||||
|
seen.add(model);
|
||||||
|
normalized.push(model);
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalized.sort((a, b) => a.localeCompare(b));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseExcludedModelsText = (value: string): string[] =>
|
||||||
|
normalizeExcludedModels(value.split(/[\n,]+/));
|
||||||
|
|
||||||
|
export const parseDisableCoolingValue = (value: unknown): boolean | undefined => {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value !== 0;
|
||||||
|
if (typeof value !== 'string') return undefined;
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
if (TRUTHY_TEXT_VALUES.has(normalized)) return true;
|
||||||
|
if (FALSY_TEXT_VALUES.has(normalized)) return false;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
|
||||||
|
export function normalizeAuthIndexValue(value: unknown): string | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
|
||||||
|
const raw = file['runtime_only'] ?? file.runtimeOnly;
|
||||||
|
if (typeof raw === 'boolean') return raw;
|
||||||
|
if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuthFileStats(file: AuthFileItem, stats: KeyStats): KeyStatBucket {
|
||||||
|
const defaultStats: KeyStatBucket = { success: 0, failure: 0 };
|
||||||
|
const rawFileName = file?.name || '';
|
||||||
|
|
||||||
|
// 兼容 auth_index 和 authIndex 两种字段名(API 返回的是 auth_index)
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
|
||||||
|
// 尝试根据 authIndex 匹配
|
||||||
|
if (authIndexKey && stats.byAuthIndex?.[authIndexKey]) {
|
||||||
|
return stats.byAuthIndex[authIndexKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试根据 source (文件名) 匹配
|
||||||
|
const fileNameId = rawFileName ? normalizeUsageSourceId(rawFileName) : '';
|
||||||
|
if (fileNameId && stats.bySource?.[fileNameId]) {
|
||||||
|
const fromName = stats.bySource[fileNameId];
|
||||||
|
if (fromName.success > 0 || fromName.failure > 0) {
|
||||||
|
return fromName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试去掉扩展名后匹配
|
||||||
|
if (rawFileName) {
|
||||||
|
const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, '');
|
||||||
|
if (nameWithoutExt && nameWithoutExt !== rawFileName) {
|
||||||
|
const nameWithoutExtId = normalizeUsageSourceId(nameWithoutExt);
|
||||||
|
const fromNameWithoutExt = nameWithoutExtId ? stats.bySource?.[nameWithoutExtId] : undefined;
|
||||||
|
if (
|
||||||
|
fromNameWithoutExt &&
|
||||||
|
(fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0)
|
||||||
|
) {
|
||||||
|
return fromNameWithoutExt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatModified = (item: AuthFileItem): string => {
|
||||||
|
const raw = item['modtime'] ?? item.modified;
|
||||||
|
if (!raw) return '-';
|
||||||
|
const asNumber = Number(raw);
|
||||||
|
const date =
|
||||||
|
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
|
||||||
|
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
|
||||||
|
: new Date(String(raw));
|
||||||
|
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查模型是否被 OAuth 排除
|
||||||
|
export const isModelExcluded = (
|
||||||
|
modelId: string,
|
||||||
|
providerType: string,
|
||||||
|
excluded: Record<string, string[]>
|
||||||
|
): boolean => {
|
||||||
|
const providerKey = normalizeProviderKey(providerType);
|
||||||
|
const excludedModels = excluded[providerKey] || excluded[providerType] || [];
|
||||||
|
return excludedModels.some((pattern) => {
|
||||||
|
if (pattern.includes('*')) {
|
||||||
|
// 支持通配符匹配:先转义正则特殊字符,再将 * 视为通配符
|
||||||
|
const regexSafePattern = pattern
|
||||||
|
.split('*')
|
||||||
|
.map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||||
|
.join('.*');
|
||||||
|
const regex = new RegExp(`^${regexSafePattern}$`, 'i');
|
||||||
|
return regex.test(modelId);
|
||||||
|
}
|
||||||
|
return pattern.toLowerCase() === modelId.toLowerCase();
|
||||||
|
});
|
||||||
|
};
|
||||||
318
src/features/authFiles/hooks/useAuthFilesData.ts
Normal file
318
src/features/authFiles/hooks/useAuthFilesData.ts
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import { useCallback, useRef, useState, type ChangeEvent, type RefObject } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { authFilesApi } from '@/services/api';
|
||||||
|
import { apiClient } from '@/services/api/client';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { formatFileSize } from '@/utils/format';
|
||||||
|
import { MAX_AUTH_FILE_SIZE, getTypeLabel, isRuntimeOnlyAuthFile } from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
type DeleteAllOptions = {
|
||||||
|
filter: string;
|
||||||
|
onResetFilterToAll: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesDataResult = {
|
||||||
|
files: AuthFileItem[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
uploading: boolean;
|
||||||
|
deleting: string | null;
|
||||||
|
deletingAll: boolean;
|
||||||
|
statusUpdating: Record<string, boolean>;
|
||||||
|
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
loadFiles: () => Promise<void>;
|
||||||
|
handleUploadClick: () => void;
|
||||||
|
handleFileChange: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
|
||||||
|
handleDelete: (name: string) => void;
|
||||||
|
handleDeleteAll: (options: DeleteAllOptions) => void;
|
||||||
|
handleDownload: (name: string) => Promise<void>;
|
||||||
|
handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesDataOptions = {
|
||||||
|
refreshKeyStats: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFilesDataResult {
|
||||||
|
const { refreshKeyStats } = options;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
|
const [statusUpdating, setStatusUpdating] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const loadFiles = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await authFilesApi.list();
|
||||||
|
setFiles(data?.files || []);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleUploadClick = useCallback(() => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const fileList = event.target.files;
|
||||||
|
if (!fileList || fileList.length === 0) return;
|
||||||
|
|
||||||
|
const filesToUpload = Array.from(fileList);
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
const invalidFiles: string[] = [];
|
||||||
|
const oversizedFiles: string[] = [];
|
||||||
|
|
||||||
|
filesToUpload.forEach((file) => {
|
||||||
|
if (!file.name.endsWith('.json')) {
|
||||||
|
invalidFiles.push(file.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_AUTH_FILE_SIZE) {
|
||||||
|
oversizedFiles.push(file.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
validFiles.push(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
|
showNotification(t('auth_files.upload_error_json'), 'error');
|
||||||
|
}
|
||||||
|
if (oversizedFiles.length > 0) {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
let successCount = 0;
|
||||||
|
const failed: { name: string; message: string }[] = [];
|
||||||
|
|
||||||
|
for (const file of validFiles) {
|
||||||
|
try {
|
||||||
|
await authFilesApi.upload(file);
|
||||||
|
successCount++;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
failed.push({ name: file.name, message: errorMessage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
|
||||||
|
showNotification(
|
||||||
|
`${t('auth_files.upload_success')}${suffix}`,
|
||||||
|
failed.length ? 'warning' : 'success'
|
||||||
|
);
|
||||||
|
await loadFiles();
|
||||||
|
await refreshKeyStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed.length > 0) {
|
||||||
|
const details = failed.map((item) => `${item.name}: ${item.message}`).join('; ');
|
||||||
|
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(false);
|
||||||
|
event.target.value = '';
|
||||||
|
},
|
||||||
|
[loadFiles, refreshKeyStats, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
showConfirmation({
|
||||||
|
title: t('auth_files.delete_title', { defaultValue: 'Delete File' }),
|
||||||
|
message: `${t('auth_files.delete_confirm')} "${name}" ?`,
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
setDeleting(name);
|
||||||
|
try {
|
||||||
|
await authFilesApi.deleteFile(name);
|
||||||
|
showNotification(t('auth_files.delete_success'), 'success');
|
||||||
|
setFiles((prev) => prev.filter((item) => item.name !== name));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteAll = useCallback(
|
||||||
|
(deleteAllOptions: DeleteAllOptions) => {
|
||||||
|
const { filter, onResetFilterToAll } = deleteAllOptions;
|
||||||
|
const isFiltered = filter !== 'all';
|
||||||
|
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');
|
||||||
|
|
||||||
|
showConfirmation({
|
||||||
|
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
|
||||||
|
message: confirmMessage,
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
setDeletingAll(true);
|
||||||
|
try {
|
||||||
|
if (!isFiltered) {
|
||||||
|
await authFilesApi.deleteAll();
|
||||||
|
showNotification(t('auth_files.delete_all_success'), 'success');
|
||||||
|
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
|
||||||
|
} else {
|
||||||
|
const filesToDelete = files.filter(
|
||||||
|
(f) => f.type === filter && !isRuntimeOnlyAuthFile(f)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filesToDelete.length === 0) {
|
||||||
|
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
|
||||||
|
setDeletingAll(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const deletedNames: string[] = [];
|
||||||
|
|
||||||
|
for (const file of filesToDelete) {
|
||||||
|
try {
|
||||||
|
await authFilesApi.deleteFile(file.name);
|
||||||
|
success++;
|
||||||
|
deletedNames.push(file.name);
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name)));
|
||||||
|
|
||||||
|
if (failed === 0) {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }),
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onResetFilterToAll();
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setDeletingAll(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[files, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.getRaw(
|
||||||
|
`/auth-files/download?name=${encodeURIComponent(name)}`,
|
||||||
|
{ responseType: 'blob' }
|
||||||
|
);
|
||||||
|
const blob = new Blob([response.data]);
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
showNotification(t('auth_files.download_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStatusToggle = useCallback(
|
||||||
|
async (item: AuthFileItem, enabled: boolean) => {
|
||||||
|
const name = item.name;
|
||||||
|
const nextDisabled = !enabled;
|
||||||
|
const previousDisabled = item.disabled === true;
|
||||||
|
|
||||||
|
setStatusUpdating((prev) => ({ ...prev, [name]: true }));
|
||||||
|
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authFilesApi.setStatus(name, nextDisabled);
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f))
|
||||||
|
);
|
||||||
|
showNotification(
|
||||||
|
enabled
|
||||||
|
? t('auth_files.status_enabled_success', { name })
|
||||||
|
: t('auth_files.status_disabled_success', { name }),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f))
|
||||||
|
);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${errorMessage}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setStatusUpdating((prev) => {
|
||||||
|
if (!prev[name]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[name];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
uploading,
|
||||||
|
deleting,
|
||||||
|
deletingAll,
|
||||||
|
statusUpdating,
|
||||||
|
fileInputRef,
|
||||||
|
loadFiles,
|
||||||
|
handleUploadClick,
|
||||||
|
handleFileChange,
|
||||||
|
handleDelete,
|
||||||
|
handleDeleteAll,
|
||||||
|
handleDownload,
|
||||||
|
handleStatusToggle
|
||||||
|
};
|
||||||
|
}
|
||||||
86
src/features/authFiles/hooks/useAuthFilesModels.ts
Normal file
86
src/features/authFiles/hooks/useAuthFilesModels.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { authFilesApi } from '@/services/api';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
type ModelsError = 'unsupported' | null;
|
||||||
|
|
||||||
|
export type UseAuthFilesModelsResult = {
|
||||||
|
modelsModalOpen: boolean;
|
||||||
|
modelsLoading: boolean;
|
||||||
|
modelsList: AuthFileModelItem[];
|
||||||
|
modelsFileName: string;
|
||||||
|
modelsFileType: string;
|
||||||
|
modelsError: ModelsError;
|
||||||
|
showModels: (item: AuthFileItem) => Promise<void>;
|
||||||
|
closeModelsModal: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesModels(): UseAuthFilesModelsResult {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||||
|
|
||||||
|
const [modelsModalOpen, setModelsModalOpen] = useState(false);
|
||||||
|
const [modelsLoading, setModelsLoading] = useState(false);
|
||||||
|
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
|
||||||
|
const [modelsFileName, setModelsFileName] = useState('');
|
||||||
|
const [modelsFileType, setModelsFileType] = useState('');
|
||||||
|
const [modelsError, setModelsError] = useState<ModelsError>(null);
|
||||||
|
const modelsCacheRef = useRef<Map<string, AuthFileModelItem[]>>(new Map());
|
||||||
|
|
||||||
|
const closeModelsModal = useCallback(() => {
|
||||||
|
setModelsModalOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showModels = useCallback(
|
||||||
|
async (item: AuthFileItem) => {
|
||||||
|
setModelsFileName(item.name);
|
||||||
|
setModelsFileType(item.type || '');
|
||||||
|
setModelsList([]);
|
||||||
|
setModelsError(null);
|
||||||
|
setModelsModalOpen(true);
|
||||||
|
|
||||||
|
const cached = modelsCacheRef.current.get(item.name);
|
||||||
|
if (cached) {
|
||||||
|
setModelsList(cached);
|
||||||
|
setModelsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setModelsLoading(true);
|
||||||
|
try {
|
||||||
|
const models = await authFilesApi.getModelsForAuthFile(item.name);
|
||||||
|
modelsCacheRef.current.set(item.name, models);
|
||||||
|
setModelsList(models);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
if (
|
||||||
|
errorMessage.includes('404') ||
|
||||||
|
errorMessage.includes('not found') ||
|
||||||
|
errorMessage.includes('Not Found')
|
||||||
|
) {
|
||||||
|
setModelsError('unsupported');
|
||||||
|
} else {
|
||||||
|
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setModelsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
modelsModalOpen,
|
||||||
|
modelsLoading,
|
||||||
|
modelsList,
|
||||||
|
modelsFileName,
|
||||||
|
modelsFileType,
|
||||||
|
modelsError,
|
||||||
|
showModels,
|
||||||
|
closeModelsModal
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
504
src/features/authFiles/hooks/useAuthFilesOauth.tsx
Normal file
504
src/features/authFiles/hooks/useAuthFilesOauth.tsx
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
import { authFilesApi } from '@/services/api';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
|
||||||
|
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||||
|
import { normalizeProviderKey } from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
type UnsupportedError = 'unsupported' | null;
|
||||||
|
type ViewMode = 'diagram' | 'list';
|
||||||
|
|
||||||
|
export type UseAuthFilesOauthResult = {
|
||||||
|
excluded: Record<string, string[]>;
|
||||||
|
excludedError: UnsupportedError;
|
||||||
|
modelAlias: Record<string, OAuthModelAliasEntry[]>;
|
||||||
|
modelAliasError: UnsupportedError;
|
||||||
|
allProviderModels: Record<string, AuthFileModelItem[]>;
|
||||||
|
providerList: string[];
|
||||||
|
loadExcluded: () => Promise<void>;
|
||||||
|
loadModelAlias: () => Promise<void>;
|
||||||
|
deleteExcluded: (provider: string) => void;
|
||||||
|
deleteModelAlias: (provider: string) => void;
|
||||||
|
handleMappingUpdate: (provider: string, sourceModel: string, newAlias: string) => Promise<void>;
|
||||||
|
handleDeleteLink: (provider: string, sourceModel: string, alias: string) => void;
|
||||||
|
handleToggleFork: (
|
||||||
|
provider: string,
|
||||||
|
sourceModel: string,
|
||||||
|
alias: string,
|
||||||
|
fork: boolean
|
||||||
|
) => Promise<void>;
|
||||||
|
handleRenameAlias: (oldAlias: string, newAlias: string) => Promise<void>;
|
||||||
|
handleDeleteAlias: (aliasName: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesOauthOptions = {
|
||||||
|
viewMode: ViewMode;
|
||||||
|
files: AuthFileItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesOauth(options: UseAuthFilesOauthOptions): UseAuthFilesOauthResult {
|
||||||
|
const { viewMode, files } = options;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
|
|
||||||
|
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
||||||
|
const [excludedError, setExcludedError] = useState<UnsupportedError>(null);
|
||||||
|
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
|
||||||
|
const [modelAliasError, setModelAliasError] = useState<UnsupportedError>(null);
|
||||||
|
const [allProviderModels, setAllProviderModels] = useState<Record<string, AuthFileModelItem[]>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const excludedUnsupportedRef = useRef(false);
|
||||||
|
const mappingsUnsupportedRef = useRef(false);
|
||||||
|
|
||||||
|
const providerList = useMemo(() => {
|
||||||
|
const providers = new Set<string>();
|
||||||
|
|
||||||
|
Object.keys(modelAlias).forEach((provider) => {
|
||||||
|
const key = provider.trim().toLowerCase();
|
||||||
|
if (key) providers.add(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (typeof file.type === 'string') {
|
||||||
|
const key = file.type.trim().toLowerCase();
|
||||||
|
if (key) providers.add(key);
|
||||||
|
}
|
||||||
|
if (typeof file.provider === 'string') {
|
||||||
|
const key = file.provider.trim().toLowerCase();
|
||||||
|
if (key) providers.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(providers);
|
||||||
|
}, [files, modelAlias]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewMode !== 'diagram') return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadAllModels = async () => {
|
||||||
|
if (providerList.length === 0) {
|
||||||
|
if (!cancelled) setAllProviderModels({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
providerList.map(async (provider) => {
|
||||||
|
try {
|
||||||
|
const models = await authFilesApi.getModelDefinitions(provider);
|
||||||
|
return { provider, models };
|
||||||
|
} catch {
|
||||||
|
return { provider, models: [] as AuthFileModelItem[] };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const nextModels: Record<string, AuthFileModelItem[]> = {};
|
||||||
|
results.forEach(({ provider, models }) => {
|
||||||
|
if (models.length > 0) {
|
||||||
|
nextModels[provider] = models;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setAllProviderModels(nextModels);
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadAllModels();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [providerList, viewMode]);
|
||||||
|
|
||||||
|
const loadExcluded = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFilesApi.getOauthExcludedModels();
|
||||||
|
excludedUnsupportedRef.current = false;
|
||||||
|
setExcluded(res || {});
|
||||||
|
setExcludedError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status =
|
||||||
|
typeof err === 'object' && err !== null && 'status' in err
|
||||||
|
? (err as { status?: unknown }).status
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
setExcluded({});
|
||||||
|
setExcludedError('unsupported');
|
||||||
|
if (!excludedUnsupportedRef.current) {
|
||||||
|
excludedUnsupportedRef.current = true;
|
||||||
|
showNotification(t('oauth_excluded.upgrade_required'), 'warning');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
|
}, [showNotification, t]);
|
||||||
|
|
||||||
|
const loadModelAlias = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFilesApi.getOauthModelAlias();
|
||||||
|
mappingsUnsupportedRef.current = false;
|
||||||
|
setModelAlias(res || {});
|
||||||
|
setModelAliasError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status =
|
||||||
|
typeof err === 'object' && err !== null && 'status' in err
|
||||||
|
? (err as { status?: unknown }).status
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
setModelAlias({});
|
||||||
|
setModelAliasError('unsupported');
|
||||||
|
if (!mappingsUnsupportedRef.current) {
|
||||||
|
mappingsUnsupportedRef.current = true;
|
||||||
|
showNotification(t('oauth_model_alias.upgrade_required'), 'warning');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
|
}, [showNotification, t]);
|
||||||
|
|
||||||
|
const deleteExcluded = useCallback(
|
||||||
|
(provider: string) => {
|
||||||
|
const providerLabel = provider.trim() || provider;
|
||||||
|
showConfirmation({
|
||||||
|
title: t('oauth_excluded.delete_title', { defaultValue: 'Delete Exclusion' }),
|
||||||
|
message: t('oauth_excluded.delete_confirm', { provider: providerLabel }),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
const providerKey = normalizeProviderKey(provider);
|
||||||
|
if (!providerKey) {
|
||||||
|
showNotification(t('oauth_excluded.provider_required'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await authFilesApi.deleteOauthExcludedEntry(providerKey);
|
||||||
|
await loadExcluded();
|
||||||
|
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
try {
|
||||||
|
const current = await authFilesApi.getOauthExcludedModels();
|
||||||
|
const next: Record<string, string[]> = {};
|
||||||
|
Object.entries(current).forEach(([key, models]) => {
|
||||||
|
if (normalizeProviderKey(key) === providerKey) return;
|
||||||
|
next[key] = models;
|
||||||
|
});
|
||||||
|
await authFilesApi.replaceOauthExcludedModels(next);
|
||||||
|
await loadExcluded();
|
||||||
|
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||||||
|
} catch (fallbackErr: unknown) {
|
||||||
|
const errorMessage =
|
||||||
|
fallbackErr instanceof Error
|
||||||
|
? fallbackErr.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: '';
|
||||||
|
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadExcluded, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteModelAlias = useCallback(
|
||||||
|
(provider: string) => {
|
||||||
|
showConfirmation({
|
||||||
|
title: t('oauth_model_alias.delete_title', { defaultValue: 'Delete Mappings' }),
|
||||||
|
message: t('oauth_model_alias.delete_confirm', { provider }),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await authFilesApi.deleteOauthModelAlias(provider);
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.delete_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadModelAlias, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMappingUpdate = useCallback(
|
||||||
|
async (provider: string, sourceModel: string, newAlias: string) => {
|
||||||
|
if (!provider || !sourceModel || !newAlias) return;
|
||||||
|
const normalizedProvider = normalizeProviderKey(provider);
|
||||||
|
if (!normalizedProvider) return;
|
||||||
|
|
||||||
|
const providerKey = Object.keys(modelAlias).find(
|
||||||
|
(key) => normalizeProviderKey(key) === normalizedProvider
|
||||||
|
);
|
||||||
|
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||||||
|
|
||||||
|
const nameTrim = sourceModel.trim();
|
||||||
|
const aliasTrim = newAlias.trim();
|
||||||
|
const nameKey = nameTrim.toLowerCase();
|
||||||
|
const aliasKey = aliasTrim.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentMappings.some(
|
||||||
|
(m) =>
|
||||||
|
(m.name ?? '').trim().toLowerCase() === nameKey &&
|
||||||
|
(m.alias ?? '').trim().toLowerCase() === aliasKey
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMappings: OAuthModelAliasEntry[] = [
|
||||||
|
...currentMappings,
|
||||||
|
{ name: nameTrim, alias: aliasTrim, fork: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteLink = useCallback(
|
||||||
|
(provider: string, sourceModel: string, alias: string) => {
|
||||||
|
const nameTrim = sourceModel.trim();
|
||||||
|
const aliasTrim = alias.trim();
|
||||||
|
if (!provider || !nameTrim || !aliasTrim) return;
|
||||||
|
|
||||||
|
showConfirmation({
|
||||||
|
title: t('oauth_model_alias.delete_link_title', { defaultValue: 'Unlink mapping' }),
|
||||||
|
message: (
|
||||||
|
<Trans
|
||||||
|
i18nKey="oauth_model_alias.delete_link_confirm"
|
||||||
|
values={{ provider, sourceModel: nameTrim, alias: aliasTrim }}
|
||||||
|
components={{ code: <code /> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
const normalizedProvider = normalizeProviderKey(provider);
|
||||||
|
const providerKey = Object.keys(modelAlias).find(
|
||||||
|
(key) => normalizeProviderKey(key) === normalizedProvider
|
||||||
|
);
|
||||||
|
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||||||
|
const nameKey = nameTrim.toLowerCase();
|
||||||
|
const aliasKey = aliasTrim.toLowerCase();
|
||||||
|
const nextMappings = currentMappings.filter(
|
||||||
|
(m) =>
|
||||||
|
(m.name ?? '').trim().toLowerCase() !== nameKey ||
|
||||||
|
(m.alias ?? '').trim().toLowerCase() !== aliasKey
|
||||||
|
);
|
||||||
|
if (nextMappings.length === currentMappings.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (nextMappings.length === 0) {
|
||||||
|
await authFilesApi.deleteOauthModelAlias(normalizedProvider);
|
||||||
|
} else {
|
||||||
|
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||||||
|
}
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleFork = useCallback(
|
||||||
|
async (provider: string, sourceModel: string, alias: string, fork: boolean) => {
|
||||||
|
const normalizedProvider = normalizeProviderKey(provider);
|
||||||
|
if (!normalizedProvider) return;
|
||||||
|
|
||||||
|
const providerKey = Object.keys(modelAlias).find(
|
||||||
|
(key) => normalizeProviderKey(key) === normalizedProvider
|
||||||
|
);
|
||||||
|
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||||||
|
const nameKey = sourceModel.trim().toLowerCase();
|
||||||
|
const aliasKey = alias.trim().toLowerCase();
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const nextMappings = currentMappings.map((m) => {
|
||||||
|
const mName = (m.name ?? '').trim().toLowerCase();
|
||||||
|
const mAlias = (m.alias ?? '').trim().toLowerCase();
|
||||||
|
if (mName === nameKey && mAlias === aliasKey) {
|
||||||
|
changed = true;
|
||||||
|
return fork ? { ...m, fork: true } : { name: m.name, alias: m.alias };
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!changed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRenameAlias = useCallback(
|
||||||
|
async (oldAlias: string, newAlias: string) => {
|
||||||
|
const oldTrim = oldAlias.trim();
|
||||||
|
const newTrim = newAlias.trim();
|
||||||
|
if (!oldTrim || !newTrim || oldTrim === newTrim) return;
|
||||||
|
|
||||||
|
const oldKey = oldTrim.toLowerCase();
|
||||||
|
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
|
||||||
|
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === oldKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (providersToUpdate.length === 0) return;
|
||||||
|
|
||||||
|
let hadFailure = false;
|
||||||
|
let failureMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
providersToUpdate.map(([provider, mappings]) => {
|
||||||
|
const nextMappings = mappings.map((m) =>
|
||||||
|
(m.alias ?? '').trim().toLowerCase() === oldKey ? { ...m, alias: newTrim } : m
|
||||||
|
);
|
||||||
|
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const failures = results.filter(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === 'rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
hadFailure = true;
|
||||||
|
const reason = failures[0].reason;
|
||||||
|
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await loadModelAlias();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadFailure) {
|
||||||
|
showNotification(
|
||||||
|
failureMessage
|
||||||
|
? `${t('oauth_model_alias.save_failed')}: ${failureMessage}`
|
||||||
|
: t('oauth_model_alias.save_failed'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteAlias = useCallback(
|
||||||
|
(aliasName: string) => {
|
||||||
|
const aliasTrim = aliasName.trim();
|
||||||
|
if (!aliasTrim) return;
|
||||||
|
const aliasKey = aliasTrim.toLowerCase();
|
||||||
|
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
|
||||||
|
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === aliasKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (providersToUpdate.length === 0) return;
|
||||||
|
|
||||||
|
showConfirmation({
|
||||||
|
title: t('oauth_model_alias.delete_alias_title', { defaultValue: 'Delete Alias' }),
|
||||||
|
message: (
|
||||||
|
<Trans
|
||||||
|
i18nKey="oauth_model_alias.delete_alias_confirm"
|
||||||
|
values={{ alias: aliasTrim }}
|
||||||
|
components={{ code: <code /> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
let hadFailure = false;
|
||||||
|
let failureMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
providersToUpdate.map(([provider, mappings]) => {
|
||||||
|
const nextMappings = mappings.filter(
|
||||||
|
(m) => (m.alias ?? '').trim().toLowerCase() !== aliasKey
|
||||||
|
);
|
||||||
|
if (nextMappings.length === 0) {
|
||||||
|
return authFilesApi.deleteOauthModelAlias(provider);
|
||||||
|
}
|
||||||
|
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const failures = results.filter(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === 'rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
hadFailure = true;
|
||||||
|
const reason = failures[0].reason;
|
||||||
|
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await loadModelAlias();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadFailure) {
|
||||||
|
showNotification(
|
||||||
|
failureMessage
|
||||||
|
? `${t('oauth_model_alias.delete_failed')}: ${failureMessage}`
|
||||||
|
: t('oauth_model_alias.delete_failed'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showNotification(t('oauth_model_alias.delete_success'), 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
excluded,
|
||||||
|
excludedError,
|
||||||
|
modelAlias,
|
||||||
|
modelAliasError,
|
||||||
|
allProviderModels,
|
||||||
|
providerList,
|
||||||
|
loadExcluded,
|
||||||
|
loadModelAlias,
|
||||||
|
deleteExcluded,
|
||||||
|
deleteModelAlias,
|
||||||
|
handleMappingUpdate,
|
||||||
|
handleDeleteLink,
|
||||||
|
handleToggleFork,
|
||||||
|
handleRenameAlias,
|
||||||
|
handleDeleteAlias
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
254
src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor.ts
Normal file
254
src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { authFilesApi } from '@/services/api';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import { formatFileSize } from '@/utils/format';
|
||||||
|
import {
|
||||||
|
MAX_AUTH_FILE_SIZE,
|
||||||
|
normalizeExcludedModels,
|
||||||
|
parseDisableCoolingValue,
|
||||||
|
parseExcludedModelsText,
|
||||||
|
parsePriorityValue
|
||||||
|
} from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
export type PrefixProxyEditorField =
|
||||||
|
| 'prefix'
|
||||||
|
| 'proxyUrl'
|
||||||
|
| 'priority'
|
||||||
|
| 'excludedModelsText'
|
||||||
|
| 'disableCooling';
|
||||||
|
|
||||||
|
export type PrefixProxyEditorState = {
|
||||||
|
fileName: string;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
originalText: string;
|
||||||
|
rawText: string;
|
||||||
|
json: Record<string, unknown> | null;
|
||||||
|
prefix: string;
|
||||||
|
proxyUrl: string;
|
||||||
|
priority: string;
|
||||||
|
excludedModelsText: string;
|
||||||
|
disableCooling: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesPrefixProxyEditorOptions = {
|
||||||
|
disableControls: boolean;
|
||||||
|
loadFiles: () => Promise<void>;
|
||||||
|
loadKeyStats: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesPrefixProxyEditorResult = {
|
||||||
|
prefixProxyEditor: PrefixProxyEditorState | null;
|
||||||
|
prefixProxyUpdatedText: string;
|
||||||
|
prefixProxyDirty: boolean;
|
||||||
|
openPrefixProxyEditor: (name: string) => Promise<void>;
|
||||||
|
closePrefixProxyEditor: () => void;
|
||||||
|
handlePrefixProxyChange: (field: PrefixProxyEditorField, value: string) => void;
|
||||||
|
handlePrefixProxySave: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): string => {
|
||||||
|
if (!editor?.json) return editor?.rawText ?? '';
|
||||||
|
const next: Record<string, unknown> = { ...editor.json };
|
||||||
|
if ('prefix' in next || editor.prefix.trim()) {
|
||||||
|
next.prefix = editor.prefix;
|
||||||
|
}
|
||||||
|
if ('proxy_url' in next || editor.proxyUrl.trim()) {
|
||||||
|
next.proxy_url = editor.proxyUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedPriority = parsePriorityValue(editor.priority);
|
||||||
|
if (parsedPriority !== undefined) {
|
||||||
|
next.priority = parsedPriority;
|
||||||
|
} else if ('priority' in next) {
|
||||||
|
delete next.priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
const excludedModels = parseExcludedModelsText(editor.excludedModelsText);
|
||||||
|
if (excludedModels.length > 0) {
|
||||||
|
next.excluded_models = excludedModels;
|
||||||
|
} else if ('excluded_models' in next) {
|
||||||
|
delete next.excluded_models;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDisableCooling = parseDisableCoolingValue(editor.disableCooling);
|
||||||
|
if (parsedDisableCooling !== undefined) {
|
||||||
|
next.disable_cooling = parsedDisableCooling;
|
||||||
|
} else if ('disable_cooling' in next) {
|
||||||
|
delete next.disable_cooling;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesPrefixProxyEditor(
|
||||||
|
options: UseAuthFilesPrefixProxyEditorOptions
|
||||||
|
): UseAuthFilesPrefixProxyEditorResult {
|
||||||
|
const { disableControls, loadFiles, loadKeyStats } = options;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||||
|
|
||||||
|
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
|
||||||
|
|
||||||
|
const prefixProxyUpdatedText = buildPrefixProxyUpdatedText(prefixProxyEditor);
|
||||||
|
const prefixProxyDirty =
|
||||||
|
Boolean(prefixProxyEditor?.json) &&
|
||||||
|
Boolean(prefixProxyEditor?.originalText) &&
|
||||||
|
prefixProxyUpdatedText !== prefixProxyEditor?.originalText;
|
||||||
|
|
||||||
|
const closePrefixProxyEditor = () => {
|
||||||
|
setPrefixProxyEditor(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPrefixProxyEditor = async (name: string) => {
|
||||||
|
if (disableControls) return;
|
||||||
|
if (prefixProxyEditor?.fileName === name) {
|
||||||
|
setPrefixProxyEditor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrefixProxyEditor({
|
||||||
|
fileName: name,
|
||||||
|
loading: true,
|
||||||
|
saving: false,
|
||||||
|
error: null,
|
||||||
|
originalText: '',
|
||||||
|
rawText: '',
|
||||||
|
json: null,
|
||||||
|
prefix: '',
|
||||||
|
proxyUrl: '',
|
||||||
|
priority: '',
|
||||||
|
excludedModelsText: '',
|
||||||
|
disableCooling: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawText = await authFilesApi.downloadText(name);
|
||||||
|
const trimmed = rawText.trim();
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: t('auth_files.prefix_proxy_invalid_json'),
|
||||||
|
rawText: trimmed,
|
||||||
|
originalText: trimmed
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: t('auth_files.prefix_proxy_invalid_json'),
|
||||||
|
rawText: trimmed,
|
||||||
|
originalText: trimmed
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = parsed as Record<string, unknown>;
|
||||||
|
const originalText = JSON.stringify(json);
|
||||||
|
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
|
||||||
|
const proxyUrl = typeof json.proxy_url === 'string' ? json.proxy_url : '';
|
||||||
|
const priority = parsePriorityValue(json.priority);
|
||||||
|
const excludedModels = normalizeExcludedModels(json.excluded_models);
|
||||||
|
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
|
||||||
|
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
originalText,
|
||||||
|
rawText: originalText,
|
||||||
|
json,
|
||||||
|
prefix,
|
||||||
|
proxyUrl,
|
||||||
|
priority: priority !== undefined ? String(priority) : '',
|
||||||
|
excludedModelsText: excludedModels.join('\n'),
|
||||||
|
disableCooling:
|
||||||
|
disableCoolingValue === undefined ? '' : disableCoolingValue ? 'true' : 'false',
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : t('notification.download_failed');
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return { ...prev, loading: false, error: errorMessage, rawText: '' };
|
||||||
|
});
|
||||||
|
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrefixProxyChange = (field: PrefixProxyEditorField, value: string) => {
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
if (field === 'prefix') return { ...prev, prefix: value };
|
||||||
|
if (field === 'proxyUrl') return { ...prev, proxyUrl: value };
|
||||||
|
if (field === 'priority') return { ...prev, priority: value };
|
||||||
|
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: value };
|
||||||
|
return { ...prev, disableCooling: value };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrefixProxySave = async () => {
|
||||||
|
if (!prefixProxyEditor?.json) return;
|
||||||
|
if (!prefixProxyDirty) return;
|
||||||
|
|
||||||
|
const name = prefixProxyEditor.fileName;
|
||||||
|
const payload = prefixProxyUpdatedText;
|
||||||
|
const fileSize = new Blob([payload]).size;
|
||||||
|
if (fileSize > MAX_AUTH_FILE_SIZE) {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return { ...prev, saving: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = new File([payload], name, { type: 'application/json' });
|
||||||
|
await authFilesApi.upload(file);
|
||||||
|
showNotification(t('auth_files.prefix_proxy_saved_success', { name }), 'success');
|
||||||
|
await loadFiles();
|
||||||
|
await loadKeyStats();
|
||||||
|
setPrefixProxyEditor(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error');
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return { ...prev, saving: false };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefixProxyEditor,
|
||||||
|
prefixProxyUpdatedText,
|
||||||
|
prefixProxyDirty,
|
||||||
|
openPrefixProxyEditor,
|
||||||
|
closePrefixProxyEditor,
|
||||||
|
handlePrefixProxyChange,
|
||||||
|
handlePrefixProxySave
|
||||||
|
};
|
||||||
|
}
|
||||||
35
src/features/authFiles/hooks/useAuthFilesStats.ts
Normal file
35
src/features/authFiles/hooks/useAuthFilesStats.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { usageApi } from '@/services/api';
|
||||||
|
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||||
|
|
||||||
|
export type UseAuthFilesStatsResult = {
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
loadKeyStats: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesStats(): UseAuthFilesStatsResult {
|
||||||
|
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
||||||
|
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||||
|
const loadingKeyStatsRef = useRef(false);
|
||||||
|
|
||||||
|
const loadKeyStats = useCallback(async () => {
|
||||||
|
if (loadingKeyStatsRef.current) return;
|
||||||
|
loadingKeyStatsRef.current = true;
|
||||||
|
try {
|
||||||
|
const usageResponse = await usageApi.getUsage();
|
||||||
|
const usageData = usageResponse?.usage ?? usageResponse;
|
||||||
|
const stats = await usageApi.getKeyStats(usageData);
|
||||||
|
setKeyStats(stats);
|
||||||
|
const details = collectUsageDetails(usageData);
|
||||||
|
setUsageDetails(details);
|
||||||
|
} catch {
|
||||||
|
// 静默失败
|
||||||
|
} finally {
|
||||||
|
loadingKeyStatsRef.current = false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { keyStats, usageDetails, loadKeyStats };
|
||||||
|
}
|
||||||
|
|
||||||
28
src/features/authFiles/hooks/useAuthFilesStatusBarCache.ts
Normal file
28
src/features/authFiles/hooks/useAuthFilesStatusBarCache.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { calculateStatusBarData, type UsageDetail } from '@/utils/usage';
|
||||||
|
import { normalizeAuthIndexValue } from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
export type AuthFileStatusBarData = ReturnType<typeof calculateStatusBarData>;
|
||||||
|
|
||||||
|
export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails: UsageDetail[]) {
|
||||||
|
return useMemo(() => {
|
||||||
|
const cache = new Map<string, AuthFileStatusBarData>();
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
|
||||||
|
if (authIndexKey) {
|
||||||
|
const filteredDetails = usageDetails.filter((detail) => {
|
||||||
|
const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index);
|
||||||
|
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
|
||||||
|
});
|
||||||
|
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}, [files, usageDetails]);
|
||||||
|
}
|
||||||
|
|
||||||
30
src/features/authFiles/uiState.ts
Normal file
30
src/features/authFiles/uiState.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export type AuthFilesUiState = {
|
||||||
|
filter?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
|
||||||
|
|
||||||
|
export const readAuthFilesUiState = (): AuthFilesUiState | null => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(AUTH_FILES_UI_STATE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as AuthFilesUiState;
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeAuthFilesUiState = (state: AuthFilesUiState) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(AUTH_FILES_UI_STATE_KEY, JSON.stringify(state));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user