From b83d13c7e1bcbcae29d0e5a31b7de5728b614491 Mon Sep 17 00:00:00 2001 From: lanyeeee Date: Fri, 8 Aug 2025 05:16:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=86=E9=A2=91=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components.d.ts | 8 + package.json | 2 + pnpm-lock.yaml | 26 +++ src/AppContent.vue | 82 ++++++- src/components/ColorfulTag.vue | 60 +++++ src/components/SimpleCheckbox.vue | 24 ++ src/components/TaskToQueueAnimation.vue | 68 ++++++ src/components/UpInfoBadge.vue | 20 ++ src/injection_keys.ts | 6 + src/main.ts | 2 + src/panes/DownloadPane/DownloadPane.vue | 3 + src/panes/FavPane/FavPane.vue | 3 + src/panes/SearchPane/SearchPane.vue | 163 ++++++++++++++ .../SearchPane/components/CollectionCard.vue | 29 +++ .../SearchPane/components/EpisodeCard.vue | 208 +++++++++++++++++ .../components/NormalSeasonPanel.vue | 213 ++++++++++++++++++ .../components/NormalSinglePanel.vue | 39 ++++ .../components/PartsDialogContent.vue | 52 +++++ src/panes/WatchLaterPane/WatchLaterPane.vue | 3 + src/store.ts | 4 +- src/utils.tsx | 177 +++++++++++++++ 21 files changed, 1188 insertions(+), 4 deletions(-) create mode 100644 src/components/ColorfulTag.vue create mode 100644 src/components/SimpleCheckbox.vue create mode 100644 src/components/TaskToQueueAnimation.vue create mode 100644 src/components/UpInfoBadge.vue create mode 100644 src/injection_keys.ts create mode 100644 src/panes/DownloadPane/DownloadPane.vue create mode 100644 src/panes/FavPane/FavPane.vue create mode 100644 src/panes/SearchPane/SearchPane.vue create mode 100644 src/panes/SearchPane/components/CollectionCard.vue create mode 100644 src/panes/SearchPane/components/EpisodeCard.vue create mode 100644 src/panes/SearchPane/components/NormalSeasonPanel.vue create mode 100644 src/panes/SearchPane/components/NormalSinglePanel.vue create mode 100644 src/panes/SearchPane/components/PartsDialogContent.vue create mode 100644 src/panes/WatchLaterPane/WatchLaterPane.vue diff --git a/components.d.ts b/components.d.ts index 11db451..672a1e1 100644 --- a/components.d.ts +++ b/components.d.ts @@ -8,13 +8,16 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + ColorfulTag: typeof import('./src/components/ColorfulTag.vue')['default'] FloatLabelInput: typeof import('./src/components/FloatLabelInput.vue')['default'] NA: typeof import('naive-ui')['NA'] NButton: typeof import('naive-ui')['NButton'] NCheckbox: typeof import('naive-ui')['NCheckbox'] + NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NDialog: typeof import('naive-ui')['NDialog'] NDialogProvider: typeof import('naive-ui')['NDialogProvider'] + NDropdown: typeof import('naive-ui')['NDropdown'] NEl: typeof import('naive-ui')['NEl'] NIcon: typeof import('naive-ui')['NIcon'] NInput: typeof import('naive-ui')['NInput'] @@ -29,10 +32,15 @@ declare module 'vue' { NRadioButton: typeof import('naive-ui')['NRadioButton'] NRadioGroup: typeof import('naive-ui')['NRadioGroup'] NSelect: typeof import('naive-ui')['NSelect'] + NTab: typeof import('naive-ui')['NTab'] NTabPane: typeof import('naive-ui')['NTabPane'] NTabs: typeof import('naive-ui')['NTabs'] + NTime: typeof import('naive-ui')['NTime'] NTooltip: typeof import('naive-ui')['NTooltip'] NVirtualList: typeof import('naive-ui')['NVirtualList'] + SimpleCheckbox: typeof import('./src/components/SimpleCheckbox.vue')['default'] + TaskToQueueAnimation: typeof import('./src/components/TaskToQueueAnimation.vue')['default'] TitleBar: typeof import('./src/components/TitleBar.vue')['default'] + UpInfoBadge: typeof import('./src/components/UpInfoBadge.vue')['default'] } } diff --git a/package.json b/package.json index 98039d0..c4a513e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "^2.3.0", + "@viselect/vue": "^3.9.0", + "lazysizes": "^5.3.2", "naive-ui": "^2.42.0", "pinia": "^3.0.3", "unplugin-auto-import": "^19.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 960af5b..925e1da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,12 @@ importers: '@tauri-apps/plugin-os': specifier: ^2.3.0 version: 2.3.0 + '@viselect/vue': + specifier: ^3.9.0 + version: 3.9.0(vue@3.5.17(typescript@5.6.3)) + lazysizes: + specifier: ^5.3.2 + version: 5.3.2 naive-ui: specifier: ^2.42.0 version: 2.42.0(vue@3.5.17(typescript@5.6.3)) @@ -871,6 +877,14 @@ packages: peerDependencies: vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 + '@viselect/vanilla@3.9.0': + resolution: {integrity: sha512-E9eBgoi/crJ0SlZMAc+Yst7nU324LZ5LLvcXjzWEcrfllscdpTml2OLOKHC7O8Bbz19OybSLv6VexxnjlJrLxQ==} + + '@viselect/vue@3.9.0': + resolution: {integrity: sha512-CS4nzdKn1bZB/Upr3oCPlEGPXWRyvbjOvhlAkETVEkg1tkqS9vR9GVAytXGBbztIhCXRImTlCCO9G5NCNlDYng==} + peerDependencies: + vue: '>=3.2.0' + '@vitejs/plugin-vue-jsx@5.0.1': resolution: {integrity: sha512-X7qmQMXbdDh+sfHUttXokPD0cjPkMFoae7SgbkF9vi3idGUKmxLcnU2Ug49FHwiKXebfzQRIm5yK3sfCJzNBbg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1457,6 +1471,9 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + lazysizes@5.3.2: + resolution: {integrity: sha512-22UzWP+Vedi/sMeOr8O7FWimRVtiNJV2HCa+V8+peZOw6QbswN9k58VUhd7i6iK5bw5QkYrF01LJbeJe0PV8jg==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2793,6 +2810,13 @@ snapshots: transitivePeerDependencies: - vue + '@viselect/vanilla@3.9.0': {} + + '@viselect/vue@3.9.0(vue@3.5.17(typescript@5.6.3))': + dependencies: + '@viselect/vanilla': 3.9.0 + vue: 3.5.17(typescript@5.6.3) + '@vitejs/plugin-vue-jsx@5.0.1(vite@6.3.5(jiti@2.4.2))(vue@3.5.17(typescript@5.6.3))': dependencies: '@babel/core': 7.28.0 @@ -3425,6 +3449,8 @@ snapshots: kolorist@1.8.0: {} + lazysizes@5.3.2: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 diff --git a/src/AppContent.vue b/src/AppContent.vue index 3d68110..bad8434 100644 --- a/src/AppContent.vue +++ b/src/AppContent.vue @@ -1,12 +1,27 @@ + + diff --git a/src/components/SimpleCheckbox.vue b/src/components/SimpleCheckbox.vue new file mode 100644 index 0000000..f8f4df3 --- /dev/null +++ b/src/components/SimpleCheckbox.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/components/TaskToQueueAnimation.vue b/src/components/TaskToQueueAnimation.vue new file mode 100644 index 0000000..1d7e088 --- /dev/null +++ b/src/components/TaskToQueueAnimation.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/components/UpInfoBadge.vue b/src/components/UpInfoBadge.vue new file mode 100644 index 0000000..e8b0d91 --- /dev/null +++ b/src/components/UpInfoBadge.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/injection_keys.ts b/src/injection_keys.ts new file mode 100644 index 0000000..edf940d --- /dev/null +++ b/src/injection_keys.ts @@ -0,0 +1,6 @@ +import type { InjectionKey, Ref } from 'vue' +import SearchPane from './panes/SearchPane/SearchPane.vue' + +export const navDownloadButtonRefKey = Symbol() as InjectionKey> + +export const searchPaneRefKey = Symbol() as InjectionKey | undefined>> diff --git a/src/main.ts b/src/main.ts index 00b6659..5bce4ce 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,8 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import 'virtual:uno.css' +import 'lazysizes' +import 'lazysizes/plugins/parent-fit/ls.parent-fit' const pinia = createPinia() const app = createApp(App) diff --git a/src/panes/DownloadPane/DownloadPane.vue b/src/panes/DownloadPane/DownloadPane.vue new file mode 100644 index 0000000..98ce566 --- /dev/null +++ b/src/panes/DownloadPane/DownloadPane.vue @@ -0,0 +1,3 @@ + diff --git a/src/panes/FavPane/FavPane.vue b/src/panes/FavPane/FavPane.vue new file mode 100644 index 0000000..56ebaae --- /dev/null +++ b/src/panes/FavPane/FavPane.vue @@ -0,0 +1,3 @@ + diff --git a/src/panes/SearchPane/SearchPane.vue b/src/panes/SearchPane/SearchPane.vue new file mode 100644 index 0000000..03e1c9a --- /dev/null +++ b/src/panes/SearchPane/SearchPane.vue @@ -0,0 +1,163 @@ + + + diff --git a/src/panes/SearchPane/components/CollectionCard.vue b/src/panes/SearchPane/components/CollectionCard.vue new file mode 100644 index 0000000..1dafe49 --- /dev/null +++ b/src/panes/SearchPane/components/CollectionCard.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/panes/SearchPane/components/EpisodeCard.vue b/src/panes/SearchPane/components/EpisodeCard.vue new file mode 100644 index 0000000..0ae919a --- /dev/null +++ b/src/panes/SearchPane/components/EpisodeCard.vue @@ -0,0 +1,208 @@ + + + diff --git a/src/panes/SearchPane/components/NormalSeasonPanel.vue b/src/panes/SearchPane/components/NormalSeasonPanel.vue new file mode 100644 index 0000000..13c91a3 --- /dev/null +++ b/src/panes/SearchPane/components/NormalSeasonPanel.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/src/panes/SearchPane/components/NormalSinglePanel.vue b/src/panes/SearchPane/components/NormalSinglePanel.vue new file mode 100644 index 0000000..a17ec0f --- /dev/null +++ b/src/panes/SearchPane/components/NormalSinglePanel.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/panes/SearchPane/components/PartsDialogContent.vue b/src/panes/SearchPane/components/PartsDialogContent.vue new file mode 100644 index 0000000..ecd5ad3 --- /dev/null +++ b/src/panes/SearchPane/components/PartsDialogContent.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/panes/WatchLaterPane/WatchLaterPane.vue b/src/panes/WatchLaterPane/WatchLaterPane.vue new file mode 100644 index 0000000..8545029 --- /dev/null +++ b/src/panes/WatchLaterPane/WatchLaterPane.vue @@ -0,0 +1,3 @@ + diff --git a/src/store.ts b/src/store.ts index be68477..d6f0bf7 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,10 +1,12 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { Config, UserInfo } from './bindings.ts' +import { CurrentNavName } from './AppContent.vue' export const useStore = defineStore('store', () => { const config = ref() const userInfo = ref() + const currentNavName = ref('search') - return { config, userInfo } + return { config, userInfo, currentNavName } }) diff --git a/src/utils.tsx b/src/utils.tsx index f5c3425..b709b33 100644 --- a/src/utils.tsx +++ b/src/utils.tsx @@ -1,3 +1,180 @@ +import { DropdownOption, NIcon } from 'naive-ui' +import { ref, nextTick, render, h } from 'vue' +import { PhChecks, PhCheck, PhX } from '@phosphor-icons/vue' +import { SelectionEvent } from '@viselect/vue' +import TaskToQueueAnimation from './components/TaskToQueueAnimation.vue' +import { EpisodeInfo } from './panes/SearchPane/components/EpisodeCard.vue' + +export function extractAid(url: string): number | undefined { + const parsedUrl = new URL(url) + const pathname = parsedUrl.pathname + const segments = pathname.split('/') + for (const segment of segments) { + if (segment.toLowerCase().startsWith('av')) { + const aidString = segment.substring(2) + const aid = parseInt(aidString, 10) + if (!isNaN(aid)) { + return aid + } + } + } +} + +export function extractBvid(url: string): string | undefined { + const parsedUrl = new URL(url) + const pathname = parsedUrl.pathname + const segments = pathname.split('/') + for (const segment of segments) { + if (segment.toLowerCase().startsWith('bv')) { + return segment + } + } +} + +export function useEpisodeDropdown(onCheck: () => void, onUncheck: () => void, onSelectAll: () => void) { + const dropdownX = ref(0) + const dropdownY = ref(0) + const dropdownShowing = ref(false) + const dropdownOptions: DropdownOption[] = [ + { + label: '勾选', + key: 'check', + icon: () => ( + + + + ), + props: { + onClick: onCheck, + }, + }, + { + label: '取消勾选', + key: 'uncheck', + icon: () => ( + + + + ), + props: { + onClick: onUncheck, + }, + }, + { + label: '全选', + key: 'select-all', + icon: () => ( + + + + ), + props: { + onClick: onSelectAll, + }, + }, + ] + + async function showDropdown(e: MouseEvent) { + dropdownShowing.value = false + await nextTick() + dropdownShowing.value = true + dropdownX.value = e.clientX + dropdownY.value = e.clientY + } + + return { + dropdownX, + dropdownY, + dropdownShowing, + dropdownOptions, + showDropdown, + } +} + +export function useEpisodeSelection() { + const selectedIds = ref>(new Set()) + + function extractIds(elements: Element[]): number[] { + return elements + .map((element) => element.getAttribute('data-key')) + .filter(Boolean) + .map(Number) + } + + function updateSelectedIds({ + store: { + changed: { added, removed }, + }, + }: SelectionEvent) { + extractIds(added).forEach((id) => selectedIds.value.add(id)) + extractIds(removed).forEach((id) => selectedIds.value.delete(id)) + } + + function unselectAll({ event, selection }: SelectionEvent) { + if (!event?.ctrlKey && !event?.metaKey) { + selection.clearSelection() + selectedIds.value.clear() + } + } + + return { + selectedIds, + updateSelectedIds, + unselectAll, + } +} + +export function playTaskToQueueAnimation(from: Element, to: Element) { + const startRect = from.getBoundingClientRect() + const endRect = to.getBoundingClientRect() + + const container = document.createElement('div') + document.body.appendChild(container) + + const taskToQueueAnimation = h(TaskToQueueAnimation, { + startX: startRect.left + startRect.width / 2, + startY: startRect.top + startRect.height / 2, + endX: endRect.left + endRect.width / 2, + endY: endRect.top + endRect.height / 2, + onAnimationEnd: () => { + render(null, container) + document.body.removeChild(container) + }, + }) + + render(taskToQueueAnimation, container) +} + +// el是否至少有一部分在可视区域内 +export function isElementInViewport(el: Element): boolean { + const rect = el.getBoundingClientRect() + const viewportHeight = window.innerHeight || document.documentElement.clientHeight + const viewportWidth = window.innerWidth || document.documentElement.clientWidth + + // 检查垂直方向上是否有交集 + const verticalInView = rect.bottom > 0 && rect.top < viewportHeight + + // 检查水平方向上是否有交集 + const horizontalInView = rect.right > 0 && rect.left < viewportWidth + + // 必须同时在垂直和水平方向上都有交集,才算部分可见 + return verticalInView && horizontalInView +} + +export function useEpisodeCard( + downloadEpisode: (episodeInfo: EpisodeInfo) => Promise, + checkboxChecked?: (episodeInfo: EpisodeInfo) => boolean, + handleCheckboxClick?: (episodeInfo: EpisodeInfo) => void, + handleContextMenu?: (episodeInfo: EpisodeInfo) => void, +) { + return { + downloadEpisode, + checkboxChecked, + handleCheckboxClick, + handleContextMenu, + } +} + export function ensureHttps(url: string): string { if (url.startsWith('http://')) { return url.replace('http://', 'https://')