diff --git a/CHANGELOG.md b/CHANGELOG.md
index c01b8d39..d9a7972e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,56 @@
+# [3.2.0-beta.5] - 2026-01-24
+
+## Backend
+
+### Performance
+
+- 新增共享 HTTP 客户端连接池,复用 TCP/SSL 连接,减少每次请求的握手开销
+- RSS 刷新改为并发拉取所有订阅源(`asyncio.gather`),多源场景下速度提升约 10 倍
+- 种子文件下载改为并发获取,下载多个种子时速度提升约 5 倍
+- 重命名模块并发获取所有种子文件列表,速度提升约 20 倍
+- 通知发送改为并发执行,移除 2 秒硬编码延迟
+- 新增 TMDB 和 Mikan 解析结果的内存缓存,避免重复 API 调用
+- 为 `Torrent.url`、`Torrent.rss_id`、`Bangumi.title_raw`、`Bangumi.deleted`、`RSSItem.url` 添加数据库索引
+- RSS 批量启用/禁用改为单次事务操作,替代逐条提交
+- 预编译正则表达式(种子名解析规则、过滤器匹配),避免运行时重复编译
+- `SeasonCollector` 在循环外创建,复用单次认证
+- `check_first_run` 缓存默认配置字典,避免每次创建新对象
+- 通知模块中的同步数据库调用改为 `asyncio.to_thread`,避免阻塞事件循环
+- RSS 解析去重从 O(n²) 列表查找改为 O(1) 集合查找
+- 文件后缀判断使用 `frozenset` 替代列表,提升查找效率
+- `Episode`/`SeasonInfo` 数据类添加 `__slots__`,减少内存占用
+- RSS XML 解析返回元组列表,替代三个独立列表再 zip 的模式
+- qBittorrent 规则设置改为并发执行
+
+## Frontend
+
+### Performance
+
+- 下载器 store 使用 `shallowRef` 替代 `ref`,避免大数组的深层响应式代理
+- 表格列定义改为 `computed`,避免每次渲染重建
+- RSS 表格列与数据分离,数据变化时不重建列配置
+- 日历页移除重复的 `getAll()` 调用
+- `ab-select` 的 `watchEffect` 改为 `watch`,消除挂载时的无效 emit
+- `useClipboard` 提升到 store 顶层,避免每次 `copy()` 创建新实例
+- `setInterval` 替换为 `useIntervalFn`,自动生命周期管理,防止内存泄漏
+- 共享 `ruleTemplate` 对象改为浅拷贝,避免意外的引用共变
+- `ab-add-rss` 移除不必要的 `setTimeout` 延迟
+
+### Fixes
+
+- 修复 `ab-image.vue` 中 `
+
diff --git a/webui/src/components/ab-setting.vue b/webui/src/components/ab-setting.vue
index ff51af9a..a68c0deb 100644
--- a/webui/src/components/ab-setting.vue
+++ b/webui/src/components/ab-setting.vue
@@ -7,6 +7,7 @@ withDefaults(defineProps(), {
bottomLine: false,
});
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = defineModel('data');
diff --git a/webui/src/components/basic/ab-button.vue b/webui/src/components/basic/ab-button.vue
index 4850f34d..6e340b18 100644
--- a/webui/src/components/basic/ab-button.vue
+++ b/webui/src/components/basic/ab-button.vue
@@ -16,7 +16,7 @@ const props = withDefaults(
}
);
-defineEmits(['click']);
+defineEmits<{ click: [] }>();
const buttonSize = computed(() => {
switch (props.size) {
diff --git a/webui/src/components/basic/ab-data-list.vue b/webui/src/components/basic/ab-data-list.vue
index 242e87e5..4698d28f 100644
--- a/webui/src/components/basic/ab-data-list.vue
+++ b/webui/src/components/basic/ab-data-list.vue
@@ -4,13 +4,16 @@ import { ref, computed } from 'vue';
export interface DataListColumn {
key: string;
title: string;
- render?: (row: any) => string;
+ render?: (row: Record) => string;
hidden?: boolean;
}
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type DataItem = Record;
+
const props = withDefaults(
defineProps<{
- items: any[];
+ items: DataItem[];
columns: DataListColumn[];
selectable?: boolean;
keyField?: string;
@@ -22,18 +25,18 @@ const props = withDefaults(
);
const emit = defineEmits<{
- (e: 'select', keys: any[]): void;
- (e: 'action', action: string, item: any): void;
- (e: 'item-click', item: any): void;
+ (e: 'select', keys: unknown[]): void;
+ (e: 'action', action: string, item: DataItem): void;
+ (e: 'item-click', item: DataItem): void;
}>();
-const selectedKeys = ref>(new Set());
+const selectedKeys = ref>(new Set());
const visibleColumns = computed(() =>
props.columns.filter((col) => !col.hidden)
);
-function toggleSelect(key: any) {
+function toggleSelect(key: unknown) {
if (selectedKeys.value.has(key)) {
selectedKeys.value.delete(key);
} else {
@@ -51,7 +54,7 @@ function toggleSelectAll() {
emit('select', Array.from(selectedKeys.value));
}
-function getCellValue(item: any, column: DataListColumn): string {
+function getCellValue(item: DataItem, column: DataListColumn): string {
if (column.render) {
return column.render(item);
}
diff --git a/webui/src/components/basic/ab-search.vue b/webui/src/components/basic/ab-search.vue
index 297c685d..cecef2da 100644
--- a/webui/src/components/basic/ab-search.vue
+++ b/webui/src/components/basic/ab-search.vue
@@ -13,7 +13,7 @@ withDefaults(
}
);
-defineEmits(['select', 'search']);
+defineEmits<{ select: []; search: [] }>();
const inputValue = defineModel('inputValue');
diff --git a/webui/src/components/basic/ab-select.vue b/webui/src/components/basic/ab-select.vue
index 2ba5e7d5..2319c8d7 100644
--- a/webui/src/components/basic/ab-select.vue
+++ b/webui/src/components/basic/ab-select.vue
@@ -57,8 +57,8 @@ function getDisabled(item: SelectItem | string) {
return isString(item) ? false : item.disabled;
}
-watchEffect(() => {
- emit('update:modelValue', selected.value);
+watch(selected, (val) => {
+ emit('update:modelValue', val);
});
diff --git a/webui/src/hooks/useMyI18n.ts b/webui/src/hooks/useMyI18n.ts
index 3a51719a..89fbaf62 100644
--- a/webui/src/hooks/useMyI18n.ts
+++ b/webui/src/hooks/useMyI18n.ts
@@ -11,10 +11,15 @@ const messages = {
type Languages = keyof typeof messages;
+function normalizeLocale(locale: string): Languages {
+ if (locale.startsWith('zh')) return 'zh-CN';
+ return 'en';
+}
+
export const useMyI18n = createSharedComposable(() => {
const lang = useLocalStorage(
'lang',
- navigator.language as Languages
+ normalizeLocale(navigator.language)
);
const i18n = createI18n({
@@ -39,7 +44,7 @@ export const useMyI18n = createSharedComposable(() => {
function returnUserLangText(texts: {
[k in Languages]: string;
}) {
- return texts[lang.value];
+ return texts[lang.value] ?? texts['en'];
}
function returnUserLangMsg(res: ApiSuccess) {
diff --git a/webui/src/pages/index/calendar.vue b/webui/src/pages/index/calendar.vue
index 09d986c2..cc89f98b 100644
--- a/webui/src/pages/index/calendar.vue
+++ b/webui/src/pages/index/calendar.vue
@@ -25,7 +25,6 @@ async function refreshCalendar() {
}
onActivated(() => {
- getAll();
refreshCalendar();
});
diff --git a/webui/src/pages/index/downloader.vue b/webui/src/pages/index/downloader.vue
index 4f3e526e..b2cafc92 100644
--- a/webui/src/pages/index/downloader.vue
+++ b/webui/src/pages/index/downloader.vue
@@ -24,21 +24,18 @@ const isNull = computed(() => {
return config.value.downloader.host === '';
});
-let timer: ReturnType | null = null;
+const { pause, resume } = useIntervalFn(getAll, 5000, { immediate: false });
-onActivated(() => {
- getConfig();
+onActivated(async () => {
+ await getConfig();
if (!isNull.value) {
getAll();
- timer = setInterval(getAll, 5000);
+ resume();
}
});
onDeactivated(() => {
- if (timer) {
- clearInterval(timer);
- timer = null;
- }
+ pause();
clearSelection();
});
@@ -94,82 +91,80 @@ function isGroupAllSelected(group: TorrentGroup): boolean {
return group.torrents.every((t) => selectedHashes.value.includes(t.hash));
}
-function tableColumns(): DataTableColumns {
- return [
- {
- type: 'selection',
+const tableColumnsValue = computed>(() => [
+ {
+ type: 'selection',
+ },
+ {
+ title: t('downloader.torrent.name'),
+ key: 'name',
+ ellipsis: { tooltip: true },
+ minWidth: 200,
+ },
+ {
+ title: t('downloader.torrent.progress'),
+ key: 'progress',
+ width: 160,
+ render(row: QbTorrentInfo) {
+ return (
+
+ );
},
- {
- title: t('downloader.torrent.name'),
- key: 'name',
- ellipsis: { tooltip: true },
- minWidth: 200,
+ },
+ {
+ title: t('downloader.torrent.status'),
+ key: 'state',
+ width: 100,
+ render(row: QbTorrentInfo) {
+ return ;
},
- {
- title: t('downloader.torrent.progress'),
- key: 'progress',
- width: 160,
- render(row: QbTorrentInfo) {
- return (
-
- );
- },
+ },
+ {
+ title: t('downloader.torrent.size'),
+ key: 'size',
+ width: 100,
+ render(row: QbTorrentInfo) {
+ return formatSize(row.size);
},
- {
- title: t('downloader.torrent.status'),
- key: 'state',
- width: 100,
- render(row: QbTorrentInfo) {
- return ;
- },
+ },
+ {
+ title: t('downloader.torrent.dlspeed'),
+ key: 'dlspeed',
+ width: 110,
+ render(row: QbTorrentInfo) {
+ return formatSpeed(row.dlspeed);
},
- {
- title: t('downloader.torrent.size'),
- key: 'size',
- width: 100,
- render(row: QbTorrentInfo) {
- return formatSize(row.size);
- },
+ },
+ {
+ title: t('downloader.torrent.upspeed'),
+ key: 'upspeed',
+ width: 110,
+ render(row: QbTorrentInfo) {
+ return formatSpeed(row.upspeed);
},
- {
- title: t('downloader.torrent.dlspeed'),
- key: 'dlspeed',
- width: 110,
- render(row: QbTorrentInfo) {
- return formatSpeed(row.dlspeed);
- },
+ },
+ {
+ title: 'ETA',
+ key: 'eta',
+ width: 80,
+ render(row: QbTorrentInfo) {
+ return formatEta(row.eta);
},
- {
- title: t('downloader.torrent.upspeed'),
- key: 'upspeed',
- width: 110,
- render(row: QbTorrentInfo) {
- return formatSpeed(row.upspeed);
- },
+ },
+ {
+ title: t('downloader.torrent.peers'),
+ key: 'peers',
+ width: 90,
+ render(row: QbTorrentInfo) {
+ return `${row.num_seeds} / ${row.num_leechs}`;
},
- {
- title: 'ETA',
- key: 'eta',
- width: 80,
- render(row: QbTorrentInfo) {
- return formatEta(row.eta);
- },
- },
- {
- title: t('downloader.torrent.peers'),
- key: 'peers',
- width: 90,
- render(row: QbTorrentInfo) {
- return `${row.num_seeds} / ${row.num_leechs}`;
- },
- },
- ];
-}
+ },
+]);
function tableRowKey(row: QbTorrentInfo) {
return row.hash;
@@ -242,7 +237,7 @@ function groupCheckedKeys(group: TorrentGroup): string[] {
:default-open="true"
>
-import { NDataTable } from 'naive-ui';
+import { NDataTable, type DataTableColumns } from 'naive-ui';
import type { RSS } from '#/rss';
definePage({
@@ -16,62 +16,51 @@ onActivated(() => {
getAll();
});
-const RSSTableOptions = computed(() => {
- const columns = [
- {
- type: 'selection',
+const rssColumns = computed>(() => [
+ {
+ type: 'selection',
+ },
+ {
+ title: t('rss.name'),
+ key: 'name',
+ className: 'text-h3',
+ ellipsis: {
+ tooltip: true,
},
- {
- title: t('rss.name'),
- key: 'name',
- className: 'text-h3',
- ellipsis: {
- tooltip: true,
- },
+ },
+ {
+ title: t('rss.url'),
+ key: 'url',
+ className: 'text-h3',
+ minWidth: 400,
+ align: 'center',
+ ellipsis: {
+ tooltip: true,
},
- {
- title: t('rss.url'),
- key: 'url',
- className: 'text-h3',
- minWidth: 400,
- align: 'center',
- ellipsis: {
- tooltip: true,
- },
+ },
+ {
+ title: t('rss.status'),
+ key: 'status',
+ className: 'text-h3',
+ align: 'right',
+ minWidth: 200,
+ render(rss: RSS) {
+ return (
+
+ {rss.parser &&
}
+ {rss.aggregate &&
}
+ {rss.enabled ? (
+
+ ) : (
+
+ )}
+
+ );
},
- {
- title: t('rss.status'),
- key: 'status',
- className: 'text-h3',
- align: 'right',
- minWidth: 200,
- render(rss: RSS) {
- return (
-
- {rss.parser &&
}
- {rss.aggregate &&
}
- {rss.enabled ? (
-
- ) : (
-
- )}
-
- );
- },
- },
- ];
+ },
+]);
- const rowKey = (rss: RSS) => rss.id;
-
- return {
- columns,
- data: rss.value,
- pagination: false,
- bordered: false,
- rowKey,
- maxHeight: 500,
- } as unknown as InstanceType;
-});
+const rssRowKey = (row: RSS) => row.id;
@@ -108,7 +97,12 @@ const RSSTableOptions = computed(() => {
(selectedRSS = (e as number[]))"
>
diff --git a/webui/src/store/bangumi.ts b/webui/src/store/bangumi.ts
index d28fe672..d6e3f0ac 100644
--- a/webui/src/store/bangumi.ts
+++ b/webui/src/store/bangumi.ts
@@ -2,13 +2,13 @@ import type { BangumiRule } from '#/bangumi';
import { ruleTemplate } from '#/bangumi';
export const useBangumiStore = defineStore('bangumi', () => {
- const bangumi = ref();
+ const bangumi = ref([]);
const editRule = reactive<{
show: boolean;
item: BangumiRule;
}>({
show: false,
- item: ruleTemplate,
+ item: { ...ruleTemplate },
});
async function getAll() {
diff --git a/webui/src/store/downloader.ts b/webui/src/store/downloader.ts
index 8b030ce6..718e9853 100644
--- a/webui/src/store/downloader.ts
+++ b/webui/src/store/downloader.ts
@@ -1,7 +1,7 @@
import type { QbTorrentInfo, TorrentGroup } from '#/downloader';
export const useDownloaderStore = defineStore('downloader', () => {
- const torrents = ref([]);
+ const torrents = shallowRef([]);
const selectedHashes = ref([]);
const loading = ref(false);
diff --git a/webui/src/store/log.ts b/webui/src/store/log.ts
index 3d85a69a..9a339182 100644
--- a/webui/src/store/log.ts
+++ b/webui/src/store/log.ts
@@ -27,14 +27,13 @@ export const useLogStore = defineStore('log', () => {
immediateCallback: true,
});
- function copy() {
- const { copy: copyLog, isSupported } = useClipboard({
- source: log.value,
- legacy: true,
- });
+ const { copy: clipboardCopy, isSupported: clipboardSupported } = useClipboard({
+ legacy: true,
+ });
- if (isSupported.value) {
- copyLog();
+ function copy() {
+ if (clipboardSupported.value) {
+ clipboardCopy(log.value);
message.success(t('notify.copy_success'));
} else {
message.error(t('notify.copy_failed'));
diff --git a/webui/tsconfig.json b/webui/tsconfig.json
index 392d886c..1f42e235 100644
--- a/webui/tsconfig.json
+++ b/webui/tsconfig.json
@@ -13,7 +13,7 @@
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
- "noImplicitAny": false,
+ "noImplicitAny": true,
"baseUrl": "./",
"types": ["vite-plugin-pwa/client"],
"paths": {