From 90193d5d0cfcd23f7f1529be4f2ed7ef9d9f0e10 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 1 Sep 2026 23:49:53 +0800 Subject: [PATCH] feat: distinguish application and resource updates --- src/api/types.ts | 29 +- src/components/system/SystemUpdatePrompt.vue | 387 +++++++++++------- .../__tests__/SystemUpdatePrompt.spec.ts | 100 ++++- src/composables/useSystemUpdateStatus.ts | 57 +++ .../default/components/UserProfile.vue | 75 ++++ src/locales/en-US.ts | 31 +- src/locales/zh-CN.ts | 23 +- src/locales/zh-TW.ts | 24 +- 8 files changed, 568 insertions(+), 158 deletions(-) create mode 100644 src/composables/useSystemUpdateStatus.ts diff --git a/src/api/types.ts b/src/api/types.ts index 0a728132..59b47df8 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 { diff --git a/src/components/system/SystemUpdatePrompt.vue b/src/components/system/SystemUpdatePrompt.vue index a144e046..29d8b85e 100644 --- a/src/components/system/SystemUpdatePrompt.vue +++ b/src/components/system/SystemUpdatePrompt.vue @@ -1,8 +1,9 @@ @@ -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; } diff --git a/src/components/system/__tests__/SystemUpdatePrompt.spec.ts b/src/components/system/__tests__/SystemUpdatePrompt.spec.ts index cb901fa6..44e79a5b 100644 --- a/src/components/system/__tests__/SystemUpdatePrompt.spec.ts +++ b/src/components/system/__tests__/SystemUpdatePrompt.spec.ts @@ -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()), 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' }) }) }) diff --git a/src/composables/useSystemUpdateStatus.ts b/src/composables/useSystemUpdateStatus.ts new file mode 100644 index 00000000..75510fc9 --- /dev/null +++ b/src/composables/useSystemUpdateStatus.ts @@ -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(null) +let pollingConsumers = 0 +let pollingTimer: ReturnType | null = null + +/** 共享后台更新状态,避免升级提示和头像菜单各自维护过期快照。 */ +export function useSystemUpdateStatus() { + async function loadStatus() { + try { + status.value = await api.get('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 } +} diff --git a/src/layouts/default/components/UserProfile.vue b/src/layouts/default/components/UserProfile.vue index 051f75a9..6db601ce 100644 --- a/src/layouts/default/components/UserProfile.vue +++ b/src/layouts/default/components/UserProfile.vue @@ -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(null) const { isRestarting, startSystemRestart, finishSystemRestart } = useSystemRestartStatus() +const { + status: systemUpdateStatus, + startPolling: startSystemUpdatePolling, + stopPolling: stopSystemUpdatePolling, + requestMenuUpdate, +} = useSystemUpdateStatus() let progressDialogController: ReturnType | null = null let siteAuthDialogController: ReturnType | null = null let customCssDialogController: ReturnType | 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(() => { + 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(() => { {{ t('user.siteAuth') }} + +