Compare commits

..

22 Commits

Author SHA1 Message Date
Supra4E8C
71556a51c5 fix(usage): prevent gaps in request trend fill by matching point colors 2026-01-05 17:32:01 +08:00
LTbinglingfeng
2a92ea8862 feat(AuthFilesPage): add title section with file count badge 2026-01-05 00:18:35 +08:00
LTbinglingfeng
681fc3cee5 fix(quota): cap per-page credentials to 14 2026-01-05 00:00:22 +08:00
Supra4E8C
916dd3ec26 Merge pull request #44 from moxi000/dev
feat: 优化配额管理页面 UI 与交互
2026-01-04 23:38:44 +08:00
moxi
692f7f3cde fix(quota): allow refresh without creds 2026-01-04 18:48:27 +08:00
Supra4E8C
bf20f3d86e fix(PageTransition): prevent unnecessary execution in useEffect when pathname matches 2026-01-04 18:25:54 +08:00
Supra4E8C
b7e720133d feat(auth-files): add file size validation for uploads 2026-01-04 18:14:18 +08:00
moxi
e914337e57 feat(button): enhance button component to conditionally render children
- Added a check to determine if children are present before rendering them in the button.
- Improved button rendering logic for better handling of empty or false children values.
2026-01-04 01:12:48 +08:00
moxi
6364bac1f2 feat(quota): improve refresh button functionality and update translations
- Added a new `isRefreshing` state to streamline loading logic for the refresh button.
- Updated the refresh button's disabled and loading states for better user experience.
- Simplified the refresh button content display.
- Revised translations for the refresh action in both English and Chinese locales.
- Enhanced styles for button alignment and SVG display.
2026-01-04 01:05:58 +08:00
moxi
38a3e20427 feat(quota): enhance QuotaSection with improved view mode handling and refresh functionality
- Introduced effective view mode logic to manage 'paged' and 'all' views based on file count.
- Added a warning for too many files when in 'all' view, prompting users to switch to 'paged'.
- Updated refresh button to handle loading states more effectively and provide clearer user feedback.
- Enhanced UI with new translations for view modes and refresh actions.
- Adjusted styles for better alignment and spacing in the view mode toggle and refresh button.
2026-01-04 00:45:34 +08:00
moxi
334d75f2dd fix: lint error 2026-01-04 00:04:36 +08:00
moxi
42eb783395 feat: 优化配额管理页面 UI 与交互
- 卡片布局改为 CSS Grid 自适应,最小宽度 380px,支持 1080p 下显示 4 列
- 分页控件重构:移除数字输入框,改为 [按页显示] / [显示全部] 切换按钮
- 动态计算每页数量:按页模式固定显示 3 行(行数 * 动态列数)
- Header 布局优化:凭证计数移至标题旁(淡蓝色气泡),刷新按钮合并为图标
- 安全限制:凭证数超过 30 个时禁用显示全部功能并弹窗提示
2026-01-03 22:43:58 +08:00
Supra4E8C
84b219957e Revert "style(config): allow editor wrapper to grow flexibly with min-height"
This reverts commit 1d8729ec53.
2026-01-03 15:54:48 +08:00
Supra4E8C
f5c1ef36ce fix(api-keys): validate api key charset 2026-01-03 15:51:32 +08:00
Supra4E8C
fae4fb0fed refactor(utils): simplify maskApiKey to show only 2 chars at each end 2026-01-03 15:42:34 +08:00
Supra4E8C
1d8729ec53 style(config): allow editor wrapper to grow flexibly with min-height 2026-01-03 15:30:40 +08:00
Supra4E8C
c6ef8a259f Merge branch 'dev' of https://github.com/router-for-me/Cli-Proxy-API-Management-Center into dev 2026-01-03 15:05:54 +08:00
Supra4E8C
0efef5a789 style(config): improve editor wrapper responsive height with clamp and dvh 2026-01-03 14:52:56 +08:00
LTbinglingfeng
db376c7504 fix(layout): wire header refresh to page loaders and quota config refresh 2026-01-03 01:40:54 +08:00
LTbinglingfeng
8232812ac2 feat(ui): show AIStudio models for virtual auth files and adjust Gemini OAuth spacing 2026-01-02 22:42:20 +08:00
LTbinglingfeng
2ae06a8860 perf(ui): smooth gsap page transitions 2026-01-02 20:26:41 +08:00
LTbinglingfeng
dc58a0701f fix(logs): parse latency durations with minutes 2026-01-02 20:11:16 +08:00
26 changed files with 576 additions and 243 deletions

