From 455ed07a20077ca804404ef3d996646c90c1d877 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 4 Sep 2026 07:26:37 +0800 Subject: [PATCH] fix: keep system update prompt above mobile footer --- src/components/system/SystemUpdatePrompt.vue | 36 ++++++- .../__tests__/SystemUpdatePrompt.spec.ts | 16 ++++ .../__tests__/useFooterDockHeight.spec.ts | 94 +++++++++++++++++++ src/composables/useFooterDockHeight.ts | 61 ++++++++++++ 4 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 src/composables/__tests__/useFooterDockHeight.spec.ts create mode 100644 src/composables/useFooterDockHeight.ts diff --git a/src/components/system/SystemUpdatePrompt.vue b/src/components/system/SystemUpdatePrompt.vue index c924812e..7e3c81d2 100644 --- a/src/components/system/SystemUpdatePrompt.vue +++ b/src/components/system/SystemUpdatePrompt.vue @@ -2,6 +2,7 @@ import api from '@/api' import type { SystemUpdateItemStatus, SystemUpdateStatus, SystemUpdateType } from '@/api/types' import { useConfirm } from '@/composables/useConfirm' +import { useFooterDockHeight } from '@/composables/useFooterDockHeight' import { useSystemRestartStatus } from '@/composables/useSystemRestart' import { SYSTEM_UPDATE_MENU_EVENT, useSystemUpdateStatus } from '@/composables/useSystemUpdateStatus' import { useToast } from 'vue-toastification' @@ -16,6 +17,7 @@ const { t } = useI18n() const { createConfirm } = useConfirm() const { startSystemRestart, finishSystemRestart } = useSystemRestartStatus() const { status, setStatus, startPolling, stopPolling } = useSystemUpdateStatus() +const { footerDockHeight } = useFooterDockHeight() const toast = useToast() const actionPending = ref(false) const pendingTarget = ref(null) @@ -36,6 +38,12 @@ type ReminderStore = Partial> const reminders = ref(readReminders()) const reminderClock = ref(Date.now()) +const promptStyle = computed>(() => { + if (!footerDockHeight.value) return {} + + return { '--system-update-footer-height': `${footerDockHeight.value}px` } +}) + const updateItems = computed(() => { if (!status.value) return [] if (status.value.updates?.length) return status.value.updates @@ -323,6 +331,7 @@ window.addEventListener(SYSTEM_UPDATE_MENU_EVENT, handleMenuUpdate) v-if="visible" class="system-update-prompt" :class="{ 'system-update-prompt--avoid-agent': props.avoidAgentAssistant }" + :style="promptStyle" >
@@ -413,12 +422,28 @@ window.addEventListener(SYSTEM_UPDATE_MENU_EVENT, handleMenuUpdate) diff --git a/src/components/system/__tests__/SystemUpdatePrompt.spec.ts b/src/components/system/__tests__/SystemUpdatePrompt.spec.ts index aae87c1d..ab9c186f 100644 --- a/src/components/system/__tests__/SystemUpdatePrompt.spec.ts +++ b/src/components/system/__tests__/SystemUpdatePrompt.spec.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ confirm: vi.fn(), finishRestart: vi.fn(), + footerDockHeight: null as { value: number | null } | null, get: vi.fn(), post: vi.fn(), startRestart: vi.fn(), @@ -21,6 +22,12 @@ vi.mock('@/api', () => ({ }, })) vi.mock('@/composables/useConfirm', () => ({ useConfirm: () => ({ createConfirm: mocks.confirm }) })) +vi.mock('@/composables/useFooterDockHeight', async () => { + const { ref } = await import('vue') + const footerDockHeight = ref(null) + mocks.footerDockHeight = footerDockHeight + return { useFooterDockHeight: () => ({ footerDockHeight }) } +}) vi.mock('@/composables/useSystemRestart', () => ({ useSystemRestartStatus: () => ({ finishSystemRestart: mocks.finishRestart, @@ -67,6 +74,7 @@ describe('SystemUpdatePrompt', () => { localStorage.clear() mocks.confirm.mockResolvedValue(true) mocks.updateStatus!.value = availableStatus + mocks.footerDockHeight!.value = null }) it('asks an administrator to start the background download', async () => { @@ -123,6 +131,14 @@ describe('SystemUpdatePrompt', () => { expect(title.closest('.system-update-prompt')).toHaveClass('system-update-prompt--avoid-agent') }) + it('binds the measured Footer height to the floating prompt', async () => { + mocks.footerDockHeight!.value = 104 + await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } }) + + const title = await screen.findByText('systemUpdate.applicationAvailableTitle') + expect(title.closest('.system-update-prompt')).toHaveStyle('--system-update-footer-height: 104px') + }) + it('snoozes the current version for 24 hours', async () => { await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } }) diff --git a/src/composables/__tests__/useFooterDockHeight.spec.ts b/src/composables/__tests__/useFooterDockHeight.spec.ts new file mode 100644 index 00000000..44433cf1 --- /dev/null +++ b/src/composables/__tests__/useFooterDockHeight.spec.ts @@ -0,0 +1,94 @@ +import { useFooterDockHeight } from '@/composables/useFooterDockHeight' +import { mount } from '@vue/test-utils' +import { defineComponent, h, nextTick } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +class ResizeObserverMock implements ResizeObserver { + static instances: ResizeObserverMock[] = [] + + readonly targets = new Set() + + constructor(private readonly callback: ResizeObserverCallback) { + ResizeObserverMock.instances.push(this) + } + + observe(target: Element) { + this.targets.add(target) + } + + unobserve(target: Element) { + this.targets.delete(target) + } + + disconnect() { + this.targets.clear() + } + + trigger() { + const entries = [...this.targets].map(target => ({ target }) as ResizeObserverEntry) + this.callback(entries, this) + } +} + +describe('useFooterDockHeight', () => { + let footerHeight = 72 + + beforeEach(() => { + ResizeObserverMock.instances = [] + vi.stubGlobal('ResizeObserver', ResizeObserverMock) + + const footer = document.createElement('footer') + footer.className = 'footer-nav-container' + Object.defineProperty(footer, 'offsetHeight', { get: () => footerHeight }) + document.body.append(footer) + }) + + afterEach(() => { + document.body.replaceChildren() + vi.unstubAllGlobals() + }) + + it('tracks the visible Footer height and releases the observer on unmount', async () => { + const harness = defineComponent({ + setup() { + return useFooterDockHeight() + }, + render: () => h('div'), + }) + const wrapper = mount(harness) + + await nextTick() + await nextTick() + + expect(wrapper.vm.footerDockHeight).toBe(72) + const observer = ResizeObserverMock.instances.at(-1) + expect(observer?.targets).toContain(document.querySelector('.footer-nav-container')) + + footerHeight = 124 + observer?.trigger() + await nextTick() + + expect(wrapper.vm.footerDockHeight).toBe(124) + wrapper.unmount() + expect(observer?.targets.size).toBe(0) + }) + + it('falls back to null after the Footer is removed', async () => { + const harness = defineComponent({ + setup() { + return useFooterDockHeight() + }, + render: () => h('div'), + }) + const wrapper = mount(harness) + + await nextTick() + await nextTick() + document.querySelector('.footer-nav-container')?.remove() + await nextTick() + await Promise.resolve() + + expect(wrapper.vm.footerDockHeight).toBeNull() + wrapper.unmount() + }) +}) diff --git a/src/composables/useFooterDockHeight.ts b/src/composables/useFooterDockHeight.ts new file mode 100644 index 00000000..f34df261 --- /dev/null +++ b/src/composables/useFooterDockHeight.ts @@ -0,0 +1,61 @@ +import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue' + +/** + * 观察通过 Teleport 挂载到 body 的 App 底部 Footer,并返回其实际占用高度。 + * + * Footer 高度包含底部安全区;调用方可以直接将该值作为 fixed 浮层的底部避让距离, + * 在 Footer 尚未挂载或当前不可见时则回退为 null,由 CSS 使用设备安全区兜底。 + */ +export function useFooterDockHeight() { + const footerDockHeight = ref(null) + let resizeObserver: ResizeObserver | null = null + let mutationObserver: MutationObserver | null = null + let observedFooter: HTMLElement | null = null + + /** 更新 ResizeObserver 当前绑定的 Footer 节点。 */ + function observeFooter(footerElement: HTMLElement | null) { + if (!resizeObserver || observedFooter === footerElement) return + + if (observedFooter) resizeObserver.unobserve(observedFooter) + + observedFooter = footerElement + if (footerElement) resizeObserver.observe(footerElement) + } + + /** 读取 Footer 的布局高度,并在 Footer 生命周期变化时同步到响应式状态。 */ + function measureFooter() { + const footerElement = document.querySelector('.footer-nav-container') as HTMLElement | null + observeFooter(footerElement) + + const measuredHeight = footerElement?.offsetHeight || 0 + footerDockHeight.value = measuredHeight > 0 ? measuredHeight : null + } + + /** 将首次测量放到 Teleport 完成后的下一轮 DOM 更新中。 */ + function scheduleMeasure() { + void nextTick(measureFooter) + } + + onMounted(() => { + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(measureFooter) + } + + if (typeof MutationObserver !== 'undefined' && document.body) { + mutationObserver = new MutationObserver(measureFooter) + mutationObserver.observe(document.body, { childList: true }) + } + + scheduleMeasure() + }) + + onBeforeUnmount(() => { + resizeObserver?.disconnect() + mutationObserver?.disconnect() + resizeObserver = null + mutationObserver = null + observedFooter = null + }) + + return { footerDockHeight } +}