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;