diff --git a/src/entries/content-script/app/pages/SiteDetailPage.vue b/src/entries/content-script/app/pages/SiteDetailPage.vue index df69d3a5c..2dc639ffa 100644 --- a/src/entries/content-script/app/pages/SiteDetailPage.vue +++ b/src/entries/content-script/app/pages/SiteDetailPage.vue @@ -1,4 +1,5 @@ @@ -76,6 +107,9 @@ function handleSearch() { @click="handleRemoteDownload(true)" /> + + + diff --git a/src/entries/content-script/app/pages/SiteListPage.vue b/src/entries/content-script/app/pages/SiteListPage.vue index a9ce3cbd2..f13cf92ef 100644 --- a/src/entries/content-script/app/pages/SiteListPage.vue +++ b/src/entries/content-script/app/pages/SiteListPage.vue @@ -11,6 +11,9 @@ import { copyTextToClipboard, doKeywordSearch, siteInstance, wrapperConfirmFn } import AdvanceListModuleDialog from "../components/AdvanceListModuleDialog.vue"; import SpeedDialBtn from "../components/SpeedDialBtn.vue"; +import CollectionAddDialog from "@/options/components/CollectionAddDialog.vue"; + +import { DEFAULT_COLLECTION_ID, buildTorrentCollectionKey } from "@/shared/types.ts"; const metadataStore = useMetadataStore(); const runtimeStore = useRuntimeStore(); @@ -104,6 +107,43 @@ async function handleSearch() { doKeywordSearch(keywords); } + +// ===================== 收藏功能 ===================== +const showCollectionDialog = ref(false); +// 当页面只有一个种子时,直接用该种子作为收藏对象; +// 当页面有多个种子时,使用 collectionBatchTorrents 批量添加。 +const collectionDialogTorrent = ref(null); +const collectionBatchTorrents = shallowRef([]); + +async function handleCollectAll() { + const { torrents } = await parseListPage().catch(() => ({ torrents: [] as ITorrent[] })); + if (torrents.length === 0) return; + + const hasCustom = metadataStore.getCustomCollections().length > 0; + + if (!hasCustom) { + // 直接批量添加到默认收藏夹(一次 IO) + await metadataStore.addTorrentsToCollections(torrents, [DEFAULT_COLLECTION_ID]); + runtimeStore.showSnakebar(`已将 ${torrents.length} 个种子添加到默认收藏夹`, { color: "success" }); + } else { + // 若只有一个种子,用单种子对话框;多个种子时用第一个代理(仅选择收藏夹) + collectionBatchTorrents.value = torrents; + collectionDialogTorrent.value = torrents[0] ?? null; + showCollectionDialog.value = true; + } +} + +async function onCollectionSaved() { + // 批量模式:将所有解析到的种子同步到与第一个种子相同的收藏夹 + const key0 = collectionDialogTorrent.value ? buildTorrentCollectionKey(collectionDialogTorrent.value) : null; + if (!key0 || collectionBatchTorrents.value.length <= 1) return; + + const selectedIds = metadataStore.getTorrentCollectionIds(key0); + if (selectedIds.length === 0) return; // 用户未选择任何收藏夹,无需同步 + + // 将剩余种子批量加入相同的收藏夹 + await metadataStore.addTorrentsToCollections(collectionBatchTorrents.value.slice(1), selectedIds); +} @@ -149,8 +189,16 @@ async function handleSearch() { @click="handleAdvanceListModule" /> + + diff --git a/src/entries/options/components/CollectionAddDialog.vue b/src/entries/options/components/CollectionAddDialog.vue new file mode 100644 index 000000000..e696a865e --- /dev/null +++ b/src/entries/options/components/CollectionAddDialog.vue @@ -0,0 +1,93 @@ + + + + + + + + {{ t("MyCollection.selectDialog.title") }} + + + + + + + + + + {{ torrent?.title }} + + + + + + + + + {{ collection.name }} + + ({{ t("MyCollection.folderPanel.torrentCount", [collection.torrentIds.length]) }}) + + + + + + + + + + {{ t("common.dialog.cancel") }} + {{ t("common.dialog.ok") }} + + + + + + diff --git a/src/entries/options/plugins/router.ts b/src/entries/options/plugins/router.ts index 3b22af982..0ee378a98 100644 --- a/src/entries/options/plugins/router.ts +++ b/src/entries/options/plugins/router.ts @@ -77,6 +77,12 @@ export const routes: RouteRecordRaw[] = [ meta: { icon: "mdi-history" }, component: () => import("../views/Overview/DownloadHistory/Index.vue"), }, + { + path: "/my-collection", + name: "MyCollection", + meta: { icon: "mdi-bookmark-multiple" }, + component: () => import("../views/Overview/MyCollection/Index.vue"), + }, ], }, { diff --git a/src/entries/options/stores/metadata.ts b/src/entries/options/stores/metadata.ts index 2d765ad9b..a5ea82e07 100644 --- a/src/entries/options/stores/metadata.ts +++ b/src/entries/options/stores/metadata.ts @@ -8,28 +8,35 @@ import { type ISearchEntryRequestConfig, type ISiteMetadata, type ISiteUserConfig, + type ITorrent, type TSiteHost, type TSiteID, } from "@ptd/site"; import { IBackupServerMetadata, + ICollectionFolder, IDownloaderMetadata, IMediaServerMetadata, IMetadataPiniaStorageSchema, ISearchSolution, + TCollectionId, TDownloaderKey, TMediaServerKey, TSearchSnapshotKey, TSolutionKey, ISearchSolutionMetadata, + TTorrentCollectionKey, + DEFAULT_COLLECTION_ID, + buildTorrentCollectionKey, } from "@/shared/types.ts"; +import { extStorage } from "@/storage.ts"; import { sendMessage } from "@/messages.ts"; import { useRuntimeStore } from "@/options/stores/runtime.ts"; type TSimplePatchFieldKey = keyof Pick< IMetadataPiniaStorageSchema, - "sites" | "solutions" | "snapshots" | "downloaders" | "mediaServers" | "backupServers" + "sites" | "solutions" | "snapshots" | "downloaders" | "mediaServers" | "backupServers" | "collections" >; export const useMetadataStore = defineStore("metadata", { @@ -42,6 +49,17 @@ export const useMetadataStore = defineStore("metadata", { mediaServers: {}, backupServers: {}, + collections: { + [DEFAULT_COLLECTION_ID]: { + id: DEFAULT_COLLECTION_ID, + name: "默认收藏夹", + color: "primary", + sortIndex: 0, + isDefault: true, + torrentIds: [], + }, + }, + defaultSolutionId: "default", defaultDownloader: {}, @@ -325,6 +343,21 @@ export const useMetadataStore = defineStore("metadata", { getBackupServers(state) { return Object.values(state.backupServers); }, + + getCollectionIds(state) { + return Object.keys(state.collections); + }, + + getCollections(state) { + return Object.values(state.collections); + }, + + /** 检查种子是否已收藏(在任意收藏夹中) */ + isTorrentCollected(state) { + return (torrentKey: TTorrentCollectionKey): boolean => { + return Object.values(state.collections).some((c) => c.torrentIds.includes(torrentKey)); + }; + }, }, actions: { async simplePatch< @@ -478,5 +511,167 @@ export const useMetadataStore = defineStore("metadata", { delete this.backupServers[backupServerId]; await this.$save(); }, + + // ==================== 收藏夹相关 ==================== + + /** 获取所有收藏夹,按 sortIndex 排序 */ + getSortedCollections(): ICollectionFolder[] { + return Object.values(this.collections).sort((a, b) => { + // 默认收藏夹排在最前面 + if (a.id === DEFAULT_COLLECTION_ID) return -1; + if (b.id === DEFAULT_COLLECTION_ID) return 1; + return (a.sortIndex ?? 0) - (b.sortIndex ?? 0); + }); + }, + + /** 返回自定义收藏夹(排除默认收藏夹),按 sortIndex 排序 */ + getCustomCollections(): ICollectionFolder[] { + return Object.values(this.collections) + .filter((c) => !c.isDefault) + .sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0)); + }, + + /** 添加自定义收藏夹 */ + async addCollection(collectionConfig: Omit) { + const id = nanoid(); + this.collections[id] = { ...collectionConfig, id, torrentIds: [] }; + await this.$save(); + return id; + }, + + /** 编辑收藏夹属性(名称、颜色、排序等,不影响 torrentIds) */ + async editCollection(id: TCollectionId, patch: Partial>) { + if (!this.collections[id]) return; + Object.assign(this.collections[id], patch); + await this.$save(); + }, + + /** + * 删除自定义收藏夹(不删除种子数据)。 + * 注意:默认收藏夹不可删除。 + */ + async removeCollection(id: TCollectionId) { + if (!this.collections[id] || this.collections[id].isDefault) return; + delete this.collections[id]; + await this.$save(); + }, + + /** 获取种子所在的收藏夹 ID 列表 */ + getTorrentCollectionIds(torrentKey: TTorrentCollectionKey): TCollectionId[] { + return Object.values(this.collections) + .filter((c) => c.torrentIds.includes(torrentKey)) + .map((c) => c.id); + }, + + /** 将种子加入指定收藏夹,并在 extStorage 中存储种子数据 */ + async addTorrentToCollections(torrent: ITorrent, collectionIds: TCollectionId[]) { + const key = buildTorrentCollectionKey(torrent); + + // 保存种子数据 + const stored = (await extStorage.getItem("collectionTorrents")) ?? {}; + if (!stored[key]) { + stored[key] = torrent; + await extStorage.setItem("collectionTorrents", stored); + } + + // 更新收藏夹中的种子列表 + for (const cid of collectionIds) { + if (this.collections[cid] && !this.collections[cid].torrentIds.includes(key)) { + this.collections[cid].torrentIds.push(key); + } + } + await this.$save(); + }, + + /** 批量将多个种子加入指定收藏夹(一次性读写 extStorage,避免多次 IO) */ + async addTorrentsToCollections(torrents: ITorrent[], collectionIds: TCollectionId[]) { + if (torrents.length === 0 || collectionIds.length === 0) return; + + const stored = (await extStorage.getItem("collectionTorrents")) ?? {}; + + for (const torrent of torrents) { + const key = buildTorrentCollectionKey(torrent); + stored[key] = torrent; + for (const cid of collectionIds) { + if (this.collections[cid] && !this.collections[cid].torrentIds.includes(key)) { + this.collections[cid].torrentIds.push(key); + } + } + } + + await extStorage.setItem("collectionTorrents", stored); + await this.$save(); + }, + + /** 从指定收藏夹中移除种子(不删除全局种子数据) */ + async removeTorrentFromCollection(torrentKey: TTorrentCollectionKey, collectionId: TCollectionId) { + if (!this.collections[collectionId]) return; + this.collections[collectionId].torrentIds = this.collections[collectionId].torrentIds.filter( + (k) => k !== torrentKey, + ); + await this.$save(); + }, + + /** 从所有收藏夹中删除种子,并从 extStorage 中删除种子数据 */ + async removeTorrentFromAllCollections(torrentKey: TTorrentCollectionKey) { + for (const cid in this.collections) { + this.collections[cid].torrentIds = this.collections[cid].torrentIds.filter((k) => k !== torrentKey); + } + + // 从 extStorage 中删除种子数据 + const stored = (await extStorage.getItem("collectionTorrents")) ?? {}; + if (stored[torrentKey]) { + delete stored[torrentKey]; + await extStorage.setItem("collectionTorrents", stored); + } + + await this.$save(); + }, + + /** 更新种子所在的收藏夹集合(先清理再添加) */ + async updateTorrentCollections(torrent: ITorrent, newCollectionIds: TCollectionId[]) { + const key = buildTorrentCollectionKey(torrent); + + // 从所有收藏夹中移除该种子 + for (const cid in this.collections) { + this.collections[cid].torrentIds = this.collections[cid].torrentIds.filter((k) => k !== key); + } + + if (newCollectionIds.length > 0) { + // 保存种子数据 + const stored = (await extStorage.getItem("collectionTorrents")) ?? {}; + stored[key] = torrent; + await extStorage.setItem("collectionTorrents", stored); + + // 将种子加入选中的收藏夹 + for (const cid of newCollectionIds) { + if (this.collections[cid]) { + this.collections[cid].torrentIds.push(key); + } + } + } else { + // 如果没有选中任何收藏夹,则删除全局种子数据 + const stored = (await extStorage.getItem("collectionTorrents")) ?? {}; + if (stored[key]) { + delete stored[key]; + await extStorage.setItem("collectionTorrents", stored); + } + } + + await this.$save(); + }, + + /** 获取收藏夹中的种子数据 */ + async getCollectionTorrents(collectionId: TCollectionId): Promise { + const folder = this.collections[collectionId]; + if (!folder || folder.torrentIds.length === 0) return []; + const stored = (await extStorage.getItem("collectionTorrents")) ?? {}; + return folder.torrentIds.map((k) => stored[k]).filter(Boolean) as ITorrent[]; + }, + + /** 获取所有收藏种子数据 */ + async getAllCollectionTorrents(): Promise> { + return (await extStorage.getItem("collectionTorrents")) ?? {}; + }, }, }); diff --git a/src/entries/options/views/Overview/MyCollection/EditCollectionDialog.vue b/src/entries/options/views/Overview/MyCollection/EditCollectionDialog.vue new file mode 100644 index 000000000..e5c6f4f6d --- /dev/null +++ b/src/entries/options/views/Overview/MyCollection/EditCollectionDialog.vue @@ -0,0 +1,118 @@ + + + + + + + + + {{ collection ? t("MyCollection.editDialog.titleEdit") : t("MyCollection.editDialog.titleAdd") }} + + + + + + + + + + + + + + + {{ t("MyCollection.editDialog.color") }} + + + {{ c }} + + + + + + + {{ t("common.dialog.cancel") }} + {{ t("common.dialog.ok") }} + + + + + + diff --git a/src/entries/options/views/Overview/MyCollection/Index.vue b/src/entries/options/views/Overview/MyCollection/Index.vue new file mode 100644 index 000000000..38128d1bc --- /dev/null +++ b/src/entries/options/views/Overview/MyCollection/Index.vue @@ -0,0 +1,295 @@ + + + + + + + + + + + {{ t("MyCollection.folderPanel.title") }} + + + + + + + + + + {{ collection.name }} + + + {{ t("MyCollection.folderPanel.torrentCount", [collection.torrentIds.length]) }} + + + + + + + + + + + + + + + + + + + + + + + + + + configStore.updateTableBehavior('MyCollection', 'itemsPerPage', v)" + @update:sortBy="(v) => configStore.updateTableBehavior('MyCollection', 'sortBy', v)" + > + + + + + + + + + + + + + {{ item.size ? formatSize(item.size) : "-" }} + + + + {{ item.time ? formatDate(item.time) : "-" }} + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/entries/options/views/Overview/SearchEntity/ActionTd.vue b/src/entries/options/views/Overview/SearchEntity/ActionTd.vue index d6b86ef48..bf3c6c537 100644 --- a/src/entries/options/views/Overview/SearchEntity/ActionTd.vue +++ b/src/entries/options/views/Overview/SearchEntity/ActionTd.vue @@ -3,10 +3,12 @@ import { computed, ref } from "vue"; import { sendMessage } from "@/messages.ts"; import type { ISearchResultTorrent } from "@/shared/types.ts"; +import { buildTorrentCollectionKey, DEFAULT_COLLECTION_ID } from "@/shared/types.ts"; import { useRuntimeStore } from "@/options/stores/runtime.ts"; import { useMetadataStore } from "@/options/stores/metadata.ts"; import SentToDownloaderDialog from "@/options/components/SentToDownloaderDialog/Index.vue"; +import CollectionAddDialog from "@/options/components/CollectionAddDialog.vue"; const { torrentItems, density = "default" } = defineProps<{ torrentItems: ISearchResultTorrent[]; @@ -67,6 +69,37 @@ function sendToDownloader(defaultDownload = false) { isDefaultSend.value = defaultDownload; showDownloadClientDialog.value = true; } + +// ===================== 收藏功能 ===================== +// 仅当 torrentItems 只有一个种子时才显示收藏按钮(单行操作) +const singleTorrent = computed(() => (torrentItems.length === 1 ? torrentItems[0] : null)); + +const isCollected = computed(() => { + if (!singleTorrent.value) return false; + return metadataStore.isTorrentCollected(buildTorrentCollectionKey(singleTorrent.value)); +}); + +const showCollectionDialog = ref(false); +const collectionDialogTorrent = ref(null); + +async function handleCollect() { + if (!singleTorrent.value) return; + const hasCustom = metadataStore.getCustomCollections().length > 0; + if (!hasCustom) { + // 直接添加/移除 默认收藏夹 + if (isCollected.value) { + await metadataStore.updateTorrentCollections(singleTorrent.value, []); + runtimeStore.showSnakebar("已从收藏中移除", { color: "info" }); + } else { + await metadataStore.addTorrentToCollections(singleTorrent.value, [DEFAULT_COLLECTION_ID]); + runtimeStore.showSnakebar("已添加到默认收藏夹", { color: "success" }); + } + } else { + // 显示收藏夹选择对话框 + collectionDialogTorrent.value = singleTorrent.value; + showCollectionDialog.value = true; + } +} @@ -106,6 +139,15 @@ function sendToDownloader(defaultDownload = false) { title="下载种子文件到本地" @click="() => localDlTorrentDownloadLink()" /> + + @@ -114,6 +156,9 @@ function sendToDownloader(defaultDownload = false) { :torrent-items="torrentItems" :is-default-send="isDefaultSend" /> + + + diff --git a/src/entries/shared/types/storages/metadata.ts b/src/entries/shared/types/storages/metadata.ts index 8ac50d2b2..bbc790e7b 100644 --- a/src/entries/shared/types/storages/metadata.ts +++ b/src/entries/shared/types/storages/metadata.ts @@ -2,6 +2,7 @@ import type { ISearchCategories, ISearchEntryRequestConfig, ISiteUserConfig, + ITorrent, IUserInfo, TSiteHost, TSiteID as TSiteKey, @@ -91,6 +92,25 @@ export interface IBackupServerMetadata extends IBackupConfig { lastBackupAt?: number; // 上次备份时间 } +export type TCollectionId = string; +export const DEFAULT_COLLECTION_ID = "default"; // 默认收藏夹 ID + +/** 唯一标识一个种子:site:id */ +export type TTorrentCollectionKey = string; + +export function buildTorrentCollectionKey(torrent: Pick): TTorrentCollectionKey { + return `${torrent.site}:${torrent.id}`; +} + +export interface ICollectionFolder { + id: TCollectionId; + name: string; + color?: string; // 标签颜色 + sortIndex?: number; // 排序索引 + isDefault?: boolean; // 是否是默认收藏夹(不可删除) + torrentIds: TTorrentCollectionKey[]; // 收藏的种子唯一键列表 +} + export interface IMetadataPiniaStorageSchema { // 站点配置(用户配置) sites: Record; @@ -113,6 +133,9 @@ export interface IMetadataPiniaStorageSchema { // 备份服务器配置 backupServers: Record; + // 收藏夹配置(含收藏夹中的种子ID列表) + collections: Record; + // 默认搜索方案 defaultSolutionId: TSolutionKey | "default"; diff --git a/src/entries/shared/types/storages/other.ts b/src/entries/shared/types/storages/other.ts index aa48c51f0..7b6633c04 100644 --- a/src/entries/shared/types/storages/other.ts +++ b/src/entries/shared/types/storages/other.ts @@ -2,8 +2,10 @@ * 本处存放未使用 pinia 管理的其他 chrome.storage.local 使用到的存储结构类型 */ import type { TSiteID } from "@ptd/site"; -import type { IStoredUserInfo, TSearchSnapshotKey } from "./metadata.ts"; +import type { ITorrent } from "@ptd/site"; +import type { IStoredUserInfo, TSearchSnapshotKey, TTorrentCollectionKey } from "./metadata.ts"; import type { ISearchData } from "./runtime.ts"; export type TUserInfoStorageSchema = Record>; // 用于存储用户信息 export type TSearchResultSnapshotStorageSchema = Record; // 用于存储搜索结果快照 +export type TCollectionTorrentsStorageSchema = Record; // 用于存储收藏夹种子信息 diff --git a/src/entries/storage.ts b/src/entries/storage.ts index 1114cb561..60369b684 100644 --- a/src/entries/storage.ts +++ b/src/entries/storage.ts @@ -5,6 +5,7 @@ import { IMetadataPiniaStorageSchema, TUserInfoStorageSchema, TSearchResultSnapshotStorageSchema, + TCollectionTorrentsStorageSchema, } from "@/shared/types.ts"; export interface IExtensionStorageSchema { @@ -15,6 +16,7 @@ export interface IExtensionStorageSchema { userInfo: TUserInfoStorageSchema; // 用于存储用户信息 searchResultSnapshot: TSearchResultSnapshotStorageSchema; // 用于存储搜索结果快照 + collectionTorrents: TCollectionTorrentsStorageSchema; // 用于存储收藏夹种子信息 } export type TExtensionStorageKey = keyof IExtensionStorageSchema; diff --git a/src/locales/en.json b/src/locales/en.json index 7427a8392..9c4fe78e0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -508,7 +508,6 @@ "table": { "enabled": "Enabled ?", "autodl": "Auto DL ?", - "action": { "status": "Status", "setPathAndTag": "Set Suggest Download Path and Tags", @@ -731,5 +730,33 @@ "anidbClientMessages": "Please apply for a Client ID at https://anidb.net/perl-bin/animedb.pl?show=client and fill it in the format `Name/Version`.", "bangumiApiKey": "Bangumi API Key", "bangumiApiMessages": "You can generate an Access Token at https://next.bgm.tv/demo/access-token" + }, + "MyCollection": { + "folderPanel": { + "title": "Collections", + "add": "New Collection", + "torrentCount": "{0} torrents" + }, + "table": { + "site": "Site", + "title": "Title", + "size": "Size", + "time": "Published" + }, + "filterPlaceholder": "Filter torrents", + "action": { + "remove": "Remove (from all collections)", + "removeSelected": "Remove Selected" + }, + "editDialog": { + "titleAdd": "New Collection", + "titleEdit": "Edit Collection", + "name": "Collection Name", + "nameRequired": "Please enter a collection name", + "color": "Label Color" + }, + "selectDialog": { + "title": "Select Collection" + } } } diff --git a/src/locales/zh_CN.json b/src/locales/zh_CN.json index cc1bfa3cc..945c054a2 100644 --- a/src/locales/zh_CN.json +++ b/src/locales/zh_CN.json @@ -507,7 +507,6 @@ }, "table": { "enabled": "启用?", - "autodl": "自动下载?", "action": { "status": "状态", @@ -728,5 +727,33 @@ "anidbClientMessages": "请在 https://anidb.net/perl-bin/animedb.pl?show=client 中申请 Client ID, 并按照 `Name/Version` 的格式填写", "bangumiApiKey": "Bangumi API Key", "bangumiApiMessages": "你可以在 https://next.bgm.tv/demo/access-token 生成一个 Access Token" + }, + "MyCollection": { + "folderPanel": { + "title": "收藏夹", + "add": "新建收藏夹", + "torrentCount": "{0} 个种子" + }, + "table": { + "site": "站点", + "title": "标题", + "size": "大小", + "time": "发布时间" + }, + "filterPlaceholder": "过滤收藏种子", + "action": { + "remove": "取消收藏(从所有收藏夹中删除)", + "removeSelected": "取消收藏" + }, + "editDialog": { + "titleAdd": "新建收藏夹", + "titleEdit": "编辑收藏夹", + "name": "收藏夹名称", + "nameRequired": "请输入收藏夹名称", + "color": "标签颜色" + }, + "selectDialog": { + "title": "选择收藏夹" + } } }
{{ torrent?.title }}