mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 17:26:41 +08:00
feat: distinguish application and resource updates
This commit is contained in:
+28
-1
@@ -2392,7 +2392,33 @@ export interface SubscribeShareStatistics {
|
||||
/** 后端 API 的固定 envelope;失败及无返回值操作允许 data 为 null。 */
|
||||
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 {
|
||||
state: SystemUpdateState
|
||||
current_version: string
|
||||
@@ -2408,6 +2434,7 @@ export interface SystemUpdateStatus {
|
||||
error?: string | null
|
||||
can_update: boolean
|
||||
can_install: boolean
|
||||
updates?: SystemUpdateItemStatus[]
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { SystemUpdateStatus } from '@/api/types'
|
||||
import type { SystemUpdateItemStatus, SystemUpdateStatus, SystemUpdateType } from '@/api/types'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useSystemRestartStatus } from '@/composables/useSystemRestart'
|
||||
import { SYSTEM_UPDATE_MENU_EVENT, useSystemUpdateStatus } from '@/composables/useSystemUpdateStatus'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -14,14 +15,14 @@ const props = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const { createConfirm } = useConfirm()
|
||||
const { startSystemRestart, finishSystemRestart } = useSystemRestartStatus()
|
||||
const { status, startPolling, stopPolling } = useSystemUpdateStatus()
|
||||
const toast = useToast()
|
||||
const status = ref<SystemUpdateStatus | null>(null)
|
||||
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 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
|
||||
|
||||
interface UpdateReminder {
|
||||
@@ -30,38 +31,80 @@ interface UpdateReminder {
|
||||
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 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 updateItems = computed<SystemUpdateItemStatus[]>(() => {
|
||||
if (!status.value) return []
|
||||
if (status.value.updates?.length) return status.value.updates
|
||||
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 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
|
||||
})
|
||||
const visibleItems = computed(() =>
|
||||
updateItems.value.filter(item => {
|
||||
if (!['available', 'downloading', 'ready', 'installing', 'failed'].includes(item.state)) return false
|
||||
return !['available', 'ready'].includes(item.state) || !isCurrentVersionSuppressed(item)
|
||||
}),
|
||||
)
|
||||
|
||||
function readReminder(): UpdateReminder | null {
|
||||
const visible = computed(() => props.enabled && visibleItems.value.length > 0)
|
||||
|
||||
function readReminders(): ReminderStore {
|
||||
try {
|
||||
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 {
|
||||
return null
|
||||
return {}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function saveReminder(value: UpdateReminder) {
|
||||
reminder.value = value
|
||||
function saveReminders(value: ReminderStore) {
|
||||
reminders.value = value
|
||||
localStorage.setItem(REMINDER_STORAGE_KEY, JSON.stringify(value))
|
||||
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() {
|
||||
if (reminderTimer) clearTimeout(reminderTimer)
|
||||
reminderTimer = null
|
||||
@@ -70,8 +113,8 @@ function clearReminderTimer() {
|
||||
/** 到期时主动恢复提示,页面无需刷新。 */
|
||||
function scheduleReminderExpiry() {
|
||||
clearReminderTimer()
|
||||
const expiresAt = reminder.value?.snoozedUntil || 0
|
||||
if (reminder.value?.ignored || reminder.value?.version !== status.value?.version || expiresAt <= Date.now()) return
|
||||
const expiresAt = Math.max(...Object.values(reminders.value).map(reminder => reminder?.snoozedUntil || 0), 0)
|
||||
if (expiresAt <= Date.now()) return
|
||||
reminderTimer = setTimeout(() => {
|
||||
reminderClock.value = Date.now()
|
||||
}, expiresAt - Date.now())
|
||||
@@ -84,88 +127,111 @@ function formatBytes(value: number) {
|
||||
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() {
|
||||
async function startDownload(item: SystemUpdateItemStatus) {
|
||||
if (actionPending.value) return
|
||||
actionPending.value = true
|
||||
pendingTarget.value = item.type
|
||||
try {
|
||||
status.value = await api.post<SystemUpdateStatus>('system/update/download')
|
||||
scheduleStatusPoll(500)
|
||||
status.value = await api.post<SystemUpdateStatus>('system/update/download', { target: item.type })
|
||||
} catch (error) {
|
||||
console.error('[SystemUpdate] 启动下载失败', error)
|
||||
toast.error(t('systemUpdate.downloadFailed'))
|
||||
} finally {
|
||||
actionPending.value = false
|
||||
pendingTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function postpone() {
|
||||
if (!status.value?.version) return
|
||||
const snoozedUntil = Date.now() + SNOOZE_DURATION
|
||||
function postpone(item: SystemUpdateItemStatus) {
|
||||
const version = itemVersion(item)
|
||||
if (!version) return
|
||||
reminderClock.value = Date.now()
|
||||
saveReminder({ version: status.value.version, snoozedUntil })
|
||||
saveReminders({
|
||||
...reminders.value,
|
||||
[item.type]: { version, snoozedUntil: Date.now() + SNOOZE_DURATION },
|
||||
})
|
||||
}
|
||||
|
||||
function ignoreVersion() {
|
||||
if (!status.value?.version) return
|
||||
saveReminder({ version: status.value.version, ignored: true })
|
||||
function ignoreVersion(item: SystemUpdateItemStatus) {
|
||||
const version = itemVersion(item)
|
||||
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
|
||||
const confirmed = await createConfirm({
|
||||
type: 'warn',
|
||||
title: t('systemUpdate.restartTitle'),
|
||||
content: t('systemUpdate.restartDescription'),
|
||||
title: t(item.type === 'resources' ? 'systemUpdate.resourcesRestartTitle' : 'systemUpdate.applicationRestartTitle'),
|
||||
content: t(
|
||||
item.type === 'resources'
|
||||
? 'systemUpdate.resourcesRestartDescription'
|
||||
: 'systemUpdate.applicationRestartDescription',
|
||||
),
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
actionPending.value = true
|
||||
pendingTarget.value = item.type
|
||||
startSystemRestart()
|
||||
try {
|
||||
await api.post<null>('system/update/install')
|
||||
status.value = status.value ? { ...status.value, state: 'installing' } : null
|
||||
await api.post<null>('system/update/install', { target: item.type })
|
||||
replaceItem({ ...item, state: 'installing' })
|
||||
pollServiceRecovery()
|
||||
} catch (error) {
|
||||
console.error('[SystemUpdate] 启动安装失败', error)
|
||||
finishSystemRestart()
|
||||
actionPending.value = false
|
||||
pendingTarget.value = null
|
||||
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) {
|
||||
if (attempt >= 90) {
|
||||
finishSystemRestart()
|
||||
actionPending.value = false
|
||||
pendingTarget.value = null
|
||||
toast.error(t('app.restartTimeout'))
|
||||
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(
|
||||
() => props.enabled,
|
||||
enabled => {
|
||||
if (enabled) {
|
||||
void loadStatus().then(() => scheduleStatusPoll())
|
||||
} else {
|
||||
clearPollTimer()
|
||||
if (enabled) startPolling()
|
||||
else {
|
||||
stopPolling()
|
||||
clearReminderTimer()
|
||||
}
|
||||
},
|
||||
@@ -199,7 +299,7 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => status.value?.version,
|
||||
() => updateItems.value.map(item => `${item.type}:${itemVersion(item)}`).join(','),
|
||||
() => {
|
||||
reminderClock.value = Date.now()
|
||||
scheduleReminderExpiry()
|
||||
@@ -207,10 +307,13 @@ watch(
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPollTimer()
|
||||
if (props.enabled) stopPolling()
|
||||
window.removeEventListener(SYSTEM_UPDATE_MENU_EVENT, handleMenuUpdate)
|
||||
clearRestartTimer()
|
||||
clearReminderTimer()
|
||||
})
|
||||
|
||||
window.addEventListener(SYSTEM_UPDATE_MENU_EVENT, handleMenuUpdate)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -221,85 +324,89 @@ onBeforeUnmount(() => {
|
||||
: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>
|
||||
<div v-for="(item, index) in visibleItems" :key="item.type" class="system-update-prompt__section">
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VAvatar :color="item.type === 'resources' ? 'info' : 'primary'" variant="tonal" size="38">
|
||||
<VIcon :icon="item.type === 'resources' ? 'mdi-database-cog-outline' : 'mdi-update'" size="22" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle class="system-update-prompt__title">{{ titleFor(item) }}</VCardTitle>
|
||||
<VCardSubtitle v-for="line in versionLines(item)" :key="line">{{ line }}</VCardSubtitle>
|
||||
<template v-if="item.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(item)"
|
||||
/>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</template>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText v-if="status?.state === 'available'" class="pt-1">
|
||||
{{ t('systemUpdate.availableDescription') }}
|
||||
</VCardText>
|
||||
<VCardText v-if="item.state === 'available'" class="pt-1">{{ descriptionFor(item) }}</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="item.state === 'downloading'" class="pt-1">
|
||||
<div class="d-flex justify-space-between text-body-2 mb-2">
|
||||
<span>{{ t('systemUpdate.downloading') }}</span>
|
||||
<span>{{ item.progress }}%</span>
|
||||
</div>
|
||||
<VProgressLinear
|
||||
:model-value="item.progress"
|
||||
: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">
|
||||
{{ t('systemUpdate.readyDescription') }}
|
||||
</VCardText>
|
||||
<VCardText v-else-if="item.state === 'ready'" class="pt-1">{{ descriptionFor(item) }}</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="item.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>
|
||||
<VCardText v-else-if="item.state === 'failed'" class="pt-1 text-error">{{
|
||||
item.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-if="item.state === 'available'" class="px-4 pb-4 pt-0">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="postpone(item)">{{ t('systemUpdate.later') }}</VBtn>
|
||||
<VBtn color="primary" :loading="actionPending && pendingTarget === item.type" @click="startDownload(item)">
|
||||
<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="item.state === 'ready'" class="px-4 pb-4 pt-0">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="postpone(item)">{{ t('systemUpdate.restartLater') }}</VBtn>
|
||||
<VBtn color="primary" :loading="actionPending && pendingTarget === item.type" @click="confirmInstall(item)">
|
||||
<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>
|
||||
<VCardActions v-else-if="item.state === 'failed'" class="px-4 pb-4 pt-0">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" :loading="actionPending && pendingTarget === item.type" @click="startDownload(item)">
|
||||
<VIcon icon="mdi-refresh" start />
|
||||
{{ t('common.retry') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
<VDivider v-if="index < visibleItems.length - 1" />
|
||||
</div>
|
||||
</VCard>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -310,7 +417,9 @@ onBeforeUnmount(() => {
|
||||
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));
|
||||
width: min(400px, calc(100vw - 32px));
|
||||
max-height: min(80vh, 680px);
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SystemUpdatePrompt from '@/components/system/SystemUpdatePrompt.vue'
|
||||
import type { SystemUpdateStatus } from '@/api/types'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -10,6 +11,7 @@ const mocks = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
startRestart: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
updateStatus: null as { value: SystemUpdateStatus | null } | null,
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
@@ -25,13 +27,26 @@ vi.mock('@/composables/useSystemRestart', () => ({
|
||||
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-i18n', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('vue-i18n')>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
const availableStatus = {
|
||||
const availableStatus: SystemUpdateStatus = {
|
||||
state: 'available',
|
||||
current_version: 'v3.0.0',
|
||||
version: 'v3.1.0',
|
||||
@@ -48,7 +63,7 @@ describe('SystemUpdatePrompt', () => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mocks.confirm.mockResolvedValue(true)
|
||||
mocks.get.mockResolvedValue(availableStatus)
|
||||
mocks.updateStatus!.value = availableStatus
|
||||
})
|
||||
|
||||
it('asks an administrator to start the background download', async () => {
|
||||
@@ -61,16 +76,16 @@ describe('SystemUpdatePrompt', () => {
|
||||
})
|
||||
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 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('5.0 MB / 20.0 MB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('requires confirmation before restarting with a prepared update', async () => {
|
||||
mocks.get.mockResolvedValue({
|
||||
mocks.updateStatus!.value = {
|
||||
...availableStatus,
|
||||
state: 'ready',
|
||||
downloaded_bytes: 20 * 1024 * 1024,
|
||||
@@ -78,14 +93,14 @@ describe('SystemUpdatePrompt', () => {
|
||||
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.post).toHaveBeenCalledWith('system/update/install', { target: 'application' })
|
||||
expect(mocks.startRestart).toHaveBeenCalledOnce()
|
||||
expect(screen.getByText('systemUpdate.installing')).toBeInTheDocument()
|
||||
})
|
||||
@@ -101,7 +116,7 @@ describe('SystemUpdatePrompt', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -110,22 +125,77 @@ describe('SystemUpdatePrompt', () => {
|
||||
|
||||
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)
|
||||
await waitFor(() => expect(screen.queryByText('systemUpdate.applicationAvailableTitle')).not.toBeInTheDocument())
|
||||
const saved = JSON.parse(localStorage.getItem('moviepilot.system-update-reminders') || '{}')
|
||||
expect(saved.application.version).toBe('v3.1.0')
|
||||
expect(saved.application.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())
|
||||
await waitFor(() => expect(screen.queryByText('systemUpdate.applicationAvailableTitle')).not.toBeInTheDocument())
|
||||
|
||||
view.unmount()
|
||||
mocks.get.mockResolvedValue({ ...availableStatus, version: 'v3.2.0' })
|
||||
mocks.updateStatus!.value = { ...availableStatus, version: 'v3.2.0' }
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import { useSystemRestartStatus } from '@/composables/useSystemRestart'
|
||||
import { useSystemUpdateStatus } from '@/composables/useSystemUpdateStatus'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import type { SystemUpdateItemStatus } from '@/api/types'
|
||||
|
||||
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.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 { isRestarting, startSystemRestart, finishSystemRestart } = useSystemRestartStatus()
|
||||
const {
|
||||
status: systemUpdateStatus,
|
||||
startPolling: startSystemUpdatePolling,
|
||||
stopPolling: stopSystemUpdatePolling,
|
||||
requestMenuUpdate,
|
||||
} = useSystemUpdateStatus()
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let siteAuthDialogController: 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 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模式相关
|
||||
const uiModes = computed(() => [
|
||||
{
|
||||
@@ -547,6 +598,7 @@ onUnmounted(() => {
|
||||
restartPollingId.value = null
|
||||
}
|
||||
finishSystemRestart()
|
||||
if (canAdmin.value) stopSystemUpdatePolling()
|
||||
closeRestartProgress()
|
||||
siteAuthDialogController?.close()
|
||||
customCssDialogController?.close()
|
||||
@@ -610,6 +662,29 @@ onUnmounted(() => {
|
||||
<VListItemTitle>{{ t('user.siteAuth') }}</VListItemTitle>
|
||||
</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模式设置 - 使用嵌套菜单 -->
|
||||
<VMenu location="end" offset-x width="15rem" v-model="showUIModeMenu" :close-on-content-click="true">
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
|
||||
+30
-1
@@ -87,6 +87,34 @@ export default {
|
||||
exit: 'Exit',
|
||||
},
|
||||
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',
|
||||
availableDescription: 'The update downloads in the background without interrupting current tasks.',
|
||||
readyTitle: 'The update is ready',
|
||||
@@ -2441,7 +2469,8 @@ export default {
|
||||
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',
|
||||
autoUpdateResourceHint:
|
||||
'Check site resource updates in the background and apply the downloaded package before restarting',
|
||||
// Scraping Switch Settings
|
||||
scrapingSwitchSettings: 'Scraping Switch Settings',
|
||||
scrapingSwitchSettingsDesc: 'Control various media file scraping function switches',
|
||||
|
||||
+22
-1
@@ -85,6 +85,27 @@ export default {
|
||||
exit: '退出',
|
||||
},
|
||||
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 新版本',
|
||||
availableDescription: '更新包将在后台下载,下载完成前不会影响当前任务。',
|
||||
readyTitle: '更新包已准备完成',
|
||||
@@ -2398,7 +2419,7 @@ export default {
|
||||
moviePilotDevUpdate: '跟踪 Dev 开发版',
|
||||
moviePilotDevUpdateHint: '每次重启时更新到当前 v3 开发分支的最新代码',
|
||||
autoUpdateResource: '自动更新站点资源',
|
||||
autoUpdateResourceHint: '重启时自动检测和更新站点资源包',
|
||||
autoUpdateResourceHint: '后台检查站点资源包,确认后在重启前应用已下载的完整资源包',
|
||||
// 刮削开关设置
|
||||
scrapingSwitchSettings: '刮削开关设置',
|
||||
scrapingSwitchSettingsDesc: '控制各类媒体文件的刮削功能开关',
|
||||
|
||||
+23
-1
@@ -85,6 +85,28 @@ export default {
|
||||
exit: '退出',
|
||||
},
|
||||
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 新版本',
|
||||
availableDescription: '更新包將在後台下載,下載完成前不會影響目前任務。',
|
||||
readyTitle: '更新包已準備完成',
|
||||
@@ -2397,7 +2419,7 @@ export default {
|
||||
moviePilotDevUpdate: '追蹤 Dev 開發版',
|
||||
moviePilotDevUpdateHint: '每次重新啟動時更新到目前 v3 開發分支的最新程式碼',
|
||||
autoUpdateResource: '自動更新站點資源',
|
||||
autoUpdateResourceHint: '重啟時自動檢測和更新站點資源包',
|
||||
autoUpdateResourceHint: '在背景檢查站點資源包,確認後於重新啟動前套用完整資源包',
|
||||
// 刮削開關設定
|
||||
scrapingSwitchSettings: '刮削開關設定',
|
||||
scrapingSwitchSettingsDesc: '控制各類媒體檔案的刮削功能開關',
|
||||
|
||||
Reference in New Issue
Block a user