mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
feat(system): add release update prompt
This commit is contained in:
@@ -2241,6 +2241,26 @@ export interface SubscribeShareStatistics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 后端 API 的固定 envelope;失败及无返回值操作允许 data 为 null。 */
|
/** 后端 API 的固定 envelope;失败及无返回值操作允许 data 为 null。 */
|
||||||
|
export type SystemUpdateState = 'idle' | 'available' | 'downloading' | 'ready' | 'installing' | 'failed'
|
||||||
|
|
||||||
|
/** 后端后台更新状态机快照。 */
|
||||||
|
export interface SystemUpdateStatus {
|
||||||
|
state: SystemUpdateState
|
||||||
|
current_version: string
|
||||||
|
version?: string | null
|
||||||
|
frontend_version?: string | null
|
||||||
|
release_name?: string | null
|
||||||
|
release_notes?: string | null
|
||||||
|
published_at?: string | null
|
||||||
|
checked_at?: string | null
|
||||||
|
downloaded_bytes: number
|
||||||
|
total_bytes: number
|
||||||
|
progress: number
|
||||||
|
error?: string | null
|
||||||
|
can_update: boolean
|
||||||
|
can_install: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiResponse<T = unknown> {
|
export interface ApiResponse<T = unknown> {
|
||||||
success: boolean
|
success: boolean
|
||||||
message: string
|
message: string
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import api from '@/api'
|
||||||
|
import type { SystemUpdateStatus } from '@/api/types'
|
||||||
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { useSystemRestartStatus } from '@/composables/useSystemRestart'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
avoidAgentAssistant?: boolean
|
||||||
|
enabled: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { createConfirm } = useConfirm()
|
||||||
|
const { startSystemRestart, finishSystemRestart } = useSystemRestartStatus()
|
||||||
|
const toast = useToast()
|
||||||
|
const status = ref<SystemUpdateStatus | null>(null)
|
||||||
|
const actionPending = ref(false)
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let restartTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let reminderTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const REMINDER_STORAGE_KEY = 'moviepilot.system-update-reminder'
|
||||||
|
const SNOOZE_DURATION = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
interface UpdateReminder {
|
||||||
|
version: string
|
||||||
|
snoozedUntil?: number
|
||||||
|
ignored?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const reminder = ref<UpdateReminder | null>(readReminder())
|
||||||
|
const reminderClock = ref(Date.now())
|
||||||
|
|
||||||
|
const visible = computed(() => {
|
||||||
|
if (!props.enabled || !status.value) return false
|
||||||
|
if (['available', 'ready'].includes(status.value.state) && isCurrentVersionSuppressed.value) return false
|
||||||
|
return ['available', 'downloading', 'ready', 'installing', 'failed'].includes(status.value.state)
|
||||||
|
})
|
||||||
|
|
||||||
|
const downloadedSize = computed(() => formatBytes(status.value?.downloaded_bytes || 0))
|
||||||
|
const totalSize = computed(() => formatBytes(status.value?.total_bytes || 0))
|
||||||
|
const isCurrentVersionSuppressed = computed(() => {
|
||||||
|
if (!status.value?.version || reminder.value?.version !== status.value.version) return false
|
||||||
|
if (reminder.value.ignored) return true
|
||||||
|
return (reminder.value.snoozedUntil || 0) > reminderClock.value
|
||||||
|
})
|
||||||
|
|
||||||
|
function readReminder(): UpdateReminder | null {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(REMINDER_STORAGE_KEY) || 'null')
|
||||||
|
return saved && typeof saved.version === 'string' ? saved : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveReminder(value: UpdateReminder) {
|
||||||
|
reminder.value = value
|
||||||
|
localStorage.setItem(REMINDER_STORAGE_KEY, JSON.stringify(value))
|
||||||
|
scheduleReminderExpiry()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearReminderTimer() {
|
||||||
|
if (reminderTimer) clearTimeout(reminderTimer)
|
||||||
|
reminderTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 到期时主动恢复提示,页面无需刷新。 */
|
||||||
|
function scheduleReminderExpiry() {
|
||||||
|
clearReminderTimer()
|
||||||
|
const expiresAt = reminder.value?.snoozedUntil || 0
|
||||||
|
if (reminder.value?.ignored || reminder.value?.version !== status.value?.version || expiresAt <= Date.now()) return
|
||||||
|
reminderTimer = setTimeout(() => {
|
||||||
|
reminderClock.value = Date.now()
|
||||||
|
}, expiresAt - Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用紧凑二进制单位展示下载量,避免进度提示宽度跳动。 */
|
||||||
|
function formatBytes(value: number) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return '0 MB'
|
||||||
|
const megabytes = value / 1024 / 1024
|
||||||
|
return `${megabytes >= 100 ? megabytes.toFixed(0) : megabytes.toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPollTimer() {
|
||||||
|
if (pollTimer) clearTimeout(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRestartTimer() {
|
||||||
|
if (restartTimer) clearTimeout(restartTimer)
|
||||||
|
restartTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleStatusPoll(delay = 3000) {
|
||||||
|
clearPollTimer()
|
||||||
|
if (!props.enabled || !['downloading', 'installing'].includes(status.value?.state || '')) return
|
||||||
|
pollTimer = setTimeout(async () => {
|
||||||
|
await loadStatus()
|
||||||
|
scheduleStatusPoll()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
if (!props.enabled) return
|
||||||
|
try {
|
||||||
|
status.value = await api.get<SystemUpdateStatus>('system/update/status', { feedback: 'silent' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SystemUpdate] 获取更新状态失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDownload() {
|
||||||
|
if (actionPending.value) return
|
||||||
|
actionPending.value = true
|
||||||
|
try {
|
||||||
|
status.value = await api.post<SystemUpdateStatus>('system/update/download')
|
||||||
|
scheduleStatusPoll(500)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SystemUpdate] 启动下载失败', error)
|
||||||
|
toast.error(t('systemUpdate.downloadFailed'))
|
||||||
|
} finally {
|
||||||
|
actionPending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function postpone() {
|
||||||
|
if (!status.value?.version) return
|
||||||
|
const snoozedUntil = Date.now() + SNOOZE_DURATION
|
||||||
|
reminderClock.value = Date.now()
|
||||||
|
saveReminder({ version: status.value.version, snoozedUntil })
|
||||||
|
}
|
||||||
|
|
||||||
|
function ignoreVersion() {
|
||||||
|
if (!status.value?.version) return
|
||||||
|
saveReminder({ version: status.value.version, ignored: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmInstall() {
|
||||||
|
if (actionPending.value) return
|
||||||
|
const confirmed = await createConfirm({
|
||||||
|
type: 'warn',
|
||||||
|
title: t('systemUpdate.restartTitle'),
|
||||||
|
content: t('systemUpdate.restartDescription'),
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
actionPending.value = true
|
||||||
|
startSystemRestart()
|
||||||
|
try {
|
||||||
|
await api.post<null>('system/update/install')
|
||||||
|
status.value = status.value ? { ...status.value, state: 'installing' } : null
|
||||||
|
pollServiceRecovery()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SystemUpdate] 启动安装失败', error)
|
||||||
|
finishSystemRestart()
|
||||||
|
actionPending.value = false
|
||||||
|
toast.error(t('systemUpdate.installFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务重启后强制刷新,确保浏览器加载与新后端配套的前端资源。 */
|
||||||
|
function pollServiceRecovery(attempt = 0) {
|
||||||
|
if (attempt >= 90) {
|
||||||
|
finishSystemRestart()
|
||||||
|
actionPending.value = false
|
||||||
|
toast.error(t('app.restartTimeout'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearRestartTimer()
|
||||||
|
restartTimer = setTimeout(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await api.get<null>('system/ping', { timeout: 3000, feedback: 'silent' })
|
||||||
|
finishSystemRestart()
|
||||||
|
window.location.reload()
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[SystemUpdate] 等待服务重启完成', error)
|
||||||
|
pollServiceRecovery(attempt + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
attempt === 0 ? 5000 : 3000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.enabled,
|
||||||
|
enabled => {
|
||||||
|
if (enabled) {
|
||||||
|
void loadStatus().then(() => scheduleStatusPoll())
|
||||||
|
} else {
|
||||||
|
clearPollTimer()
|
||||||
|
clearReminderTimer()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => status.value?.version,
|
||||||
|
() => {
|
||||||
|
reminderClock.value = Date.now()
|
||||||
|
scheduleReminderExpiry()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearPollTimer()
|
||||||
|
clearRestartTimer()
|
||||||
|
clearReminderTimer()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition name="system-update-prompt">
|
||||||
|
<VCard
|
||||||
|
v-if="visible"
|
||||||
|
class="system-update-prompt"
|
||||||
|
:class="{ 'system-update-prompt--avoid-agent': props.avoidAgentAssistant }"
|
||||||
|
elevation="12"
|
||||||
|
>
|
||||||
|
<VCardItem>
|
||||||
|
<template #prepend>
|
||||||
|
<VAvatar color="primary" variant="tonal" size="38">
|
||||||
|
<VIcon icon="mdi-update" size="22" />
|
||||||
|
</VAvatar>
|
||||||
|
</template>
|
||||||
|
<VCardTitle class="system-update-prompt__title">
|
||||||
|
{{ status?.state === 'ready' ? t('systemUpdate.readyTitle') : t('systemUpdate.availableTitle') }}
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardSubtitle v-if="status?.version">{{ status.current_version }} → {{ status.version }}</VCardSubtitle>
|
||||||
|
<template v-if="status?.state === 'available'" #append>
|
||||||
|
<VMenu location="bottom end">
|
||||||
|
<template #activator="{ props: menuProps }">
|
||||||
|
<IconBtn v-bind="menuProps" :title="t('systemUpdate.moreActions')" size="small">
|
||||||
|
<VIcon icon="mdi-dots-vertical" />
|
||||||
|
</IconBtn>
|
||||||
|
</template>
|
||||||
|
<VList density="compact">
|
||||||
|
<VListItem
|
||||||
|
:title="t('systemUpdate.ignoreVersion')"
|
||||||
|
prepend-icon="mdi-bell-off-outline"
|
||||||
|
@click="ignoreVersion"
|
||||||
|
/>
|
||||||
|
</VList>
|
||||||
|
</VMenu>
|
||||||
|
</template>
|
||||||
|
</VCardItem>
|
||||||
|
|
||||||
|
<VCardText v-if="status?.state === 'available'" class="pt-1">
|
||||||
|
{{ t('systemUpdate.availableDescription') }}
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VCardText v-else-if="status?.state === 'downloading'" class="pt-1">
|
||||||
|
<div class="d-flex justify-space-between text-body-2 mb-2">
|
||||||
|
<span>{{ t('systemUpdate.downloading') }}</span>
|
||||||
|
<span>{{ status.progress }}%</span>
|
||||||
|
</div>
|
||||||
|
<VProgressLinear :model-value="status.progress" color="primary" height="6" rounded />
|
||||||
|
<div class="text-caption text-medium-emphasis mt-2">{{ downloadedSize }} / {{ totalSize }}</div>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VCardText v-else-if="status?.state === 'ready'" class="pt-1">
|
||||||
|
{{ t('systemUpdate.readyDescription') }}
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VCardText v-else-if="status?.state === 'installing'" class="pt-1 d-flex align-center ga-3">
|
||||||
|
<VProgressCircular indeterminate color="primary" size="22" width="2" />
|
||||||
|
<span>{{ t('systemUpdate.installing') }}</span>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VCardText v-else-if="status?.state === 'failed'" class="pt-1 text-error">
|
||||||
|
{{ status.error || t('systemUpdate.downloadFailed') }}
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VCardActions v-if="status?.state === 'available'" class="px-4 pb-4 pt-0">
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn variant="text" @click="postpone">{{ t('systemUpdate.later') }}</VBtn>
|
||||||
|
<VBtn color="primary" :loading="actionPending" @click="startDownload">
|
||||||
|
<VIcon icon="mdi-download" start />
|
||||||
|
{{ t('systemUpdate.updateNow') }}
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
|
||||||
|
<VCardActions v-else-if="status?.state === 'ready'" class="px-4 pb-4 pt-0">
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn variant="text" @click="postpone">{{ t('systemUpdate.restartLater') }}</VBtn>
|
||||||
|
<VBtn color="primary" :loading="actionPending" @click="confirmInstall">
|
||||||
|
<VIcon icon="mdi-restart" start />
|
||||||
|
{{ t('systemUpdate.restartNow') }}
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
|
||||||
|
<VCardActions v-else-if="status?.state === 'failed'" class="px-4 pb-4 pt-0">
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn color="primary" :loading="actionPending" @click="startDownload">
|
||||||
|
<VIcon icon="mdi-refresh" start />
|
||||||
|
{{ t('common.retry') }}
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.system-update-prompt {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2400;
|
||||||
|
right: max(20px, env(safe-area-inset-right));
|
||||||
|
bottom: max(20px, env(safe-area-inset-bottom));
|
||||||
|
width: min(380px, calc(100vw - 32px));
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-prompt__title {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-prompt--avoid-agent {
|
||||||
|
bottom: max(220px, env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-prompt :deep(.v-card-text) {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-prompt-enter-active,
|
||||||
|
.system-update-prompt-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 180ms ease,
|
||||||
|
transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-prompt-enter-from,
|
||||||
|
.system-update-prompt-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.system-update-prompt {
|
||||||
|
right: 16px;
|
||||||
|
bottom: max(16px, env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-prompt--avoid-agent {
|
||||||
|
bottom: max(210px, env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import SystemUpdatePrompt from '@/components/system/SystemUpdatePrompt.vue'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
confirm: vi.fn(),
|
||||||
|
finishRestart: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
startRestart: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: {
|
||||||
|
get: mocks.get,
|
||||||
|
post: mocks.post,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('@/composables/useConfirm', () => ({ useConfirm: () => ({ createConfirm: mocks.confirm }) }))
|
||||||
|
vi.mock('@/composables/useSystemRestart', () => ({
|
||||||
|
useSystemRestartStatus: () => ({
|
||||||
|
finishSystemRestart: mocks.finishRestart,
|
||||||
|
startSystemRestart: mocks.startRestart,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
vi.mock('vue-toastification', () => ({ useToast: () => ({ error: mocks.toastError }) }))
|
||||||
|
vi.mock('vue-i18n', async importOriginal => ({
|
||||||
|
...(await importOriginal<typeof import('vue-i18n')>()),
|
||||||
|
useI18n: () => ({ t: (key: string) => key }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const availableStatus = {
|
||||||
|
state: 'available',
|
||||||
|
current_version: 'v3.0.0',
|
||||||
|
version: 'v3.1.0',
|
||||||
|
frontend_version: null,
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: 0,
|
||||||
|
progress: 0,
|
||||||
|
can_update: true,
|
||||||
|
can_install: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SystemUpdatePrompt', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
mocks.confirm.mockResolvedValue(true)
|
||||||
|
mocks.get.mockResolvedValue(availableStatus)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks an administrator to start the background download', async () => {
|
||||||
|
mocks.post.mockResolvedValue({
|
||||||
|
...availableStatus,
|
||||||
|
state: 'downloading',
|
||||||
|
downloaded_bytes: 5 * 1024 * 1024,
|
||||||
|
total_bytes: 20 * 1024 * 1024,
|
||||||
|
progress: 25,
|
||||||
|
})
|
||||||
|
await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } })
|
||||||
|
|
||||||
|
expect(await screen.findByText('systemUpdate.availableTitle')).toBeInTheDocument()
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /systemUpdate.updateNow/ }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.post).toHaveBeenCalledWith('system/update/download'))
|
||||||
|
expect(screen.getByText('25%')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('5.0 MB / 20.0 MB')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires confirmation before restarting with a prepared update', async () => {
|
||||||
|
mocks.get.mockResolvedValue({
|
||||||
|
...availableStatus,
|
||||||
|
state: 'ready',
|
||||||
|
downloaded_bytes: 20 * 1024 * 1024,
|
||||||
|
total_bytes: 20 * 1024 * 1024,
|
||||||
|
progress: 100,
|
||||||
|
can_update: false,
|
||||||
|
can_install: true,
|
||||||
|
})
|
||||||
|
mocks.post.mockResolvedValue(null)
|
||||||
|
await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } })
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: /systemUpdate.restartNow/ }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||||
|
expect(mocks.post).toHaveBeenCalledWith('system/update/install')
|
||||||
|
expect(mocks.startRestart).toHaveBeenCalledOnce()
|
||||||
|
expect(screen.getByText('systemUpdate.installing')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not query update state without administrator permission', async () => {
|
||||||
|
await renderWithProviders(SystemUpdatePrompt, { props: { enabled: false } })
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(mocks.get).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves above the Agent assistant when both prompts are enabled', async () => {
|
||||||
|
await renderWithProviders(SystemUpdatePrompt, {
|
||||||
|
props: { avoidAgentAssistant: true, enabled: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = await screen.findByText('systemUpdate.availableTitle')
|
||||||
|
expect(title.closest('.system-update-prompt')).toHaveClass('system-update-prompt--avoid-agent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snoozes the current version for 24 hours', async () => {
|
||||||
|
await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } })
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: /systemUpdate.later/ }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByText('systemUpdate.availableTitle')).not.toBeInTheDocument())
|
||||||
|
const saved = JSON.parse(localStorage.getItem('moviepilot.system-update-reminder') || '{}')
|
||||||
|
expect(saved.version).toBe('v3.1.0')
|
||||||
|
expect(saved.snoozedUntil).toBeGreaterThan(Date.now() + 23 * 60 * 60 * 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores only the selected version', async () => {
|
||||||
|
const view = await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } })
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: /systemUpdate.moreActions/ }))
|
||||||
|
await fireEvent.click(await screen.findByText('systemUpdate.ignoreVersion'))
|
||||||
|
await waitFor(() => expect(screen.queryByText('systemUpdate.availableTitle')).not.toBeInTheDocument())
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
mocks.get.mockResolvedValue({ ...availableStatus, version: 'v3.2.0' })
|
||||||
|
await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } })
|
||||||
|
|
||||||
|
expect(await screen.findByText('systemUpdate.availableTitle')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
type ThemeCustomizerSettings,
|
type ThemeCustomizerSettings,
|
||||||
} from '@/composables/useThemeCustomizer'
|
} from '@/composables/useThemeCustomizer'
|
||||||
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
|
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
|
||||||
|
import SystemUpdatePrompt from '@/components/system/SystemUpdatePrompt.vue'
|
||||||
|
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
// PWA模式检测
|
// PWA模式检测
|
||||||
@@ -512,6 +513,7 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<SystemUpdatePrompt :enabled="canAdmin" :avoid-agent-assistant="showAgentAssistant" />
|
||||||
<!-- 👉 Offline Page -->
|
<!-- 👉 Offline Page -->
|
||||||
<OfflinePage />
|
<OfflinePage />
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ vi.mock('@layouts/components/VerticalNavLink.vue', () => ({ default: mocks.navLi
|
|||||||
vi.mock('@layouts/components/VerticalNavSectionTitle.vue', () => ({ default: mocks.emptyComponent }))
|
vi.mock('@layouts/components/VerticalNavSectionTitle.vue', () => ({ default: mocks.emptyComponent }))
|
||||||
vi.mock('@/components/agent/AgentAssistantWidget.vue', () => ({ default: mocks.emptyComponent }))
|
vi.mock('@/components/agent/AgentAssistantWidget.vue', () => ({ default: mocks.emptyComponent }))
|
||||||
vi.mock('@/components/misc/ThemeLogoMark.vue', () => ({ default: mocks.emptyComponent }))
|
vi.mock('@/components/misc/ThemeLogoMark.vue', () => ({ default: mocks.emptyComponent }))
|
||||||
|
vi.mock('@/components/system/SystemUpdatePrompt.vue', () => ({ default: mocks.emptyComponent }))
|
||||||
vi.mock('@/components/theme/ThemeCustomizer.vue', () => ({ default: mocks.emptyComponent }))
|
vi.mock('@/components/theme/ThemeCustomizer.vue', () => ({ default: mocks.emptyComponent }))
|
||||||
vi.mock('@/layouts/default/components/Footer.vue', () => ({ default: mocks.emptyComponent }))
|
vi.mock('@/layouts/default/components/Footer.vue', () => ({ default: mocks.emptyComponent }))
|
||||||
vi.mock('@/layouts/default/components/HeaderTab.vue', () => ({ default: mocks.emptyComponent }))
|
vi.mock('@/layouts/default/components/HeaderTab.vue', () => ({ default: mocks.emptyComponent }))
|
||||||
|
|||||||
+20
-2
@@ -86,6 +86,24 @@ export default {
|
|||||||
sortModeHint: 'Drag sorting mode is active',
|
sortModeHint: 'Drag sorting mode is active',
|
||||||
exit: 'Exit',
|
exit: 'Exit',
|
||||||
},
|
},
|
||||||
|
systemUpdate: {
|
||||||
|
availableTitle: 'A new MoviePilot version is available',
|
||||||
|
availableDescription: 'The update downloads in the background without interrupting current tasks.',
|
||||||
|
readyTitle: 'The update is ready',
|
||||||
|
readyDescription: 'Restart to install the new version. Running tasks will stop gracefully.',
|
||||||
|
downloading: 'Downloading update',
|
||||||
|
installing: 'Restarting and installing update...',
|
||||||
|
updateNow: 'Update',
|
||||||
|
later: 'Later',
|
||||||
|
moreActions: 'More update options',
|
||||||
|
ignoreVersion: 'Ignore this version',
|
||||||
|
restartNow: 'Restart now',
|
||||||
|
restartLater: 'Restart later',
|
||||||
|
restartTitle: 'Restart to install the update?',
|
||||||
|
restartDescription: 'The system will stop current tasks and switch to the new version after restarting.',
|
||||||
|
downloadFailed: 'Failed to download the update',
|
||||||
|
installFailed: 'Failed to start update installation',
|
||||||
|
},
|
||||||
mediaType: {
|
mediaType: {
|
||||||
movie: 'Movie',
|
movie: 'Movie',
|
||||||
tv: 'TV Show',
|
tv: 'TV Show',
|
||||||
@@ -2350,8 +2368,8 @@ export default {
|
|||||||
imageProxyAllowedPrivateRangeAdd: 'Add CIDR, e.g.: 198.18.0.0/15',
|
imageProxyAllowedPrivateRangeAdd: 'Add CIDR, e.g.: 198.18.0.0/15',
|
||||||
proxyHost: 'Proxy Server',
|
proxyHost: 'Proxy Server',
|
||||||
proxyHostHint: 'Set proxy server address, support: http(s), socks5, socks5h, etc.',
|
proxyHostHint: 'Set proxy server address, support: http(s), socks5, socks5h, etc.',
|
||||||
moviePilotAutoUpdate: 'Auto Update MoviePilot',
|
moviePilotDevUpdate: 'Track Dev builds',
|
||||||
moviePilotAutoUpdateHint: 'Automatically update MoviePilot to the latest release version when restarting',
|
moviePilotDevUpdateHint: 'Update to the latest code on the current v3 development branch at every restart',
|
||||||
autoUpdateResource: 'Auto Update Resource',
|
autoUpdateResource: 'Auto Update Resource',
|
||||||
autoUpdateResourceHint: 'Automatically detect and update site resource package when restarting',
|
autoUpdateResourceHint: 'Automatically detect and update site resource package when restarting',
|
||||||
// Scraping Switch Settings
|
// Scraping Switch Settings
|
||||||
|
|||||||
+20
-2
@@ -84,6 +84,24 @@ export default {
|
|||||||
sortModeHint: '已进入拖拽排序模式',
|
sortModeHint: '已进入拖拽排序模式',
|
||||||
exit: '退出',
|
exit: '退出',
|
||||||
},
|
},
|
||||||
|
systemUpdate: {
|
||||||
|
availableTitle: '发现 MoviePilot 新版本',
|
||||||
|
availableDescription: '更新包将在后台下载,下载完成前不会影响当前任务。',
|
||||||
|
readyTitle: '更新包已准备完成',
|
||||||
|
readyDescription: '重启后将安装新版本,当前正在执行的任务会安全停止。',
|
||||||
|
downloading: '正在下载更新包',
|
||||||
|
installing: '正在重启并安装更新...',
|
||||||
|
updateNow: '升级',
|
||||||
|
later: '稍后',
|
||||||
|
moreActions: '更多更新选项',
|
||||||
|
ignoreVersion: '忽略此版本',
|
||||||
|
restartNow: '立即重启',
|
||||||
|
restartLater: '稍后重启',
|
||||||
|
restartTitle: '确认重启安装更新?',
|
||||||
|
restartDescription: '系统将停止当前任务并重启,重启完成后正式切换到新版本。',
|
||||||
|
downloadFailed: '更新包下载失败',
|
||||||
|
installFailed: '无法启动更新安装',
|
||||||
|
},
|
||||||
mediaType: {
|
mediaType: {
|
||||||
movie: '电影',
|
movie: '电影',
|
||||||
tv: '电视剧',
|
tv: '电视剧',
|
||||||
@@ -2309,8 +2327,8 @@ export default {
|
|||||||
imageProxyAllowedPrivateRangeAdd: '添加 CIDR,如:198.18.0.0/15',
|
imageProxyAllowedPrivateRangeAdd: '添加 CIDR,如:198.18.0.0/15',
|
||||||
proxyHost: '代理服务器',
|
proxyHost: '代理服务器',
|
||||||
proxyHostHint: '设置代理服务器地址,支持:http(s)、socks5、socks5h 等协议',
|
proxyHostHint: '设置代理服务器地址,支持:http(s)、socks5、socks5h 等协议',
|
||||||
moviePilotAutoUpdate: '自动更新MoviePilot',
|
moviePilotDevUpdate: '跟踪 Dev 开发版',
|
||||||
moviePilotAutoUpdateHint: '重启时自动更新MoviePilot到最新发行版本',
|
moviePilotDevUpdateHint: '每次重启时更新到当前 v3 开发分支的最新代码',
|
||||||
autoUpdateResource: '自动更新站点资源',
|
autoUpdateResource: '自动更新站点资源',
|
||||||
autoUpdateResourceHint: '重启时自动检测和更新站点资源包',
|
autoUpdateResourceHint: '重启时自动检测和更新站点资源包',
|
||||||
// 刮削开关设置
|
// 刮削开关设置
|
||||||
|
|||||||
+20
-2
@@ -84,6 +84,24 @@ export default {
|
|||||||
sortModeHint: '已進入拖拽排序模式',
|
sortModeHint: '已進入拖拽排序模式',
|
||||||
exit: '退出',
|
exit: '退出',
|
||||||
},
|
},
|
||||||
|
systemUpdate: {
|
||||||
|
availableTitle: '發現 MoviePilot 新版本',
|
||||||
|
availableDescription: '更新包將在後台下載,下載完成前不會影響目前任務。',
|
||||||
|
readyTitle: '更新包已準備完成',
|
||||||
|
readyDescription: '重新啟動後將安裝新版本,目前執行中的任務會安全停止。',
|
||||||
|
downloading: '正在下載更新包',
|
||||||
|
installing: '正在重新啟動並安裝更新...',
|
||||||
|
updateNow: '升級',
|
||||||
|
later: '稍後',
|
||||||
|
moreActions: '更多更新選項',
|
||||||
|
ignoreVersion: '忽略此版本',
|
||||||
|
restartNow: '立即重新啟動',
|
||||||
|
restartLater: '稍後重新啟動',
|
||||||
|
restartTitle: '確認重新啟動安裝更新?',
|
||||||
|
restartDescription: '系統將停止目前任務並重新啟動,完成後正式切換到新版本。',
|
||||||
|
downloadFailed: '更新包下載失敗',
|
||||||
|
installFailed: '無法啟動更新安裝',
|
||||||
|
},
|
||||||
mediaType: {
|
mediaType: {
|
||||||
movie: '電影',
|
movie: '電影',
|
||||||
tv: '電視劇',
|
tv: '電視劇',
|
||||||
@@ -2308,8 +2326,8 @@ export default {
|
|||||||
imageProxyAllowedPrivateRangeAdd: '添加 CIDR,如:198.18.0.0/15',
|
imageProxyAllowedPrivateRangeAdd: '添加 CIDR,如:198.18.0.0/15',
|
||||||
proxyHost: '代理服務器',
|
proxyHost: '代理服務器',
|
||||||
proxyHostHint: '設置代理服務器地址,支持:http(s)、socks5、socks5h 等協議',
|
proxyHostHint: '設置代理服務器地址,支持:http(s)、socks5、socks5h 等協議',
|
||||||
moviePilotAutoUpdate: '自動更新MoviePilot',
|
moviePilotDevUpdate: '追蹤 Dev 開發版',
|
||||||
moviePilotAutoUpdateHint: '重啟時自動更新MoviePilot到最新發行版本',
|
moviePilotDevUpdateHint: '每次重新啟動時更新到目前 v3 開發分支的最新程式碼',
|
||||||
autoUpdateResource: '自動更新站點資源',
|
autoUpdateResource: '自動更新站點資源',
|
||||||
autoUpdateResourceHint: '重啟時自動檢測和更新站點資源包',
|
autoUpdateResourceHint: '重啟時自動檢測和更新站點資源包',
|
||||||
// 刮削開關設定
|
// 刮削開關設定
|
||||||
|
|||||||
@@ -1082,13 +1082,10 @@ function onMediaServerChange(mediaserver: MediaServerConf, name: string) {
|
|||||||
if (index !== -1) mediaServers.value[index] = mediaserver
|
if (index !== -1) mediaServers.value[index] = mediaserver
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加计算属性
|
const moviePilotDevUpdate = computed({
|
||||||
const moviePilotAutoUpdate = computed({
|
get: () => SystemSettings.value.Advanced.MOVIEPILOT_AUTO_UPDATE === 'dev',
|
||||||
get: () => {
|
set: enabled => {
|
||||||
return ['release', 'dev'].includes(SystemSettings.value.Advanced.MOVIEPILOT_AUTO_UPDATE)
|
SystemSettings.value.Advanced.MOVIEPILOT_AUTO_UPDATE = enabled ? 'dev' : 'false'
|
||||||
},
|
|
||||||
set: val => {
|
|
||||||
SystemSettings.value.Advanced.MOVIEPILOT_AUTO_UPDATE = val ? 'release' : 'false'
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2080,9 +2077,9 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="6">
|
<VCol cols="12" md="6">
|
||||||
<VSwitch
|
<VSwitch
|
||||||
v-model="moviePilotAutoUpdate"
|
v-model="moviePilotDevUpdate"
|
||||||
:label="t('setting.system.moviePilotAutoUpdate')"
|
:label="t('setting.system.moviePilotDevUpdate')"
|
||||||
:hint="t('setting.system.moviePilotAutoUpdateHint')"
|
:hint="t('setting.system.moviePilotDevUpdateHint')"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|||||||
@@ -962,7 +962,7 @@ describe('AccountSettingSystem', () => {
|
|||||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('高级设置保存成功')
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('高级设置保存成功')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('round-trips all advanced system switches and preserves the auto-update wire value', async () => {
|
it('round-trips all advanced system switches and enables only the Dev update mode', async () => {
|
||||||
await renderSettings()
|
await renderSettings()
|
||||||
const dialog = await openAdvancedTab('系统')
|
const dialog = await openAdvancedTab('系统')
|
||||||
for (const label of [
|
for (const label of [
|
||||||
@@ -974,7 +974,7 @@ describe('AccountSettingSystem', () => {
|
|||||||
'分享工作流数据',
|
'分享工作流数据',
|
||||||
'大内存模式',
|
'大内存模式',
|
||||||
'数据库WAL模式',
|
'数据库WAL模式',
|
||||||
'自动更新MoviePilot',
|
'跟踪 Dev 开发版',
|
||||||
'自动更新站点资源',
|
'自动更新站点资源',
|
||||||
]) {
|
]) {
|
||||||
await fireEvent.click(dialog.getByLabelText(label))
|
await fireEvent.click(dialog.getByLabelText(label))
|
||||||
@@ -989,7 +989,7 @@ describe('AccountSettingSystem', () => {
|
|||||||
BIG_MEMORY_MODE: true,
|
BIG_MEMORY_MODE: true,
|
||||||
DB_WAL_ENABLE: true,
|
DB_WAL_ENABLE: true,
|
||||||
GLOBAL_IMAGE_CACHE: true,
|
GLOBAL_IMAGE_CACHE: true,
|
||||||
MOVIEPILOT_AUTO_UPDATE: 'release',
|
MOVIEPILOT_AUTO_UPDATE: 'dev',
|
||||||
PLUGIN_STATISTIC_SHARE: false,
|
PLUGIN_STATISTIC_SHARE: false,
|
||||||
SUBSCRIBE_STATISTIC_SHARE: false,
|
SUBSCRIBE_STATISTIC_SHARE: false,
|
||||||
USAGE_STATISTIC_SHARE: false,
|
USAGE_STATISTIC_SHARE: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user