diff --git a/package.json b/package.json index 2ce978f..52b65e3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "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}\"", "type-check": "tsc --noEmit" }, diff --git a/src/components/common/PageTransition.tsx b/src/components/common/PageTransition.tsx index 83b9e90..1da4636 100644 --- a/src/components/common/PageTransition.tsx +++ b/src/components/common/PageTransition.tsx @@ -70,17 +70,27 @@ export function PageTransition({ : toIndex > fromIndex ? 'forward' : 'backward'; - setTransitionDirection(nextDirection); - setLayers((prev) => { - const prevCurrent = prev[prev.length - 1]; - return [ - prevCurrent - ? { ...prevCurrent, status: 'exiting' } - : { key: location.key, location, status: 'exiting' }, - { key: location.key, location, status: 'current' }, - ]; + + let cancelled = false; + + queueMicrotask(() => { + if (cancelled) return; + setTransitionDirection(nextDirection); + setLayers((prev) => { + 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, location, diff --git a/src/components/quota/QuotaSection.tsx b/src/components/quota/QuotaSection.tsx index 98c7d4e..377285c 100644 --- a/src/components/quota/QuotaSection.tsx +++ b/src/components/quota/QuotaSection.tsx @@ -2,28 +2,29 @@ * 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 { 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 | ((prev: T) => T); type QuotaSetter = (updater: QuotaUpdater) => void; -const MIN_CARD_PAGE_SIZE = 3; -const MAX_CARD_PAGE_SIZE = 30; +type ViewMode = 'paged' | 'all'; -const clampCardPageSize = (value: number) => - Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value))); +const MAX_SHOW_ALL_THRESHOLD = 30; interface QuotaPaginationState { pageSize: number; @@ -40,7 +41,7 @@ interface QuotaPaginationState { const useQuotaPagination = (items: T[], defaultPageSize = 6): QuotaPaginationState => { const [page, setPage] = useState(1); - const [pageSize, setPageSizeState] = useState(() => clampCardPageSize(defaultPageSize)); + const [pageSize, setPageSizeState] = useState(defaultPageSize); const [loading, setLoadingState] = useState(false); const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null); @@ -57,7 +58,7 @@ const useQuotaPagination = (items: T[], defaultPageSize = 6): QuotaPaginatio }, [items, currentPage, pageSize]); const setPageSize = useCallback((size: number) => { - setPageSizeState(clampCardPageSize(size)); + setPageSizeState(size); setPage(1); }, []); @@ -107,10 +108,17 @@ export function QuotaSection({ Record >; + /* Removed useRef */ + const [columns, gridRef] = useGridColumns(380); // Min card width 380px matches SCSS + const [viewMode, setViewMode] = useState('paged'); + const [showTooManyWarning, setShowTooManyWarning] = useState(false); + const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [ files, - config.filterFn + config ]); + const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD; + const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode; const { pageSize, @@ -121,19 +129,59 @@ export function QuotaSection({ goToPrev, goToNext, loading: sectionLoading, - loadingScope, 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 + setPageSize(columns * 3); + } + }, [effectiveViewMode, columns, filteredFiles.length, setPageSize]); + const { quota, loadQuota } = useQuotaLoader(config); - const handleRefreshPage = useCallback(() => { - loadQuota(pageItems, 'page', setLoading); - }, [loadQuota, pageItems, setLoading]); + const pendingQuotaRefreshRef = useRef(false); + const prevFilesLoadingRef = useRef(loading); - const handleRefreshAll = useCallback(() => { - loadQuota(filteredFiles, 'all', setLoading); - }, [loadQuota, filteredFiles, setLoading]); + 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; @@ -153,28 +201,56 @@ export function QuotaSection({ }); }, [filteredFiles, loading, setQuota]); + const titleNode = ( +
+ {t(`${config.i18nPrefix}.title`)} + {filteredFiles.length > 0 && ( + + {filteredFiles.length} + + )} +
+ ); + + const isRefreshing = sectionLoading || loading; + return ( +
+ + +
- } @@ -186,31 +262,7 @@ export function QuotaSection({ /> ) : ( <> -
-
- - { - const value = e.currentTarget.valueAsNumber; - if (!Number.isFinite(value)) return; - setPageSize(value); - }} - /> -
-
- -
- {filteredFiles.length} {t('auth_files.files_count')} -
-
-
-
+
{pageItems.map((item) => ( ({ /> ))}
- {filteredFiles.length > pageSize && ( + {filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
+
+
+ )}
); } diff --git a/src/components/quota/useGridColumns.ts b/src/components/quota/useGridColumns.ts new file mode 100644 index 0000000..206ccd8 --- /dev/null +++ b/src/components/quota/useGridColumns.ts @@ -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(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]; +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 00d71a9..d4f3dc0 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -20,6 +20,7 @@ export function Button({ disabled, ...rest }: PropsWithChildren) { + const hasChildren = children !== null && children !== undefined && children !== false; const classes = [ 'btn', `btn-${variant}`, @@ -33,7 +34,7 @@ export function Button({ return ( ); } diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 97d7a75..8282918 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -54,19 +54,28 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P ); useEffect(() => { + let cancelled = false; + if (open) { if (closeTimerRef.current !== null) { window.clearTimeout(closeTimerRef.current); closeTimerRef.current = null; } - setIsVisible(true); - setIsClosing(false); - return; + queueMicrotask(() => { + if (cancelled) return; + setIsVisible(true); + setIsClosing(false); + }); + } else if (isVisible) { + queueMicrotask(() => { + if (cancelled) return; + startClose(false); + }); } - if (isVisible) { - startClose(false); - } + return () => { + cancelled = true; + }; }, [open, isVisible, startClose]); const handleClose = useCallback(() => { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4728ba8..7c1fc43 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -328,6 +328,9 @@ "search_placeholder": "Filter by name, type, or provider", "page_size_label": "Per page", "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_qwen": "Qwen", "filter_gemini": "Gemini", @@ -710,7 +713,8 @@ "quota_management": { "title": "Quota Management", "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": { "title": "Management Center Info", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 23d52ce..f5f286a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -328,6 +328,9 @@ "search_placeholder": "输入名称、类型或提供方关键字", "page_size_label": "单页数量", "page_size_unit": "个/页", + "view_mode_paged": "按页显示", + "view_mode_all": "显示全部", + "too_many_files_warning": "您的凭证总数过多,全部加载会导致页面卡顿,请保持单页浏览。", "filter_all": "全部", "filter_qwen": "Qwen", "filter_gemini": "Gemini", @@ -710,7 +713,8 @@ "quota_management": { "title": "配额管理", "description": "集中查看 OAuth 额度与剩余情况", - "refresh_files": "刷新认证文件" + "refresh_files": "刷新认证文件", + "refresh_files_and_quota": "刷新认证文件&额度" }, "system_info": { "title": "管理中心信息", diff --git a/src/pages/QuotaPage.module.scss b/src/pages/QuotaPage.module.scss index 46857b3..5d52e9a 100644 --- a/src/pages/QuotaPage.module.scss +++ b/src/pages/QuotaPage.module.scss @@ -30,6 +30,36 @@ display: flex; gap: $spacing-sm; 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 { @@ -76,11 +106,7 @@ .geminiCliGrid { display: grid; gap: $spacing-md; - grid-template-columns: repeat(3, minmax(0, 1fr)); - - @include tablet { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } + grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); @include mobile { grid-template-columns: 1fr; @@ -112,28 +138,28 @@ } } +.viewModeToggle { + display: flex; + gap: $spacing-xs; + align-items: center; +} + .antigravityCard { - background-image: linear-gradient( - 180deg, - rgba(224, 247, 250, 0.12), - rgba(224, 247, 250, 0) - ); + background-image: linear-gradient(180deg, + rgba(224, 247, 250, 0.12), + rgba(224, 247, 250, 0)); } .codexCard { - background-image: linear-gradient( - 180deg, - rgba(255, 243, 224, 0.18), - rgba(255, 243, 224, 0) - ); + background-image: linear-gradient(180deg, + rgba(255, 243, 224, 0.18), + rgba(255, 243, 224, 0)); } .geminiCliCard { - background-image: linear-gradient( - 180deg, - rgba(231, 239, 255, 0.2), - rgba(231, 239, 255, 0) - ); + background-image: linear-gradient(180deg, + rgba(231, 239, 255, 0.2), + rgba(231, 239, 255, 0)); } .quotaSection { @@ -331,3 +357,32 @@ background-color: var(--bg-secondary); 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; + } +} diff --git a/src/styles/themes.scss b/src/styles/themes.scss index 65dde1f..1892763 100644 --- a/src/styles/themes.scss +++ b/src/styles/themes.scss @@ -32,6 +32,9 @@ --failure-badge-text: #991b1b; --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-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); } @@ -66,6 +69,9 @@ --failure-badge-text: #fca5a5; --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-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3); }