feat: distinguish application and resource updates

This commit is contained in:
jxxghp
2026-09-01 23:49:53 +08:00
parent b07d60ca34
commit 90193d5d0c
8 changed files with 568 additions and 158 deletions
+28 -1
View File
@@ -2392,7 +2392,33 @@ export interface SubscribeShareStatistics {
/** 后端 API 的固定 envelope;失败及无返回值操作允许 data 为 null。 */ /** 后端 API 的固定 envelope;失败及无返回值操作允许 data 为 null。 */
export type SystemUpdateState = 'idle' | 'available' | 'downloading' | 'ready' | 'installing' | 'failed' export type SystemUpdateState = 'idle' | 'available' | 'downloading' | 'ready' | 'installing' | 'failed'
/** 后端后台更新状态机快照。 */ /** 后台更新支持的两类升级目标。 */
export type SystemUpdateType = 'application' | 'resources'
/** 单类升级的后台状态。 */
export interface SystemUpdateItemStatus {
type: SystemUpdateType
state: SystemUpdateState
current_version?: string | null
version?: string | null
frontend_version?: string | null
current_auth_version?: string | null
auth_version?: string | null
current_indexer_version?: string | null
indexer_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 SystemUpdateStatus { export interface SystemUpdateStatus {
state: SystemUpdateState state: SystemUpdateState
current_version: string current_version: string
@@ -2408,6 +2434,7 @@ export interface SystemUpdateStatus {
error?: string | null error?: string | null
can_update: boolean can_update: boolean
can_install: boolean can_install: boolean
updates?: SystemUpdateItemStatus[]
} }
export interface ApiResponse<T = unknown> { export interface ApiResponse<T = unknown> {
+248 -139
View File
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import api from '@/api' import api from '@/api'
import type { SystemUpdateStatus } from '@/api/types' import type { SystemUpdateItemStatus, SystemUpdateStatus, SystemUpdateType } from '@/api/types'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { useSystemRestartStatus } from '@/composables/useSystemRestart' import { useSystemRestartStatus } from '@/composables/useSystemRestart'
import { SYSTEM_UPDATE_MENU_EVENT, useSystemUpdateStatus } from '@/composables/useSystemUpdateStatus'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -14,14 +15,14 @@ const props = defineProps<{
const { t } = useI18n() const { t } = useI18n()
const { createConfirm } = useConfirm() const { createConfirm } = useConfirm()
const { startSystemRestart, finishSystemRestart } = useSystemRestartStatus() const { startSystemRestart, finishSystemRestart } = useSystemRestartStatus()
const { status, startPolling, stopPolling } = useSystemUpdateStatus()
const toast = useToast() const toast = useToast()
const status = ref<SystemUpdateStatus | null>(null)
const actionPending = ref(false) const actionPending = ref(false)
let pollTimer: ReturnType<typeof setTimeout> | null = null const pendingTarget = ref<SystemUpdateType | null>(null)
let restartTimer: ReturnType<typeof setTimeout> | null = null let restartTimer: ReturnType<typeof setTimeout> | null = null
let reminderTimer: ReturnType<typeof setTimeout> | null = null let reminderTimer: ReturnType<typeof setTimeout> | null = null
const REMINDER_STORAGE_KEY = 'moviepilot.system-update-reminder' const REMINDER_STORAGE_KEY = 'moviepilot.system-update-reminders'
const SNOOZE_DURATION = 24 * 60 * 60 * 1000 const SNOOZE_DURATION = 24 * 60 * 60 * 1000
interface UpdateReminder { interface UpdateReminder {
@@ -30,38 +31,80 @@ interface UpdateReminder {
ignored?: boolean ignored?: boolean
} }
const reminder = ref<UpdateReminder | null>(readReminder()) type ReminderStore = Partial<Record<SystemUpdateType, UpdateReminder>>
const reminders = ref<ReminderStore>(readReminders())
const reminderClock = ref(Date.now()) const reminderClock = ref(Date.now())
const visible = computed(() => { const updateItems = computed<SystemUpdateItemStatus[]>(() => {
if (!props.enabled || !status.value) return false if (!status.value) return []
if (['available', 'ready'].includes(status.value.state) && isCurrentVersionSuppressed.value) return false if (status.value.updates?.length) return status.value.updates
return ['available', 'downloading', 'ready', 'installing', 'failed'].includes(status.value.state) return [
{
type: 'application',
state: status.value.state,
current_version: status.value.current_version,
version: status.value.version,
frontend_version: status.value.frontend_version,
release_name: status.value.release_name,
release_notes: status.value.release_notes,
published_at: status.value.published_at,
checked_at: status.value.checked_at,
downloaded_bytes: status.value.downloaded_bytes,
total_bytes: status.value.total_bytes,
progress: status.value.progress,
error: status.value.error,
can_update: status.value.can_update,
can_install: status.value.can_install,
},
]
}) })
const downloadedSize = computed(() => formatBytes(status.value?.downloaded_bytes || 0)) const visibleItems = computed(() =>
const totalSize = computed(() => formatBytes(status.value?.total_bytes || 0)) updateItems.value.filter(item => {
const isCurrentVersionSuppressed = computed(() => { if (!['available', 'downloading', 'ready', 'installing', 'failed'].includes(item.state)) return false
if (!status.value?.version || reminder.value?.version !== status.value.version) return false return !['available', 'ready'].includes(item.state) || !isCurrentVersionSuppressed(item)
if (reminder.value.ignored) return true }),
return (reminder.value.snoozedUntil || 0) > reminderClock.value )
})
function readReminder(): UpdateReminder | null { const visible = computed(() => props.enabled && visibleItems.value.length > 0)
function readReminders(): ReminderStore {
try { try {
const saved = JSON.parse(localStorage.getItem(REMINDER_STORAGE_KEY) || 'null') const saved = JSON.parse(localStorage.getItem(REMINDER_STORAGE_KEY) || 'null')
return saved && typeof saved.version === 'string' ? saved : null if (saved && typeof saved === 'object' && !Array.isArray(saved)) {
if (typeof saved.version === 'string') return { application: saved }
return saved
}
} catch { } catch {
return null return {}
} }
return {}
} }
function saveReminder(value: UpdateReminder) { function saveReminders(value: ReminderStore) {
reminder.value = value reminders.value = value
localStorage.setItem(REMINDER_STORAGE_KEY, JSON.stringify(value)) localStorage.setItem(REMINDER_STORAGE_KEY, JSON.stringify(value))
scheduleReminderExpiry() scheduleReminderExpiry()
} }
function itemVersion(item: SystemUpdateItemStatus): string {
if (item.type === 'application') return item.version || ''
return [item.version, item.auth_version, item.indexer_version].filter(Boolean).join('|')
}
function itemReminder(item: SystemUpdateItemStatus): UpdateReminder | undefined {
return reminders.value[item.type]
}
function isCurrentVersionSuppressed(item: SystemUpdateItemStatus): boolean {
const reminder = itemReminder(item)
const version = itemVersion(item)
if (!version || reminder?.version !== version) return false
if (reminder.ignored) return true
return (reminder.snoozedUntil || 0) > reminderClock.value
}
function clearReminderTimer() { function clearReminderTimer() {
if (reminderTimer) clearTimeout(reminderTimer) if (reminderTimer) clearTimeout(reminderTimer)
reminderTimer = null reminderTimer = null
@@ -70,8 +113,8 @@ function clearReminderTimer() {
/** 到期时主动恢复提示,页面无需刷新。 */ /** 到期时主动恢复提示,页面无需刷新。 */
function scheduleReminderExpiry() { function scheduleReminderExpiry() {
clearReminderTimer() clearReminderTimer()
const expiresAt = reminder.value?.snoozedUntil || 0 const expiresAt = Math.max(...Object.values(reminders.value).map(reminder => reminder?.snoozedUntil || 0), 0)
if (reminder.value?.ignored || reminder.value?.version !== status.value?.version || expiresAt <= Date.now()) return if (expiresAt <= Date.now()) return
reminderTimer = setTimeout(() => { reminderTimer = setTimeout(() => {
reminderClock.value = Date.now() reminderClock.value = Date.now()
}, expiresAt - Date.now()) }, expiresAt - Date.now())
@@ -84,88 +127,111 @@ function formatBytes(value: number) {
return `${megabytes >= 100 ? megabytes.toFixed(0) : megabytes.toFixed(1)} MB` return `${megabytes >= 100 ? megabytes.toFixed(0) : megabytes.toFixed(1)} MB`
} }
function clearPollTimer() {
if (pollTimer) clearTimeout(pollTimer)
pollTimer = null
}
function clearRestartTimer() { function clearRestartTimer() {
if (restartTimer) clearTimeout(restartTimer) if (restartTimer) clearTimeout(restartTimer)
restartTimer = null restartTimer = null
} }
function scheduleStatusPoll(delay = 3000) { async function startDownload(item: SystemUpdateItemStatus) {
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 if (actionPending.value) return
actionPending.value = true actionPending.value = true
pendingTarget.value = item.type
try { try {
status.value = await api.post<SystemUpdateStatus>('system/update/download') status.value = await api.post<SystemUpdateStatus>('system/update/download', { target: item.type })
scheduleStatusPoll(500)
} catch (error) { } catch (error) {
console.error('[SystemUpdate] 启动下载失败', error) console.error('[SystemUpdate] 启动下载失败', error)
toast.error(t('systemUpdate.downloadFailed')) toast.error(t('systemUpdate.downloadFailed'))
} finally { } finally {
actionPending.value = false actionPending.value = false
pendingTarget.value = null
} }
} }
function postpone() { function postpone(item: SystemUpdateItemStatus) {
if (!status.value?.version) return const version = itemVersion(item)
const snoozedUntil = Date.now() + SNOOZE_DURATION if (!version) return
reminderClock.value = Date.now() reminderClock.value = Date.now()
saveReminder({ version: status.value.version, snoozedUntil }) saveReminders({
...reminders.value,
[item.type]: { version, snoozedUntil: Date.now() + SNOOZE_DURATION },
})
} }
function ignoreVersion() { function ignoreVersion(item: SystemUpdateItemStatus) {
if (!status.value?.version) return const version = itemVersion(item)
saveReminder({ version: status.value.version, ignored: true }) if (!version) return
saveReminders({ ...reminders.value, [item.type]: { version, ignored: true } })
} }
async function confirmInstall() { function replaceItem(item: SystemUpdateItemStatus) {
if (!status.value?.updates?.length) {
status.value = { ...status.value!, state: item.state }
return
}
status.value = {
...status.value,
updates: status.value.updates.map(current => (current.type === item.type ? item : current)),
}
}
async function confirmInstall(item: SystemUpdateItemStatus) {
if (actionPending.value) return if (actionPending.value) return
const confirmed = await createConfirm({ const confirmed = await createConfirm({
type: 'warn', type: 'warn',
title: t('systemUpdate.restartTitle'), title: t(item.type === 'resources' ? 'systemUpdate.resourcesRestartTitle' : 'systemUpdate.applicationRestartTitle'),
content: t('systemUpdate.restartDescription'), content: t(
item.type === 'resources'
? 'systemUpdate.resourcesRestartDescription'
: 'systemUpdate.applicationRestartDescription',
),
}) })
if (!confirmed) return if (!confirmed) return
actionPending.value = true actionPending.value = true
pendingTarget.value = item.type
startSystemRestart() startSystemRestart()
try { try {
await api.post<null>('system/update/install') await api.post<null>('system/update/install', { target: item.type })
status.value = status.value ? { ...status.value, state: 'installing' } : null replaceItem({ ...item, state: 'installing' })
pollServiceRecovery() pollServiceRecovery()
} catch (error) { } catch (error) {
console.error('[SystemUpdate] 启动安装失败', error) console.error('[SystemUpdate] 启动安装失败', error)
finishSystemRestart() finishSystemRestart()
actionPending.value = false actionPending.value = false
pendingTarget.value = null
toast.error(t('systemUpdate.installFailed')) toast.error(t('systemUpdate.installFailed'))
} }
} }
async function handleMenuUpdate(event: Event) {
const target = (event as CustomEvent<{ target?: SystemUpdateType }>).detail?.target
if (!target) return
const item = updateItems.value.find(current => current.type === target)
if (!item || !['available', 'ready'].includes(item.state)) return
if (item.state === 'ready') {
await confirmInstall(item)
return
}
const confirmed = await createConfirm({
type: 'warn',
title: t(
item.type === 'resources' ? 'systemUpdate.resourcesDownloadTitle' : 'systemUpdate.applicationDownloadTitle',
),
content: t(
item.type === 'resources'
? 'systemUpdate.resourcesDownloadDescription'
: 'systemUpdate.applicationDownloadDescription',
),
})
if (confirmed) await startDownload(item)
}
/** 服务重启后强制刷新,确保浏览器加载与新后端配套的前端资源。 */ /** 服务重启后强制刷新,确保浏览器加载与新后端配套的前端资源。 */
function pollServiceRecovery(attempt = 0) { function pollServiceRecovery(attempt = 0) {
if (attempt >= 90) { if (attempt >= 90) {
finishSystemRestart() finishSystemRestart()
actionPending.value = false actionPending.value = false
pendingTarget.value = null
toast.error(t('app.restartTimeout')) toast.error(t('app.restartTimeout'))
return return
} }
@@ -185,13 +251,47 @@ function pollServiceRecovery(attempt = 0) {
) )
} }
function titleFor(item: SystemUpdateItemStatus): string {
if (item.type === 'resources')
return item.state === 'ready' ? t('systemUpdate.resourcesReadyTitle') : t('systemUpdate.resourcesAvailableTitle')
return item.state === 'ready' ? t('systemUpdate.applicationReadyTitle') : t('systemUpdate.applicationAvailableTitle')
}
function descriptionFor(item: SystemUpdateItemStatus): string {
if (item.type === 'resources')
return item.state === 'ready'
? t('systemUpdate.resourcesReadyDescription')
: t('systemUpdate.resourcesAvailableDescription')
return item.state === 'ready'
? t('systemUpdate.applicationReadyDescription')
: t('systemUpdate.applicationAvailableDescription')
}
function versionLines(item: SystemUpdateItemStatus): string[] {
if (item.type === 'application') {
return item.version
? [
`${item.current_version || ''}${item.version}`,
...(item.frontend_version ? [`${t('systemUpdate.frontendLabel')}: ${item.frontend_version}`] : []),
]
: []
}
const lines: string[] = []
if (item.auth_version)
lines.push(`${t('systemUpdate.authResourceLabel')}: ${item.current_auth_version || ''}${item.auth_version}`)
if (item.indexer_version)
lines.push(
`${t('systemUpdate.indexerResourceLabel')}: ${item.current_indexer_version || ''}${item.indexer_version}`,
)
return lines
}
watch( watch(
() => props.enabled, () => props.enabled,
enabled => { enabled => {
if (enabled) { if (enabled) startPolling()
void loadStatus().then(() => scheduleStatusPoll()) else {
} else { stopPolling()
clearPollTimer()
clearReminderTimer() clearReminderTimer()
} }
}, },
@@ -199,7 +299,7 @@ watch(
) )
watch( watch(
() => status.value?.version, () => updateItems.value.map(item => `${item.type}:${itemVersion(item)}`).join(','),
() => { () => {
reminderClock.value = Date.now() reminderClock.value = Date.now()
scheduleReminderExpiry() scheduleReminderExpiry()
@@ -207,10 +307,13 @@ watch(
) )
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearPollTimer() if (props.enabled) stopPolling()
window.removeEventListener(SYSTEM_UPDATE_MENU_EVENT, handleMenuUpdate)
clearRestartTimer() clearRestartTimer()
clearReminderTimer() clearReminderTimer()
}) })
window.addEventListener(SYSTEM_UPDATE_MENU_EVENT, handleMenuUpdate)
</script> </script>
<template> <template>
@@ -221,85 +324,89 @@ onBeforeUnmount(() => {
:class="{ 'system-update-prompt--avoid-agent': props.avoidAgentAssistant }" :class="{ 'system-update-prompt--avoid-agent': props.avoidAgentAssistant }"
elevation="12" elevation="12"
> >
<VCardItem> <div v-for="(item, index) in visibleItems" :key="item.type" class="system-update-prompt__section">
<template #prepend> <VCardItem>
<VAvatar color="primary" variant="tonal" size="38"> <template #prepend>
<VIcon icon="mdi-update" size="22" /> <VAvatar :color="item.type === 'resources' ? 'info' : 'primary'" variant="tonal" size="38">
</VAvatar> <VIcon :icon="item.type === 'resources' ? 'mdi-database-cog-outline' : 'mdi-update'" size="22" />
</template> </VAvatar>
<VCardTitle class="system-update-prompt__title"> </template>
{{ status?.state === 'ready' ? t('systemUpdate.readyTitle') : t('systemUpdate.availableTitle') }} <VCardTitle class="system-update-prompt__title">{{ titleFor(item) }}</VCardTitle>
</VCardTitle> <VCardSubtitle v-for="line in versionLines(item)" :key="line">{{ line }}</VCardSubtitle>
<VCardSubtitle v-if="status?.version">{{ status.current_version }} {{ status.version }}</VCardSubtitle> <template v-if="item.state === 'available'" #append>
<template v-if="status?.state === 'available'" #append> <VMenu location="bottom end">
<VMenu location="bottom end"> <template #activator="{ props: menuProps }">
<template #activator="{ props: menuProps }"> <IconBtn v-bind="menuProps" :title="t('systemUpdate.moreActions')" size="small">
<IconBtn v-bind="menuProps" :title="t('systemUpdate.moreActions')" size="small"> <VIcon icon="mdi-dots-vertical" />
<VIcon icon="mdi-dots-vertical" /> </IconBtn>
</IconBtn> </template>
</template> <VList density="compact">
<VList density="compact"> <VListItem
<VListItem :title="t('systemUpdate.ignoreVersion')"
:title="t('systemUpdate.ignoreVersion')" prepend-icon="mdi-bell-off-outline"
prepend-icon="mdi-bell-off-outline" @click="ignoreVersion(item)"
@click="ignoreVersion" />
/> </VList>
</VList> </VMenu>
</VMenu> </template>
</template> </VCardItem>
</VCardItem>
<VCardText v-if="status?.state === 'available'" class="pt-1"> <VCardText v-if="item.state === 'available'" class="pt-1">{{ descriptionFor(item) }}</VCardText>
{{ t('systemUpdate.availableDescription') }}
</VCardText>
<VCardText v-else-if="status?.state === 'downloading'" class="pt-1"> <VCardText v-else-if="item.state === 'downloading'" class="pt-1">
<div class="d-flex justify-space-between text-body-2 mb-2"> <div class="d-flex justify-space-between text-body-2 mb-2">
<span>{{ t('systemUpdate.downloading') }}</span> <span>{{ t('systemUpdate.downloading') }}</span>
<span>{{ status.progress }}%</span> <span>{{ item.progress }}%</span>
</div> </div>
<VProgressLinear :model-value="status.progress" color="primary" height="6" rounded /> <VProgressLinear
<div class="text-caption text-medium-emphasis mt-2">{{ downloadedSize }} / {{ totalSize }}</div> :model-value="item.progress"
</VCardText> :color="item.type === 'resources' ? 'info' : 'primary'"
height="6"
rounded
/>
<div class="text-caption text-medium-emphasis mt-2">
{{ formatBytes(item.downloaded_bytes) }} / {{ formatBytes(item.total_bytes) }}
</div>
</VCardText>
<VCardText v-else-if="status?.state === 'ready'" class="pt-1"> <VCardText v-else-if="item.state === 'ready'" class="pt-1">{{ descriptionFor(item) }}</VCardText>
{{ t('systemUpdate.readyDescription') }}
</VCardText>
<VCardText v-else-if="status?.state === 'installing'" class="pt-1 d-flex align-center ga-3"> <VCardText v-else-if="item.state === 'installing'" class="pt-1 d-flex align-center ga-3">
<VProgressCircular indeterminate color="primary" size="22" width="2" /> <VProgressCircular indeterminate color="primary" size="22" width="2" />
<span>{{ t('systemUpdate.installing') }}</span> <span>{{ t('systemUpdate.installing') }}</span>
</VCardText> </VCardText>
<VCardText v-else-if="status?.state === 'failed'" class="pt-1 text-error"> <VCardText v-else-if="item.state === 'failed'" class="pt-1 text-error">{{
{{ status.error || t('systemUpdate.downloadFailed') }} item.error || t('systemUpdate.downloadFailed')
</VCardText> }}</VCardText>
<VCardActions v-if="status?.state === 'available'" class="px-4 pb-4 pt-0"> <VCardActions v-if="item.state === 'available'" class="px-4 pb-4 pt-0">
<VSpacer /> <VSpacer />
<VBtn variant="text" @click="postpone">{{ t('systemUpdate.later') }}</VBtn> <VBtn variant="text" @click="postpone(item)">{{ t('systemUpdate.later') }}</VBtn>
<VBtn color="primary" :loading="actionPending" @click="startDownload"> <VBtn color="primary" :loading="actionPending && pendingTarget === item.type" @click="startDownload(item)">
<VIcon icon="mdi-download" start /> <VIcon icon="mdi-download" start />
{{ t('systemUpdate.updateNow') }} {{ t('systemUpdate.updateNow') }}
</VBtn> </VBtn>
</VCardActions> </VCardActions>
<VCardActions v-else-if="status?.state === 'ready'" class="px-4 pb-4 pt-0"> <VCardActions v-else-if="item.state === 'ready'" class="px-4 pb-4 pt-0">
<VSpacer /> <VSpacer />
<VBtn variant="text" @click="postpone">{{ t('systemUpdate.restartLater') }}</VBtn> <VBtn variant="text" @click="postpone(item)">{{ t('systemUpdate.restartLater') }}</VBtn>
<VBtn color="primary" :loading="actionPending" @click="confirmInstall"> <VBtn color="primary" :loading="actionPending && pendingTarget === item.type" @click="confirmInstall(item)">
<VIcon icon="mdi-restart" start /> <VIcon icon="mdi-restart" start />
{{ t('systemUpdate.restartNow') }} {{ t('systemUpdate.restartNow') }}
</VBtn> </VBtn>
</VCardActions> </VCardActions>
<VCardActions v-else-if="status?.state === 'failed'" class="px-4 pb-4 pt-0"> <VCardActions v-else-if="item.state === 'failed'" class="px-4 pb-4 pt-0">
<VSpacer /> <VSpacer />
<VBtn color="primary" :loading="actionPending" @click="startDownload"> <VBtn color="primary" :loading="actionPending && pendingTarget === item.type" @click="startDownload(item)">
<VIcon icon="mdi-refresh" start /> <VIcon icon="mdi-refresh" start />
{{ t('common.retry') }} {{ t('common.retry') }}
</VBtn> </VBtn>
</VCardActions> </VCardActions>
<VDivider v-if="index < visibleItems.length - 1" />
</div>
</VCard> </VCard>
</Transition> </Transition>
</template> </template>
@@ -310,7 +417,9 @@ onBeforeUnmount(() => {
z-index: 2400; z-index: 2400;
right: max(20px, env(safe-area-inset-right)); right: max(20px, env(safe-area-inset-right));
bottom: max(20px, env(safe-area-inset-bottom)); bottom: max(20px, env(safe-area-inset-bottom));
width: min(380px, calc(100vw - 32px)); width: min(400px, calc(100vw - 32px));
max-height: min(80vh, 680px);
overflow-y: auto;
border-radius: 8px; border-radius: 8px;
} }
@@ -1,4 +1,5 @@
import SystemUpdatePrompt from '@/components/system/SystemUpdatePrompt.vue' import SystemUpdatePrompt from '@/components/system/SystemUpdatePrompt.vue'
import type { SystemUpdateStatus } from '@/api/types'
import { fireEvent, screen, waitFor } from '@testing-library/vue' import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render' import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -10,6 +11,7 @@ const mocks = vi.hoisted(() => ({
post: vi.fn(), post: vi.fn(),
startRestart: vi.fn(), startRestart: vi.fn(),
toastError: vi.fn(), toastError: vi.fn(),
updateStatus: null as { value: SystemUpdateStatus | null } | null,
})) }))
vi.mock('@/api', () => ({ vi.mock('@/api', () => ({
@@ -25,13 +27,26 @@ vi.mock('@/composables/useSystemRestart', () => ({
startSystemRestart: mocks.startRestart, startSystemRestart: mocks.startRestart,
}), }),
})) }))
vi.mock('@/composables/useSystemUpdateStatus', async () => {
const { ref } = await import('vue')
const status = ref(null)
mocks.updateStatus = status
return {
SYSTEM_UPDATE_MENU_EVENT: 'moviepilot:system-update-menu',
useSystemUpdateStatus: () => ({
status,
startPolling: vi.fn(),
stopPolling: vi.fn(),
}),
}
})
vi.mock('vue-toastification', () => ({ useToast: () => ({ error: mocks.toastError }) })) vi.mock('vue-toastification', () => ({ useToast: () => ({ error: mocks.toastError }) }))
vi.mock('vue-i18n', async importOriginal => ({ vi.mock('vue-i18n', async importOriginal => ({
...(await importOriginal<typeof import('vue-i18n')>()), ...(await importOriginal<typeof import('vue-i18n')>()),
useI18n: () => ({ t: (key: string) => key }), useI18n: () => ({ t: (key: string) => key }),
})) }))
const availableStatus = { const availableStatus: SystemUpdateStatus = {
state: 'available', state: 'available',
current_version: 'v3.0.0', current_version: 'v3.0.0',
version: 'v3.1.0', version: 'v3.1.0',
@@ -48,7 +63,7 @@ describe('SystemUpdatePrompt', () => {
vi.clearAllMocks() vi.clearAllMocks()
localStorage.clear() localStorage.clear()
mocks.confirm.mockResolvedValue(true) mocks.confirm.mockResolvedValue(true)
mocks.get.mockResolvedValue(availableStatus) mocks.updateStatus!.value = availableStatus
}) })
it('asks an administrator to start the background download', async () => { it('asks an administrator to start the background download', async () => {
@@ -61,16 +76,16 @@ describe('SystemUpdatePrompt', () => {
}) })
await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } }) await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } })
expect(await screen.findByText('systemUpdate.availableTitle')).toBeInTheDocument() expect(await screen.findByText('systemUpdate.applicationAvailableTitle')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: /systemUpdate.updateNow/ })) await fireEvent.click(screen.getByRole('button', { name: /systemUpdate.updateNow/ }))
await waitFor(() => expect(mocks.post).toHaveBeenCalledWith('system/update/download')) await waitFor(() => expect(mocks.post).toHaveBeenCalledWith('system/update/download', { target: 'application' }))
expect(screen.getByText('25%')).toBeInTheDocument() expect(screen.getByText('25%')).toBeInTheDocument()
expect(screen.getByText('5.0 MB / 20.0 MB')).toBeInTheDocument() expect(screen.getByText('5.0 MB / 20.0 MB')).toBeInTheDocument()
}) })
it('requires confirmation before restarting with a prepared update', async () => { it('requires confirmation before restarting with a prepared update', async () => {
mocks.get.mockResolvedValue({ mocks.updateStatus!.value = {
...availableStatus, ...availableStatus,
state: 'ready', state: 'ready',
downloaded_bytes: 20 * 1024 * 1024, downloaded_bytes: 20 * 1024 * 1024,
@@ -78,14 +93,14 @@ describe('SystemUpdatePrompt', () => {
progress: 100, progress: 100,
can_update: false, can_update: false,
can_install: true, can_install: true,
}) }
mocks.post.mockResolvedValue(null) mocks.post.mockResolvedValue(null)
await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } }) await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } })
await fireEvent.click(await screen.findByRole('button', { name: /systemUpdate.restartNow/ })) await fireEvent.click(await screen.findByRole('button', { name: /systemUpdate.restartNow/ }))
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce()) await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
expect(mocks.post).toHaveBeenCalledWith('system/update/install') expect(mocks.post).toHaveBeenCalledWith('system/update/install', { target: 'application' })
expect(mocks.startRestart).toHaveBeenCalledOnce() expect(mocks.startRestart).toHaveBeenCalledOnce()
expect(screen.getByText('systemUpdate.installing')).toBeInTheDocument() expect(screen.getByText('systemUpdate.installing')).toBeInTheDocument()
}) })
@@ -101,7 +116,7 @@ describe('SystemUpdatePrompt', () => {
props: { avoidAgentAssistant: true, enabled: true }, props: { avoidAgentAssistant: true, enabled: true },
}) })
const title = await screen.findByText('systemUpdate.availableTitle') const title = await screen.findByText('systemUpdate.applicationAvailableTitle')
expect(title.closest('.system-update-prompt')).toHaveClass('system-update-prompt--avoid-agent') expect(title.closest('.system-update-prompt')).toHaveClass('system-update-prompt--avoid-agent')
}) })
@@ -110,22 +125,77 @@ describe('SystemUpdatePrompt', () => {
await fireEvent.click(await screen.findByRole('button', { name: /systemUpdate.later/ })) await fireEvent.click(await screen.findByRole('button', { name: /systemUpdate.later/ }))
await waitFor(() => expect(screen.queryByText('systemUpdate.availableTitle')).not.toBeInTheDocument()) await waitFor(() => expect(screen.queryByText('systemUpdate.applicationAvailableTitle')).not.toBeInTheDocument())
const saved = JSON.parse(localStorage.getItem('moviepilot.system-update-reminder') || '{}') const saved = JSON.parse(localStorage.getItem('moviepilot.system-update-reminders') || '{}')
expect(saved.version).toBe('v3.1.0') expect(saved.application.version).toBe('v3.1.0')
expect(saved.snoozedUntil).toBeGreaterThan(Date.now() + 23 * 60 * 60 * 1000) expect(saved.application.snoozedUntil).toBeGreaterThan(Date.now() + 23 * 60 * 60 * 1000)
}) })
it('ignores only the selected version', async () => { it('ignores only the selected version', async () => {
const view = await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } }) const view = await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } })
await fireEvent.click(await screen.findByRole('button', { name: /systemUpdate.moreActions/ })) await fireEvent.click(await screen.findByRole('button', { name: /systemUpdate.moreActions/ }))
await fireEvent.click(await screen.findByText('systemUpdate.ignoreVersion')) await fireEvent.click(await screen.findByText('systemUpdate.ignoreVersion'))
await waitFor(() => expect(screen.queryByText('systemUpdate.availableTitle')).not.toBeInTheDocument()) await waitFor(() => expect(screen.queryByText('systemUpdate.applicationAvailableTitle')).not.toBeInTheDocument())
view.unmount() view.unmount()
mocks.get.mockResolvedValue({ ...availableStatus, version: 'v3.2.0' }) mocks.updateStatus!.value = { ...availableStatus, version: 'v3.2.0' }
await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } }) await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } })
expect(await screen.findByText('systemUpdate.availableTitle')).toBeInTheDocument() expect(await screen.findByText('systemUpdate.applicationAvailableTitle')).toBeInTheDocument()
})
it('renders application and resource updates as separate upgrade types', async () => {
mocks.updateStatus!.value = {
...availableStatus,
updates: [
{ ...availableStatus, type: 'application', state: 'idle', can_update: false },
{
type: 'resources',
state: 'available',
current_auth_version: '3.0.2',
auth_version: '3.0.3',
current_indexer_version: '3.0.7',
indexer_version: '3.0.8',
downloaded_bytes: 0,
total_bytes: 0,
progress: 0,
can_update: true,
can_install: false,
},
],
}
await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } })
expect(await screen.findByText('systemUpdate.resourcesAvailableTitle')).toBeInTheDocument()
expect(screen.getByText('systemUpdate.authResourceLabel: 3.0.2 → 3.0.3')).toBeInTheDocument()
expect(screen.getByText('systemUpdate.indexerResourceLabel: 3.0.7 → 3.0.8')).toBeInTheDocument()
})
it('uses the same resource confirmation flow when opened from the avatar menu', async () => {
mocks.updateStatus!.value = {
...availableStatus,
updates: [
{
type: 'resources',
state: 'available',
version: '10',
auth_version: '3.0.3',
indexer_version: '3.0.8',
downloaded_bytes: 0,
total_bytes: 0,
progress: 0,
can_update: true,
can_install: false,
},
],
}
mocks.post.mockResolvedValue({ ...mocks.updateStatus!.value, state: 'downloading' })
await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } })
window.dispatchEvent(new CustomEvent('moviepilot:system-update-menu', { detail: { target: 'resources' } }))
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
expect(mocks.post).toHaveBeenCalledWith('system/update/download', { target: 'resources' })
}) })
}) })
+57
View File
@@ -0,0 +1,57 @@
import type { SystemUpdateStatus, SystemUpdateType } from '@/api/types'
import api from '@/api'
/** 头像菜单向全局升级提示发送的升级动作事件。 */
export const SYSTEM_UPDATE_MENU_EVENT = 'moviepilot:system-update-menu'
const status = ref<SystemUpdateStatus | null>(null)
let pollingConsumers = 0
let pollingTimer: ReturnType<typeof setTimeout> | null = null
/** 共享后台更新状态,避免升级提示和头像菜单各自维护过期快照。 */
export function useSystemUpdateStatus() {
async function loadStatus() {
try {
status.value = await api.get<SystemUpdateStatus>('system/update/status', { feedback: 'silent' })
} catch (error) {
console.error('[SystemUpdate] 获取更新状态失败', error)
}
return status.value
}
function clearPollingTimer() {
if (pollingTimer) clearTimeout(pollingTimer)
pollingTimer = null
}
function schedulePolling() {
clearPollingTimer()
if (pollingConsumers <= 0) return
const hasActiveDownload = status.value?.updates?.some(item => ['downloading', 'installing'].includes(item.state))
pollingTimer = setTimeout(
async () => {
await loadStatus()
schedulePolling()
},
hasActiveDownload ? 3000 : 60000,
)
}
function startPolling() {
pollingConsumers += 1
if (pollingConsumers === 1) {
void loadStatus().then(schedulePolling)
}
}
function stopPolling() {
pollingConsumers = Math.max(0, pollingConsumers - 1)
if (pollingConsumers === 0) clearPollingTimer()
}
function requestMenuUpdate(target: SystemUpdateType) {
window.dispatchEvent(new CustomEvent(SYSTEM_UPDATE_MENU_EVENT, { detail: { target } }))
}
return { status, loadStatus, startPolling, stopPolling, requestMenuUpdate }
}
@@ -24,7 +24,9 @@ import {
type ThemeCustomizerSettings, type ThemeCustomizerSettings,
} from '@/composables/useThemeCustomizer' } from '@/composables/useThemeCustomizer'
import { useSystemRestartStatus } from '@/composables/useSystemRestart' import { useSystemRestartStatus } from '@/composables/useSystemRestart'
import { useSystemUpdateStatus } from '@/composables/useSystemUpdateStatus'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission' import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import type { SystemUpdateItemStatus } from '@/api/types'
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue')) const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue')) const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue'))
@@ -68,6 +70,12 @@ const isGlassTheme = computed(() => currentThemeName.value === 'glass')
// 重启轮询控制标识 // 重启轮询控制标识
const restartPollingId = ref<number | null>(null) const restartPollingId = ref<number | null>(null)
const { isRestarting, startSystemRestart, finishSystemRestart } = useSystemRestartStatus() const { isRestarting, startSystemRestart, finishSystemRestart } = useSystemRestartStatus()
const {
status: systemUpdateStatus,
startPolling: startSystemUpdatePolling,
stopPolling: stopSystemUpdatePolling,
requestMenuUpdate,
} = useSystemUpdateStatus()
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let siteAuthDialogController: ReturnType<typeof openSharedDialog> | null = null let siteAuthDialogController: ReturnType<typeof openSharedDialog> | null = null
let customCssDialogController: ReturnType<typeof openSharedDialog> | null = null let customCssDialogController: ReturnType<typeof openSharedDialog> | null = null
@@ -242,6 +250,49 @@ const userName = computed(() => userStore.userName)
const avatar = computed(() => userStore.avatar || avatar1) const avatar = computed(() => userStore.avatar || avatar1)
const userLevel = computed(() => userStore.level) const userLevel = computed(() => userStore.level)
const systemUpdateItems = computed<SystemUpdateItemStatus[]>(() => {
if (systemUpdateStatus.value?.updates?.length) return systemUpdateStatus.value.updates
if (!systemUpdateStatus.value) return []
return [
{
type: 'application',
state: systemUpdateStatus.value.state,
current_version: systemUpdateStatus.value.current_version,
version: systemUpdateStatus.value.version,
frontend_version: systemUpdateStatus.value.frontend_version,
downloaded_bytes: systemUpdateStatus.value.downloaded_bytes,
total_bytes: systemUpdateStatus.value.total_bytes,
progress: systemUpdateStatus.value.progress,
error: systemUpdateStatus.value.error,
can_update: systemUpdateStatus.value.can_update,
can_install: systemUpdateStatus.value.can_install,
},
]
})
const systemUpdateMenuItems = computed(() =>
systemUpdateItems.value.filter(item => ['available', 'ready'].includes(item.state)),
)
function systemUpdateMenuVersion(item: SystemUpdateItemStatus): string {
if (item.type === 'application') return item.version || ''
return [item.auth_version, item.indexer_version].filter(Boolean).join(' / ')
}
function openSystemUpdate(item: SystemUpdateItemStatus) {
showUserMenu.value = false
requestMenuUpdate(item.type)
}
watch(
canAdmin,
enabled => {
if (enabled) startSystemUpdatePolling()
else stopSystemUpdatePolling()
},
{ immediate: true },
)
// UI模式相关 // UI模式相关
const uiModes = computed(() => [ const uiModes = computed(() => [
{ {
@@ -547,6 +598,7 @@ onUnmounted(() => {
restartPollingId.value = null restartPollingId.value = null
} }
finishSystemRestart() finishSystemRestart()
if (canAdmin.value) stopSystemUpdatePolling()
closeRestartProgress() closeRestartProgress()
siteAuthDialogController?.close() siteAuthDialogController?.close()
customCssDialogController?.close() customCssDialogController?.close()
@@ -610,6 +662,29 @@ onUnmounted(() => {
<VListItemTitle>{{ t('user.siteAuth') }}</VListItemTitle> <VListItemTitle>{{ t('user.siteAuth') }}</VListItemTitle>
</VListItem> </VListItem>
<template v-if="systemUpdateMenuItems.length">
<VDivider class="my-2" />
<VListItem
v-for="item in systemUpdateMenuItems"
:key="item.type"
link
class="mb-1 rounded-lg"
hover
@click="openSystemUpdate(item)"
>
<template #prepend>
<VIcon :icon="item.type === 'resources' ? 'mdi-database-cog-outline' : 'mdi-update'" />
</template>
<VListItemTitle>
{{
t(item.state === 'ready' ? 'systemUpdate.menuRestartTo' : 'systemUpdate.menuUpdateTo', {
version: systemUpdateMenuVersion(item),
})
}}
</VListItemTitle>
</VListItem>
</template>
<!-- 👉 UI模式设置 - 使用嵌套菜单 --> <!-- 👉 UI模式设置 - 使用嵌套菜单 -->
<VMenu location="end" offset-x width="15rem" v-model="showUIModeMenu" :close-on-content-click="true"> <VMenu location="end" offset-x width="15rem" v-model="showUIModeMenu" :close-on-content-click="true">
<template v-slot:activator="{ props: menuProps }"> <template v-slot:activator="{ props: menuProps }">
+30 -1
View File
@@ -87,6 +87,34 @@ export default {
exit: 'Exit', exit: 'Exit',
}, },
systemUpdate: { systemUpdate: {
applicationAvailableTitle: 'MoviePilot application update available',
applicationAvailableDescription:
'The application update downloads in the background, including the frontend resources declared by the backend version.',
applicationReadyTitle: 'Application update is ready',
applicationReadyDescription:
'The application and its matching frontend resources will be switched before the next start.',
resourcesAvailableTitle: 'Site resource update available',
resourcesAvailableDescription:
'Authentication and index resources download in the background without interrupting current tasks.',
resourcesReadyTitle: 'Site resource package is ready',
resourcesReadyDescription: 'The complete site resource package will be applied before the next start.',
frontendLabel: 'Frontend',
authResourceLabel: 'Authentication resources',
indexerResourceLabel: 'Index resources',
applicationDownloadTitle: 'Download the application update?',
applicationDownloadDescription:
'The backend release and the matching frontend resources declared by it will download in the background.',
resourcesDownloadTitle: 'Download the site resource package?',
resourcesDownloadDescription:
'The complete authentication and index resource package will download in the background.',
applicationRestartTitle: 'Restart to install the application update?',
applicationRestartDescription:
'The application and its matching frontend resources will be switched before the new process starts.',
resourcesRestartTitle: 'Restart to install the site resources?',
resourcesRestartDescription:
'The downloaded complete resource package will be applied before startup; the application will not restart again afterward.',
menuUpdateTo: 'Update to {version}',
menuRestartTo: 'Restart to apply {version}',
availableTitle: 'A new MoviePilot version is available', availableTitle: 'A new MoviePilot version is available',
availableDescription: 'The update downloads in the background without interrupting current tasks.', availableDescription: 'The update downloads in the background without interrupting current tasks.',
readyTitle: 'The update is ready', readyTitle: 'The update is ready',
@@ -2441,7 +2469,8 @@ export default {
moviePilotDevUpdate: 'Track Dev builds', moviePilotDevUpdate: 'Track Dev builds',
moviePilotDevUpdateHint: 'Update to the latest code on the current v3 development branch at every restart', 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:
'Check site resource updates in the background and apply the downloaded package before restarting',
// Scraping Switch Settings // Scraping Switch Settings
scrapingSwitchSettings: 'Scraping Switch Settings', scrapingSwitchSettings: 'Scraping Switch Settings',
scrapingSwitchSettingsDesc: 'Control various media file scraping function switches', scrapingSwitchSettingsDesc: 'Control various media file scraping function switches',
+22 -1
View File
@@ -85,6 +85,27 @@ export default {
exit: '退出', exit: '退出',
}, },
systemUpdate: { systemUpdate: {
applicationAvailableTitle: '发现 MoviePilot 主程序更新',
applicationAvailableDescription: '主程序更新将在后台下载,包含后端版本对应的前端资源,下载完成前不会影响当前任务。',
applicationReadyTitle: '主程序更新包已准备完成',
applicationReadyDescription: '重启前会先切换主程序和配套前端资源,随后启动应用。',
resourcesAvailableTitle: '发现站点资源更新',
resourcesAvailableDescription: '认证资源和索引资源将在后台下载,下载完成前不会影响当前任务。',
resourcesReadyTitle: '站点资源包已准备完成',
resourcesReadyDescription: '重启前会把已下载的完整站点资源包应用到当前程序目录,随后启动应用。',
frontendLabel: '配套前端',
authResourceLabel: '认证资源',
indexerResourceLabel: '索引资源',
applicationDownloadTitle: '确认下载主程序更新?',
applicationDownloadDescription: '确认后将在后台下载后端版本及其声明的配套前端资源,下载完成后再确认重启。',
resourcesDownloadTitle: '确认下载站点资源包?',
resourcesDownloadDescription: '确认后将在后台下载完整的认证资源和索引资源,下载完成后再确认重启。',
applicationRestartTitle: '确认重启应用安装主程序更新?',
applicationRestartDescription: '应用将在退出后先切换主程序和配套前端资源,再启动新版本。',
resourcesRestartTitle: '确认重启应用安装站点资源?',
resourcesRestartDescription: '应用将在退出后先应用已下载的完整站点资源包,再启动当前版本,不会在启动后再次重启。',
menuUpdateTo: '升级到 {version}',
menuRestartTo: '重启应用完成更新 {version}',
availableTitle: '发现 MoviePilot 新版本', availableTitle: '发现 MoviePilot 新版本',
availableDescription: '更新包将在后台下载,下载完成前不会影响当前任务。', availableDescription: '更新包将在后台下载,下载完成前不会影响当前任务。',
readyTitle: '更新包已准备完成', readyTitle: '更新包已准备完成',
@@ -2398,7 +2419,7 @@ export default {
moviePilotDevUpdate: '跟踪 Dev 开发版', moviePilotDevUpdate: '跟踪 Dev 开发版',
moviePilotDevUpdateHint: '每次重启时更新到当前 v3 开发分支的最新代码', moviePilotDevUpdateHint: '每次重启时更新到当前 v3 开发分支的最新代码',
autoUpdateResource: '自动更新站点资源', autoUpdateResource: '自动更新站点资源',
autoUpdateResourceHint: '重启时自动检测和更新站点资源包', autoUpdateResourceHint: '后台检查站点资源包,确认后在重启前应用已下载的完整资源包',
// 刮削开关设置 // 刮削开关设置
scrapingSwitchSettings: '刮削开关设置', scrapingSwitchSettings: '刮削开关设置',
scrapingSwitchSettingsDesc: '控制各类媒体文件的刮削功能开关', scrapingSwitchSettingsDesc: '控制各类媒体文件的刮削功能开关',
+23 -1
View File
@@ -85,6 +85,28 @@ export default {
exit: '退出', exit: '退出',
}, },
systemUpdate: { systemUpdate: {
applicationAvailableTitle: '發現 MoviePilot 主程式更新',
applicationAvailableDescription: '主程式更新將在背景下載,包含後端版本對應的前端資源,下載完成前不會影響目前任務。',
applicationReadyTitle: '主程式更新包已準備完成',
applicationReadyDescription: '重新啟動前會先切換主程式和配套前端資源,之後啟動應用程式。',
resourcesAvailableTitle: '發現站點資源更新',
resourcesAvailableDescription: '認證資源和索引資源將在背景下載,下載完成前不會影響目前任務。',
resourcesReadyTitle: '站點資源包已準備完成',
resourcesReadyDescription: '重新啟動前會套用已下載的完整站點資源包,之後啟動應用程式。',
frontendLabel: '配套前端',
authResourceLabel: '認證資源',
indexerResourceLabel: '索引資源',
applicationDownloadTitle: '確認下載主程式更新?',
applicationDownloadDescription: '確認後將在背景下載後端版本及其宣告的配套前端資源,下載完成後再確認重新啟動。',
resourcesDownloadTitle: '確認下載站點資源包?',
resourcesDownloadDescription: '確認後將在背景下載完整的認證資源和索引資源,下載完成後再確認重新啟動。',
applicationRestartTitle: '確認重新啟動安裝主程式更新?',
applicationRestartDescription: '應用程式退出後會先切換主程式和配套前端資源,再啟動新版本。',
resourcesRestartTitle: '確認重新啟動安裝站點資源?',
resourcesRestartDescription:
'應用程式退出後會先套用已下載的完整站點資源包,再啟動目前版本,不會在啟動後再次重新啟動。',
menuUpdateTo: '升級到 {version}',
menuRestartTo: '重新啟動套用更新 {version}',
availableTitle: '發現 MoviePilot 新版本', availableTitle: '發現 MoviePilot 新版本',
availableDescription: '更新包將在後台下載,下載完成前不會影響目前任務。', availableDescription: '更新包將在後台下載,下載完成前不會影響目前任務。',
readyTitle: '更新包已準備完成', readyTitle: '更新包已準備完成',
@@ -2397,7 +2419,7 @@ export default {
moviePilotDevUpdate: '追蹤 Dev 開發版', moviePilotDevUpdate: '追蹤 Dev 開發版',
moviePilotDevUpdateHint: '每次重新啟動時更新到目前 v3 開發分支的最新程式碼', moviePilotDevUpdateHint: '每次重新啟動時更新到目前 v3 開發分支的最新程式碼',
autoUpdateResource: '自動更新站點資源', autoUpdateResource: '自動更新站點資源',
autoUpdateResourceHint: '重啟時自動檢測和更新站點資源包', autoUpdateResourceHint: '在背景檢查站點資源包,確認後於重新啟動前套用完整資源包',
// 刮削開關設定 // 刮削開關設定
scrapingSwitchSettings: '刮削開關設定', scrapingSwitchSettings: '刮削開關設定',
scrapingSwitchSettingsDesc: '控制各類媒體檔案的刮削功能開關', scrapingSwitchSettingsDesc: '控制各類媒體檔案的刮削功能開關',