mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 19:20:49 +08:00
feat(auth-files): add quota management features and enhance UI layout
This commit is contained in:
@@ -416,7 +416,12 @@
|
|||||||
"prefix_placeholder": "",
|
"prefix_placeholder": "",
|
||||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||||
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
|
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
|
||||||
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully"
|
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully",
|
||||||
|
"card_tools_title": "Tools",
|
||||||
|
"quota_refresh_single": "Refresh quota",
|
||||||
|
"quota_refresh_hint": "Refresh quota for this credential only",
|
||||||
|
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
|
||||||
|
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
|
||||||
},
|
},
|
||||||
"antigravity_quota": {
|
"antigravity_quota": {
|
||||||
"title": "Antigravity Quota",
|
"title": "Antigravity Quota",
|
||||||
|
|||||||
@@ -416,7 +416,12 @@
|
|||||||
"prefix_placeholder": "",
|
"prefix_placeholder": "",
|
||||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||||
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
||||||
"prefix_proxy_saved_success": "已更新 \"{{name}}\""
|
"prefix_proxy_saved_success": "已更新 \"{{name}}\"",
|
||||||
|
"card_tools_title": "配置管理",
|
||||||
|
"quota_refresh_single": "刷新额度",
|
||||||
|
"quota_refresh_hint": "仅刷新当前凭证的额度数据",
|
||||||
|
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
|
||||||
|
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
|
||||||
},
|
},
|
||||||
"antigravity_quota": {
|
"antigravity_quota": {
|
||||||
"title": "Antigravity 额度",
|
"title": "Antigravity 额度",
|
||||||
|
|||||||
@@ -184,6 +184,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fileGridQuotaManaged {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(520px, 1fr));
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.antigravityGrid {
|
.antigravityGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
@@ -469,6 +481,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fileCardLayout {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileCardLayoutQuota {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 156px;
|
||||||
|
gap: $spacing-md;
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileCardMain {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileCardSidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
border-left: 1px dashed var(--border-color);
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px dashed var(--border-color);
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: $spacing-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileCardSidebarHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileCardSidebarTitle {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileCardSidebarHint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.cardHeader {
|
.cardHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
import { useEffect, useMemo, useRef, useState, useCallback, type ReactNode } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useInterval } from '@/hooks/useInterval';
|
import { useInterval } from '@/hooks/useInterval';
|
||||||
@@ -17,12 +17,16 @@ import {
|
|||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconInfo,
|
IconInfo,
|
||||||
|
IconRefreshCw,
|
||||||
IconTrash2,
|
IconTrash2,
|
||||||
} from '@/components/ui/icons';
|
} from '@/components/ui/icons';
|
||||||
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
import type { TFunction } from 'i18next';
|
||||||
|
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota';
|
||||||
|
import { useAuthStore, useNotificationStore, useQuotaStore, useThemeStore } from '@/stores';
|
||||||
import { authFilesApi, usageApi } from '@/services/api';
|
import { authFilesApi, usageApi } from '@/services/api';
|
||||||
import { apiClient } from '@/services/api/client';
|
import { apiClient } from '@/services/api/client';
|
||||||
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
|
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
|
||||||
|
import { getStatusFromError, resolveAuthProvider } from '@/utils/quota';
|
||||||
import {
|
import {
|
||||||
calculateStatusBarData,
|
calculateStatusBarData,
|
||||||
collectUsageDetails,
|
collectUsageDetails,
|
||||||
@@ -91,6 +95,49 @@ const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
|
|||||||
const clampCardPageSize = (value: number) =>
|
const clampCardPageSize = (value: number) =>
|
||||||
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
||||||
|
|
||||||
|
type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli';
|
||||||
|
|
||||||
|
const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli']);
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QuotaProgressBarProps = {
|
||||||
|
percent: number | null;
|
||||||
|
highThreshold: number;
|
||||||
|
mediumThreshold: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type AuthFilesUiState = {
|
type AuthFilesUiState = {
|
||||||
filter?: string;
|
filter?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -195,6 +242,12 @@ export function AuthFilesPage() {
|
|||||||
const { showNotification, showConfirmation } = useNotificationStore();
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
const antigravityQuota = useQuotaStore((state) => state.antigravityQuota);
|
||||||
|
const codexQuota = useQuotaStore((state) => state.codexQuota);
|
||||||
|
const geminiCliQuota = useQuotaStore((state) => state.geminiCliQuota);
|
||||||
|
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
|
||||||
|
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
|
||||||
|
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||||
@@ -248,6 +301,12 @@ export function AuthFilesPage() {
|
|||||||
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
const disableControls = connectionStatus !== 'connected';
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
const normalizedFilter = normalizeProviderKey(String(filter));
|
||||||
|
const quotaFilterType: QuotaProviderType | null = QUOTA_PROVIDER_TYPES.has(
|
||||||
|
normalizedFilter as QuotaProviderType
|
||||||
|
)
|
||||||
|
? (normalizedFilter as QuotaProviderType)
|
||||||
|
: null;
|
||||||
|
|
||||||
const providerList = useMemo(() => {
|
const providerList = useMemo(() => {
|
||||||
const providers = new Set<string>();
|
const providers = new Set<string>();
|
||||||
@@ -1397,6 +1456,124 @@ export function AuthFilesPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
|
||||||
|
const provider = resolveAuthProvider(file);
|
||||||
|
if (!QUOTA_PROVIDER_TYPES.has(provider as QuotaProviderType)) return null;
|
||||||
|
return provider as QuotaProviderType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQuotaConfig = (type: QuotaProviderType) => {
|
||||||
|
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
|
||||||
|
if (type === 'codex') return CODEX_CONFIG;
|
||||||
|
return GEMINI_CLI_CONFIG;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQuotaState = (type: QuotaProviderType, fileName: string) => {
|
||||||
|
if (type === 'antigravity') return antigravityQuota[fileName];
|
||||||
|
if (type === 'codex') return codexQuota[fileName];
|
||||||
|
return geminiCliQuota[fileName];
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateQuotaState = useCallback(
|
||||||
|
(
|
||||||
|
type: QuotaProviderType,
|
||||||
|
updater: (prev: Record<string, unknown>) => Record<string, unknown>
|
||||||
|
) => {
|
||||||
|
if (type === 'antigravity') {
|
||||||
|
setAntigravityQuota(updater as never);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'codex') {
|
||||||
|
setCodexQuota(updater as never);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGeminiCliQuota(updater as never);
|
||||||
|
},
|
||||||
|
[setAntigravityQuota, setCodexQuota, setGeminiCliQuota]
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshQuotaForFile = useCallback(
|
||||||
|
async (file: AuthFileItem, quotaType: QuotaProviderType) => {
|
||||||
|
if (disableControls) return;
|
||||||
|
if (isRuntimeOnlyAuthFile(file)) return;
|
||||||
|
if (file.disabled) return;
|
||||||
|
|
||||||
|
const currentState = getQuotaState(quotaType, file.name);
|
||||||
|
if (currentState?.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
updateQuotaState(quotaType, (prev) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: config.buildLoadingState()
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await config.fetchQuota(file, t);
|
||||||
|
updateQuotaState(quotaType, (prev) => ({
|
||||||
|
...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(quotaType, (prev) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: config.buildErrorState(message, status)
|
||||||
|
}));
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.quota_refresh_failed', { name: file.name, message }),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disableControls, getQuotaState, showNotification, t, updateQuotaState]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderQuotaSection = (item: AuthFileItem, quotaType: QuotaProviderType) => {
|
||||||
|
const config = getQuotaConfig(quotaType) as unknown as {
|
||||||
|
i18nPrefix: string;
|
||||||
|
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const quota = getQuotaState(quotaType, item.name) as
|
||||||
|
| { status?: string; error?: string; errorStatus?: number }
|
||||||
|
| undefined;
|
||||||
|
const quotaStatus = quota?.status ?? 'idle';
|
||||||
|
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' ? (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
|
||||||
|
) : 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 渲染单个认证文件卡片
|
// 渲染单个认证文件卡片
|
||||||
const renderFileCard = (item: AuthFileItem) => {
|
const renderFileCard = (item: AuthFileItem) => {
|
||||||
const fileStats = resolveAuthFileStats(item, keyStats);
|
const fileStats = resolveAuthFileStats(item, keyStats);
|
||||||
@@ -1405,120 +1582,167 @@ export function AuthFilesPage() {
|
|||||||
const showModelsButton = !isRuntimeOnly || isAistudio;
|
const showModelsButton = !isRuntimeOnly || isAistudio;
|
||||||
const typeColor = getTypeColor(item.type || 'unknown');
|
const typeColor = getTypeColor(item.type || 'unknown');
|
||||||
|
|
||||||
|
const quotaType =
|
||||||
|
quotaFilterType && resolveQuotaType(item) === quotaFilterType ? quotaFilterType : null;
|
||||||
|
|
||||||
|
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
|
||||||
|
const quotaState = quotaType ? getQuotaState(quotaType, item.name) : undefined;
|
||||||
|
const quotaRefreshing = quotaState?.status === 'loading';
|
||||||
|
|
||||||
|
const providerCardClass =
|
||||||
|
quotaType === 'antigravity'
|
||||||
|
? styles.antigravityCard
|
||||||
|
: quotaType === 'codex'
|
||||||
|
? styles.codexCard
|
||||||
|
: quotaType === 'gemini-cli'
|
||||||
|
? styles.geminiCliCard
|
||||||
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.name}
|
key={item.name}
|
||||||
className={`${styles.fileCard} ${item.disabled ? styles.fileCardDisabled : ''}`}
|
className={`${styles.fileCard} ${providerCardClass} ${item.disabled ? styles.fileCardDisabled : ''}`}
|
||||||
>
|
>
|
||||||
<div className={styles.cardHeader}>
|
<div
|
||||||
<span
|
className={`${styles.fileCardLayout} ${showQuotaLayout ? styles.fileCardLayoutQuota : ''}`}
|
||||||
className={styles.typeBadge}
|
>
|
||||||
style={{
|
<div className={styles.fileCardMain}>
|
||||||
backgroundColor: typeColor.bg,
|
<div className={styles.cardHeader}>
|
||||||
color: typeColor.text,
|
<span
|
||||||
...(typeColor.border ? { border: typeColor.border } : {}),
|
className={styles.typeBadge}
|
||||||
}}
|
style={{
|
||||||
>
|
backgroundColor: typeColor.bg,
|
||||||
{getTypeLabel(item.type || 'unknown')}
|
color: typeColor.text,
|
||||||
</span>
|
...(typeColor.border ? { border: typeColor.border } : {}),
|
||||||
<span className={styles.fileName}>{item.name}</span>
|
}}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.cardMeta}>
|
|
||||||
<span>
|
|
||||||
{t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{t('auth_files.file_modified')}: {formatModified(item)}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* 状态监测栏 */}
|
|
||||||
{renderStatusBar(item)}
|
|
||||||
|
|
||||||
<div className={styles.cardActions}>
|
|
||||||
{showModelsButton && (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => showModels(item)}
|
|
||||||
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={() => showDetails(item)}
|
|
||||||
className={styles.iconButton}
|
|
||||||
title={t('common.info', { defaultValue: '关于' })}
|
|
||||||
disabled={disableControls}
|
|
||||||
>
|
>
|
||||||
<IconInfo className={styles.actionIcon} size={16} />
|
{getTypeLabel(item.type || 'unknown')}
|
||||||
</Button>
|
</span>
|
||||||
<Button
|
<span className={styles.fileName}>{item.name}</span>
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDownload(item.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={() => void openPrefixProxyEditor(item.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={() => handleDelete(item.name)}
|
|
||||||
className={styles.iconButton}
|
|
||||||
title={t('auth_files.delete_button')}
|
|
||||||
disabled={disableControls || deleting === item.name}
|
|
||||||
>
|
|
||||||
{deleting === item.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={!item.disabled}
|
|
||||||
disabled={disableControls || statusUpdating[item.name] === true}
|
|
||||||
onChange={(value) => void handleStatusToggle(item, value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{isRuntimeOnly && (
|
<div className={styles.cardMeta}>
|
||||||
<div className={styles.virtualBadge}>
|
<span>
|
||||||
{t('auth_files.type_virtual') || '虚拟认证文件'}
|
{t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('auth_files.file_modified')}: {formatModified(item)}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* 状态监测栏 */}
|
||||||
|
{renderStatusBar(item)}
|
||||||
|
|
||||||
|
{showQuotaLayout && quotaType && renderQuotaSection(item, quotaType)}
|
||||||
|
|
||||||
|
<div className={styles.cardActions}>
|
||||||
|
{showModelsButton && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => showModels(item)}
|
||||||
|
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={() => showDetails(item)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('common.info', { defaultValue: '关于' })}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconInfo className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDownload(item.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={() => void openPrefixProxyEditor(item.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={() => handleDelete(item.name)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.delete_button')}
|
||||||
|
disabled={disableControls || deleting === item.name}
|
||||||
|
>
|
||||||
|
{deleting === item.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={!item.disabled}
|
||||||
|
disabled={disableControls || statusUpdating[item.name] === true}
|
||||||
|
onChange={(value) => void handleStatusToggle(item, value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isRuntimeOnly && (
|
||||||
|
<div className={styles.virtualBadge}>
|
||||||
|
{t('auth_files.type_virtual') || '虚拟认证文件'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showQuotaLayout && quotaType && (
|
||||||
|
<div className={styles.fileCardSidebar}>
|
||||||
|
<div className={styles.fileCardSidebarHeader}>
|
||||||
|
<span className={styles.fileCardSidebarTitle}>
|
||||||
|
{t('auth_files.card_tools_title')}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className={styles.iconButton}
|
||||||
|
onClick={() => void refreshQuotaForFile(item, quotaType)}
|
||||||
|
disabled={disableControls || item.disabled}
|
||||||
|
loading={quotaRefreshing}
|
||||||
|
title={t('auth_files.quota_refresh_single')}
|
||||||
|
aria-label={t('auth_files.quota_refresh_single')}
|
||||||
|
>
|
||||||
|
{!quotaRefreshing && <IconRefreshCw className={styles.actionIcon} size={16} />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fileCardSidebarHint}>{t('auth_files.quota_refresh_hint')}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1625,7 +1849,11 @@ export function AuthFilesPage() {
|
|||||||
description={t('auth_files.search_empty_desc')}
|
description={t('auth_files.search_empty_desc')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.fileGrid}>{pageItems.map(renderFileCard)}</div>
|
<div
|
||||||
|
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''}`}
|
||||||
|
>
|
||||||
|
{pageItems.map(renderFileCard)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 分页 */}
|
{/* 分页 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user