perf(app): add activity lifecycle management (#579)

This commit is contained in:
InfinityPacer
2026-07-23 15:59:50 +08:00
committed by GitHub
parent 4da764ddef
commit 787076355b
13 changed files with 690 additions and 263 deletions
+35 -71
View File
@@ -25,10 +25,11 @@ import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette
import { getDisplayImageUrl } from '@/utils/imageUtils' import { getDisplayImageUrl } from '@/utils/imageUtils'
import { configureApexChartsTheme } from '@/utils/apexCharts' import { configureApexChartsTheme } from '@/utils/apexCharts'
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus' import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation'
const LOGIN_WALLPAPER_ROUTE = '/login' const LOGIN_WALLPAPER_ROUTE = '/login'
const BACKGROUND_CROSSFADE_DURATION_MS = 1500 const BACKGROUND_CROSSFADE_DURATION_MS = 1500
const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 60_000
const MEDIA_DENSE_OPTICAL_DEFER_MS = 1_600 const MEDIA_DENSE_OPTICAL_DEFER_MS = 1_600
// 生效主题 // 生效主题
@@ -73,7 +74,7 @@ const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0) const activeImageIndex = ref(0)
const previousImageIndex = ref<number | null>(null) const previousImageIndex = ref<number | null>(null)
const isBackgroundCrossfading = ref(false) 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 isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
const isGlassTheme = computed(() => globalTheme.name.value === 'glass') const isGlassTheme = computed(() => globalTheme.name.value === 'glass')
const effectiveGlassSettings = useEffectiveGlassSettings() const effectiveGlassSettings = useEffectiveGlassSettings()
@@ -118,7 +119,7 @@ let backgroundRetryTimer: number | null = null
let backgroundRequestController: AbortController | null = null let backgroundRequestController: AbortController | null = null
let backgroundCrossfadeTimer: number | null = null let backgroundCrossfadeTimer: number | null = null
let authenticatedStateTimer: number | null = null let authenticatedStateTimer: number | null = null
let windowBlurRenderThrottleTimer: number | null = null let backgroundRotationVersion = 0
// 读取并同步透明主题背景设置到根组件响应式状态。 // 读取并同步透明主题背景设置到根组件响应式状态。
function applyTransparentBackgroundSettings() { function applyTransparentBackgroundSettings() {
@@ -171,52 +172,6 @@ void router.isReady().then(() => {
isInitialRouteReady.value = true 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 heartbeatInterval: number | null = null
let connectionRetryTimer: number | null = null let connectionRetryTimer: number | null = null
let connectionProbePromise: Promise<boolean> | null = null let connectionProbePromise: Promise<boolean> | null = null
@@ -397,18 +352,14 @@ function handleSystemThemeChange() {
/** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */ /** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */
function handleVisibilityThemeSync() { function handleVisibilityThemeSync() {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
restoreForegroundRendering()
syncThemePreferenceFromStorage() syncThemePreferenceFromStorage()
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck() if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
} else {
throttleBackgroundRendering()
} }
} }
/** 页面从缓存或重新聚焦恢复时刷新主题偏好和异常连接状态。 */ /** 页面从缓存或重新聚焦恢复时刷新主题偏好和异常连接状态。 */
function handlePageShowThemeSync() { function handlePageShowThemeSync() {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
restoreForegroundRendering()
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck() if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
} }
syncThemePreferenceFromStorage() syncThemePreferenceFromStorage()
@@ -461,27 +412,32 @@ async function fetchBackgroundImages() {
// 背景图片轮换函数 // 背景图片轮换函数
function rotateBackgroundImage() { function rotateBackgroundImage() {
if (isRenderThrottled.value) return if (!allowsDecorativeMotion.value) return
if (backgroundImages.value.length > 1) { if (backgroundImages.value.length > 1) {
// 计算下一个图片索引 // 计算下一个图片索引
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
// 预加载下一张图片 const requestVersion = ++backgroundRotationVersion
preloadImage(backgroundImages.value[nextIndex]).then(success => {
// 只有图片成功加载才切换 void commitPreloadedBackgroundRotation({
if (success) { canCommit: () => allowsDecorativeMotion.value && requestVersion === backgroundRotationVersion,
activateBackgroundImage(nextIndex) commit: () => activateBackgroundImage(nextIndex),
} preload: () => preloadImage(backgroundImages.value[nextIndex]),
}) })
} }
} }
// 停止轮询并使已经发起的壁纸预加载失效,避免非活动状态收到迟到提交。
function stopBackgroundRotation() {
backgroundRotationVersion += 1
removeBackgroundTimer('background-rotation')
}
// 开始背景图片轮换 // 开始背景图片轮换
function startBackgroundRotation() { function startBackgroundRotation() {
// 清除现有定时器 stopBackgroundRotation()
removeBackgroundTimer('background-rotation')
if (backgroundImages.value.length > 1) { if (allowsDecorativeMotion.value && backgroundImages.value.length > 1) {
// 使用优化的定时器管理器,后台时自动暂停 // 使用优化的定时器管理器,后台时自动暂停
addBackgroundTimer( addBackgroundTimer(
'background-rotation', 'background-rotation',
@@ -495,6 +451,16 @@ function startBackgroundRotation() {
} }
} }
watch(appActivityState, state => {
resetBackgroundCrossfade()
if (state === 'active') {
startBackgroundRotation()
} else {
stopBackgroundRotation()
}
})
// 停止登录页、透明主题或玻璃主题背景图加载、重试和轮播。 // 停止登录页、透明主题或玻璃主题背景图加载、重试和轮播。
function stopBackgroundLoading() { function stopBackgroundLoading() {
backgroundRequestController?.abort() backgroundRequestController?.abort()
@@ -506,7 +472,7 @@ function stopBackgroundLoading() {
} }
resetBackgroundCrossfade() resetBackgroundCrossfade()
removeBackgroundTimer('background-rotation') stopBackgroundRotation()
} }
// 初始化登录后的全局设置和用户设置状态。 // 初始化登录后的全局设置和用户设置状态。
@@ -647,8 +613,6 @@ onMounted(async () => {
document.addEventListener('visibilitychange', handleVisibilityThemeSync) document.addEventListener('visibilitychange', handleVisibilityThemeSync)
window.addEventListener('pageshow', handlePageShowThemeSync) window.addEventListener('pageshow', handlePageShowThemeSync)
window.addEventListener('focus', handlePageShowThemeSync) window.addEventListener('focus', handlePageShowThemeSync)
window.addEventListener('focus', handleWindowFocusRenderThrottle)
window.addEventListener('blur', handleWindowBlurRenderThrottle)
window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged) window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。 // 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。
@@ -698,7 +662,6 @@ onUnmounted(() => {
window.clearTimeout(authenticatedStateTimer) window.clearTimeout(authenticatedStateTimer)
authenticatedStateTimer = null authenticatedStateTimer = null
} }
clearWindowBlurRenderThrottleTimer()
// 停止心跳 // 停止心跳
stopHeartbeat() stopHeartbeat()
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange) prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
@@ -706,8 +669,6 @@ onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityThemeSync) document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
window.removeEventListener('pageshow', handlePageShowThemeSync) window.removeEventListener('pageshow', handlePageShowThemeSync)
window.removeEventListener('focus', handlePageShowThemeSync) window.removeEventListener('focus', handlePageShowThemeSync)
window.removeEventListener('focus', handleWindowFocusRenderThrottle)
window.removeEventListener('blur', handleWindowBlurRenderThrottle)
window.removeEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged) window.removeEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
}) })
</script> </script>
@@ -717,8 +678,10 @@ onUnmounted(() => {
class="app-wrapper" class="app-wrapper"
:class="{ :class="{
'app-wrapper--background-transition': isBackgroundCrossfading, 'app-wrapper--background-transition': isBackgroundCrossfading,
'app-wrapper--decorative-motion-paused': !allowsDecorativeMotion,
'app-wrapper--render-throttled': isRenderThrottled, 'app-wrapper--render-throttled': isRenderThrottled,
}" }"
:data-app-activity-state="appActivityState"
:style="appWrapperStyle" :style="appWrapperStyle"
> >
<!-- 登录页透明主题和玻璃主题共用动态壁纸场景 --> <!-- 登录页透明主题和玻璃主题共用动态壁纸场景 -->
@@ -889,14 +852,15 @@ html[data-glass-appearance='frosted'] .background-container.is-glass-theme .back
.global-blur-layer { .global-blur-layer {
backdrop-filter: none; backdrop-filter: none;
} }
}
.app-wrapper--decorative-motion-paused {
.login-bg-decor *, .login-bg-decor *,
.login-logo, .login-logo,
.login-logo-wrapper, .login-logo-wrapper,
.login-logo-wrapper::before, .login-logo-wrapper::before,
.login-title, .login-title,
.login-subtitle, .login-subtitle {
.agent-assistant-fab * {
animation-play-state: paused !important; animation-play-state: paused !important;
} }
} }
+62 -38
View File
@@ -35,10 +35,13 @@ interface AgentAssistantEntryBubbleInput {
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
active?: boolean active?: boolean
/** 是否允许入口的随机动作、指针跟随和自动贴边,不影响 thinking 等业务状态。 */
motionActive?: boolean
thinking?: boolean thinking?: boolean
}>(), }>(),
{ {
active: true, active: true,
motionActive: true,
thinking: false, thinking: false,
}, },
) )
@@ -214,7 +217,7 @@ const {
playAction: playAgentPetAction, playAction: playAgentPetAction,
scheduleRandomAction: scheduleFabRandomAction, scheduleRandomAction: scheduleFabRandomAction,
} = useAgentPetMachine({ } = useAgentPetMachine({
active: () => props.active, active: () => props.active && props.motionActive,
docked: fabDocked, docked: fabDocked,
dragging: fabDragging, dragging: fabDragging,
pressed: fabPressed, pressed: fabPressed,
@@ -237,8 +240,7 @@ function getViewportSize() {
// 取布局视口和可见视口的较小值,避免两者短暂不同步时把入口计算到屏幕外。 // 取布局视口和可见视口的较小值,避免两者短暂不同步时把入口计算到屏幕外。
return { return {
height: height: visualHeight > 0 && layoutHeight > 0 ? Math.min(visualHeight, layoutHeight) : visualHeight || layoutHeight,
visualHeight > 0 && layoutHeight > 0 ? Math.min(visualHeight, layoutHeight) : visualHeight || layoutHeight,
width: visualWidth > 0 && layoutWidth > 0 ? Math.min(visualWidth, layoutWidth) : visualWidth || layoutWidth, width: visualWidth > 0 && layoutWidth > 0 ? Math.min(visualWidth, layoutWidth) : visualWidth || layoutWidth,
} }
} }
@@ -826,17 +828,18 @@ function updateFabPointerFromPoint(point: FabPointerPoint) {
// 使用 requestAnimationFrame 合并高频指针事件,降低全局跟随的渲染开销。 // 使用 requestAnimationFrame 合并高频指针事件,降低全局跟随的渲染开销。
function queueFabPointerUpdate(clientX: number, clientY: number) { function queueFabPointerUpdate(clientX: number, clientY: number) {
if (!props.active) return if (!props.active || !props.motionActive) return
fabPendingPointerPoint = { clientX, clientY } fabPendingPointerPoint = { clientX, clientY }
if (fabPointerFrame) return if (fabPointerFrame) return
fabPointerFrame = window.requestAnimationFrame(() => { fabPointerFrame = window.requestAnimationFrame(() => {
fabPointerFrame = 0 fabPointerFrame = 0
if (!fabPendingPointerPoint) return const point = fabPendingPointerPoint
updateFabPointerFromPoint(fabPendingPointerPoint)
fabPendingPointerPoint = null fabPendingPointerPoint = null
if (!point || !props.active || !props.motionActive) return
updateFabPointerFromPoint(point)
}) })
} }
@@ -850,22 +853,6 @@ function updateFabPointer(event: PointerEvent) {
queueFabPointerUpdate(event.clientX, event.clientY) 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() { function clearFabIdleTimer() {
if (fabIdleTimer === null) return if (fabIdleTimer === null) return
@@ -895,11 +882,20 @@ function suppressNextFabClick() {
// 在入口靠近右侧边缘且空闲时安排自动贴边收起。 // 在入口靠近右侧边缘且空闲时安排自动贴边收起。
function scheduleFabAutoDock() { function scheduleFabAutoDock() {
clearFabIdleTimer() 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 = window.setTimeout(() => {
fabIdleTimer = null fabIdleTimer = null
if (fabDocked.value || hasKeepOpenFabBubbles.value || !shouldFabAutoDock()) return if (!props.active || !props.motionActive || fabDocked.value || hasKeepOpenFabBubbles.value || !shouldFabAutoDock())
return
if (fabRandomAction.value) { if (fabRandomAction.value) {
scheduleFabAutoDock() scheduleFabAutoDock()
@@ -915,14 +911,36 @@ function pauseFabAutoDock() {
clearFabIdleTimer() clearFabIdleTimer()
} }
// 取消挂起的全局指针帧并移除监听器 // 取消挂起的全局指针帧,避免状态切换后迟到回调重新写入位移
function teardownFabPointerTracking() { function cancelFabPointerUpdate() {
if (fabPointerFrame) { if (fabPointerFrame) {
window.cancelAnimationFrame(fabPointerFrame) window.cancelAnimationFrame(fabPointerFrame)
fabPointerFrame = 0 fabPointerFrame = 0
} }
fabPendingPointerPoint = null 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('pointermove', handleGlobalFabPointer)
window.removeEventListener('pointerdown', 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(clearFabIdleTimer)
onScopeDispose(clearFabSuppressNextClickTimer) onScopeDispose(clearFabSuppressNextClickTimer)
onScopeDispose(resetFabBubbles) onScopeDispose(resetFabBubbles)
@@ -1752,12 +1783,8 @@ defineExpose({
.agent-assistant-fab__bubbles--arrow-notification::before { .agent-assistant-fab__bubbles--arrow-notification::before {
--agent-assistant-bubble-arrow-border: rgba(var(--v-theme-primary), 0.22); --agent-assistant-bubble-arrow-border: rgba(var(--v-theme-primary), 0.22);
--agent-assistant-bubble-arrow-bg: linear-gradient( --agent-assistant-bubble-arrow-bg:
135deg, linear-gradient(135deg, rgba(var(--v-theme-primary), 0.1), transparent 48%), rgba(var(--v-theme-surface), 0.94);
rgba(var(--v-theme-primary), 0.1),
transparent 48%
),
rgba(var(--v-theme-surface), 0.94);
} }
.agent-assistant-fab__bubbles--arrow-success::before { .agent-assistant-fab__bubbles--arrow-success::before {
@@ -1778,11 +1805,8 @@ defineExpose({
.agent-assistant-fab__bubbles--arrow-toast::before { .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-border: rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.3);
--agent-assistant-bubble-arrow-bg: linear-gradient( --agent-assistant-bubble-arrow-bg:
135deg, linear-gradient(135deg, rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.12), transparent 54%),
rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.12),
transparent 54%
),
rgba(var(--v-theme-surface), 0.95); rgba(var(--v-theme-surface), 0.95);
} }
+17 -13
View File
@@ -158,9 +158,12 @@ const userStore = useUserStore()
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
modelValue?: boolean modelValue?: boolean
/** 是否允许面板的装饰性动效,不影响消息流、thinking 或输入反馈。 */
motionActive?: boolean
}>(), }>(),
{ {
modelValue: false, modelValue: false,
motionActive: true,
}, },
) )
@@ -211,13 +214,11 @@ let pendingMessageScrollToBottom = false
let streamPersistTimer: number | null = null let streamPersistTimer: number | null = null
let userAbortRequested = false let userAbortRequested = false
let streamRecoveryTimer: number | null = null let streamRecoveryTimer: number | null = null
let pendingStreamRecovery: let pendingStreamRecovery: {
| { sessionId: string
sessionId: string startedAt: number
startedAt: number attempts: number
attempts: number } | null = null
}
| null = null
const md = new MarkdownIt({ const md = new MarkdownIt({
html: true, html: true,
@@ -250,12 +251,11 @@ const filteredSlashCommands = computed(() => {
const query = slashCommandQuery.value const query = slashCommandQuery.value
if (!query) return slashCommands.value if (!query) return slashCommands.value
return slashCommands.value return slashCommands.value.filter(command => {
.filter(command => { const haystack = `${command.command} ${command.description} ${command.category || ''}`.toLowerCase()
const haystack = `${command.command} ${command.description} ${command.category || ''}`.toLowerCase()
return haystack.includes(query) return haystack.includes(query)
}) })
}) })
// 判断是否展示命令建议浮层。 // 判断是否展示命令建议浮层。
const showSlashCommandMenu = computed( const showSlashCommandMenu = computed(
@@ -1956,6 +1956,7 @@ onScopeDispose(() => {
<aside <aside
v-show="isOpen" v-show="isOpen"
class="agent-assistant-panel" class="agent-assistant-panel"
:class="{ 'is-motion-paused': !props.motionActive, 'is-open': isOpen }"
:style="drawerStyle" :style="drawerStyle"
role="dialog" role="dialog"
:aria-label="t('agentAssistant.title')" :aria-label="t('agentAssistant.title')"
@@ -2515,13 +2516,16 @@ onScopeDispose(() => {
position: absolute; position: absolute;
display: block; display: block;
border-radius: 0 0 999px 999px; border-radius: 0 0 999px 999px;
animation: agent-fab-blink 4.8s ease-in-out infinite;
block-size: 0.24rem; block-size: 0.24rem;
border-block-end: 0.1rem solid var(--agent-assistant-mini-robot-eye); border-block-end: 0.1rem solid var(--agent-assistant-mini-robot-eye);
inline-size: 0.22rem; inline-size: 0.22rem;
inset-block-start: 0.16rem; inset-block-start: 0.16rem;
} }
.agent-assistant-panel.is-open:not(.is-motion-paused) .agent-assistant-mini-bot__eye {
animation: agent-fab-blink 4.8s ease-in-out 1;
}
.agent-assistant-mini-bot__eye--left { .agent-assistant-mini-bot__eye--left {
inset-inline-start: 0.22rem; inset-inline-start: 0.22rem;
} }
+15 -2
View File
@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import AgentAssistantEntry from './AgentAssistantEntry.vue' import AgentAssistantEntry from './AgentAssistantEntry.vue'
import AgentAssistantPanel from './AgentAssistantPanel.vue' import AgentAssistantPanel from './AgentAssistantPanel.vue'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry> type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
const panelOpen = ref(false) const panelOpen = ref(false)
const thinking = ref(false) const thinking = ref(false)
const entryRef = ref<AgentAssistantEntryRef | null>(null) const entryRef = ref<AgentAssistantEntryRef | null>(null)
const { allowsDecorativeMotion } = useAppActivityLifecycle()
// 打开 Agent 面板并清空入口预览气泡。 // 打开 Agent 面板并清空入口预览气泡。
function openPanel() { function openPanel() {
@@ -23,6 +25,17 @@ function handleAssistantPreview(value: string) {
</script> </script>
<template> <template>
<AgentAssistantEntry ref="entryRef" :active="!panelOpen" :thinking="thinking" @open="openPanel" /> <AgentAssistantEntry
<AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" /> ref="entryRef"
:active="!panelOpen"
:motion-active="allowsDecorativeMotion"
:thinking="thinking"
@open="openPanel"
/>
<AgentAssistantPanel
v-model="panelOpen"
:motion-active="allowsDecorativeMotion"
@assistant-preview="handleAssistantPreview"
@thinking-change="thinking = $event"
/>
</template> </template>
@@ -0,0 +1,71 @@
import { shallowMount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AgentAssistantEntry from '@/components/agent/AgentAssistantEntry.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
describe('AgentAssistantEntry lifecycle motion', () => {
let animationFrameCallbacks: Map<number, FrameRequestCallback>
let nextAnimationFrameId: number
beforeEach(() => {
vi.useFakeTimers()
animationFrameCallbacks = new Map()
nextAnimationFrameId = 1
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
const id = nextAnimationFrameId++
animationFrameCallbacks.set(id, callback)
return id
}),
)
vi.stubGlobal(
'cancelAnimationFrame',
vi.fn((id: number) => {
animationFrameCallbacks.delete(id)
}),
)
})
afterEach(() => {
vi.useRealTimers()
})
it('cancels pointer frames and auto-dock timers when decorative motion stops', async () => {
const wrapper = shallowMount(AgentAssistantEntry, {
global: {
stubs: {
AgentPetStage: true,
VIcon: true,
},
},
props: {
active: true,
motionActive: true,
},
})
await nextTick()
const pointerEvent = new Event('pointermove')
Object.assign(pointerEvent, { clientX: 480, clientY: 320 })
const frameCountBeforePointer = animationFrameCallbacks.size
window.dispatchEvent(pointerEvent)
const pointerFrameId = nextAnimationFrameId - 1
expect(animationFrameCallbacks.size).toBe(frameCountBeforePointer + 1)
expect(vi.getTimerCount()).toBeGreaterThanOrEqual(2)
await wrapper.setProps({ motionActive: false })
await nextTick()
expect(cancelAnimationFrame).toHaveBeenCalledWith(pointerFrameId)
expect(animationFrameCallbacks.has(pointerFrameId)).toBe(false)
expect(vi.getTimerCount()).toBe(0)
wrapper.unmount()
})
})
@@ -0,0 +1,58 @@
import { effectScope, nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AGENT_PET_RANDOM_ACTION_MIN_DELAY } from '../agentPetActions'
import { useAgentPetMachine } from '../useAgentPetMachine'
describe('useAgentPetMachine', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(Math, 'random').mockReturnValue(0)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('runs finite random actions and clears the queue when decorative motion stops', async () => {
const active = ref(true)
const docked = ref(false)
const dragging = ref(false)
const pressed = ref(false)
const thinking = ref(false)
const scope = effectScope()
const machine = scope.run(() =>
useAgentPetMachine({
active,
docked,
dragging,
pressed,
scheduleAutoDock: vi.fn(),
shouldAutoDock: () => false,
thinking,
}),
)
expect(machine).toBeDefined()
machine?.scheduleRandomAction()
vi.advanceTimersByTime(AGENT_PET_RANDOM_ACTION_MIN_DELAY)
expect(machine?.currentAction.value).toBe('wave')
active.value = false
await nextTick()
expect(machine?.currentAction.value).toBeNull()
expect(vi.getTimerCount()).toBe(0)
active.value = true
await nextTick()
vi.advanceTimersByTime(AGENT_PET_RANDOM_ACTION_MIN_DELAY)
expect(machine?.currentAction.value).not.toBeNull()
scope.stop()
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -27,7 +27,6 @@
z-index: 3; z-index: 3;
display: block; display: block;
border-radius: 999px; border-radius: 999px;
animation: agent-fab-antenna-idle 3.9s ease-in-out infinite;
background: var(--agent-assistant-robot-outline); background: var(--agent-assistant-robot-outline);
block-size: 0.66rem; block-size: 0.66rem;
inline-size: 0.18rem; inline-size: 0.18rem;
@@ -58,7 +57,6 @@
display: block; display: block;
border: 2px solid var(--agent-assistant-robot-outline); border: 2px solid var(--agent-assistant-robot-outline);
border-radius: 11px; border-radius: 11px;
animation: agent-fab-head-idle 4.6s ease-in-out infinite;
background: linear-gradient( background: linear-gradient(
145deg, 145deg,
var(--agent-assistant-robot-shell-start) 0%, var(--agent-assistant-robot-shell-start) 0%,
@@ -97,7 +95,6 @@
position: absolute; position: absolute;
display: block; display: block;
border-radius: 0 0 999px 999px; border-radius: 0 0 999px 999px;
animation: agent-fab-blink 4.8s ease-in-out infinite;
block-size: 0.42rem; block-size: 0.42rem;
border-block-end: 0.15rem solid var(--agent-assistant-robot-eye); border-block-end: 0.15rem solid var(--agent-assistant-robot-eye);
inline-size: 0.42rem; inline-size: 0.42rem;
@@ -136,7 +133,6 @@
display: block; display: block;
border: 2px solid var(--agent-assistant-robot-outline); border: 2px solid var(--agent-assistant-robot-outline);
border-radius: 0.65rem 0.65rem 0.55rem 0.55rem; border-radius: 0.65rem 0.65rem 0.55rem 0.55rem;
animation: agent-fab-body-idle 4.2s ease-in-out infinite;
background: linear-gradient( background: linear-gradient(
145deg, 145deg,
var(--agent-assistant-robot-shell-mid) 0%, var(--agent-assistant-robot-shell-mid) 0%,
@@ -204,14 +200,12 @@
} }
.agent-assistant-fab__arm--left { .agent-assistant-fab__arm--left {
animation: agent-fab-arm-left-idle 3.8s ease-in-out infinite;
inset-inline-start: 0.9rem; inset-inline-start: 0.9rem;
transform: rotate(17deg); transform: rotate(17deg);
transform-origin: top center; transform-origin: top center;
} }
.agent-assistant-fab__arm--right { .agent-assistant-fab__arm--right {
animation: agent-fab-arm-right-idle 4.1s ease-in-out infinite;
inset-inline-start: 3.08rem; inset-inline-start: 3.08rem;
transform: rotate(-17deg); transform: rotate(-17deg);
transform-origin: top center; transform-origin: top center;
@@ -225,13 +219,11 @@
} }
.agent-assistant-fab__leg--left { .agent-assistant-fab__leg--left {
animation: agent-fab-leg-left-idle 4.8s ease-in-out infinite;
inset-inline-start: 1.48rem; inset-inline-start: 1.48rem;
transform-origin: top center; transform-origin: top center;
} }
.agent-assistant-fab__leg--right { .agent-assistant-fab__leg--right {
animation: agent-fab-leg-right-idle 4.8s ease-in-out 0.35s infinite;
inset-inline-start: 2.46rem; inset-inline-start: 2.46rem;
transform-origin: top center; transform-origin: top center;
} }
@@ -257,8 +249,7 @@
} }
.agent-assistant-fab__trigger:focus-visible .agent-assistant-fab__bot { .agent-assistant-fab__trigger:focus-visible .agent-assistant-fab__bot {
filter: filter: drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
drop-shadow(0 0 0.34rem rgba(var(--v-theme-primary), 0.55)); drop-shadow(0 0 0.34rem rgba(var(--v-theme-primary), 0.55));
} }
@@ -315,83 +306,6 @@
transform: translate(0.34rem, 0.02rem) rotate(2deg) scale(0.82); transform: translate(0.34rem, 0.02rem) rotate(2deg) scale(0.82);
} }
@keyframes agent-fab-head-idle {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
50% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-1.8deg);
}
}
@keyframes agent-fab-body-idle {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
}
50% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.04rem)) scaleY(0.97);
}
}
@keyframes agent-fab-antenna-idle {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
50% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(15deg);
}
}
@keyframes agent-fab-arm-left-idle {
0%,
100% {
transform: rotate(17deg);
}
50% {
transform: rotate(12deg) translateY(0.05rem);
}
}
@keyframes agent-fab-arm-right-idle {
0%,
100% {
transform: rotate(-17deg);
}
50% {
transform: rotate(-11deg) translateY(0.05rem);
}
}
@keyframes agent-fab-leg-left-idle {
0%,
100% {
transform: rotate(0deg);
}
50% {
transform: rotate(4deg) translateY(0.03rem);
}
}
@keyframes agent-fab-leg-right-idle {
0%,
100% {
transform: rotate(0deg);
}
50% {
transform: rotate(-4deg) translateY(0.03rem);
}
}
@keyframes agent-fab-core-pulse { @keyframes agent-fab-core-pulse {
0%, 0%,
100% { 100% {
@@ -0,0 +1,24 @@
import { appActivityLifecycle, type AppActivityState } from '@/utils/appActivityLifecycle'
const appActivityState = ref<AppActivityState>(appActivityLifecycle.getState())
/**
* 提供应用级活动状态;所有消费者共享同一套全局事件监听和生命周期时钟。
*/
export function useAppActivityLifecycle() {
const release = appActivityLifecycle.acquire()
const unsubscribe = appActivityLifecycle.subscribe(state => {
appActivityState.value = state
})
onScopeDispose(() => {
unsubscribe()
release()
})
return {
allowsDecorativeMotion: computed(() => appActivityState.value === 'active'),
isSuspended: computed(() => appActivityState.value === 'suspended'),
state: readonly(appActivityState),
}
}
@@ -0,0 +1,98 @@
import {
APP_ACTIVITY_IDLE_DELAY_MS,
APP_ACTIVITY_SUSPEND_DELAY_MS,
AppActivityLifecycle,
type AppActivityState,
} from '@/utils/appActivityLifecycle'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('AppActivityLifecycle', () => {
let lifecycle: AppActivityLifecycle
let release: (() => void) | null
let states: AppActivityState[]
let focused: boolean
let visibility: DocumentVisibilityState
beforeEach(() => {
vi.useFakeTimers()
focused = true
visibility = 'visible'
vi.spyOn(document, 'hasFocus').mockImplementation(() => focused)
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibility)
lifecycle = new AppActivityLifecycle()
states = []
lifecycle.subscribe(state => states.push(state))
release = lifecycle.acquire()
})
afterEach(() => {
release?.()
release = null
vi.useRealTimers()
vi.restoreAllMocks()
})
it('enters idle after focused inactivity and wakes on interaction', () => {
vi.advanceTimersByTime(APP_ACTIVITY_IDLE_DELAY_MS)
expect(lifecycle.getState()).toBe('idle')
document.dispatchEvent(new Event('pointerdown'))
expect(lifecycle.getState()).toBe('active')
expect(states).toContain('idle')
})
it('keeps the idle deadline relative to the latest high-frequency activity', () => {
vi.advanceTimersByTime(APP_ACTIVITY_IDLE_DELAY_MS - 1_000)
document.dispatchEvent(new Event('pointermove'))
vi.advanceTimersByTime(1_000)
expect(lifecycle.getState()).toBe('active')
vi.advanceTimersByTime(APP_ACTIVITY_IDLE_DELAY_MS - 1_000)
expect(lifecycle.getState()).toBe('idle')
})
it('moves through passive and suspended while the visible window is unfocused', () => {
focused = false
window.dispatchEvent(new Event('blur'))
expect(lifecycle.getState()).toBe('passive')
vi.advanceTimersByTime(APP_ACTIVITY_SUSPEND_DELAY_MS)
expect(lifecycle.getState()).toBe('suspended')
focused = true
window.dispatchEvent(new Event('focus'))
expect(lifecycle.getState()).toBe('active')
})
it('suspends immediately while hidden and restores according to focus', () => {
visibility = 'hidden'
document.dispatchEvent(new Event('visibilitychange'))
expect(lifecycle.getState()).toBe('suspended')
visibility = 'visible'
focused = false
document.dispatchEvent(new Event('visibilitychange'))
expect(lifecycle.getState()).toBe('passive')
})
it('removes global listeners after the final consumer releases', () => {
const removeDocumentListener = vi.spyOn(document, 'removeEventListener')
const removeWindowListener = vi.spyOn(window, 'removeEventListener')
release?.()
release = null
expect(removeDocumentListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function))
expect(removeWindowListener).toHaveBeenCalledWith('blur', expect.any(Function))
expect(removeWindowListener).toHaveBeenCalledWith('focus', expect.any(Function))
})
})
@@ -0,0 +1,61 @@
import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation'
import { describe, expect, it, vi } from 'vitest'
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(promiseResolve => {
resolve = promiseResolve
})
return { promise, resolve }
}
describe('commitPreloadedBackgroundRotation', () => {
it('drops a successful preload when the rotation becomes inactive before completion', async () => {
const preload = deferred<boolean>()
const commit = vi.fn()
let active = true
const result = commitPreloadedBackgroundRotation({
canCommit: () => active,
commit,
preload: () => preload.promise,
})
active = false
preload.resolve(true)
await expect(result).resolves.toBe(false)
expect(commit).not.toHaveBeenCalled()
})
it('commits a successful preload while the request remains current', async () => {
const commit = vi.fn()
await expect(
commitPreloadedBackgroundRotation({
canCommit: () => true,
commit,
preload: async () => true,
}),
).resolves.toBe(true)
expect(commit).toHaveBeenCalledOnce()
})
it('drops an obsolete preload even when decorative motion becomes active again', async () => {
const preload = deferred<boolean>()
const commit = vi.fn()
const requestVersion = 1
let currentVersion = requestVersion
const result = commitPreloadedBackgroundRotation({
canCommit: () => requestVersion === currentVersion,
commit,
preload: () => preload.promise,
})
currentVersion += 1
preload.resolve(true)
await expect(result).resolves.toBe(false)
expect(commit).not.toHaveBeenCalled()
})
})
+186
View File
@@ -0,0 +1,186 @@
/**
* 应用活动状态:active 允许交互与装饰动效;idle 表示前台静置;passive 表示短时失焦;
* suspended 表示隐藏或持续失焦,需要释放高成本渲染资源。
*/
export type AppActivityState = 'active' | 'idle' | 'passive' | 'suspended'
export const APP_ACTIVITY_IDLE_DELAY_MS = 3 * 60_000
export const APP_ACTIVITY_SUSPEND_DELAY_MS = 60_000
type AppActivityStateListener = (state: AppActivityState) => void
const activityEvents = ['keydown', 'pointerdown', 'pointermove', 'scroll', 'touchstart', 'wheel'] as const
/**
* 统一管理页面活动状态,确保装饰动效、定时器和高成本渲染共享相同的前后台语义。
*/
export class AppActivityLifecycle {
private state: AppActivityState = 'active'
private listeners = new Set<AppActivityStateListener>()
private idleTimer: number | null = null
private suspendTimer: number | null = null
private lastActivityAt = Date.now()
private acquireCount = 0
private started = false
private readonly handleActivity = () => {
if (document.visibilityState !== 'visible' || !document.hasFocus()) return
this.lastActivityAt = Date.now()
this.setState('active')
this.scheduleIdle()
}
private readonly handleBlur = () => {
if (document.visibilityState === 'hidden') return
this.clearIdleTimer()
this.setState('passive')
this.scheduleSuspend()
}
private readonly handleFocus = () => {
if (document.visibilityState !== 'visible') return
this.clearSuspendTimer()
this.lastActivityAt = Date.now()
this.setState('active')
this.scheduleIdle()
}
private readonly handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
this.clearTimers()
this.setState('suspended')
return
}
if (document.hasFocus()) {
this.handleFocus()
} else {
this.handleBlur()
}
}
/** 获取当前生命周期状态。 */
getState() {
return this.state
}
/** 获取最近一次有效前台交互时间。 */
getLastActivityAt() {
return this.lastActivityAt
}
/** 订阅状态变化;订阅时立即回放当前状态。 */
subscribe(listener: AppActivityStateListener) {
this.listeners.add(listener)
listener(this.state)
return () => {
this.listeners.delete(listener)
}
}
/**
* 获取生命周期使用权;首个消费者注册全局监听,最后一个消费者释放全部资源。
*/
acquire() {
this.acquireCount += 1
if (!this.started) this.start()
let released = false
return () => {
if (released) return
released = true
this.acquireCount = Math.max(0, this.acquireCount - 1)
if (this.acquireCount === 0) this.stop()
}
}
/** 供路由或测试路径显式登记一次有效前台活动。 */
markActivity() {
this.handleActivity()
}
private start() {
if (this.started) return
this.started = true
activityEvents.forEach(event => document.addEventListener(event, this.handleActivity, { passive: true }))
document.addEventListener('visibilitychange', this.handleVisibilityChange)
window.addEventListener('blur', this.handleBlur)
window.addEventListener('focus', this.handleFocus)
this.handleVisibilityChange()
}
private stop() {
if (!this.started) return
this.clearTimers()
activityEvents.forEach(event => document.removeEventListener(event, this.handleActivity))
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
window.removeEventListener('blur', this.handleBlur)
window.removeEventListener('focus', this.handleFocus)
this.started = false
}
private setState(state: AppActivityState) {
if (this.state === state) return
this.state = state
this.listeners.forEach(listener => listener(state))
}
private scheduleIdle() {
if (this.idleTimer !== null) return
const elapsed = Date.now() - this.lastActivityAt
const delay = Math.max(0, APP_ACTIVITY_IDLE_DELAY_MS - elapsed)
this.idleTimer = window.setTimeout(() => {
this.idleTimer = null
if (document.visibilityState !== 'visible' || !document.hasFocus()) return
const remaining = APP_ACTIVITY_IDLE_DELAY_MS - (Date.now() - this.lastActivityAt)
if (remaining > 0) {
this.scheduleIdle()
return
}
this.setState('idle')
}, delay)
}
private scheduleSuspend() {
this.clearSuspendTimer()
this.suspendTimer = window.setTimeout(() => {
this.suspendTimer = null
if (document.visibilityState === 'visible' && !document.hasFocus()) this.setState('suspended')
}, APP_ACTIVITY_SUSPEND_DELAY_MS)
}
private clearIdleTimer() {
if (this.idleTimer === null) return
window.clearTimeout(this.idleTimer)
this.idleTimer = null
}
private clearSuspendTimer() {
if (this.suspendTimer === null) return
window.clearTimeout(this.suspendTimer)
this.suspendTimer = null
}
private clearTimers() {
this.clearIdleTimer()
this.clearSuspendTimer()
}
}
export const appActivityLifecycle = new AppActivityLifecycle()
+42 -52
View File
@@ -1,20 +1,24 @@
import { appActivityLifecycle, type AppActivityState } from '@/utils/appActivityLifecycle'
/** /**
* 后台管理器 * 后台管理器
* 统一管理定时器和后台活动,减少iOS系统杀掉应用的概率 * 统一管理定时器和后台活动,减少iOS系统杀掉应用的概率
*/ */
export class BackgroundManager { export class BackgroundManager {
private timers: Map<string, { private timers: Map<
callback: () => void string,
interval: number {
timer: ReturnType<typeof setInterval> | null callback: () => void
pausedAt?: number interval: number
runInBackground?: boolean timer: ReturnType<typeof setInterval> | null
}> = new Map() pausedAt?: number
runInBackground?: boolean
private readonly activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click'] }
private readonly handleVisibilityChange = () => { > = new Map()
private readonly handleActivityStateChange = (state: AppActivityState) => {
const wasBackground = this.isBackground const wasBackground = this.isBackground
this.isBackground = document.hidden this.isBackground = state === 'suspended'
if (this.isBackground && !wasBackground) { if (this.isBackground && !wasBackground) {
console.log('Background: 进入后台,暂停定时器') console.log('Background: 进入后台,暂停定时器')
@@ -27,44 +31,30 @@ export class BackgroundManager {
private readonly handleBeforeUnload = () => { private readonly handleBeforeUnload = () => {
this.destroy() this.destroy()
} }
private readonly updateActivity = () => {
this.lastActivityTime = Date.now()
}
private isBackground = false private isBackground = false
private isDestroyed = false private isDestroyed = false
private lastActivityTime = Date.now()
private isInitialized = false private isInitialized = false
private releaseActivityLifecycle: (() => void) | null = null
private stopActivityStateSubscription: (() => void) | null = null
private ensureInitialized() { private ensureInitialized() {
if (this.isInitialized || this.isDestroyed) return if (this.isInitialized || this.isDestroyed) return
this.isInitialized = true this.isInitialized = true
this.isBackground = document.hidden this.releaseActivityLifecycle = appActivityLifecycle.acquire()
this.setupVisibilityListener() this.stopActivityStateSubscription = appActivityLifecycle.subscribe(this.handleActivityStateChange)
this.setupActivityTracking()
}
private setupVisibilityListener() {
document.addEventListener('visibilitychange', this.handleVisibilityChange)
window.addEventListener('beforeunload', this.handleBeforeUnload) window.addEventListener('beforeunload', this.handleBeforeUnload)
} }
private setupActivityTracking() {
// 按需跟踪用户活动,避免应用启动时就注册一批全局监听。
this.activityEvents.forEach(event => {
document.addEventListener(event, this.updateActivity, { passive: true })
})
}
private removeLifecycleListeners() { private removeLifecycleListeners() {
if (!this.isInitialized) return if (!this.isInitialized) return
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
window.removeEventListener('beforeunload', this.handleBeforeUnload) window.removeEventListener('beforeunload', this.handleBeforeUnload)
this.activityEvents.forEach(event => { this.stopActivityStateSubscription?.()
document.removeEventListener(event, this.updateActivity) this.stopActivityStateSubscription = null
}) this.releaseActivityLifecycle?.()
this.releaseActivityLifecycle = null
this.isInitialized = false this.isInitialized = false
} }
@@ -72,32 +62,32 @@ export class BackgroundManager {
* 添加定时器 * 添加定时器
*/ */
addTimer( addTimer(
id: string, id: string,
callback: () => void, callback: () => void,
interval: number, interval: number,
options: { options: {
runInBackground?: boolean runInBackground?: boolean
skipInitialRun?: boolean skipInitialRun?: boolean
} = {} } = {},
) { ) {
const { runInBackground = false, skipInitialRun = false } = options const { runInBackground = false, skipInitialRun = false } = options
if (this.isDestroyed) return if (this.isDestroyed) return
this.ensureInitialized() this.ensureInitialized()
this.removeTimer(id) this.removeTimer(id)
const timerConfig = { const timerConfig = {
callback, callback,
interval, interval,
timer: null as ReturnType<typeof setInterval> | null, timer: null as ReturnType<typeof setInterval> | null,
runInBackground runInBackground,
} }
// 创建定时器 // 创建定时器
const wrappedCallback = () => { const wrappedCallback = () => {
if (this.isDestroyed) return if (this.isDestroyed) return
// 只有在前台运行,或者明确允许后台运行时才执行 // 只有在前台运行,或者明确允许后台运行时才执行
if (!this.isBackground || runInBackground) { if (!this.isBackground || runInBackground) {
try { try {
@@ -159,7 +149,7 @@ export class BackgroundManager {
if (!timerConfig.timer) { if (!timerConfig.timer) {
const wrappedCallback = () => { const wrappedCallback = () => {
if (this.isDestroyed) return if (this.isDestroyed) return
if (!this.isBackground || timerConfig.runInBackground) { if (!this.isBackground || timerConfig.runInBackground) {
try { try {
timerConfig.callback() timerConfig.callback()
@@ -199,7 +189,7 @@ export class BackgroundManager {
interval: config.interval, interval: config.interval,
status: config.timer ? 'running' : 'paused', status: config.timer ? 'running' : 'paused',
runInBackground: config.runInBackground || false, runInBackground: config.runInBackground || false,
pausedAt: config.pausedAt pausedAt: config.pausedAt,
})) }))
} }
@@ -207,14 +197,14 @@ export class BackgroundManager {
* 检查用户是否活跃 * 检查用户是否活跃
*/ */
isUserActive(maxInactiveTime = 5 * 60 * 1000): boolean { isUserActive(maxInactiveTime = 5 * 60 * 1000): boolean {
return Date.now() - this.lastActivityTime < maxInactiveTime return Date.now() - appActivityLifecycle.getLastActivityAt() < maxInactiveTime
} }
/** /**
* 获取最后活动时间 * 获取最后活动时间
*/ */
getLastActivityTime(): number { getLastActivityTime(): number {
return this.lastActivityTime return appActivityLifecycle.getLastActivityAt()
} }
/** /**
@@ -231,8 +221,8 @@ export class BackgroundManager {
isBackground: this.isBackground, isBackground: this.isBackground,
isDestroyed: this.isDestroyed, isDestroyed: this.isDestroyed,
timerCount: this.timers.size, timerCount: this.timers.size,
lastActivityTime: this.lastActivityTime, lastActivityTime: appActivityLifecycle.getLastActivityAt(),
isUserActive: this.isUserActive() isUserActive: this.isUserActive(),
} }
} }
@@ -241,7 +231,7 @@ export class BackgroundManager {
*/ */
destroy() { destroy() {
this.isDestroyed = true this.isDestroyed = true
// 清理所有定时器 // 清理所有定时器
this.timers.forEach((timerConfig, id) => { this.timers.forEach((timerConfig, id) => {
if (timerConfig.timer) { if (timerConfig.timer) {
@@ -266,13 +256,13 @@ export const backgroundManager = new BackgroundManager()
* 便捷的定时器管理函数 * 便捷的定时器管理函数
*/ */
export function addBackgroundTimer( export function addBackgroundTimer(
id: string, id: string,
callback: () => void, callback: () => void,
interval: number, interval: number,
options?: { options?: {
runInBackground?: boolean runInBackground?: boolean
skipInitialRun?: boolean skipInitialRun?: boolean
} },
) { ) {
backgroundManager.addTimer(id, callback, interval, options) backgroundManager.addTimer(id, callback, interval, options)
} }
+20
View File
@@ -0,0 +1,20 @@
interface PreloadedBackgroundRotationOptions {
/** 提交前重新判断当前生命周期和请求版本是否仍允许切换。 */
canCommit: () => boolean
/** 将已经完成预加载的壁纸切换为活动背景。 */
commit: () => void
/** 预加载目标壁纸,并以布尔值表示是否可安全显示。 */
preload: () => Promise<boolean>
}
/**
* 将壁纸预加载与最终提交分离,确保异步加载期间失效的轮换请求不会改变可见背景。
*/
export async function commitPreloadedBackgroundRotation(options: PreloadedBackgroundRotationOptions) {
const succeeded = await options.preload()
if (!succeeded || !options.canCommit()) return false
options.commit()
return true
}