mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 17:26:41 +08:00
fix: keep system update prompt above mobile footer
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user