feat: 优化配额管理页面 UI 与交互

- 卡片布局改为 CSS Grid 自适应,最小宽度 380px,支持 1080p 下显示 4 列
- 分页控件重构:移除数字输入框,改为 [按页显示] / [显示全部] 切换按钮
- 动态计算每页数量:按页模式固定显示 3 行(行数 * 动态列数)
- Header 布局优化:凭证计数移至标题旁(淡蓝色气泡),刷新按钮合并为图标
- 安全限制:凭证数超过 30 个时禁用显示全部功能并弹窗提示
This commit is contained in:
moxi
2026-01-03 22:43:58 +08:00
parent 84b219957e
commit 42eb783395
4 changed files with 192 additions and 74 deletions

View File

@@ -13,17 +13,17 @@ import { QuotaCard } from './QuotaCard';
import type { QuotaStatusState } from './QuotaCard'; import type { QuotaStatusState } from './QuotaCard';
import { useQuotaLoader } from './useQuotaLoader'; import { useQuotaLoader } from './useQuotaLoader';
import type { QuotaConfig } from './quotaConfigs'; import type { QuotaConfig } from './quotaConfigs';
import { useGridColumns } from './useGridColumns';
import { IconRefreshCw } from '@/components/ui/icons';
import styles from '@/pages/QuotaPage.module.scss'; import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T); type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void; type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
const MIN_CARD_PAGE_SIZE = 3; type ViewMode = 'paged' | 'all';
const MAX_CARD_PAGE_SIZE = 30;
const clampCardPageSize = (value: number) => const MAX_SHOW_ALL_THRESHOLD = 30;
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
interface QuotaPaginationState<T> { interface QuotaPaginationState<T> {
pageSize: number; pageSize: number;
@@ -40,7 +40,7 @@ interface QuotaPaginationState<T> {
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => { const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSizeState] = useState(() => clampCardPageSize(defaultPageSize)); const [pageSize, setPageSizeState] = useState(defaultPageSize);
const [loading, setLoadingState] = useState(false); const [loading, setLoadingState] = useState(false);
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null); const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
@@ -57,7 +57,7 @@ const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginatio
}, [items, currentPage, pageSize]); }, [items, currentPage, pageSize]);
const setPageSize = useCallback((size: number) => { const setPageSize = useCallback((size: number) => {
setPageSizeState(clampCardPageSize(size)); setPageSizeState(size);
setPage(1); setPage(1);
}, []); }, []);
@@ -107,6 +107,11 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
Record<string, TState> 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)), [ const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
files, files,
config.filterFn config.filterFn
@@ -125,15 +130,25 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
setLoading setLoading
} = useQuotaPagination(filteredFiles); } = useQuotaPagination(filteredFiles);
// Update page size based on view mode and columns
useEffect(() => {
if (viewMode === 'all') {
setPageSize(Math.max(1, filteredFiles.length));
} else {
// Paged mode: 3 rows * columns
setPageSize(columns * 3);
}
}, [viewMode, columns, filteredFiles.length, setPageSize]);
const { quota, loadQuota } = useQuotaLoader(config); const { quota, loadQuota } = useQuotaLoader(config);
const handleRefreshPage = useCallback(() => { const handleRefresh = useCallback(() => {
loadQuota(pageItems, 'page', setLoading); if (viewMode === 'all') {
}, [loadQuota, pageItems, setLoading]); loadQuota(filteredFiles, 'all', setLoading);
} else {
const handleRefreshAll = useCallback(() => { loadQuota(pageItems, 'page', setLoading);
loadQuota(filteredFiles, 'all', setLoading); }
}, [loadQuota, filteredFiles, setLoading]); }, [loadQuota, filteredFiles, pageItems, viewMode, setLoading]);
useEffect(() => { useEffect(() => {
if (loading) return; if (loading) return;
@@ -153,28 +168,53 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
}); });
}, [filteredFiles, loading, setQuota]); }, [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>
);
return ( return (
<Card <Card
title={t(`${config.i18nPrefix}.title`)} title={titleNode}
extra={ extra={
<div className={styles.headerActions}> <div className={styles.headerActions}>
<div className={styles.viewModeToggle}>
<Button
variant={viewMode === 'paged' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setViewMode('paged')}
>
{t('auth_files.view_mode_paged')}
</Button>
<Button
variant={viewMode === '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 <Button
variant="secondary" variant="ghost"
size="sm" size="sm"
onClick={handleRefreshPage} onClick={handleRefresh}
disabled={disabled || sectionLoading || pageItems.length === 0}
loading={sectionLoading && loadingScope === 'page'}
>
{t(`${config.i18nPrefix}.refresh_button`)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshAll}
disabled={disabled || sectionLoading || filteredFiles.length === 0} disabled={disabled || sectionLoading || filteredFiles.length === 0}
loading={sectionLoading && loadingScope === 'all'} loading={sectionLoading}
title={t(`${config.i18nPrefix}.refresh_button`)}
> >
{t(`${config.i18nPrefix}.fetch_all`)} {!sectionLoading && <IconRefreshCw size={18} />}
</Button> </Button>
</div> </div>
} }
@@ -186,31 +226,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
/> />
) : ( ) : (
<> <>
<div className={config.controlsClassName}> <div ref={gridRef} className={config.gridClassName}>
<div className={config.controlClassName}>
<label>{t('auth_files.page_size_label')}</label>
<input
className={styles.pageSizeSelect}
type="number"
min={MIN_CARD_PAGE_SIZE}
max={MAX_CARD_PAGE_SIZE}
step={1}
value={pageSize}
onChange={(e) => {
const value = e.currentTarget.valueAsNumber;
if (!Number.isFinite(value)) return;
setPageSize(value);
}}
/>
</div>
<div className={config.controlClassName}>
<label>{t('common.info')}</label>
<div className={styles.statsInfo}>
{filteredFiles.length} {t('auth_files.files_count')}
</div>
</div>
</div>
<div className={config.gridClassName}>
{pageItems.map((item) => ( {pageItems.map((item) => (
<QuotaCard <QuotaCard
key={item.name} key={item.name}
@@ -224,7 +240,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
/> />
))} ))}
</div> </div>
{filteredFiles.length > pageSize && ( {filteredFiles.length > pageSize && viewMode === 'paged' && (
<div className={styles.pagination}> <div className={styles.pagination}>
<Button <Button
variant="secondary" variant="secondary"
@@ -253,6 +269,16 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
)} )}
</> </>
)} )}
{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> </Card>
); );
} }

