Merge pull request #44 from moxi000/dev

feat: 优化配额管理页面 UI 与交互
This commit is contained in:
Supra4E8C
2026-01-04 23:38:44 +08:00
committed by GitHub
10 changed files with 286 additions and 95 deletions

View File

@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"", "format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },

View File

@@ -70,17 +70,27 @@ export function PageTransition({
: toIndex > fromIndex : toIndex > fromIndex
? 'forward' ? 'forward'
: 'backward'; : 'backward';
setTransitionDirection(nextDirection);
setLayers((prev) => { let cancelled = false;
const prevCurrent = prev[prev.length - 1];
return [ queueMicrotask(() => {
prevCurrent if (cancelled) return;
? { ...prevCurrent, status: 'exiting' } setTransitionDirection(nextDirection);
: { key: location.key, location, status: 'exiting' }, setLayers((prev) => {
{ key: location.key, location, status: 'current' }, const prevCurrent = prev[prev.length - 1];
]; return [
prevCurrent
? { ...prevCurrent, status: 'exiting' }
: { key: location.key, location, status: 'exiting' },
{ key: location.key, location, status: 'current' },
];
});
setIsAnimating(true);
}); });
setIsAnimating(true);
return () => {
cancelled = true;
};
}, [ }, [
isAnimating, isAnimating,
location, location,

View File

@@ -2,28 +2,29 @@
* Generic quota section component. * Generic quota section component.
*/ */
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useQuotaStore, useThemeStore } from '@/stores'; import { useQuotaStore, useThemeStore } from '@/stores';
import type { AuthFileItem, ResolvedTheme } from '@/types'; import type { AuthFileItem, ResolvedTheme } from '@/types';
import { QuotaCard } from './QuotaCard'; 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 +41,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 +58,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,10 +108,17 @@ 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
]); ]);
const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD;
const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode;
const { const {
pageSize, pageSize,
@@ -121,19 +129,59 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
goToPrev, goToPrev,
goToNext, goToNext,
loading: sectionLoading, loading: sectionLoading,
loadingScope,
setLoading setLoading
} = useQuotaPagination(filteredFiles); } = 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
setPageSize(columns * 3);
}
}, [effectiveViewMode, columns, filteredFiles.length, setPageSize]);
const { quota, loadQuota } = useQuotaLoader(config); const { quota, loadQuota } = useQuotaLoader(config);
const handleRefreshPage = useCallback(() => { const pendingQuotaRefreshRef = useRef(false);
loadQuota(pageItems, 'page', setLoading); const prevFilesLoadingRef = useRef(loading);
}, [loadQuota, pageItems, setLoading]);
const handleRefreshAll = useCallback(() => { const handleRefresh = useCallback(() => {
loadQuota(filteredFiles, 'all', setLoading); pendingQuotaRefreshRef.current = true;
}, [loadQuota, filteredFiles, setLoading]); 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(() => { useEffect(() => {
if (loading) return; if (loading) return;
@@ -153,28 +201,56 @@ 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>
);
const isRefreshing = sectionLoading || loading;
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={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 <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={handleRefreshPage} onClick={handleRefresh}
disabled={disabled || sectionLoading || pageItems.length === 0} disabled={disabled || isRefreshing}
loading={sectionLoading && loadingScope === 'page'} loading={isRefreshing}
title={t('quota_management.refresh_files_and_quota')}
aria-label={t('quota_management.refresh_files_and_quota')}
> >
{t(`${config.i18nPrefix}.refresh_button`)} {!isRefreshing && <IconRefreshCw size={16} />}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshAll}
disabled={disabled || sectionLoading || filteredFiles.length === 0}
loading={sectionLoading && loadingScope === 'all'}
>
{t(`${config.i18nPrefix}.fetch_all`)}
</Button> </Button>
</div> </div>
} }
@@ -186,31 +262,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 +276,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
/> />
))} ))}
</div> </div>
{filteredFiles.length > pageSize && ( {filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
<div className={styles.pagination}> <div className={styles.pagination}>
<Button <Button
variant="secondary" variant="secondary"
@@ -253,6 +305,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

@@ -20,6 +20,7 @@ export function Button({
disabled, disabled,
...rest ...rest
}: PropsWithChildren<ButtonProps>) { }: PropsWithChildren<ButtonProps>) {
const hasChildren = children !== null && children !== undefined && children !== false;
const classes = [ const classes = [
'btn', 'btn',
`btn-${variant}`, `btn-${variant}`,
@@ -33,7 +34,7 @@ export function Button({
return ( return (
<button className={classes} disabled={disabled || loading} {...rest}> <button className={classes} disabled={disabled || loading} {...rest}>
{loading && <span className="loading-spinner" aria-hidden="true" />} {loading && <span className="loading-spinner" aria-hidden="true" />}
<span>{children}</span> {hasChildren && <span>{children}</span>}
</button> </button>
); );
} }

View File

@@ -54,19 +54,28 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
); );
useEffect(() => { useEffect(() => {
let cancelled = false;
if (open) { if (open) {
if (closeTimerRef.current !== null) { if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current); window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null; closeTimerRef.current = null;
} }
setIsVisible(true); queueMicrotask(() => {
setIsClosing(false); if (cancelled) return;
return; setIsVisible(true);
setIsClosing(false);
});
} else if (isVisible) {
queueMicrotask(() => {
if (cancelled) return;
startClose(false);
});
} }
if (isVisible) { return () => {
startClose(false); cancelled = true;
} };
}, [open, isVisible, startClose]); }, [open, isVisible, startClose]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {

View File

@@ -328,6 +328,9 @@
"search_placeholder": "Filter by name, type, or provider", "search_placeholder": "Filter by name, type, or provider",
"page_size_label": "Per page", "page_size_label": "Per page",
"page_size_unit": "items", "page_size_unit": "items",
"view_mode_paged": "Paged",
"view_mode_all": "Show all",
"too_many_files_warning": "Too many credentials. Showing all may cause performance issues, please use paged view.",
"filter_all": "All", "filter_all": "All",
"filter_qwen": "Qwen", "filter_qwen": "Qwen",
"filter_gemini": "Gemini", "filter_gemini": "Gemini",
@@ -710,7 +713,8 @@
"quota_management": { "quota_management": {
"title": "Quota Management", "title": "Quota Management",
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.", "description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
"refresh_files": "Refresh auth files" "refresh_files": "Refresh auth files",
"refresh_files_and_quota": "Refresh files & quota"
}, },
"system_info": { "system_info": {
"title": "Management Center Info", "title": "Management Center Info",

View File

@@ -328,6 +328,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",
@@ -710,7 +713,8 @@
"quota_management": { "quota_management": {
"title": "配额管理", "title": "配额管理",
"description": "集中查看 OAuth 额度与剩余情况", "description": "集中查看 OAuth 额度与剩余情况",
"refresh_files": "刷新认证文件" "refresh_files": "刷新认证文件",
"refresh_files_and_quota": "刷新认证文件&额度"
}, },
"system_info": { "system_info": {
"title": "管理中心信息", "title": "管理中心信息",

View File

@@ -30,6 +30,36 @@
display: flex; display: flex;
gap: $spacing-sm; gap: $spacing-sm;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
:global(.btn-sm) {
line-height: 16px;
}
:global(svg) {
display: block;
}
}
.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: var(--count-badge-text);
background-color: var(--count-badge-bg);
box-sizing: border-box;
} }
.errorBox { .errorBox {
@@ -76,11 +106,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 +138,28 @@
} }
} }
.viewModeToggle {
display: flex;
gap: $spacing-xs;
align-items: center;
}
.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 +357,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;
}
}

View File

@@ -32,6 +32,9 @@
--failure-badge-text: #991b1b; --failure-badge-text: #991b1b;
--failure-badge-border: #fca5a5; --failure-badge-border: #fca5a5;
--count-badge-bg: rgba(59, 130, 246, 0.14);
--count-badge-text: var(--primary-active);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
} }
@@ -66,6 +69,9 @@
--failure-badge-text: #fca5a5; --failure-badge-text: #fca5a5;
--failure-badge-border: #dc2626; --failure-badge-border: #dc2626;
--count-badge-bg: rgba(59, 130, 246, 0.25);
--count-badge-text: var(--primary-active);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3); --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3); --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3);
} }