Compare commits

..

20 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
25 changed files with 540 additions and 227 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

@@ -54,6 +54,7 @@ export function PageTransition({
useEffect(() => { useEffect(() => {
if (isAnimating) return; if (isAnimating) return;
if (location.key === currentLayerKey) return; if (location.key === currentLayerKey) return;
if (currentLayerPathname === location.pathname) return;
const scrollContainer = resolveScrollContainer(); const scrollContainer = resolveScrollContainer();
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0; exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
const resolveOrderIndex = (pathname?: string) => { const resolveOrderIndex = (pathname?: string) => {
@@ -69,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

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

View File

@@ -2,28 +2,30 @@
* 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_ITEMS_PER_PAGE = 14;
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value))); const MAX_SHOW_ALL_THRESHOLD = 30;
interface QuotaPaginationState<T> { interface QuotaPaginationState<T> {
pageSize: number; pageSize: number;
@@ -40,7 +42,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 +59,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 +109,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 +130,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, 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 { 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 +202,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 +263,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 +277,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 +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> </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

@@ -8,3 +8,4 @@ export { useLocalStorage } from './useLocalStorage';
export { useInterval } from './useInterval'; export { useInterval } from './useInterval';
export { useMediaQuery } from './useMediaQuery'; export { useMediaQuery } from './useMediaQuery';
export { usePagination } from './usePagination'; 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_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!", "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_json": "Only JSON files are allowed",
"upload_error_size": "File size cannot exceed {{maxSize}}",
"upload_success": "File uploaded successfully", "upload_success": "File uploaded successfully",
"download_success": "File downloaded successfully", "download_success": "File downloaded successfully",
"delete_success": "File deleted successfully", "delete_success": "File deleted successfully",
@@ -327,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",
@@ -709,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",
@@ -767,6 +772,7 @@
"api_key_added": "API key added successfully", "api_key_added": "API key added successfully",
"api_key_updated": "API key updated successfully", "api_key_updated": "API key updated successfully",
"api_key_deleted": "API key deleted 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_added": "Gemini key added successfully",
"gemini_key_updated": "Gemini key updated successfully", "gemini_key_updated": "Gemini key updated successfully",
"gemini_key_deleted": "Gemini key deleted successfully", "gemini_key_deleted": "Gemini key deleted successfully",

View File

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

View File

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

View File

@@ -32,6 +32,28 @@
flex-wrap: wrap; 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 { .errorBox {
padding: $spacing-md; padding: $spacing-md;
background-color: rgba(239, 68, 68, 0.1); 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 { .fileGrid {
display: grid; display: grid;

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ import {
IconTrash2, IconTrash2,
IconX, IconX,
} from '@/components/ui/icons'; } from '@/components/ui/icons';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { logsApi } from '@/services/api/logs'; import { logsApi } from '@/services/api/logs';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants'; import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
@@ -474,6 +475,8 @@ export function LogsPage() {
} }
}; };
useHeaderRefresh(() => loadLogs(false));
const clearLogs = async () => { const clearLogs = async () => {
if (!window.confirm(t('logs.clear_confirm'))) return; if (!window.confirm(t('logs.clear_confirm'))) return;
try { try {

View File

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

View File

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

View File

@@ -30,6 +30,37 @@
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;
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 { .errorBox {
@@ -76,11 +107,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 +139,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 +358,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

@@ -4,9 +4,9 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useAuthStore } from '@/stores'; import { useAuthStore } from '@/stores';
import { authFilesApi } from '@/services/api'; import { authFilesApi, configFileApi } from '@/services/api';
import { import {
QuotaSection, QuotaSection,
ANTIGRAVITY_CONFIG, ANTIGRAVITY_CONFIG,
@@ -26,6 +26,15 @@ export function QuotaPage() {
const disableControls = connectionStatus !== 'connected'; 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 () => { const loadFiles = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
@@ -40,20 +49,22 @@ export function QuotaPage() {
} }
}, [t]); }, [t]);
const handleHeaderRefresh = useCallback(async () => {
await Promise.all([loadConfig(), loadFiles()]);
}, [loadConfig, loadFiles]);
useHeaderRefresh(handleHeaderRefresh);
useEffect(() => { useEffect(() => {
loadFiles(); loadFiles();
}, [loadFiles]); loadConfig();
}, [loadFiles, loadConfig]);
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('quota_management.title')}</h1> <h1 className={styles.pageTitle}>{t('quota_management.title')}</h1>
<p className={styles.description}>{t('quota_management.description')}</p> <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> </div>
{error && <div className={styles.errorBox}>{error}</div>} {error && <div className={styles.errorBox}>{error}</div>}

View File

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

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

View File

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

View File

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

View File

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