Merge branch 'feat/4.0-perf-webui' into feat/4.0-perf

This commit is contained in:
Estrella Pan
2026-10-20 17:49:38 +02:00
16 changed files with 559 additions and 121 deletions

View File

@@ -200,18 +200,40 @@ const posterSrc = computed(() => resolvePosterUrl(bangumi.poster_link));
display: block;
}
// Card glow animation when needs review - yellow
// 待审核的黄色光晕:静态 box-shadow 放在 ::after 上,只动画 opacity(合成器线程,
// 不触发每帧重绘)。光晕在海报外侧,所以这里取消 overflow 裁剪,由子元素继承圆角
.card-poster--needs-review {
animation: card-glow 2.5s ease-in-out infinite;
overflow: visible;
> * {
border-radius: inherit;
}
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 0 16px 4px rgba(251, 191, 36, 0.6);
pointer-events: none;
animation: card-glow 2.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
&::after {
animation: none;
opacity: 0.5;
}
}
}
@keyframes card-glow {
0%,
100% {
box-shadow: var(--shadow-md), 0 0 0 0 rgba(251, 191, 36, 0);
opacity: 0;
}
50% {
box-shadow: var(--shadow-md), 0 0 16px 4px rgba(251, 191, 36, 0.6);
opacity: 1;
}
}

View File

