Files
Cli-Proxy-API-Management-Ce…/src/components/quota/QuotaSection.tsx
2026-01-05 00:00:22 +08:00

322 lines
9.7 KiB
TypeScript

/**
* Generic quota section component.
*/
import { useCallback, useEffect, useMemo, useRef, useState } 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 { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useQuotaStore, useThemeStore } from '@/stores';
import type { AuthFileItem, ResolvedTheme } from '@/types';
import { QuotaCard } from './QuotaCard';
import type { QuotaStatusState } from './QuotaCard';
import { useQuotaLoader } from './useQuotaLoader';
import type { QuotaConfig } from './quotaConfigs';
import { useGridColumns } from './useGridColumns';
import { IconRefreshCw } from '@/components/ui/icons';
import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
type ViewMode = 'paged' | 'all';
const MAX_ITEMS_PER_PAGE = 14;
const MAX_SHOW_ALL_THRESHOLD = 30;
interface QuotaPaginationState<T> {
pageSize: number;
totalPages: number;
currentPage: number;
pageItems: T[];
setPageSize: (size: number) => void;
goToPrev: () => void;
goToNext: () => void;
loading: boolean;
loadingScope: 'page' | 'all' | null;
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void;
}
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
const [page, setPage] = useState(1);
const [pageSize, setPageSizeState] = useState(defaultPageSize);
const [loading, setLoadingState] = useState(false);
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(items.length / pageSize)),
[items.length, pageSize]
);
const currentPage = useMemo(() => Math.min(page, totalPages), [page, totalPages]);
const pageItems = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return items.slice(start, start + pageSize);
}, [items, currentPage, pageSize]);
const setPageSize = useCallback((size: number) => {
setPageSizeState(size);
setPage(1);
}, []);
const goToPrev = useCallback(() => {
setPage((prev) => Math.max(1, prev - 1));
}, []);
const goToNext = useCallback(() => {
setPage((prev) => Math.min(totalPages, prev + 1));
}, [totalPages]);
const setLoading = useCallback((isLoading: boolean, scope?: 'page' | 'all' | null) => {
setLoadingState(isLoading);
setLoadingScope(isLoading ? (scope ?? null) : null);
}, []);
return {
pageSize,
totalPages,
currentPage,
pageItems,
setPageSize,
goToPrev,
goToNext,
loading,
loadingScope,
setLoading
};
};
interface QuotaSectionProps<TState extends QuotaStatusState, TData> {
config: QuotaConfig<TState, TData>;
files: AuthFileItem[];
loading: boolean;
disabled: boolean;
}
export function QuotaSection<TState extends QuotaStatusState, TData>({
config,
files,
loading,
disabled
}: QuotaSectionProps<TState, TData>) {
const { t } = useTranslation();
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
Record<string, TState>
>;
/* Removed useRef */
const [columns, gridRef] = useGridColumns(380); // Min card width 380px matches SCSS
const [viewMode, setViewMode] = useState<ViewMode>('paged');
const [showTooManyWarning, setShowTooManyWarning] = useState(false);
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
files,
config
]);
const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD;
const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode;
const {
pageSize,
totalPages,
currentPage,
pageItems,
setPageSize,
goToPrev,
goToNext,
loading: sectionLoading,
setLoading
} = useQuotaPagination(filteredFiles);
useEffect(() => {
if (showAllAllowed) return;
if (viewMode !== 'all') return;
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
setViewMode('paged');
setShowTooManyWarning(true);
});
return () => {
cancelled = true;
};
}, [showAllAllowed, viewMode]);
// Update page size based on view mode and columns
useEffect(() => {
if (effectiveViewMode === 'all') {
setPageSize(Math.max(1, filteredFiles.length));
} else {
// Paged mode: 3 rows * columns, capped to avoid oversized pages.
setPageSize(Math.min(columns * 3, MAX_ITEMS_PER_PAGE));
}
}, [effectiveViewMode, columns, filteredFiles.length, setPageSize]);
const { quota, loadQuota } = useQuotaLoader(config);
const pendingQuotaRefreshRef = useRef(false);
const prevFilesLoadingRef = useRef(loading);
const handleRefresh = useCallback(() => {
pendingQuotaRefreshRef.current = true;
void triggerHeaderRefresh();
}, []);
useEffect(() => {
const wasLoading = prevFilesLoadingRef.current;
prevFilesLoadingRef.current = loading;
if (!pendingQuotaRefreshRef.current) return;
if (loading) return;
if (!wasLoading) return;
pendingQuotaRefreshRef.current = false;
const scope = effectiveViewMode === 'all' ? 'all' : 'page';
const targets = effectiveViewMode === 'all' ? filteredFiles : pageItems;
if (targets.length === 0) return;
loadQuota(targets, scope, setLoading);
}, [loading, effectiveViewMode, filteredFiles, pageItems, loadQuota, setLoading]);
useEffect(() => {
if (loading) return;
if (filteredFiles.length === 0) {
setQuota({});
return;
}
setQuota((prev) => {
const nextState: Record<string, TState> = {};
filteredFiles.forEach((file) => {
const cached = prev[file.name];
if (cached) {
nextState[file.name] = cached;
}
});
return nextState;
});
}, [filteredFiles, loading, setQuota]);
const titleNode = (
<div className={styles.titleWrapper}>
<span>{t(`${config.i18nPrefix}.title`)}</span>
{filteredFiles.length > 0 && (
<span className={styles.countBadge}>
{filteredFiles.length}
</span>
)}
</div>
);
const isRefreshing = sectionLoading || loading;
return (
<Card
title={titleNode}
extra={
<div className={styles.headerActions}>
<div className={styles.viewModeToggle}>
<Button
variant={effectiveViewMode === 'paged' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setViewMode('paged')}
>
{t('auth_files.view_mode_paged')}
</Button>
<Button
variant={effectiveViewMode === 'all' ? 'primary' : 'secondary'}
size="sm"
onClick={() => {
if (filteredFiles.length > MAX_SHOW_ALL_THRESHOLD) {
setShowTooManyWarning(true);
} else {
setViewMode('all');
}
}}
>
{t('auth_files.view_mode_all')}
</Button>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleRefresh}
disabled={disabled || isRefreshing}
loading={isRefreshing}
title={t('quota_management.refresh_files_and_quota')}
aria-label={t('quota_management.refresh_files_and_quota')}
>
{!isRefreshing && <IconRefreshCw size={16} />}
</Button>
</div>
}
>
{filteredFiles.length === 0 ? (
<EmptyState
title={t(`${config.i18nPrefix}.empty_title`)}
description={t(`${config.i18nPrefix}.empty_desc`)}
/>
) : (
<>
<div ref={gridRef} className={config.gridClassName}>
{pageItems.map((item) => (
<QuotaCard
key={item.name}
item={item}
quota={quota[item.name]}
resolvedTheme={resolvedTheme}
i18nPrefix={config.i18nPrefix}
cardClassName={config.cardClassName}
defaultType={config.type}
renderQuotaItems={config.renderQuotaItems}
/>
))}
</div>
{filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={goToPrev}
disabled={currentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: filteredFiles.length
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={goToNext}
disabled={currentPage >= totalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</>
)}
{showTooManyWarning && (
<div className={styles.warningOverlay} onClick={() => setShowTooManyWarning(false)}>
<div className={styles.warningModal} onClick={(e) => e.stopPropagation()}>
<p>{t('auth_files.too_many_files_warning')}</p>
<Button variant="primary" size="sm" onClick={() => setShowTooManyWarning(false)}>
{t('common.confirm')}
</Button>
</div>
</div>
)}
</Card>
);
}