fix: keep system update prompt above mobile footer

This commit is contained in:
jxxghp
2026-09-04 07:26:37 +08:00
parent 565c79288d
commit 455ed07a20
4 changed files with 202 additions and 5 deletions
+31 -5
View File
@@ -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<SystemUpdateType | null>(null)
@@ -36,6 +38,12 @@ type ReminderStore = Partial<Record<SystemUpdateType, UpdateReminder>>
const reminders = ref<ReminderStore>(readReminders())
const reminderClock = ref(Date.now())
const promptStyle = computed<Record<string, string | undefined>>(() => {
if (!footerDockHeight.value) return {}
return { '--system-update-footer-height': `${footerDockHeight.value}px` }
})
const updateItems = computed<SystemUpdateItemStatus[]>(() => {
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"
>
<div v-for="(item, index) in visibleItems" :key="item.type" class="system-update-prompt__section">
<VCardItem>
@@ -413,12 +422,28 @@ window.addEventListener(SYSTEM_UPDATE_MENU_EVENT, handleMenuUpdate)
<style scoped>
.system-update-prompt {
--system-update-footer-height: env(safe-area-inset-bottom, 0px);
--system-update-prompt-bottom-gap: 20px;
position: fixed;
z-index: 2400;
right: max(20px, env(safe-area-inset-right));
bottom: max(20px, env(safe-area-inset-bottom));
bottom: calc(var(--system-update-footer-height) + var(--system-update-prompt-bottom-gap));
width: min(400px, calc(100vw - 32px));
max-height: min(80vh, 680px);
max-height: min(
80vh,
calc(
100vh - var(--system-update-footer-height) - var(--system-update-prompt-bottom-gap) -
max(16px, env(safe-area-inset-top, 0px))
)
);
max-height: min(
680px,
calc(
100dvh - var(--system-update-footer-height) - var(--system-update-prompt-bottom-gap) -
max(16px, env(safe-area-inset-top, 0px))
)
);
overflow-y: auto;
border: var(--app-overlay-border);
border-radius: var(--app-overlay-radius) !important;
@@ -431,7 +456,7 @@ window.addEventListener(SYSTEM_UPDATE_MENU_EVENT, handleMenuUpdate)
}
.system-update-prompt--avoid-agent {
bottom: max(220px, env(safe-area-inset-bottom));
bottom: calc(var(--system-update-footer-height) + 220px);
}
.system-update-prompt :deep(.v-card-text) {
@@ -453,12 +478,13 @@ window.addEventListener(SYSTEM_UPDATE_MENU_EVENT, handleMenuUpdate)
@media (max-width: 600px) {
.system-update-prompt {
--system-update-prompt-bottom-gap: 16px;
right: 16px;
bottom: max(16px, env(safe-area-inset-bottom));
}
.system-update-prompt--avoid-agent {
bottom: max(210px, env(safe-area-inset-bottom));
bottom: calc(var(--system-update-footer-height) + 210px);
}
}
</style>
@@ -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<number | null>(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 } })
@@ -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<Element>()
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()
})
})
+61
View File
@@ -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<number | null>(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 }
}