Compare commits

..

3 Commits

Author SHA1 Message Date
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
6 changed files with 95 additions and 61 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

@@ -719,9 +719,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 +755,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,13 +801,16 @@ 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>
); );
}; };

View File

@@ -50,7 +50,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 +103,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 +251,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 +294,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);

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}`}>