mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-02 21:55:22 +08:00
Merge branch 'feat/4.0-perf-webui' into feat/4.0-perf
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 上。
|
||||
|
||||
28
webui/src/hooks/useLazyModal.ts
Normal file
28
webui/src/hooks/useLazyModal.ts
Normal 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 };
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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"
|
||||
|
||||
158
webui/src/pages/index/downloader.test.ts
Normal file
158
webui/src/pages/index/downloader.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
3
webui/types/dts/auto-imports.d.ts
vendored
3
webui/types/dts/auto-imports.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user