mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
feat: 优化配额管理页面 UI 与交互
- 卡片布局改为 CSS Grid 自适应,最小宽度 380px,支持 1080p 下显示 4 列 - 分页控件重构:移除数字输入框,改为 [按页显示] / [显示全部] 切换按钮 - 动态计算每页数量:按页模式固定显示 3 行(行数 * 动态列数) - Header 布局优化:凭证计数移至标题旁(淡蓝色气泡),刷新按钮合并为图标 - 安全限制:凭证数超过 30 个时禁用显示全部功能并弹窗提示
This commit is contained in:
@@ -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]);
|
|
||||||
|
|
||||||
const handleRefreshAll = useCallback(() => {
|
|
||||||
loadQuota(filteredFiles, 'all', setLoading);
|
loadQuota(filteredFiles, 'all', setLoading);
|
||||||
}, [loadQuota, filteredFiles, setLoading]);
|
} else {
|
||||||
|
loadQuota(pageItems, 'page', 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
|
<Button
|
||||||
variant="secondary"
|
variant={viewMode === 'paged' ? 'primary' : 'secondary'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleRefreshPage}
|
onClick={() => setViewMode('paged')}
|
||||||
disabled={disabled || sectionLoading || pageItems.length === 0}
|
|
||||||
loading={sectionLoading && loadingScope === 'page'}
|
|
||||||
>
|
>
|
||||||
{t(`${config.i18nPrefix}.refresh_button`)}
|
{t('auth_files.view_mode_paged')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant={viewMode === 'all' ? 'primary' : 'secondary'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleRefreshAll}
|
onClick={() => {
|
||||||
disabled={disabled || sectionLoading || filteredFiles.length === 0}
|
if (filteredFiles.length > MAX_SHOW_ALL_THRESHOLD) {
|
||||||
loading={sectionLoading && loadingScope === 'all'}
|
setShowTooManyWarning(true);
|
||||||
|
} else {
|
||||||
|
setViewMode('all');
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t(`${config.i18nPrefix}.fetch_all`)}
|
{t('auth_files.view_mode_all')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={disabled || sectionLoading || filteredFiles.length === 0}
|
||||||
|
loading={sectionLoading}
|
||||||
|
title={t(`${config.i18nPrefix}.refresh_button`)}
|
||||||
|
>
|
||||||
|
{!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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/components/quota/useGridColumns.ts
Normal file
40
src/components/quota/useGridColumns.ts
Normal 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];
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user