From 76ec949bc1727c08a4c068d2b0fbec82c3953984 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 20 Oct 2026 17:01:46 +0200 Subject: [PATCH 1/3] =?UTF-8?q?perf(webui):=20=E4=B8=8B=E8=BD=BD=E5=99=A8?= =?UTF-8?q?=E9=A1=B5=E6=94=B9=E4=B8=BA=E5=8D=95=E4=B8=AA=E8=99=9A=E6=8B=9F?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E8=A1=A8=EF=BC=8C=E5=88=86=E7=BB=84=E4=BD=9C?= =?UTF-8?q?=E4=B8=BA=E5=8F=AF=E6=8A=98=E5=8F=A0=E7=9A=84=E8=A1=A8=E5=A4=B4?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按保存路径分组的多个 ab-fold-panel + NDataTable 合并为一个 virtual-scroll + flex-height 的 NDataTable, 只渲染可见行;表格不再放在 NCollapseItem 里,SSE 推送的新数据能刷新行 - 分组以树形父行显示(名称与数量),复选框级联勾选整组,可折叠,默认展开 - 选中项改为 Set(shallowRef),成员判断 O(1);移除未使用的 toggleKey/toggleGroup - 名称列用 CSS 省略号 + title,不再逐行测量宽度 20k 种子(prod 构建):就绪 15.1 s → 0.14 s,堆 3031 MB → 19 MB,KeepAlive 驱逐后重挂载不再崩溃 --- webui/src/pages/index/downloader.test.ts | 158 ++++++++++++++++ webui/src/pages/index/downloader.vue | 184 +++++++++++++------ webui/src/store/__tests__/downloader.test.ts | 8 +- webui/src/store/downloader.ts | 33 +--- 4 files changed, 290 insertions(+), 93 deletions(-) create mode 100644 webui/src/pages/index/downloader.test.ts diff --git a/webui/src/pages/index/downloader.test.ts b/webui/src/pages/index/downloader.test.ts new file mode 100644 index 00000000..b62fbe1c --- /dev/null +++ b/webui/src/pages/index/downloader.test.ts @@ -0,0 +1,158 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { ref } from 'vue'; +import type { Component } from 'vue'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useConfigStore } from '@/store/config'; +import { useDownloaderStore } from '@/store/downloader'; +import { mockConfig } from '@/test/mocks/api'; +import type { Config } from '#/config'; +import type { QbTorrentInfo } from '#/downloader'; + +// happy-dom 没有布局:给虚拟列表一个固定视口高度,否则一行都不渲染。 +// 必须在 naive-ui(vueuc 的 ResizeObserver 单例)加载前替换 +vi.hoisted(() => { + class FixedResizeObserver { + callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + + observe(target: Element) { + const entry = { target, contentRect: { width: 1000, height: 600 } }; + this.callback( + [entry as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver + ); + } + + unobserve() {} + disconnect() {} + } + globalThis.ResizeObserver = FixedResizeObserver; +}); + +const downloaderData = ref(null); + +vi.mock('@/hooks/useEventStream', () => ({ + useEventStream: () => ({ connected: ref(true), downloaderData }), +})); + +vi.mock('@/utils/axios', () => ({ + axios: { get: vi.fn(), post: vi.fn() }, +})); + +vi.mock('@/api/downloader', () => ({ + apiDownloader: { + getTorrents: vi.fn().mockResolvedValue([]), + instances: vi.fn().mockResolvedValue({ default: '', instances: [] }), + }, +})); + +vi.mock('@/hooks/useMyI18n', () => ({ + useMyI18n: () => ({ + t: (key: string) => key, + returnUserLangMsg: () => '', + }), +})); + +vi.mock('@/hooks/useMessage', () => ({ + useMessage: () => ({ success: vi.fn(), error: vi.fn() }), +})); + +function torrent( + hash: string, + savePath: string, + progress = 0.5 +): QbTorrentInfo { + return { + hash, + name: `torrent-${hash}`, + size: 1024, + progress, + dlspeed: 0, + upspeed: 0, + num_seeds: 0, + num_leechs: 0, + state: 'downloading', + eta: 0, + category: 'Bangumi', + save_path: savePath, + added_on: 0, + downloader_id: 'default', + }; +} + +describe('Downloader page', () => { + let DownloaderPage: Component; + + beforeAll(async () => { + vi.stubGlobal('definePage', vi.fn()); + // vitest 的自动导入不含 @vueuse/core + vi.stubGlobal('useIntervalFn', () => ({ pause: vi.fn(), resume: vi.fn() })); + DownloaderPage = (await import('./downloader.vue')).default; + }); + + beforeEach(() => { + setActivePinia(createPinia()); + useConfigStore().config = mockConfig as unknown as Config; + downloaderData.value = null; + }); + + async function mountPage() { + const wrapper = mount(DownloaderPage, { + attachTo: document.body, + global: { stubs: { 'ab-button': true, RouterLink: true } }, + }); + // 首帧在挂载后到达,与真实 SSE 一致(store 的 watch 不是 immediate) + downloaderData.value = [ + torrent('a1', '/dl/Show A'), + torrent('a2', '/dl/Show A'), + torrent('b1', '/dl/Show B'), + ]; + await flushPromises(); + return wrapper; + } + + function rowOf(wrapper: Awaited>, text: string) { + const row = wrapper + .findAll('.n-data-table-tbody .n-data-table-tr') + .find((r) => r.text().includes(text)); + if (!row) throw new Error(`row not found: ${text}`); + return row; + } + + it('should update the row when the store receives a new SSE payload', async () => { + const wrapper = await mountPage(); + expect(rowOf(wrapper, 'torrent-a1').text()).toContain('50%'); + + downloaderData.value = [torrent('a1', '/dl/Show A', 1)]; + await flushPromises(); + + expect(rowOf(wrapper, 'torrent-a1').text()).toContain('100%'); + }); + + it('should select every torrent of the group when its header checkbox is checked', async () => { + const wrapper = await mountPage(); + + await rowOf(wrapper, 'Show A (2)').find('.n-checkbox').trigger('click'); + + expect([...useDownloaderStore().selectedKeys].sort()).toEqual([ + 'default:a1', + 'default:a2', + ]); + expect(wrapper.find('.action-bar-count').text()).toContain('2'); + }); + + it('should hide the group rows when the group is collapsed', async () => { + const wrapper = await mountPage(); + + await rowOf(wrapper, 'Show A (2)') + .find('.n-data-table-expand-trigger') + .trigger('click'); + + const text = wrapper.find('.n-data-table-tbody').text(); + expect(text).not.toContain('torrent-a1'); + expect(text).toContain('torrent-b1'); + }); +}); diff --git a/webui/src/pages/index/downloader.vue b/webui/src/pages/index/downloader.vue index 04ca376d..b4e9e200 100644 --- a/webui/src/pages/index/downloader.vue +++ b/webui/src/pages/index/downloader.vue @@ -1,5 +1,10 @@