feat(system): add release update prompt

This commit is contained in:
jxxghp
2026-08-24 17:58:03 +08:00
parent 486704c6d2
commit 3a45d7e9de
10 changed files with 577 additions and 19 deletions
+20
View File
@@ -2241,6 +2241,26 @@ export interface SubscribeShareStatistics {
}
/** 后端 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> {
success: boolean
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,
} from '@/composables/useThemeCustomizer'
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
import SystemUpdatePrompt from '@/components/system/SystemUpdatePrompt.vue'
const display = useDisplay()
// PWA模式检测
@@ -512,6 +513,7 @@ onMounted(async () => {
</script>
<template>
<SystemUpdatePrompt :enabled="canAdmin" :avoid-agent-assistant="showAgentAssistant" />
<!-- 👉 Offline Page -->
<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('@/components/agent/AgentAssistantWidget.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('@/layouts/default/components/Footer.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/layouts/default/components/HeaderTab.vue', () => ({ default: mocks.emptyComponent }))
+20 -2
View File
@@ -86,6 +86,24 @@ export default {
sortModeHint: 'Drag sorting mode is active',
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: {
movie: 'Movie',
tv: 'TV Show',
@@ -2350,8 +2368,8 @@ export default {
imageProxyAllowedPrivateRangeAdd: 'Add CIDR, e.g.: 198.18.0.0/15',
proxyHost: 'Proxy Server',
proxyHostHint: 'Set proxy server address, support: http(s), socks5, socks5h, etc.',
moviePilotAutoUpdate: 'Auto Update MoviePilot',
moviePilotAutoUpdateHint: 'Automatically update MoviePilot to the latest release version when restarting',
moviePilotDevUpdate: 'Track Dev builds',
moviePilotDevUpdateHint: 'Update to the latest code on the current v3 development branch at every restart',
autoUpdateResource: 'Auto Update Resource',
autoUpdateResourceHint: 'Automatically detect and update site resource package when restarting',
// Scraping Switch Settings
+20 -2
View File
@@ -84,6 +84,24 @@ export default {
sortModeHint: '已进入拖拽排序模式',
exit: '退出',
},
systemUpdate: {
availableTitle: '发现 MoviePilot 新版本',
availableDescription: '更新包将在后台下载,下载完成前不会影响当前任务。',
readyTitle: '更新包已准备完成',
readyDescription: '重启后将安装新版本,当前正在执行的任务会安全停止。',
downloading: '正在下载更新包',
installing: '正在重启并安装更新...',
updateNow: '升级',
later: '稍后',
moreActions: '更多更新选项',
ignoreVersion: '忽略此版本',
restartNow: '立即重启',
restartLater: '稍后重启',
restartTitle: '确认重启安装更新?',
restartDescription: '系统将停止当前任务并重启,重启完成后正式切换到新版本。',
downloadFailed: '更新包下载失败',
installFailed: '无法启动更新安装',
},
mediaType: {
movie: '电影',
tv: '电视剧',
@@ -2309,8 +2327,8 @@ export default {
imageProxyAllowedPrivateRangeAdd: '添加 CIDR,如:198.18.0.0/15',
proxyHost: '代理服务器',
proxyHostHint: '设置代理服务器地址,支持:http(s)、socks5、socks5h 等协议',
moviePilotAutoUpdate: '自动更新MoviePilot',
moviePilotAutoUpdateHint: '重启时自动更新MoviePilot到最新发行版本',
moviePilotDevUpdate: '跟踪 Dev 开发版',
moviePilotDevUpdateHint: '每次重启时更新到当前 v3 开发分支的最新代码',
autoUpdateResource: '自动更新站点资源',
autoUpdateResourceHint: '重启时自动检测和更新站点资源包',
// 刮削开关设置
+20 -2
View File
@@ -84,6 +84,24 @@ export default {
sortModeHint: '已進入拖拽排序模式',
exit: '退出',
},
systemUpdate: {
availableTitle: '發現 MoviePilot 新版本',
availableDescription: '更新包將在後台下載,下載完成前不會影響目前任務。',
readyTitle: '更新包已準備完成',
readyDescription: '重新啟動後將安裝新版本,目前執行中的任務會安全停止。',
downloading: '正在下載更新包',
installing: '正在重新啟動並安裝更新...',
updateNow: '升級',
later: '稍後',
moreActions: '更多更新選項',
ignoreVersion: '忽略此版本',
restartNow: '立即重新啟動',
restartLater: '稍後重新啟動',
restartTitle: '確認重新啟動安裝更新?',
restartDescription: '系統將停止目前任務並重新啟動,完成後正式切換到新版本。',
downloadFailed: '更新包下載失敗',
installFailed: '無法啟動更新安裝',
},
mediaType: {
movie: '電影',
tv: '電視劇',
@@ -2308,8 +2326,8 @@ export default {
imageProxyAllowedPrivateRangeAdd: '添加 CIDR,如:198.18.0.0/15',
proxyHost: '代理服務器',
proxyHostHint: '設置代理服務器地址,支持:http(s)、socks5、socks5h 等協議',
moviePilotAutoUpdate: '自動更新MoviePilot',
moviePilotAutoUpdateHint: '重啟時自動更新MoviePilot到最新發行版本',
moviePilotDevUpdate: '追蹤 Dev 開發版',
moviePilotDevUpdateHint: '每次重新啟動時更新到目前 v3 開發分支的最新程式碼',
autoUpdateResource: '自動更新站點資源',
autoUpdateResourceHint: '重啟時自動檢測和更新站點資源包',
// 刮削開關設定
+7 -10
View File
@@ -1082,13 +1082,10 @@ function onMediaServerChange(mediaserver: MediaServerConf, name: string) {
if (index !== -1) mediaServers.value[index] = mediaserver
}
// 添加计算属性
const moviePilotAutoUpdate = computed({
get: () => {
return ['release', 'dev'].includes(SystemSettings.value.Advanced.MOVIEPILOT_AUTO_UPDATE)
},
set: val => {
SystemSettings.value.Advanced.MOVIEPILOT_AUTO_UPDATE = val ? 'release' : 'false'
const moviePilotDevUpdate = computed({
get: () => SystemSettings.value.Advanced.MOVIEPILOT_AUTO_UPDATE === 'dev',
set: enabled => {
SystemSettings.value.Advanced.MOVIEPILOT_AUTO_UPDATE = enabled ? 'dev' : 'false'
},
})
@@ -2080,9 +2077,9 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="moviePilotAutoUpdate"
:label="t('setting.system.moviePilotAutoUpdate')"
:hint="t('setting.system.moviePilotAutoUpdateHint')"
v-model="moviePilotDevUpdate"
:label="t('setting.system.moviePilotDevUpdate')"
:hint="t('setting.system.moviePilotDevUpdateHint')"
persistent-hint
/>
</VCol>
@@ -962,7 +962,7 @@ describe('AccountSettingSystem', () => {
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()
const dialog = await openAdvancedTab('系统')
for (const label of [
@@ -974,7 +974,7 @@ describe('AccountSettingSystem', () => {
'分享工作流数据',
'大内存模式',
'数据库WAL模式',
'自动更新MoviePilot',
'跟踪 Dev 开发版',
'自动更新站点资源',
]) {
await fireEvent.click(dialog.getByLabelText(label))
@@ -989,7 +989,7 @@ describe('AccountSettingSystem', () => {
BIG_MEMORY_MODE: true,
DB_WAL_ENABLE: true,
GLOBAL_IMAGE_CACHE: true,
MOVIEPILOT_AUTO_UPDATE: 'release',
MOVIEPILOT_AUTO_UPDATE: 'dev',
PLUGIN_STATISTIC_SHARE: false,
SUBSCRIBE_STATISTIC_SHARE: false,
USAGE_STATISTIC_SHARE: false,