From 3a45d7e9de8995a129c41b639db212e1d2dcb153 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 24 Aug 2026 17:58:03 +0800 Subject: [PATCH] feat(system): add release update prompt --- src/api/types.ts | 20 + src/components/system/SystemUpdatePrompt.vue | 353 ++++++++++++++++++ .../__tests__/SystemUpdatePrompt.spec.ts | 131 +++++++ .../default/components/DefaultLayout.vue | 2 + .../__tests__/DefaultLayout.spec.ts | 1 + src/locales/en-US.ts | 22 +- src/locales/zh-CN.ts | 22 +- src/locales/zh-TW.ts | 22 +- src/views/setting/AccountSettingSystem.vue | 17 +- .../__tests__/AccountSettingSystem.spec.ts | 6 +- 10 files changed, 577 insertions(+), 19 deletions(-) create mode 100644 src/components/system/SystemUpdatePrompt.vue create mode 100644 src/components/system/__tests__/SystemUpdatePrompt.spec.ts diff --git a/src/api/types.ts b/src/api/types.ts index 8bbea856..5ea9d7dd 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -2241,6 +2241,26 @@ export interface SubscribeShareStatistics { } /** 后端 API 的固定 envelope;失败及无返回值操作允许 data 为 null。 */ +export type SystemUpdateState = 'idle' | 'available' | 'downloading' | 'ready' | 'installing' | 'failed' + +/** 后端后台更新状态机快照。 */ +export interface SystemUpdateStatus { + state: SystemUpdateState + current_version: string + version?: string | null + frontend_version?: string | null + release_name?: string | null + release_notes?: string | null + published_at?: string | null + checked_at?: string | null + downloaded_bytes: number + total_bytes: number + progress: number + error?: string | null + can_update: boolean + can_install: boolean +} + export interface ApiResponse { success: boolean message: string diff --git a/src/components/system/SystemUpdatePrompt.vue b/src/components/system/SystemUpdatePrompt.vue new file mode 100644 index 00000000..a144e046 --- /dev/null +++ b/src/components/system/SystemUpdatePrompt.vue @@ -0,0 +1,353 @@ + + + + + diff --git a/src/components/system/__tests__/SystemUpdatePrompt.spec.ts b/src/components/system/__tests__/SystemUpdatePrompt.spec.ts new file mode 100644 index 00000000..cb901fa6 --- /dev/null +++ b/src/components/system/__tests__/SystemUpdatePrompt.spec.ts @@ -0,0 +1,131 @@ +import SystemUpdatePrompt from '@/components/system/SystemUpdatePrompt.vue' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + confirm: vi.fn(), + finishRestart: vi.fn(), + get: vi.fn(), + post: vi.fn(), + startRestart: vi.fn(), + toastError: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: { + get: mocks.get, + post: mocks.post, + }, +})) +vi.mock('@/composables/useConfirm', () => ({ useConfirm: () => ({ createConfirm: mocks.confirm }) })) +vi.mock('@/composables/useSystemRestart', () => ({ + useSystemRestartStatus: () => ({ + finishSystemRestart: mocks.finishRestart, + startSystemRestart: mocks.startRestart, + }), +})) +vi.mock('vue-toastification', () => ({ useToast: () => ({ error: mocks.toastError }) })) +vi.mock('vue-i18n', async importOriginal => ({ + ...(await importOriginal()), + useI18n: () => ({ t: (key: string) => key }), +})) + +const availableStatus = { + state: 'available', + current_version: 'v3.0.0', + version: 'v3.1.0', + frontend_version: null, + downloaded_bytes: 0, + total_bytes: 0, + progress: 0, + can_update: true, + can_install: false, +} + +describe('SystemUpdatePrompt', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.confirm.mockResolvedValue(true) + mocks.get.mockResolvedValue(availableStatus) + }) + + it('asks an administrator to start the background download', async () => { + mocks.post.mockResolvedValue({ + ...availableStatus, + state: 'downloading', + downloaded_bytes: 5 * 1024 * 1024, + total_bytes: 20 * 1024 * 1024, + progress: 25, + }) + await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } }) + + expect(await screen.findByText('systemUpdate.availableTitle')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: /systemUpdate.updateNow/ })) + + await waitFor(() => expect(mocks.post).toHaveBeenCalledWith('system/update/download')) + expect(screen.getByText('25%')).toBeInTheDocument() + expect(screen.getByText('5.0 MB / 20.0 MB')).toBeInTheDocument() + }) + + it('requires confirmation before restarting with a prepared update', async () => { + mocks.get.mockResolvedValue({ + ...availableStatus, + state: 'ready', + downloaded_bytes: 20 * 1024 * 1024, + total_bytes: 20 * 1024 * 1024, + progress: 100, + can_update: false, + can_install: true, + }) + mocks.post.mockResolvedValue(null) + await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } }) + + await fireEvent.click(await screen.findByRole('button', { name: /systemUpdate.restartNow/ })) + + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce()) + expect(mocks.post).toHaveBeenCalledWith('system/update/install') + expect(mocks.startRestart).toHaveBeenCalledOnce() + expect(screen.getByText('systemUpdate.installing')).toBeInTheDocument() + }) + + it('does not query update state without administrator permission', async () => { + await renderWithProviders(SystemUpdatePrompt, { props: { enabled: false } }) + await Promise.resolve() + expect(mocks.get).not.toHaveBeenCalled() + }) + + it('moves above the Agent assistant when both prompts are enabled', async () => { + await renderWithProviders(SystemUpdatePrompt, { + props: { avoidAgentAssistant: true, enabled: true }, + }) + + const title = await screen.findByText('systemUpdate.availableTitle') + expect(title.closest('.system-update-prompt')).toHaveClass('system-update-prompt--avoid-agent') + }) + + it('snoozes the current version for 24 hours', async () => { + await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } }) + + await fireEvent.click(await screen.findByRole('button', { name: /systemUpdate.later/ })) + + await waitFor(() => expect(screen.queryByText('systemUpdate.availableTitle')).not.toBeInTheDocument()) + const saved = JSON.parse(localStorage.getItem('moviepilot.system-update-reminder') || '{}') + expect(saved.version).toBe('v3.1.0') + expect(saved.snoozedUntil).toBeGreaterThan(Date.now() + 23 * 60 * 60 * 1000) + }) + + it('ignores only the selected version', async () => { + const view = await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } }) + await fireEvent.click(await screen.findByRole('button', { name: /systemUpdate.moreActions/ })) + await fireEvent.click(await screen.findByText('systemUpdate.ignoreVersion')) + await waitFor(() => expect(screen.queryByText('systemUpdate.availableTitle')).not.toBeInTheDocument()) + + view.unmount() + mocks.get.mockResolvedValue({ ...availableStatus, version: 'v3.2.0' }) + await renderWithProviders(SystemUpdatePrompt, { props: { enabled: true } }) + + expect(await screen.findByText('systemUpdate.availableTitle')).toBeInTheDocument() + }) +}) diff --git a/src/layouts/default/components/DefaultLayout.vue b/src/layouts/default/components/DefaultLayout.vue index 4c22d0ef..69fe049e 100644 --- a/src/layouts/default/components/DefaultLayout.vue +++ b/src/layouts/default/components/DefaultLayout.vue @@ -38,6 +38,7 @@ import { type ThemeCustomizerSettings, } from '@/composables/useThemeCustomizer' import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue' +import SystemUpdatePrompt from '@/components/system/SystemUpdatePrompt.vue' const display = useDisplay() // PWA模式检测 @@ -512,6 +513,7 @@ onMounted(async () => {