diff --git a/src/App.vue b/src/App.vue index 8b8c558d..723ff45f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -25,10 +25,11 @@ import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette import { getDisplayImageUrl } from '@/utils/imageUtils' import { configureApexChartsTheme } from '@/utils/apexCharts' import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus' +import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle' +import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation' const LOGIN_WALLPAPER_ROUTE = '/login' const BACKGROUND_CROSSFADE_DURATION_MS = 1500 -const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 60_000 const MEDIA_DENSE_OPTICAL_DEFER_MS = 1_600 // 生效主题 @@ -73,7 +74,7 @@ const backgroundImages = ref([]) const activeImageIndex = ref(0) const previousImageIndex = ref(null) const isBackgroundCrossfading = ref(false) -const isRenderThrottled = ref(document.visibilityState === 'hidden') +const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle() const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent') const isGlassTheme = computed(() => globalTheme.name.value === 'glass') const effectiveGlassSettings = useEffectiveGlassSettings() @@ -118,7 +119,7 @@ let backgroundRetryTimer: number | null = null let backgroundRequestController: AbortController | null = null let backgroundCrossfadeTimer: number | null = null let authenticatedStateTimer: number | null = null -let windowBlurRenderThrottleTimer: number | null = null +let backgroundRotationVersion = 0 // 读取并同步透明主题背景设置到根组件响应式状态。 function applyTransparentBackgroundSettings() { @@ -171,52 +172,6 @@ void router.isReady().then(() => { isInitialRouteReady.value = true }) -function clearWindowBlurRenderThrottleTimer() { - if (windowBlurRenderThrottleTimer) { - window.clearTimeout(windowBlurRenderThrottleTimer) - windowBlurRenderThrottleTimer = null - } -} - -function restoreForegroundRendering() { - const wasRenderThrottled = isRenderThrottled.value - - clearWindowBlurRenderThrottleTimer() - isRenderThrottled.value = false - - if (wasRenderThrottled && backgroundImages.value.length > 1) { - startBackgroundRotation() - rotateBackgroundImage() - } -} - -function throttleBackgroundRendering() { - clearWindowBlurRenderThrottleTimer() - resetBackgroundCrossfade() - isRenderThrottled.value = true -} - -function handleWindowBlurRenderThrottle() { - clearWindowBlurRenderThrottleTimer() - if (document.visibilityState === 'hidden') { - throttleBackgroundRendering() - return - } - - windowBlurRenderThrottleTimer = window.setTimeout(() => { - if (document.visibilityState === 'visible' && !document.hasFocus()) { - isRenderThrottled.value = true - } - windowBlurRenderThrottleTimer = null - }, WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS) -} - -function handleWindowFocusRenderThrottle() { - if (document.visibilityState === 'visible') { - restoreForegroundRendering() - } -} - let heartbeatInterval: number | null = null let connectionRetryTimer: number | null = null let connectionProbePromise: Promise | null = null @@ -397,18 +352,14 @@ function handleSystemThemeChange() { /** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */ function handleVisibilityThemeSync() { if (document.visibilityState === 'visible') { - restoreForegroundRendering() syncThemePreferenceFromStorage() if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck() - } else { - throttleBackgroundRendering() } } /** 页面从缓存或重新聚焦恢复时刷新主题偏好和异常连接状态。 */ function handlePageShowThemeSync() { if (document.visibilityState === 'visible') { - restoreForegroundRendering() if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck() } syncThemePreferenceFromStorage() @@ -461,27 +412,32 @@ async function fetchBackgroundImages() { // 背景图片轮换函数 function rotateBackgroundImage() { - if (isRenderThrottled.value) return + if (!allowsDecorativeMotion.value) return if (backgroundImages.value.length > 1) { // 计算下一个图片索引 const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length - // 预加载下一张图片 - preloadImage(backgroundImages.value[nextIndex]).then(success => { - // 只有图片成功加载才切换 - if (success) { - activateBackgroundImage(nextIndex) - } + const requestVersion = ++backgroundRotationVersion + + void commitPreloadedBackgroundRotation({ + canCommit: () => allowsDecorativeMotion.value && requestVersion === backgroundRotationVersion, + commit: () => activateBackgroundImage(nextIndex), + preload: () => preloadImage(backgroundImages.value[nextIndex]), }) } } +// 停止轮询并使已经发起的壁纸预加载失效,避免非活动状态收到迟到提交。 +function stopBackgroundRotation() { + backgroundRotationVersion += 1 + removeBackgroundTimer('background-rotation') +} + // 开始背景图片轮换 function startBackgroundRotation() { - // 清除现有定时器 - removeBackgroundTimer('background-rotation') + stopBackgroundRotation() - if (backgroundImages.value.length > 1) { + if (allowsDecorativeMotion.value && backgroundImages.value.length > 1) { // 使用优化的定时器管理器,后台时自动暂停 addBackgroundTimer( 'background-rotation', @@ -495,6 +451,16 @@ function startBackgroundRotation() { } } +watch(appActivityState, state => { + resetBackgroundCrossfade() + + if (state === 'active') { + startBackgroundRotation() + } else { + stopBackgroundRotation() + } +}) + // 停止登录页、透明主题或玻璃主题背景图加载、重试和轮播。 function stopBackgroundLoading() { backgroundRequestController?.abort() @@ -506,7 +472,7 @@ function stopBackgroundLoading() { } resetBackgroundCrossfade() - removeBackgroundTimer('background-rotation') + stopBackgroundRotation() } // 初始化登录后的全局设置和用户设置状态。 @@ -647,8 +613,6 @@ onMounted(async () => { document.addEventListener('visibilitychange', handleVisibilityThemeSync) window.addEventListener('pageshow', handlePageShowThemeSync) window.addEventListener('focus', handlePageShowThemeSync) - window.addEventListener('focus', handleWindowFocusRenderThrottle) - window.addEventListener('blur', handleWindowBlurRenderThrottle) window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged) // 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。 @@ -698,7 +662,6 @@ onUnmounted(() => { window.clearTimeout(authenticatedStateTimer) authenticatedStateTimer = null } - clearWindowBlurRenderThrottleTimer() // 停止心跳 stopHeartbeat() prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange) @@ -706,8 +669,6 @@ onUnmounted(() => { document.removeEventListener('visibilitychange', handleVisibilityThemeSync) window.removeEventListener('pageshow', handlePageShowThemeSync) window.removeEventListener('focus', handlePageShowThemeSync) - window.removeEventListener('focus', handleWindowFocusRenderThrottle) - window.removeEventListener('blur', handleWindowBlurRenderThrottle) window.removeEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged) }) @@ -717,8 +678,10 @@ onUnmounted(() => { class="app-wrapper" :class="{ 'app-wrapper--background-transition': isBackgroundCrossfading, + 'app-wrapper--decorative-motion-paused': !allowsDecorativeMotion, 'app-wrapper--render-throttled': isRenderThrottled, }" + :data-app-activity-state="appActivityState" :style="appWrapperStyle" > @@ -889,14 +852,15 @@ html[data-glass-appearance='frosted'] .background-container.is-glass-theme .back .global-blur-layer { backdrop-filter: none; } +} +.app-wrapper--decorative-motion-paused { .login-bg-decor *, .login-logo, .login-logo-wrapper, .login-logo-wrapper::before, .login-title, - .login-subtitle, - .agent-assistant-fab * { + .login-subtitle { animation-play-state: paused !important; } } diff --git a/src/components/agent/AgentAssistantEntry.vue b/src/components/agent/AgentAssistantEntry.vue index 60b462dc..825432f6 100644 --- a/src/components/agent/AgentAssistantEntry.vue +++ b/src/components/agent/AgentAssistantEntry.vue @@ -35,10 +35,13 @@ interface AgentAssistantEntryBubbleInput { const props = withDefaults( defineProps<{ active?: boolean + /** 是否允许入口的随机动作、指针跟随和自动贴边,不影响 thinking 等业务状态。 */ + motionActive?: boolean thinking?: boolean }>(), { active: true, + motionActive: true, thinking: false, }, ) @@ -214,7 +217,7 @@ const { playAction: playAgentPetAction, scheduleRandomAction: scheduleFabRandomAction, } = useAgentPetMachine({ - active: () => props.active, + active: () => props.active && props.motionActive, docked: fabDocked, dragging: fabDragging, pressed: fabPressed, @@ -237,8 +240,7 @@ function getViewportSize() { // 取布局视口和可见视口的较小值,避免两者短暂不同步时把入口计算到屏幕外。 return { - height: - visualHeight > 0 && layoutHeight > 0 ? Math.min(visualHeight, layoutHeight) : visualHeight || layoutHeight, + height: visualHeight > 0 && layoutHeight > 0 ? Math.min(visualHeight, layoutHeight) : visualHeight || layoutHeight, width: visualWidth > 0 && layoutWidth > 0 ? Math.min(visualWidth, layoutWidth) : visualWidth || layoutWidth, } } @@ -826,17 +828,18 @@ function updateFabPointerFromPoint(point: FabPointerPoint) { // 使用 requestAnimationFrame 合并高频指针事件,降低全局跟随的渲染开销。 function queueFabPointerUpdate(clientX: number, clientY: number) { - if (!props.active) return + if (!props.active || !props.motionActive) return fabPendingPointerPoint = { clientX, clientY } if (fabPointerFrame) return fabPointerFrame = window.requestAnimationFrame(() => { fabPointerFrame = 0 - if (!fabPendingPointerPoint) return - - updateFabPointerFromPoint(fabPendingPointerPoint) + const point = fabPendingPointerPoint fabPendingPointerPoint = null + if (!point || !props.active || !props.motionActive) return + + updateFabPointerFromPoint(point) }) } @@ -850,22 +853,6 @@ function updateFabPointer(event: PointerEvent) { queueFabPointerUpdate(event.clientX, event.clientY) } -// 重置机器人按压状态和眼神跟随位移。 -function resetFabPointer() { - fabPressed.value = false - fabPointerStyle.value = { - '--agent-assistant-body-x': '0px', - '--agent-assistant-body-y': '0px', - '--agent-assistant-eye-x': '0px', - '--agent-assistant-eye-y': '0px', - '--agent-assistant-head-x': '0px', - '--agent-assistant-head-y': '0px', - '--agent-assistant-pointer-x': '0px', - '--agent-assistant-pointer-y': '0px', - '--agent-assistant-robot-tilt': '0deg', - } -} - // 清理入口自动贴边计时器。 function clearFabIdleTimer() { if (fabIdleTimer === null) return @@ -895,11 +882,20 @@ function suppressNextFabClick() { // 在入口靠近右侧边缘且空闲时安排自动贴边收起。 function scheduleFabAutoDock() { clearFabIdleTimer() - if (fabDocked.value || hasKeepOpenFabBubbles.value || fabRandomAction.value || !shouldFabAutoDock()) return + if ( + !props.active || + !props.motionActive || + fabDocked.value || + hasKeepOpenFabBubbles.value || + fabRandomAction.value || + !shouldFabAutoDock() + ) + return fabIdleTimer = window.setTimeout(() => { fabIdleTimer = null - if (fabDocked.value || hasKeepOpenFabBubbles.value || !shouldFabAutoDock()) return + if (!props.active || !props.motionActive || fabDocked.value || hasKeepOpenFabBubbles.value || !shouldFabAutoDock()) + return if (fabRandomAction.value) { scheduleFabAutoDock() @@ -915,14 +911,36 @@ function pauseFabAutoDock() { clearFabIdleTimer() } -// 取消挂起的全局指针帧并移除监听器。 -function teardownFabPointerTracking() { +// 取消挂起的全局指针帧,避免状态切换后迟到回调重新写入位移。 +function cancelFabPointerUpdate() { if (fabPointerFrame) { window.cancelAnimationFrame(fabPointerFrame) fabPointerFrame = 0 } fabPendingPointerPoint = null +} + +// 重置机器人按压状态和眼神跟随位移。 +function resetFabPointer() { + cancelFabPointerUpdate() + fabPressed.value = false + fabPointerStyle.value = { + '--agent-assistant-body-x': '0px', + '--agent-assistant-body-y': '0px', + '--agent-assistant-eye-x': '0px', + '--agent-assistant-eye-y': '0px', + '--agent-assistant-head-x': '0px', + '--agent-assistant-head-y': '0px', + '--agent-assistant-pointer-x': '0px', + '--agent-assistant-pointer-y': '0px', + '--agent-assistant-robot-tilt': '0deg', + } +} + +// 取消挂起的全局指针帧并移除监听器。 +function teardownFabPointerTracking() { + cancelFabPointerUpdate() window.removeEventListener('pointermove', handleGlobalFabPointer) window.removeEventListener('pointerdown', handleGlobalFabPointer) } @@ -1432,6 +1450,19 @@ watch( }, ) +watch( + () => props.motionActive, + motionActive => { + if (motionActive) { + if (props.active && shouldFabAutoDock()) scheduleFabAutoDock() + return + } + + clearFabIdleTimer() + resetFabPointer() + }, +) + onScopeDispose(clearFabIdleTimer) onScopeDispose(clearFabSuppressNextClickTimer) onScopeDispose(resetFabBubbles) @@ -1752,12 +1783,8 @@ defineExpose({ .agent-assistant-fab__bubbles--arrow-notification::before { --agent-assistant-bubble-arrow-border: rgba(var(--v-theme-primary), 0.22); - --agent-assistant-bubble-arrow-bg: linear-gradient( - 135deg, - rgba(var(--v-theme-primary), 0.1), - transparent 48% - ), - rgba(var(--v-theme-surface), 0.94); + --agent-assistant-bubble-arrow-bg: + linear-gradient(135deg, rgba(var(--v-theme-primary), 0.1), transparent 48%), rgba(var(--v-theme-surface), 0.94); } .agent-assistant-fab__bubbles--arrow-success::before { @@ -1778,11 +1805,8 @@ defineExpose({ .agent-assistant-fab__bubbles--arrow-toast::before { --agent-assistant-bubble-arrow-border: rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.3); - --agent-assistant-bubble-arrow-bg: linear-gradient( - 135deg, - rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.12), - transparent 54% - ), + --agent-assistant-bubble-arrow-bg: + linear-gradient(135deg, rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.12), transparent 54%), rgba(var(--v-theme-surface), 0.95); } diff --git a/src/components/agent/AgentAssistantPanel.vue b/src/components/agent/AgentAssistantPanel.vue index a8fc47df..b1a5b6ae 100644 --- a/src/components/agent/AgentAssistantPanel.vue +++ b/src/components/agent/AgentAssistantPanel.vue @@ -158,9 +158,12 @@ const userStore = useUserStore() const props = withDefaults( defineProps<{ modelValue?: boolean + /** 是否允许面板的装饰性动效,不影响消息流、thinking 或输入反馈。 */ + motionActive?: boolean }>(), { modelValue: false, + motionActive: true, }, ) @@ -211,13 +214,11 @@ let pendingMessageScrollToBottom = false let streamPersistTimer: number | null = null let userAbortRequested = false let streamRecoveryTimer: number | null = null -let pendingStreamRecovery: - | { - sessionId: string - startedAt: number - attempts: number - } - | null = null +let pendingStreamRecovery: { + sessionId: string + startedAt: number + attempts: number +} | null = null const md = new MarkdownIt({ html: true, @@ -250,12 +251,11 @@ const filteredSlashCommands = computed(() => { const query = slashCommandQuery.value if (!query) return slashCommands.value - return slashCommands.value - .filter(command => { - const haystack = `${command.command} ${command.description} ${command.category || ''}`.toLowerCase() + return slashCommands.value.filter(command => { + const haystack = `${command.command} ${command.description} ${command.category || ''}`.toLowerCase() - return haystack.includes(query) - }) + return haystack.includes(query) + }) }) // 判断是否展示命令建议浮层。 const showSlashCommandMenu = computed( @@ -1956,6 +1956,7 @@ onScopeDispose(() => {