View File

@@ -0,0 +1,40 @@
import { useState, useEffect, useCallback } from 'react';
/**
* Hook to calculate the number of grid columns based on container width and item min-width.
* Returns [columns, refCallback].
*/
export function useGridColumns(
itemMinWidth: number,
gap: number = 16
): [number, (node: HTMLDivElement | null) => void] {
const [columns, setColumns] = useState(1);
const [element, setElement] = useState<HTMLDivElement | null>(null);
const refCallback = useCallback((node: HTMLDivElement | null) => {
setElement(node);
}, []);
useEffect(() => {
if (!element) return;
const updateColumns = () => {
const containerWidth = element.clientWidth;
const effectiveItemWidth = itemMinWidth + gap;
const count = Math.floor((containerWidth + gap) / effectiveItemWidth);
setColumns(Math.max(1, count));
};
updateColumns();
const observer = new ResizeObserver(() => {
updateColumns();
});
observer.observe(element);
return () => observer.disconnect();
}, [element, itemMinWidth, gap]);
return [columns, refCallback];
}

View File

@@ -327,6 +327,9 @@
"search_placeholder": "输入名称、类型或提供方关键字", "search_placeholder": "输入名称、类型或提供方关键字",
"page_size_label": "单页数量", "page_size_label": "单页数量",
"page_size_unit": "个/页", "page_size_unit": "个/页",
"view_mode_paged": "按页显示",
"view_mode_all": "显示全部",
"too_many_files_warning": "您的凭证总数过多,全部加载会导致页面卡顿,请保持单页浏览。",
"filter_all": "全部", "filter_all": "全部",
"filter_qwen": "Qwen", "filter_qwen": "Qwen",
"filter_gemini": "Gemini", "filter_gemini": "Gemini",

View File

@@ -30,6 +30,28 @@
display: flex; display: flex;
gap: $spacing-sm; gap: $spacing-sm;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
}
.titleWrapper {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.countBadge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
min-width: 24px;
padding: 0 8px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
color: #0284c7; // sky-600
background-color: #e0f2fe; // sky-100
box-sizing: border-box;
} }
.errorBox { .errorBox {
@@ -76,11 +98,7 @@
.geminiCliGrid { .geminiCliGrid {
display: grid; display: grid;
gap: $spacing-md; gap: $spacing-md;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
@include tablet {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include mobile { @include mobile {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -112,28 +130,30 @@
} }
} }
.viewModeToggle {
display: flex;
gap: 8px;
background-color: var(--bg-secondary);
padding: 4px;
border-radius: $radius-md;
}
.antigravityCard { .antigravityCard {
background-image: linear-gradient( background-image: linear-gradient(180deg,
180deg, rgba(224, 247, 250, 0.12),
rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
rgba(224, 247, 250, 0)
);
} }
.codexCard { .codexCard {
background-image: linear-gradient( background-image: linear-gradient(180deg,
180deg, rgba(255, 243, 224, 0.18),
rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0));
rgba(255, 243, 224, 0)
);
} }
.geminiCliCard { .geminiCliCard {
background-image: linear-gradient( background-image: linear-gradient(180deg,
180deg, rgba(231, 239, 255, 0.2),
rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0));
rgba(231, 239, 255, 0)
);
} }
.quotaSection { .quotaSection {
@@ -331,3 +351,32 @@
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
border-radius: $radius-md; border-radius: $radius-md;
} }
.warningOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.warningModal {
background-color: var(--bg-primary);
border-radius: $radius-lg;
padding: $spacing-lg;
max-width: 400px;
text-align: center;
box-shadow: $shadow-lg;
p {
margin: 0 0 $spacing-md 0;
color: var(--text-primary);
font-size: 14px;
line-height: 1.6;
}
}