mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 07:28:37 +08:00
perf(app): add activity lifecycle management (#579)
This commit is contained in:
+35
-71
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
> = new Map()
|
||||||
|
|
||||||
private readonly activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click']
|
private readonly handleActivityStateChange = (state: AppActivityState) => {
|
||||||
private readonly handleVisibilityChange = () => {
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +68,7 @@ export class BackgroundManager {
|
|||||||
options: {
|
options: {
|
||||||
runInBackground?: boolean
|
runInBackground?: boolean
|
||||||
skipInitialRun?: boolean
|
skipInitialRun?: boolean
|
||||||
} = {}
|
} = {},
|
||||||
) {
|
) {
|
||||||
const { runInBackground = false, skipInitialRun = false } = options
|
const { runInBackground = false, skipInitialRun = false } = options
|
||||||
|
|
||||||
@@ -91,7 +81,7 @@ export class BackgroundManager {
|
|||||||
callback,
|
callback,
|
||||||
interval,
|
interval,
|
||||||
timer: null as ReturnType<typeof setInterval> | null,
|
timer: null as ReturnType<typeof setInterval> | null,
|
||||||
runInBackground
|
runInBackground,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建定时器
|
// 创建定时器
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +262,7 @@ export function addBackgroundTimer(
|
|||||||
options?: {
|
options?: {
|
||||||
runInBackground?: boolean
|
runInBackground?: boolean
|
||||||
skipInitialRun?: boolean
|
skipInitialRun?: boolean
|
||||||
}
|
},
|
||||||
) {
|
) {
|
||||||
backgroundManager.addTimer(id, callback, interval, options)
|
backgroundManager.addTimer(id, callback, interval, options)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user