This commit is contained in:
Supra4E8C
2026-01-03 15:05:54 +08:00
11 changed files with 183 additions and 84 deletions

View File

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

View File

@@ -9,7 +9,9 @@ interface PageTransitionProps {
scrollContainerRef?: React.RefObject<HTMLElement | null>; 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'; type LayerStatus = 'current' | 'exiting';
@@ -99,6 +101,13 @@ export function PageTransition({
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' }); 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({ const tl = gsap.timeline({
onComplete: () => { onComplete: () => {
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting')); setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting'));
@@ -108,15 +117,16 @@ export function PageTransition({
// Exit animation: fly out to top (slow-to-fast) // Exit animation: fly out to top (slow-to-fast)
if (exitingLayerRef.current) { if (exitingLayerRef.current) {
gsap.set(exitingLayerRef.current, { y: scrollOffset ? -scrollOffset : 0 }); gsap.set(exitingLayerRef.current, { y: exitBaseY });
tl.fromTo( tl.fromTo(
exitingLayerRef.current, exitingLayerRef.current,
{ yPercent: 0, opacity: 1 }, { y: exitBaseY, opacity: 1 },
{ {
yPercent: transitionDirection === 'forward' ? -100 : 100, y: exitBaseY + exitToY,
opacity: 0, opacity: 0,
duration: TRANSITION_DURATION, duration: EXIT_DURATION,
ease: 'power3.in', // slow start, fast end ease: 'power2.in', // fast finish to clear screen
force3D: true,
}, },
0 0
); );
@@ -125,15 +135,16 @@ export function PageTransition({
// Enter animation: slide in from bottom (slow-to-fast) // Enter animation: slide in from bottom (slow-to-fast)
tl.fromTo( tl.fromTo(
currentLayerRef.current, currentLayerRef.current,
{ yPercent: transitionDirection === 'forward' ? 100 : -100, opacity: 0 }, { y: enterFromY, opacity: 0 },
{ {
yPercent: 0, y: 0,
opacity: 1, opacity: 1,
duration: TRANSITION_DURATION, duration: TRANSITION_DURATION,
ease: 'power2.in', // slow start, fast end ease: 'power2.out', // smooth settle
clearProps: 'transform,opacity', clearProps: 'transform,opacity',
force3D: true,
}, },
0 ENTER_DELAY
); );
return () => { return () => {

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

@@ -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

@@ -1,6 +1,7 @@
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 { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
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 { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
@@ -270,6 +271,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();
@@ -719,6 +726,8 @@ 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 (
@@ -755,10 +764,7 @@ 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 <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -769,6 +775,9 @@ export function AuthFilesPage() {
> >
<IconBot className={styles.actionIcon} size={16} /> <IconBot className={styles.actionIcon} size={16} />
</Button> </Button>
)}
{!isRuntimeOnly && (
<>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -805,6 +814,9 @@ export function AuthFilesPage() {
</Button> </Button>
</> </>
)} )}
{isRuntimeOnly && (
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
)}
</div> </div>
</div> </div>
); );
@@ -821,7 +833,12 @@ export function AuthFilesPage() {
title={t('auth_files.title_section')} title={t('auth_files.title_section')}
extra={ extra={
<div className={styles.headerActions}> <div className={styles.headerActions}>
<Button variant="secondary" size="sm" onClick={() => { loadFiles(); loadKeyStats(); }} disabled={loading}> <Button
variant="secondary"
size="sm"
onClick={handleHeaderRefresh}
disabled={loading}
>
{t('common.refresh')} {t('common.refresh')}
</Button> </Button>
<Button <Button

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';
@@ -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_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_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\s*\]?(?=\s|\[|$)\s*/i;
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/; 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_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_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; const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
@@ -102,6 +104,12 @@ const normalizeTimestampToSeconds = (value: string): string => {
return `${match[1]} ${match[2]}`; 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 = { type ParsedLogLine = {
raw: string; raw: string;
timestamp?: string; timestamp?: string;
@@ -244,9 +252,9 @@ const parseLogLine = (raw: string): ParsedLogLine => {
// latency // latency
const latencyIndex = segments.findIndex((segment) => LOG_LATENCY_REGEX.test(segment)); const latencyIndex = segments.findIndex((segment) => LOG_LATENCY_REGEX.test(segment));
if (latencyIndex >= 0) { if (latencyIndex >= 0) {
const match = segments[latencyIndex].match(LOG_LATENCY_REGEX); const extracted = extractLatency(segments[latencyIndex]);
if (match) { if (extracted) {
latency = `${match[1]}${match[2]}`; latency = extracted;
consumed.add(latencyIndex); consumed.add(latencyIndex);
} }
} }
@@ -287,8 +295,8 @@ const parseLogLine = (raw: string): ParsedLogLine => {
} else { } else {
statusCode = detectHttpStatusCode(remaining); statusCode = detectHttpStatusCode(remaining);
const latencyMatch = remaining.match(LOG_LATENCY_REGEX); const extracted = extractLatency(remaining);
if (latencyMatch) latency = `${latencyMatch[1]}${latencyMatch[2]}`; if (extracted) latency = extracted;
ip = extractIp(remaining); ip = extractIp(remaining);
@@ -467,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,6 +327,7 @@ 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' && (
<div className={styles.geminiProjectField}>
<Input <Input
label={t('auth_login.gemini_cli_project_id_label')} label={t('auth_login.gemini_cli_project_id_label')}
hint={t('auth_login.gemini_cli_project_id_hint')} hint={t('auth_login.gemini_cli_project_id_hint')}
@@ -340,6 +341,7 @@ export function OAuthPage() {
} }
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

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