View File

@@ -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"
},

View File

@@ -27,6 +27,8 @@
&--animating &__layer {
will-change: transform, opacity;
backface-visibility: hidden;
transform-style: preserve-3d;
}
// When both layers exist, current layer also needs positioning

View File

@@ -9,7 +9,9 @@ interface PageTransitionProps {
scrollContainerRef?: React.RefObject<HTMLElement | null>;
}
const TRANSITION_DURATION = 0.65;
const TRANSITION_DURATION = 0.5;
const EXIT_DURATION = 0.45;
const ENTER_DELAY = 0.08;
type LayerStatus = 'current' | 'exiting';
@@ -52,6 +54,7 @@ export function PageTransition({
useEffect(() => {
if (isAnimating) return;
if (location.key === currentLayerKey) return;
if (currentLayerPathname === location.pathname) return;
const scrollContainer = resolveScrollContainer();
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
const resolveOrderIndex = (pathname?: string) => {
@@ -67,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,
@@ -99,6 +112,13 @@ export function PageTransition({
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
}
const containerHeight = scrollContainer?.clientHeight ?? 0;
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight;
const travelDistance = Math.max(containerHeight, viewportHeight, 1);
const enterFromY = transitionDirection === 'forward' ? travelDistance : -travelDistance;
const exitToY = transitionDirection === 'forward' ? -travelDistance : travelDistance;
const exitBaseY = scrollOffset ? -scrollOffset : 0;
const tl = gsap.timeline({
onComplete: () => {
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting'));
@@ -108,15 +128,16 @@ export function PageTransition({
// Exit animation: fly out to top (slow-to-fast)
if (exitingLayerRef.current) {
gsap.set(exitingLayerRef.current, { y: scrollOffset ? -scrollOffset : 0 });
gsap.set(exitingLayerRef.current, { y: exitBaseY });
tl.fromTo(
exitingLayerRef.current,
{ yPercent: 0, opacity: 1 },
{ y: exitBaseY, opacity: 1 },
{
yPercent: transitionDirection === 'forward' ? -100 : 100,
y: exitBaseY + exitToY,
opacity: 0,
duration: TRANSITION_DURATION,
ease: 'power3.in', // slow start, fast end
duration: EXIT_DURATION,
ease: 'power2.in', // fast finish to clear screen
force3D: true,
},
0
);
@@ -125,15 +146,16 @@ export function PageTransition({
// Enter animation: slide in from bottom (slow-to-fast)
tl.fromTo(
currentLayerRef.current,
{ yPercent: transitionDirection === 'forward' ? 100 : -100, opacity: 0 },
{ y: enterFromY, opacity: 0 },
{
yPercent: 0,
y: 0,
opacity: 1,
duration: TRANSITION_DURATION,
ease: 'power2.in', // slow start, fast end
ease: 'power2.out', // smooth settle
clearProps: 'transform,opacity',
force3D: true,
},
0
ENTER_DELAY
);
return () => {

View File

@@ -36,6 +36,7 @@ import {
useThemeStore,
} from '@/stores';
import { configApi, versionApi } from '@/services/api';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />,
@@ -384,12 +385,22 @@ export function MainLayout() {
const handleRefreshAll = async () => {
clearCache();
try {
await fetchConfig(undefined, true);
showNotification(t('notification.data_refreshed'), 'success');
} catch (error: any) {
showNotification(`${t('notification.refresh_failed')}: ${error?.message || ''}`, 'error');
const results = await Promise.allSettled([
fetchConfig(undefined, true),
triggerHeaderRefresh()
]);
const rejected = results.find((result) => result.status === 'rejected');
if (rejected && rejected.status === 'rejected') {
const reason = rejected.reason;
const message =
typeof reason === 'string' ? reason : reason instanceof Error ? reason.message : '';
showNotification(
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
'error'
);
return;
}
showNotification(t('notification.data_refreshed'), 'success');
};
const handleVersionCheck = async () => {

View File

@@ -2,28 +2,30 @@
* 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> = T | ((prev: T) => T);
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => 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_ITEMS_PER_PAGE = 14;
const MAX_SHOW_ALL_THRESHOLD = 30;
interface QuotaPaginationState<T> {
pageSize: number;
@@ -40,7 +42,7 @@ interface QuotaPaginationState<T> {
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
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 +59,7 @@ const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginatio
}, [items, currentPage, pageSize]);
const setPageSize = useCallback((size: number) => {
setPageSizeState(clampCardPageSize(size));
setPageSizeState(size);
setPage(1);
}, []);
@@ -107,10 +109,17 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
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.filterFn
config
]);
const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD;
const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode;
const {
pageSize,
@@ -121,19 +130,59 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
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, 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 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 +202,56 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
});
}, [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={t(`${config.i18nPrefix}.title`)}
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={handleRefreshPage}
disabled={disabled || sectionLoading || pageItems.length === 0}
loading={sectionLoading && loadingScope === 'page'}
onClick={handleRefresh}
disabled={disabled || isRefreshing}
loading={isRefreshing}
title={t('quota_management.refresh_files_and_quota')}
aria-label={t('quota_management.refresh_files_and_quota')}
>
{t(`${config.i18nPrefix}.refresh_button`)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshAll}
disabled={disabled || sectionLoading || filteredFiles.length === 0}
loading={sectionLoading && loadingScope === 'all'}
>
{t(`${config.i18nPrefix}.fetch_all`)}
{!isRefreshing && <IconRefreshCw size={16} />}
</Button>
</div>
}
@@ -186,31 +263,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
/>
) : (
<>
<div className={config.controlsClassName}>
<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}>
<div ref={gridRef} className={config.gridClassName}>
{pageItems.map((item) => (
<QuotaCard
key={item.name}
@@ -224,7 +277,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
/>
))}
</div>
{filteredFiles.length > pageSize && (
{filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
<div className={styles.pagination}>
<Button
variant="secondary"
@@ -253,6 +306,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>
);
}

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

View File

@@ -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(() => {

View File

@@ -8,3 +8,4 @@ export { useLocalStorage } from './useLocalStorage';
export { useInterval } from './useInterval';
export { useMediaQuery } from './useMediaQuery';
export { usePagination } from './usePagination';
export { useHeaderRefresh } from './useHeaderRefresh';

View File

@@ -0,0 +1,24 @@
import { useEffect } from 'react';
export type HeaderRefreshHandler = () => void | Promise<void>;
let activeHeaderRefreshHandler: HeaderRefreshHandler | null = null;
export const triggerHeaderRefresh = async () => {
if (!activeHeaderRefreshHandler) return;
await activeHeaderRefreshHandler();
};
export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null) => {
useEffect(() => {
if (!handler) return;
activeHeaderRefreshHandler = handler;
return () => {
if (activeHeaderRefreshHandler === handler) {
activeHeaderRefreshHandler = null;
}
};
}, [handler]);
};

View File

@@ -312,6 +312,7 @@
"delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!",
"delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!",
"upload_error_json": "Only JSON files are allowed",
"upload_error_size": "File size cannot exceed {{maxSize}}",
"upload_success": "File uploaded successfully",
"download_success": "File downloaded successfully",
"delete_success": "File deleted successfully",
@@ -327,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",
@@ -709,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",
@@ -767,6 +772,7 @@
"api_key_added": "API key added successfully",
"api_key_updated": "API key updated successfully",
"api_key_deleted": "API key deleted successfully",
"api_key_invalid_chars": "API key can only contain letters, numbers, and symbols",
"gemini_key_added": "Gemini key added successfully",
"gemini_key_updated": "Gemini key updated successfully",
"gemini_key_deleted": "Gemini key deleted successfully",

View File

@@ -312,6 +312,7 @@
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
"delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!",
"upload_error_json": "只能上传JSON文件",
"upload_error_size": "文件大小不能超过 {{maxSize}}",
"upload_success": "文件上传成功",
"download_success": "文件下载成功",
"delete_success": "文件删除成功",
@@ -327,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",
@@ -709,7 +713,8 @@
"quota_management": {
"title": "配额管理",
"description": "集中查看 OAuth 额度与剩余情况",
"refresh_files": "刷新认证文件"
"refresh_files": "刷新认证文件",
"refresh_files_and_quota": "刷新认证文件&额度"
},
"system_info": {
"title": "管理中心信息",
@@ -767,6 +772,7 @@
"api_key_added": "API密钥添加成功",
"api_key_updated": "API密钥更新成功",
"api_key_deleted": "API密钥删除成功",
"api_key_invalid_chars": "API密钥仅支持英文字母、数字和符号",
"gemini_key_added": "Gemini密钥添加成功",
"gemini_key_updated": "Gemini密钥更新成功",
"gemini_key_deleted": "Gemini密钥删除成功",

View File

@@ -9,6 +9,7 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { apiKeysApi } from '@/services/api';
import { maskApiKey } from '@/utils/format';
import { isValidApiKeyCharset } from '@/utils/validation';
import styles from './ApiKeysPage.module.scss';
export function ApiKeysPage() {
@@ -83,6 +84,10 @@ export function ApiKeysPage() {
showNotification(`${t('notification.please_enter')} ${t('notification.api_key')}`, 'error');
return;
}
if (!isValidApiKeyCharset(trimmed)) {
showNotification(t('notification.api_key_invalid_chars'), 'error');
return;
}
const isEdit = editingIndex !== null;
const nextKeys = isEdit

View File

@@ -32,6 +32,28 @@
flex-wrap: wrap;
}
.titleWrapper {
display: flex;
align-items: center;
gap: $spacing-sm;
line-height: 24px;
}
.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 {
padding: $spacing-md;
background-color: rgba(239, 68, 68, 0.1);
@@ -134,19 +156,6 @@
}
}
.statsInfo {
padding: 8px 12px;
background-color: var(--bg-secondary);
border-radius: $radius-md;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
height: 38px;
box-sizing: border-box;
display: flex;
align-items: center;
}
// 卡片网格
.fileGrid {
display: grid;

View File

@@ -1,8 +1,9 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useInterval } from '@/hooks/useInterval';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useInterval } from '@/hooks/useInterval';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
@@ -80,6 +81,7 @@ const OAUTH_PROVIDER_PRESETS = [
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
const MIN_CARD_PAGE_SIZE = 3;
const MAX_CARD_PAGE_SIZE = 30;
const MAX_AUTH_FILE_SIZE = 50 * 1024;
const clampCardPageSize = (value: number) =>
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
@@ -270,6 +272,12 @@ export function AuthFilesPage() {
}
}, [showNotification, t]);
const handleHeaderRefresh = useCallback(async () => {
await Promise.all([loadFiles(), loadKeyStats(), loadExcluded()]);
}, [loadFiles, loadKeyStats, loadExcluded]);
useHeaderRefresh(handleHeaderRefresh);
useEffect(() => {
loadFiles();
loadKeyStats();
@@ -349,34 +357,42 @@ export function AuthFilesPage() {
const start = (currentPage - 1) * pageSize;
const pageItems = filtered.slice(start, start + pageSize);
// 统计信息
const totalSize = useMemo(() => files.reduce((sum, item) => sum + (item.size || 0), 0), [files]);
// 点击上传
const handleUploadClick = () => {
fileInputRef.current?.click();
};
// 点击上传
const handleUploadClick = () => {
fileInputRef.current?.click();
};
// 处理文件上传(支持多选)
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const fileList = event.target.files;
if (!fileList || fileList.length === 0) return;
const filesToUpload = Array.from(fileList);
const validFiles: File[] = [];
const invalidFiles: string[] = [];
filesToUpload.forEach((file) => {
if (file.name.endsWith('.json')) {
validFiles.push(file);
} else {
invalidFiles.push(file.name);
}
});
if (invalidFiles.length > 0) {
showNotification(t('auth_files.upload_error_json'), 'error');
}
const filesToUpload = Array.from(fileList);
const validFiles: File[] = [];
const invalidFiles: string[] = [];
const oversizedFiles: string[] = [];
filesToUpload.forEach((file) => {
if (!file.name.endsWith('.json')) {
invalidFiles.push(file.name);
return;
}
if (file.size > MAX_AUTH_FILE_SIZE) {
oversizedFiles.push(file.name);
return;
}
validFiles.push(file);
});
if (invalidFiles.length > 0) {
showNotification(t('auth_files.upload_error_json'), 'error');
}
if (oversizedFiles.length > 0) {
showNotification(
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
'error'
);
}
if (validFiles.length === 0) {
event.target.value = '';
@@ -719,9 +735,11 @@ export function AuthFilesPage() {
const renderFileCard = (item: AuthFileItem) => {
const fileStats = resolveAuthFileStats(item, keyStats);
const isRuntimeOnly = isRuntimeOnlyAuthFile(item);
const isAistudio = (item.type || '').toLowerCase() === 'aistudio';
const showModelsButton = !isRuntimeOnly || isAistudio;
const typeColor = getTypeColor(item.type || 'unknown');
return (
return (
<div key={item.name} className={styles.fileCard}>
<div className={styles.cardHeader}>
<span
@@ -753,29 +771,29 @@ export function AuthFilesPage() {
{/* 状态监测栏 */}
{renderStatusBar(item)}
<div className={styles.cardActions}>
{isRuntimeOnly ? (
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
) : (
<>
<Button
variant="secondary"
size="sm"
onClick={() => showModels(item)}
className={styles.iconButton}
title={t('auth_files.models_button', { defaultValue: '模型' })}
disabled={disableControls}
>
<IconBot className={styles.actionIcon} size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => showDetails(item)}
className={styles.iconButton}
title={t('common.info', { defaultValue: '关于' })}
disabled={disableControls}
<div className={styles.cardActions}>
{showModelsButton && (
<Button
variant="secondary"
size="sm"
onClick={() => showModels(item)}
className={styles.iconButton}
title={t('auth_files.models_button', { defaultValue: '模型' })}
disabled={disableControls}
>
<IconBot className={styles.actionIcon} size={16} />
</Button>
)}
{!isRuntimeOnly && (
<>
<Button
variant="secondary"
size="sm"
onClick={() => showDetails(item)}
className={styles.iconButton}
title={t('common.info', { defaultValue: '关于' })}
disabled={disableControls}
>
<IconInfo className={styles.actionIcon} size={16} />
</Button>
@@ -799,31 +817,46 @@ export function AuthFilesPage() {
>
{deleting === item.name ? (
<LoadingSpinner size={14} />
) : (
<IconTrash2 className={styles.actionIcon} size={16} />
)}
</Button>
</>
)}
</div>
) : (
<IconTrash2 className={styles.actionIcon} size={16} />
)}
</Button>
</>
)}
{isRuntimeOnly && (
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
)}
</div>
</div>
);
};
const titleNode = (
<div className={styles.titleWrapper}>
<span>{t('auth_files.title_section')}</span>
{files.length > 0 && <span className={styles.countBadge}>{files.length}</span>}
</div>
);
return (
<div className={styles.container}>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('auth_files.title')}</h1>
<p className={styles.description}>{t('auth_files.description')}</p>
</div>
<Card
title={t('auth_files.title_section')}
extra={
<div className={styles.headerActions}>
<Button variant="secondary" size="sm" onClick={() => { loadFiles(); loadKeyStats(); }} disabled={loading}>
{t('common.refresh')}
</Button>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('auth_files.title')}</h1>
<p className={styles.description}>{t('auth_files.description')}</p>
</div>
<Card
title={titleNode}
extra={
<div className={styles.headerActions}>
<Button
variant="secondary"
size="sm"
onClick={handleHeaderRefresh}
disabled={loading}
>
{t('common.refresh')}
</Button>
<Button
variant="secondary"
size="sm"
@@ -877,14 +910,8 @@ export function AuthFilesPage() {
onChange={handlePageSizeChange}
/>
</div>
<div className={styles.filterItem}>
<label>{t('common.info')}</label>
<div className={styles.statsInfo}>
{files.length} {t('auth_files.files_count')} · {formatFileSize(totalSize)}
</div>
</div>
</div>
</div>
</div>
</div>
{/* 卡片网格 */}
{loading ? (

View File

@@ -133,14 +133,18 @@
.editorWrapper {
width: 100%;
flex: 1;
min-height: 800px;
flex: 0 0 auto;
height: clamp(360px, 60vh, 920px);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
overflow: hidden;
position: relative;
--floating-controls-height: 0px;
@supports (height: 100dvh) {
height: clamp(360px, 60dvh, 920px);
}
// Floating search toolbar on top of the editor (but not covering content).
.floatingControls {
position: absolute;
@@ -219,8 +223,8 @@
.configCard {
display: flex;
flex-direction: column;
height: 1120px;
flex-shrink: 0;
flex: 1;
min-height: 0;
overflow: visible;
}
@@ -253,11 +257,6 @@
}
.configCard {
height: 880px;
padding: $spacing-md;
}
.editorWrapper {
min-height: 600px;
}
}

View File

@@ -16,6 +16,7 @@ import {
IconTrash2,
IconX,
} from '@/components/ui/icons';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { logsApi } from '@/services/api/logs';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
@@ -50,7 +51,8 @@ const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\s*\]?(?=\s|\[|$)\s*/i;
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i;
const LOG_LATENCY_REGEX =
/\b(?:\d+(?:\.\d+)?\s*(?:µs|us|ms|s|m))(?:\s*\d+(?:\.\d+)?\s*(?:µs|us|ms|s|m))*\b/i;
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
@@ -102,6 +104,12 @@ const normalizeTimestampToSeconds = (value: string): string => {
return `${match[1]} ${match[2]}`;
};
const extractLatency = (text: string): string | undefined => {
const match = text.match(LOG_LATENCY_REGEX);
if (!match) return undefined;
return match[0].replace(/\s+/g, '');
};
type ParsedLogLine = {
raw: string;
timestamp?: string;
@@ -244,9 +252,9 @@ const parseLogLine = (raw: string): ParsedLogLine => {
// latency
const latencyIndex = segments.findIndex((segment) => LOG_LATENCY_REGEX.test(segment));
if (latencyIndex >= 0) {
const match = segments[latencyIndex].match(LOG_LATENCY_REGEX);
if (match) {
latency = `${match[1]}${match[2]}`;
const extracted = extractLatency(segments[latencyIndex]);
if (extracted) {
latency = extracted;
consumed.add(latencyIndex);
}
}
@@ -287,8 +295,8 @@ const parseLogLine = (raw: string): ParsedLogLine => {
} else {
statusCode = detectHttpStatusCode(remaining);
const latencyMatch = remaining.match(LOG_LATENCY_REGEX);
if (latencyMatch) latency = `${latencyMatch[1]}${latencyMatch[2]}`;
const extracted = extractLatency(remaining);
if (extracted) latency = extracted;
ip = extractIp(remaining);
@@ -467,6 +475,8 @@ export function LogsPage() {
}
};
useHeaderRefresh(() => loadLogs(false));
const clearLogs = async () => {
if (!window.confirm(t('logs.clear_confirm'))) return;
try {

View File

@@ -115,6 +115,13 @@
margin-top: $spacing-sm;
}
.geminiProjectField {
:global(.form-group) {
margin-top: $spacing-sm;
gap: $spacing-sm;
}
}
.filePicker {
display: flex;
align-items: center;

View File

@@ -327,19 +327,21 @@ export function OAuthPage() {
>
<div className="hint">{t(provider.hintKey)}</div>
{provider.id === 'gemini-cli' && (
<Input
label={t('auth_login.gemini_cli_project_id_label')}
hint={t('auth_login.gemini_cli_project_id_hint')}
value={state.projectId || ''}
error={state.projectIdError}
onChange={(e) =>
updateProviderState(provider.id, {
projectId: e.target.value,
projectIdError: undefined
})
}
placeholder={t('auth_login.gemini_cli_project_id_placeholder')}
/>
<div className={styles.geminiProjectField}>
<Input
label={t('auth_login.gemini_cli_project_id_label')}
hint={t('auth_login.gemini_cli_project_id_hint')}
value={state.projectId || ''}
error={state.projectIdError}
onChange={(e) =>
updateProviderState(provider.id, {
projectId: e.target.value,
projectIdError: undefined
})
}
placeholder={t('auth_login.gemini_cli_project_id_placeholder')}
/>
</div>
)}
{state.url && (
<div className={`connection-box ${styles.authUrlBox}`}>

View File

@@ -30,6 +30,37 @@
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;
line-height: 24px;
}
.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 +107,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 +139,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 +358,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;
}
}

View File

@@ -4,9 +4,9 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useAuthStore } from '@/stores';
import { authFilesApi } from '@/services/api';
import { authFilesApi, configFileApi } from '@/services/api';
import {
QuotaSection,
ANTIGRAVITY_CONFIG,
@@ -26,6 +26,15 @@ export function QuotaPage() {
const disableControls = connectionStatus !== 'connected';
const loadConfig = useCallback(async () => {
try {
await configFileApi.fetchConfigYaml();
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
setError((prev) => prev || errorMessage);
}
}, [t]);
const loadFiles = useCallback(async () => {
setLoading(true);
setError('');
@@ -40,20 +49,22 @@ export function QuotaPage() {
}
}, [t]);
const handleHeaderRefresh = useCallback(async () => {
await Promise.all([loadConfig(), loadFiles()]);
}, [loadConfig, loadFiles]);
useHeaderRefresh(handleHeaderRefresh);
useEffect(() => {
loadFiles();
}, [loadFiles]);
loadConfig();
}, [loadFiles, loadConfig]);
return (
<div className={styles.container}>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('quota_management.title')}</h1>
<p className={styles.description}>{t('quota_management.description')}</p>
<div className={styles.headerActions}>
<Button variant="secondary" size="sm" onClick={loadFiles} disabled={loading}>
{t('quota_management.refresh_files')}
</Button>
</div>
</div>
{error && <div className={styles.errorBox}>{error}</div>}

View File

@@ -14,6 +14,7 @@ import {
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useThemeStore } from '@/stores';
import {
StatCards,
@@ -63,6 +64,8 @@ export function UsagePage() {
importing
} = useUsageData();
useHeaderRefresh(loadUsage);
// Chart lines state
const [chartLines, setChartLines] = useState<string[]>(['all']);
const MAX_CHART_LINES = 9;

View File

@@ -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);
}

View File

@@ -4,16 +4,17 @@
*/
/**
* 隐藏 API Key 中间部分
* 隐藏 API Key 中间部分,仅保留前后两位
*/
export function maskApiKey(key: string, visibleChars: number = 4): string {
if (!key || key.length <= visibleChars * 2) {
return key;
export function maskApiKey(key: string): string {
if (!key) {
return '';
}
const visibleChars = 2;
const start = key.slice(0, visibleChars);
const end = key.slice(-visibleChars);
const maskedLength = Math.min(key.length - visibleChars * 2, 20);
const maskedLength = Math.max(key.length - visibleChars * 2, 1);
const masked = '*'.repeat(maskedLength);
return `${start}${masked}${end}`;

View File

@@ -638,6 +638,8 @@ export interface ChartDataset {
data: number[];
borderColor: string;
backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient);
pointBackgroundColor?: string;
pointBorderColor?: string;
fill: boolean;
tension: number;
}
@@ -743,6 +745,8 @@ export function buildChartData(
backgroundColor: shouldFill
? (ctx) => buildAreaGradient(ctx, style.borderColor, style.backgroundColor)
: style.backgroundColor,
pointBackgroundColor: style.borderColor,
pointBorderColor: style.borderColor,
fill: shouldFill,
tension: 0.35
};

View File

@@ -35,6 +35,14 @@ export function isValidApiKey(key: string): boolean {
return !/\s/.test(key);
}
/**
* 验证 API Key 字符集(仅允许 ASCII 可见字符)
*/
export function isValidApiKeyCharset(key: string): boolean {
if (!key) return false;
return /^[\x21-\x7E]+$/.test(key);
}
/**
* 验证 JSON 格式
*/