@@ -1,9 +1,13 @@
<script lang="ts" setup>
import AbSearchModal from './search/ab-search-modal.vue';
const { showModal, provider, loading } = storeToRefs(useSearchStore());
const { toggleModal, getProviders } = useSearchStore();
// 搜索弹窗首次打开时才加载
const { comp: SearchModal } = useLazyModal(
showModal,
() => import('./search/ab-search-modal.vue')
);
onMounted(() => {
getProviders();
});
@@ -18,5 +22,5 @@ onMounted(() => {
/>
<!-- Search Modal -->
<AbSearchModal @close="toggleModal" />
<component :is="SearchModal" v-if="SearchModal" @close="toggleModal" />
</template>

View File

@@ -18,6 +18,10 @@ const { isMobile } = useBreakpointQuery();
const showAccount = ref(false);
const rssRule = ref(ruleTemplate);
const { comp: AddRss, shown: addRssShown } = useLazyModal(
showAddRSS,
() => import('@/components/ab-add-rss.vue')
);
const { start, pause, shutdown, restart, resetRule } = useProgramStore();
const { refreshPoster } = useBangumiStore();
@@ -138,7 +142,13 @@ onUnmounted(() => {
</div>
<ab-change-account v-model:show="showAccount" />
<ab-add-rss v-model:show="showAddRSS" v-model:rule="rssRule" />
<component
:is="AddRss"
v-if="AddRss"
v-model:rule="rssRule"
:show="addRssShown"
@update:show="showAddRSS = $event"
/>
</div>
</template>

View File

@@ -615,12 +615,12 @@ function handleFilterClick(
<template>
<Teleport to="body">
<!-- Backdrop -->
<transition name="overlay">
<transition name="overlay" appear>
<div v-if="showModal" class="modal-backdrop" />
</transition>
<!-- Modal -->
<transition name="modal">
<transition name="modal" appear>
<div
v-if="showModal"
class="modal-container"

View File

@@ -4,7 +4,7 @@
* store) when available.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { withSetup } from './test-utils';
@@ -59,12 +59,31 @@ async function freshEventStream() {
}
describe('useEventStream', () => {
// 单例在模块级注册 visibilitychange 监听且从不卸载;逐测试移除,避免旧实例串扰
const visibilityHandlers: EventListener[] = [];
const realAddEventListener = document.addEventListener.bind(document);
beforeEach(() => {
vi.spyOn(document, 'addEventListener').mockImplementation(
(type: string, handler: unknown, opts?: unknown) => {
if (type === 'visibilitychange') {
visibilityHandlers.push(handler as EventListener);
}
realAddEventListener(type, handler as EventListener, opts as never);
}
);
isLoggedIn.value = false;
MockEventSource.instances = [];
vi.stubGlobal('EventSource', MockEventSource);
});
afterEach(() => {
for (const h of visibilityHandlers.splice(0)) {
document.removeEventListener('visibilitychange', h);
}
vi.restoreAllMocks();
});
it('should open a connection to the events stream when logged in', async () => {
isLoggedIn.value = true;
const useEventStream = await freshEventStream();
@@ -176,4 +195,90 @@ describe('useEventStream', () => {
expect(second).toHaveBeenCalledWith(1);
});
it('should append log_append frames and replace on a log frame', async () => {
isLoggedIn.value = true;
const useEventStream = await freshEventStream();
const result = withSetup(() => useEventStream());
const es = MockEventSource.instances.at(-1)!;
es.emit('log_append', 'a\n');
expect(result.logData.value).toBe('a\n');
es.emit('log', 'snapshot\n');
es.emit('log_append', 'b\n');
expect(result.logData.value).toBe('snapshot\nb\n');
es.emit('log', 'rotated\n');
expect(result.logData.value).toBe('rotated\n');
});
it('should trim the oldest lines when appended log exceeds the cap', async () => {
isLoggedIn.value = true;
const useEventStream = await freshEventStream();
const result = withSetup(() => useEventStream());
const es = MockEventSource.instances.at(-1)!;
const line = `${'x'.repeat(99)}\n`;
es.emit('log', line.repeat(5000));
es.emit('log_append', 'tail\n');
const text = result.logData.value!;
expect(text.length).toBeLessThanOrEqual(512 * 1024);
expect(text.endsWith('x\ntail\n')).toBe(true);
expect(text.startsWith(`${'x'.repeat(99)}\n`)).toBe(true);
});
describe('hidden tab', () => {
let hidden = false;
function setHidden(value: boolean) {
hidden = value;
document.dispatchEvent(new Event('visibilitychange'));
}
beforeEach(() => {
vi.useFakeTimers();
hidden = false;
vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden);
});
afterEach(() => {
vi.useRealTimers();
});
it('should close the stream after 30 s hidden and reconnect on visible', async () => {
isLoggedIn.value = true;
const useEventStream = await freshEventStream();
const result = withSetup(() => useEventStream());
const es = MockEventSource.instances.at(-1)!;
es.onopen?.();
// 旧测试遗留的单例也会连接,故以当前数量为基线
const baseline = MockEventSource.instances.length;
setHidden(true);
vi.advanceTimersByTime(30_000);
expect(es.closed).toBe(true);
expect(result.connected.value).toBe(false);
// 隐藏期间不会重连
vi.advanceTimersByTime(60_000);
expect(MockEventSource.instances).toHaveLength(baseline);
setHidden(false);
expect(MockEventSource.instances).toHaveLength(baseline + 1);
expect(MockEventSource.instances.at(-1)!.closed).toBe(false);
});
it('should keep the stream when hidden for less than 30 s', async () => {
isLoggedIn.value = true;
const useEventStream = await freshEventStream();
withSetup(() => useEventStream());
const es = MockEventSource.instances.at(-1)!;
const baseline = MockEventSource.instances.length;
setHidden(true);
vi.advanceTimersByTime(29_000);
setHidden(false);
vi.advanceTimersByTime(60_000);
expect(es.closed).toBe(false);
expect(MockEventSource.instances).toHaveLength(baseline);
});
});
});

View File

@@ -22,6 +22,10 @@ export interface BusEvent {
const RECONNECT_BASE_DELAY_MS = 1000;
const RECONNECT_MAX_DELAY_MS = 15000;
/** 标签页持续隐藏超过该时长后关闭流,可见时重连(服务端重发快照) */
const HIDDEN_CLOSE_DELAY_MS = 30000;
/** 累积日志文本的上限,与后端 /log 的尾部读取预算一致 */
const LOG_MAX_CHARS = 512 * 1024;
/**
* 单一 SSE 连接(api/v1/events/stream),推送 status/downloader/log 更新,
@@ -56,6 +60,9 @@ export const useEventStream = createSharedComposable(() => {
let source: EventSource | null = null;
let retryCount = 0;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let hiddenTimer: ReturnType<typeof setTimeout> | undefined;
/** 因标签页长时间隐藏而主动关闭;此状态下不重连,直到页面可见 */
let suspended = false;
function teardown() {
source?.close();
@@ -71,7 +78,7 @@ export const useEventStream = createSharedComposable(() => {
);
retryCount += 1;
retryTimer = setTimeout(() => {
if (isLoggedIn.value) connect();
if (isLoggedIn.value && !suspended) connect();
}, delay);
}
@@ -111,6 +118,17 @@ export const useEventStream = createSharedComposable(() => {
logData.value = (e as MessageEvent).data;
});
es.addEventListener('log_append', (e) => {
const text = (logData.value ?? '') + (e as MessageEvent).data;
if (text.length <= LOG_MAX_CHARS) {
logData.value = text;
return;
}
// 超出上限时丢弃最旧部分,并对齐到下一行起点
const tail = text.slice(text.length - LOG_MAX_CHARS);
logData.value = tail.slice(tail.indexOf('\n') + 1);
});
es.addEventListener('update', (e) => {
try {
updateData.value = JSON.parse((e as MessageEvent).data);
@@ -153,10 +171,28 @@ export const useEventStream = createSharedComposable(() => {
function stop() {
clearTimeout(retryTimer);
clearTimeout(hiddenTimer);
suspended = false;
retryCount = 0;
teardown();
}
function onVisibilityChange() {
clearTimeout(hiddenTimer);
if (document.hidden) {
hiddenTimer = setTimeout(() => {
suspended = true;
clearTimeout(retryTimer);
teardown();
}, HIDDEN_CLOSE_DELAY_MS);
} else if (suspended) {
suspended = false;
retryCount = 0;
if (isLoggedIn.value) connect();
}
}
document.addEventListener('visibilitychange', onVisibilityChange);
// 生命周期跟随登录状态,而非某个调用方组件的挂载/卸载——这是一个跨组件
// 共享的单例连接(createSharedComposable),不能绑定到任意一个消费者的
// onBeforeUnmount 上。

View File

@@ -0,0 +1,28 @@
import type { Component, Ref } from 'vue';
/**
* 弹窗首次打开时才加载并挂载。
* `comp` 就绪后才渲染,`shown` 在挂载后的下一拍再置为 true,
* 这样 NModal 的进入过渡(appear)仍然生效。
*/
export function useLazyModal(
open: Ref<boolean>,
load: () => Promise<{ default: Component }>
) {
const comp = shallowRef<Component>();
const shown = ref(false);
watch(
open,
async (val) => {
if (val && !comp.value) {
comp.value = (await load()).default;
await nextTick();
}
shown.value = open.value;
},
{ immediate: true }
);
return { comp, shown };
}

View File

@@ -1,15 +1,9 @@
import { createI18n } from 'vue-i18n';
import { createSharedComposable, useLocalStorage } from '@vueuse/core';
import enUS from '@/i18n/en.json';
import zhCN from '@/i18n/zh-CN.json';
import type { ApiSuccess } from '#/api';
const messages = {
en: enUS,
'zh-CN': zhCN,
};
type Languages = keyof typeof messages;
type Languages = 'en' | 'zh-CN';
function normalizeLocale(locale: string): Languages {
if (locale.startsWith('zh')) return 'zh-CN';
@@ -20,19 +14,39 @@ export const i18n = createI18n({
legacy: false,
locale: normalizeLocale(navigator.language),
fallbackLocale: 'en',
messages,
// zh-CN 先放空壳以固定语言类型,真正的消息由 loadLocale 载入
messages: { en: enUS, 'zh-CN': {} },
});
// 默认语言(en)静态打包;其它语言首次使用时才加载
const loaded = new Set<Languages>(['en']);
export async function loadLocale(locale: Languages) {
if (loaded.has(locale)) return;
const { default: msgs } = await import('@/i18n/zh-CN.json');
i18n.global.setLocaleMessage(locale, msgs);
loaded.add(locale);
}
/** 首次渲染前预载已保存的语言,避免先闪出英文 */
export function loadSavedLocale() {
return loadLocale(
normalizeLocale(localStorage.getItem('lang') ?? navigator.language)
);
}
export const useMyI18n = createSharedComposable(() => {
const lang = useLocalStorage<Languages>(
'lang',
normalizeLocale(navigator.language)
);
i18n.global.locale.value = lang.value as unknown as Languages;
i18n.global.locale.value = lang.value;
watch(lang, (val) => {
i18n.global.locale.value = val as unknown as Languages;
// 先加载语言包再切换,切换期间沿用旧语言
watch(lang, async (val) => {
await loadLocale(val);
i18n.global.locale.value = val;
});
function changeLocale() {

View File

@@ -1,7 +1,7 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { router } from './router';
import { i18n } from './hooks/useMyI18n';
import { i18n, loadSavedLocale } from './hooks/useMyI18n';
import App from './App.vue';
import '@unocss/reset/tailwind-compat.css';
@@ -13,4 +13,5 @@ const app = createApp(App);
app.use(router);
app.use(pinia);
app.use(i18n);
app.mount('#app');
// 已保存的语言包加载完再渲染,避免先闪出英文
loadSavedLocale().then(() => app.mount('#app'));

View File

@@ -8,6 +8,14 @@ definePage({
});
const { editRule } = storeToRefs(useBangumiStore());
const editRuleOpen = computed({
get: () => editRule.value.show,
set: (val) => (editRule.value.show = val),
});
const { comp: EditRule, shown: editRuleShown } = useLazyModal(
editRuleOpen,
() => import('@/components/ab-edit-rule.vue')
);
// 插件页面的标题取自清单(清单到达前留空,避免闪出路由名 Plugin;清单已到达
// 或加载失败仍无匹配页面时回退为插件 id);插件市场用翻译后的标题,其它页面沿用路由名
@@ -74,9 +82,12 @@ function onApplyRule(rule: BangumiRule) {
</div>
</main>
<ab-edit-rule
v-model:show="editRule.show"
<component
:is="EditRule"
v-if="EditRule"
v-model:rule="editRule.item"
:show="editRuleShown"
@update:show="editRuleOpen = $event"
@enable="onEnableRule"
@archive="onArchiveRule"
@unarchive="onUnarchiveRule"

View File

@@ -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<QbTorrentInfo[] | null>(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<ReturnType<typeof mountPage>>, 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');
});
});

View File

@@ -1,5 +1,10 @@
<script lang="ts" setup>
import { type DataTableColumns, NDataTable } from 'naive-ui';
import type { VNodeChild } from 'vue';
import {
type DataTableColumns,
type DataTableRowKey,
NDataTable,
} from 'naive-ui';
import AbProgress from '@/components/basic/ab-progress.vue';
import AbTag from '@/components/basic/ab-tag.vue';
import { useConfirm } from '@/hooks/useConfirm';
@@ -20,8 +25,6 @@ const {
pauseSelected,
resumeSelected,
deleteSelected,
toggleKey,
toggleGroup,
clearSelection,
} = useDownloaderStore();
const { confirm } = useConfirm();
@@ -120,30 +123,89 @@ function stateType(state: string): 'neutral' | 'success' | 'info' | 'danger' {
return 'info';
}
function isGroupAllSelected(group: TorrentGroup): boolean {
return group.torrents.every((t) =>
selectedKeys.value.includes(torrentKey(t))
/** 分组头行:同一个虚拟表里以树形父行显示,children 为该组的种子 */
interface GroupRow {
key: string;
group: TorrentGroup;
children: QbTorrentInfo[];
}
type Row = QbTorrentInfo | GroupRow;
function isGroupRow(row: Row): row is GroupRow {
return 'group' in row;
}
function rowKey(row: Row): string {
return isGroupRow(row) ? row.key : torrentKey(row);
}
function rowClassName(row: Row): string {
return isGroupRow(row) ? 'group-row' : '';
}
/** 分组头行的名称格跨满其余列,种子列的渲染只处理种子行 */
function torrentCell(render: (row: QbTorrentInfo) => VNodeChild) {
return (row: Row) => (isGroupRow(row) ? null : render(row));
}
const rows = computed<GroupRow[]>(() =>
groups.value.map((group) => ({
key: `group:${group.savePath}`,
group,
children: group.torrents,
}))
);
// 记录折叠的分组,新出现的分组默认展开
const collapsed = shallowRef(new Set<string>());
const expandedKeys = computed(() =>
rows.value.filter((r) => !collapsed.value.has(r.key)).map((r) => r.key)
);
function onExpandedChange(keys: DataTableRowKey[]) {
const open = new Set(keys);
collapsed.value = new Set(
rows.value.map((r) => r.key).filter((k) => !open.has(k))
);
}
const checkedKeys = computed(() => [...selectedKeys.value]);
// 级联勾选会带上分组头的键,只保留种子键
function onCheckedChange(keys: DataTableRowKey[]) {
const groupKeys = new Set(rows.value.map((r) => r.key));
selectedKeys.value = new Set(
keys.filter((k) => !groupKeys.has(k as string)) as string[]
);
}
// 多个下载器实例时标出每个种子所在的实例
const downloaders = useDownloaderInstances();
const tableColumnsValue = computed<DataTableColumns<QbTorrentInfo>>(() => [
const tableColumnsValue = computed<DataTableColumns<Row>>(() => [
{
type: 'selection',
},
{
title: t('downloader.torrent.name'),
key: 'name',
ellipsis: { tooltip: true },
// CSS 省略号 + title:不用逐行测量宽度的 tooltip
ellipsis: true,
className: 'name-col',
minWidth: 200,
colSpan: (row: Row): number =>
isGroupRow(row) ? tableColumnsValue.value.length - 1 : 1,
cellProps: (row) => ({
title: isGroupRow(row) ? row.group.savePath : row.name,
}),
render: (row) =>
isGroupRow(row) ? `${row.group.name} (${row.group.count})` : row.name,
},
{
title: t('downloader.torrent.progress'),
key: 'progress',
width: 160,
render(row: QbTorrentInfo) {
render: torrentCell((row) => {
return h(AbProgress, {
value: row.progress * 100,
label: `${Math.round(row.progress * 100)}%`,
@@ -152,58 +214,58 @@ const tableColumnsValue = computed<DataTableColumns<QbTorrentInfo>>(() => [
? 'error'
: 'active',
});
},
}),
},
{
title: t('downloader.torrent.status'),
key: 'state',
width: 100,
render(row: QbTorrentInfo) {
render: torrentCell((row) => {
return h(AbTag, {
type: stateType(row.state),
title: stateLabel(row.state),
});
},
}),
},
{
title: t('downloader.torrent.size'),
key: 'size',
width: 100,
render(row: QbTorrentInfo) {
render: torrentCell((row) => {
return formatSize(row.size);
},
}),
},
{
title: t('downloader.torrent.dlspeed'),
key: 'dlspeed',
width: 110,
render(row: QbTorrentInfo) {
render: torrentCell((row) => {
return formatSpeed(row.dlspeed);
},
}),
},
{
title: t('downloader.torrent.upspeed'),
key: 'upspeed',
width: 110,
render(row: QbTorrentInfo) {
render: torrentCell((row) => {
return formatSpeed(row.upspeed);
},
}),
},
{
title: 'ETA',
key: 'eta',
width: 80,
render(row: QbTorrentInfo) {
render: torrentCell((row) => {
return formatEta(row.eta);
},
}),
},
{
title: t('downloader.torrent.peers'),
key: 'peers',
width: 110,
render(row: QbTorrentInfo) {
render: torrentCell((row) => {
return `${row.num_seeds} / ${row.num_leechs}`;
},
}),
},
// 放在最后并完整显示 id(触屏没有悬停提示)
...(downloaders.multiple.value
@@ -216,20 +278,6 @@ const tableColumnsValue = computed<DataTableColumns<QbTorrentInfo>>(() => [
]
: []),
]);
function onCheckedChange(group: TorrentGroup, keys: string[]) {
const groupKeys = group.torrents.map(torrentKey);
const otherSelected = selectedKeys.value.filter(
(k) => !groupKeys.includes(k)
);
selectedKeys.value = [...otherSelected, ...keys];
}
function groupCheckedKeys(group: TorrentGroup): string[] {
return group.torrents
.map(torrentKey)
.filter((k) => selectedKeys.value.includes(k));
}
</script>
<template>
@@ -302,30 +350,29 @@ function groupCheckedKeys(group: TorrentGroup): string[] {
{{ $t('downloader.empty_torrents') }}
</div>
<div v-else class="downloader-groups">
<ab-fold-panel
v-for="group in groups"
:key="group.savePath"
:title="`${group.name} (${group.count})`"
:default-open="true"
>
<NDataTable
:columns="tableColumnsValue"
:data="group.torrents"
:row-key="torrentKey"
:pagination="false"
:bordered="false"
:checked-row-keys="groupCheckedKeys(group)"
size="small"
@update:checked-row-keys="(keys: any) => onCheckedChange(group, keys as string[])"
/>
</ab-fold-panel>
</div>
<!-- 单个虚拟滚动表:只渲染可见行;不放进 NCollapseItem,SSE 数据才能刷新行 -->
<NDataTable
v-else
class="downloader-table"
:columns="tableColumnsValue"
:data="rows"
:row-key="rowKey"
:row-class-name="rowClassName"
:pagination="false"
:bordered="false"
:checked-row-keys="checkedKeys"
:expanded-row-keys="expandedKeys"
virtual-scroll
flex-height
size="small"
@update:checked-row-keys="onCheckedChange"
@update:expanded-row-keys="onExpandedChange"
/>
<Transition name="fade">
<div v-if="selectedKeys.length > 0" class="action-bar">
<div v-if="selectedKeys.size > 0" class="action-bar">
<span class="action-bar-count">
{{ selectedKeys.length }} {{ $t('downloader.selected') }}
{{ selectedKeys.size }} {{ $t('downloader.selected') }}
</span>
<div class="action-bar-buttons">
<ab-button variant="primary" size="sm" @click="resumeSelected">
@@ -354,6 +401,7 @@ function groupCheckedKeys(group: TorrentGroup): string[] {
.page-downloader {
overflow: auto;
flex-grow: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
@@ -362,14 +410,28 @@ function groupCheckedKeys(group: TorrentGroup): string[] {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
gap: 12px;
padding-bottom: 60px;
}
.downloader-groups {
display: flex;
flex-direction: column;
gap: 12px;
// flex-height:表格填满页面剩余高度,虚拟滚动在表体内进行
.downloader-table {
flex: 1;
min-height: 0;
:deep(.group-row .n-data-table-td) {
font-weight: 600;
}
// 树形缩进与展开按钮占 40px,名称不换行到下一行
:deep(.name-col) {
white-space: nowrap;
}
:deep(.name-col .n-data-table-td__ellipsis) {
max-width: calc(100% - 40px);
}
}
.downloader-empty {

View File

@@ -59,7 +59,7 @@ describe('downloader store', () => {
torrent('a2', 'default'),
torrent('a3', 'default'),
];
store.selectedKeys = store.torrents.slice(0, 3).map(torrentKey);
store.selectedKeys = new Set(store.torrents.slice(0, 3).map(torrentKey));
await store.pauseSelected();
await store.deleteSelected(true);
@@ -77,7 +77,7 @@ describe('downloader store', () => {
it('should act only on the selected instance when the same hash is in two instances', async () => {
const store = useDownloaderStore();
store.torrents = [torrent('h', 'home'), torrent('h', 'seedbox')];
store.selectedKeys = [torrentKey(store.torrents[1])];
store.selectedKeys = new Set([torrentKey(store.torrents[1])]);
await store.pauseSelected();
@@ -98,7 +98,7 @@ describe('downloader store', () => {
async (_, failure) => {
const store = useDownloaderStore();
store.torrents = [torrent('a', 'home'), torrent('b', 'seedbox')];
store.selectedKeys = store.torrents.map(torrentKey);
store.selectedKeys = new Set(store.torrents.map(torrentKey));
vi.mocked(apiDownloader.deleteTorrents).mockImplementationOnce(failure);
await expect(store.deleteSelected(false)).rejects.toBeTruthy();
@@ -113,7 +113,7 @@ describe('downloader store', () => {
it('should show the failure message when an instance returns status false', async () => {
const store = useDownloaderStore();
store.torrents = [torrent('a', 'home')];
store.selectedKeys = store.torrents.map(torrentKey);
store.selectedKeys = new Set(store.torrents.map(torrentKey));
vi.mocked(apiDownloader.deleteTorrents).mockResolvedValueOnce({
status: false,
msg_en: 'Failed to delete torrents',

View File

@@ -14,7 +14,8 @@ export function hasConfiguredDownloader(instances: PluginInstance[]): boolean {
export const useDownloaderStore = defineStore('downloader', () => {
const torrents = shallowRef<QbTorrentInfo[]>([]);
const selectedKeys = ref<string[]>([]);
// Set 而不是响应式数组:成员判断 O(1),整体替换触发更新
const selectedKeys = shallowRef(new Set<string>());
const loading = ref(false);
const { downloaderData } = useEventStream();
@@ -82,7 +83,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
const opts = {
showMessage: true,
onSuccess() {
selectedKeys.value = [];
selectedKeys.value = new Set();
},
onFinally: getAll,
};
@@ -97,7 +98,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
) {
const byInstance = new Map<string, string[]>();
for (const t of torrents.value) {
if (!selectedKeys.value.includes(torrentKey(t))) continue;
if (!selectedKeys.value.has(torrentKey(t))) continue;
const hashes = byInstance.get(t.downloader_id) ?? [];
hashes.push(t.hash);
byInstance.set(t.downloader_id, hashes);
@@ -134,30 +135,8 @@ export const useDownloaderStore = defineStore('downloader', () => {
opts
);
function toggleKey(key: string) {
const idx = selectedKeys.value.indexOf(key);
if (idx === -1) {
selectedKeys.value.push(key);
} else {
selectedKeys.value.splice(idx, 1);
}
}
function toggleGroup(group: TorrentGroup) {
const groupKeys = group.torrents.map(torrentKey);
const allSelected = groupKeys.every((k) => selectedKeys.value.includes(k));
if (allSelected) {
selectedKeys.value = selectedKeys.value.filter(
(k) => !groupKeys.includes(k)
);
} else {
const toAdd = groupKeys.filter((k) => !selectedKeys.value.includes(k));
selectedKeys.value.push(...toAdd);
}
}
function clearSelection() {
selectedKeys.value = [];
selectedKeys.value = new Set();
}
return {
@@ -170,8 +149,6 @@ export const useDownloaderStore = defineStore('downloader', () => {
pauseSelected,
resumeSelected,
deleteSelected,
toggleKey,
toggleGroup,
clearSelection,
};
});

View File

@@ -67,6 +67,8 @@ declare global {
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const isValidNexusPhpCategoryIds: typeof import('../../src/utils/nexusphp').isValidNexusPhpCategoryIds
const loadLocale: typeof import('../../src/hooks/useMyI18n').loadLocale
const loadSavedLocale: typeof import('../../src/hooks/useMyI18n').loadSavedLocale
const localizeFields: typeof import('../../src/utils/plugin-text').localizeFields
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
@@ -150,6 +152,7 @@ declare global {
const useI18n: typeof import('vue-i18n').useI18n
const useId: typeof import('vue').useId
const useIntervalFn: typeof import('@vueuse/core').useIntervalFn
const useLazyModal: typeof import('../../src/hooks/useLazyModal').useLazyModal
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
const useLogStore: typeof import('../../src/store/log').useLogStore
const useMessage: typeof import('../../src/hooks/useMessage').useMessage

View File

@@ -46,6 +46,7 @@ export default defineConfig({
}),
VueI18nPlugin({
include: resolve(import.meta.dirname, './src/i18n/**'),
dropMessageCompiler: true,
}),
],
css: {
@@ -61,7 +62,13 @@ export default defineConfig({
output: { minify: { compress: { dropConsole: true } } },
},
},
resolve: { tsconfigPaths: true },
resolve: {
tsconfigPaths: true,
// 只用运行时:模板都在构建期编译,不需要打包 @vue/compiler-*
alias: [
{ find: /^vue$/, replacement: 'vue/dist/vue.runtime.esm-bundler.js' },
],
},
preview: { headers },
server: {
proxy: {