mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
feat(system): add release update prompt
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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: '重啟時自動檢測和更新站點資源包',
|
||||
// 刮削開關設定
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user