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。 */
|
/** 后端 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> {
|
||||||
|
|||||||
@@ -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' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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: '控制各類媒體檔案的刮削功能開關',
|
||||||
|
|||||||
Reference in New Issue
Block a user