feat: 视频搜索

This commit is contained in:
lanyeeee
2025-08-08 05:16:28 +08:00
parent f137b2b8a6
commit b83d13c7e1
21 changed files with 1188 additions and 4 deletions
+8
View File
@@ -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']
}
}
+2
View File
@@ -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",
+26
View File
@@ -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
+79 -3
View File
@@ -1,12 +1,27 @@
<script setup lang="tsx">
import { onMounted, ref } from 'vue'
import { onMounted, ref, provide } from 'vue'
import { useStore } from './store.ts'
import LogDialog from './dialogs/LogDialog.vue'
import { PhClockCounterClockwise, PhInfo, PhGearSix } from '@phosphor-icons/vue'
import {
PhClockCounterClockwise,
PhInfo,
PhGearSix,
PhMagnifyingGlass,
PhStar,
PhClock,
PhDownload,
} from '@phosphor-icons/vue'
import AboutDialog from './dialogs/AboutDialog.vue'
import { platform } from '@tauri-apps/plugin-os'
import TitleBar from './components/TitleBar.vue'
import SettingsDialog from './dialogs/SettingsDialog/SettingsDialog.vue'
import SearchPane from './panes/SearchPane/SearchPane.vue'
import FavPane from './panes/FavPane/FavPane.vue'
import WatchLaterPane from './panes/WatchLaterPane/WatchLaterPane.vue'
import DownloadPane from './panes/DownloadPane/DownloadPane.vue'
import { searchPaneRefKey, navDownloadButtonRefKey } from './injection_keys.ts'
export type CurrentNavName = 'search' | 'fav' | 'watch_later' | 'download'
const currentPlatform = platform()
@@ -16,6 +31,12 @@ const logDialogShowing = ref<boolean>(false)
const aboutDialogShowing = ref<boolean>(false)
const settingsDialogShowing = ref<boolean>(false)
const searchPaneRef = ref<InstanceType<typeof SearchPane>>()
const downloadButtonRef = ref<HTMLDivElement>()
provide(searchPaneRefKey, searchPaneRef)
provide(navDownloadButtonRefKey, downloadButtonRef)
onMounted(() => {
// 屏蔽浏览器右键菜单
document.oncontextmenu = (event) => {
@@ -26,7 +47,7 @@ onMounted(() => {
<template>
<div
:class="[
:class="[
'h-screen flex flex-col',
{
'box-border border border-solid border-gray-3': currentPlatform === 'linux',
@@ -36,6 +57,55 @@ onMounted(() => {
<div v-if="store.config !== undefined" class="h-full w-full flex overflow-hidden select-none">
<div class="flex flex-col box-border p-1 border-r-solid border-r-1 border-r-[#DADADA] bg-[#F9F9F9] flex-shrink-0">
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
搜索
<template #trigger>
<div
class="flex cursor-pointer hover:text-sky-5 hover:bg-gray-2/70 rounded py-1 my-1 px-2"
@click="store.currentNavName = 'search'"
:class="{ 'text-sky-5': store.currentNavName === 'search' }">
<PhMagnifyingGlass :weight="store.currentNavName === 'search' ? 'fill' : 'regular'" size="28" />
</div>
</template>
</n-tooltip>
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
收藏
<template #trigger>
<div
class="flex cursor-pointer hover:text-sky-5 hover:bg-gray-2/70 rounded py-1 my-1 px-2"
@click="store.currentNavName = 'fav'"
:class="{ 'text-sky-5': store.currentNavName === 'fav' }">
<PhStar :weight="store.currentNavName === 'fav' ? 'fill' : 'regular'" size="28" />
</div>
</template>
</n-tooltip>
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
稍后再看
<template #trigger>
<div
class="flex cursor-pointer hover:text-sky-5 hover:bg-gray-2/70 rounded py-1 my-1 px-2"
@click="store.currentNavName = 'watch_later'"
:class="{ 'text-sky-5': store.currentNavName === 'watch_later' }">
<PhClock :weight="store.currentNavName === 'watch_later' ? 'fill' : 'regular'" size="28" />
</div>
</template>
</n-tooltip>
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
下载
<template #trigger>
<div
ref="downloadButtonRef"
class="flex cursor-pointer hover:text-sky-5 hover:bg-gray-2/70 rounded py-1 my-1 px-2"
@click="store.currentNavName = 'download'"
:class="{ 'text-sky-5': store.currentNavName === 'download' }">
<PhDownload :weight="store.currentNavName === 'download' ? 'fill' : 'regular'" size="28" />
</div>
</template>
</n-tooltip>
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
配置
<template #trigger>
@@ -69,6 +139,12 @@ onMounted(() => {
</template>
</n-tooltip>
</div>
<div class="w-full overflow-hidden">
<SearchPane v-show="store.currentNavName === 'search'" ref="searchPaneRef" />
<FavPane v-show="store.currentNavName === 'fav'" />
<WatchLaterPane v-show="store.currentNavName === 'watch_later'" />
<DownloadPane v-show="store.currentNavName === 'download'" />
</div>
</div>
<SettingsDialog v-model:showing="settingsDialogShowing" />
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
type Color =
| 'red'
| 'orange'
| 'amber'
| 'yellow'
| 'lime'
| 'green'
| 'emerald'
| 'teal'
| 'cyan'
| 'sky'
| 'blue'
| 'indigo'
| 'violet'
| 'purple'
| 'fuchsia'
| 'pink'
| 'rose'
| 'slate'
| 'gray'
| 'zinc'
| 'neutral'
| 'stone'
defineProps<{
color: Color
}>()
const colorClasses = {
red: 'bg-red-1 text-red-8',
orange: 'bg-orange-1 text-orange-8',
amber: 'bg-amber-1 text-amber-8',
yellow: 'bg-yellow-1 text-yellow-8',
lime: 'bg-lime-1 text-lime-8',
green: 'bg-green-1 text-green-8',
emerald: 'bg-emerald-1 text-emerald-8',
teal: 'bg-teal-1 text-teal-8',
cyan: 'bg-cyan-1 text-cyan-8',
sky: 'bg-sky-1 text-sky-8',
blue: 'bg-blue-1 text-blue-8 border-blue-6/50',
indigo: 'bg-indigo-1 text-indigo-8',
violet: 'bg-violet-1 text-violet-8',
purple: 'bg-purple-1 text-purple-8',
fuchsia: 'bg-fuchsia-1 text-fuchsia-8',
pink: 'bg-pink-1 text-pink-8',
rose: 'bg-rose-1 text-rose-8',
slate: 'bg-slate-1 text-slate-8',
gray: 'bg-gray-1 text-gray-8',
zinc: 'bg-zinc-1 text-zinc-8',
neutral: 'bg-neutral-1 text-neutral-8',
stone: 'bg-stone-1 text-stone-8',
}
</script>
<template>
<span :class="`px-2 rounded-sm ${colorClasses[color]}`">
<slot />
</span>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
defineProps<{
checked: boolean
onClick: () => void
}>()
</script>
<template>
<div
class="w-7 h-7 flex items-center justify-center rounded cursor-pointer border border-solid border-white"
:class="checked ? 'bg-sky-5' : 'bg-gray/50'"
@click="onClick">
<svg
v-show="checked"
class="w-5 h-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="4"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { PhDownloadSimple } from '@phosphor-icons/vue'
const props = defineProps<{
startX: number
startY: number
endX: number
endY: number
onAnimationEnd: () => void
}>()
const cssVars = {
'--start-x': `${props.startX}px`,
'--start-y': `${props.startY}px`,
'--end-x': `${props.endX}px`,
'--end-y': `${props.endY}px`,
}
</script>
<template>
<div class="fly-icon animate" :style="cssVars" @animationend="onAnimationEnd">
<PhDownloadSimple :size="20" weight="fill" />
</div>
</template>
<style scoped>
.fly-icon {
position: fixed;
z-index: 9999;
border-radius: 50%;
background-color: #0ea5e9;
color: white;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
pointer-events: none;
left: var(--start-x);
top: var(--start-y);
transform: translate(-50%, -50%);
opacity: 1;
}
.fly-icon.animate {
animation: fly-animation 0.8s;
}
@keyframes fly-animation {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
50% {
opacity: 1;
transform: translate(calc(-50% - 30px), calc(-50% - 60px)) scale(0.8);
}
100% {
opacity: 0;
transform: translate(calc(var(--end-x) - var(--start-x) - 50%), calc(var(--end-y) - var(--start-y) - 50%))
scale(0.5);
}
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { ensureHttps } from '../utils.tsx'
defineProps<{
upName: string
upAvatar: string
upUid: number
}>()
</script>
<template>
<a
class="flex items-center color-inherit no-underline"
:href="`https://space.bilibili.com/${upUid}`"
target="_blank"
draggable="false">
<img class="w-8 h-8 rounded-full" :src="`${ensureHttps(upAvatar)}@128w_128h`" alt="" draggable="false" />
<div class="line-clamp-1" :title="upName">{{ upName }}</div>
</a>
</template>
+6
View File
@@ -0,0 +1,6 @@
import type { InjectionKey, Ref } from 'vue'
import SearchPane from './panes/SearchPane/SearchPane.vue'
export const navDownloadButtonRefKey = Symbol() as InjectionKey<Ref<HTMLDivElement | undefined>>
export const searchPaneRefKey = Symbol() as InjectionKey<Ref<InstanceType<typeof SearchPane> | undefined>>
+2
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
<template>
<div>DownloadPane</div>
</template>
+3
View File
@@ -0,0 +1,3 @@
<template>
<div>FavPane</div>
</template>
+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { SelectProps, useMessage } from 'naive-ui'
import { PhMagnifyingGlass } from '@phosphor-icons/vue'
import FloatLabelInput from '../../components/FloatLabelInput.vue'
import { commands, GetNormalInfoParams, SearchParams, SearchResult } from '../../bindings.ts'
import NormalSeasonPanel from './components/NormalSeasonPanel.vue'
import NormalSinglePanel from './components/NormalSinglePanel.vue'
import { extractBvid, extractAid } from '../../utils.tsx'
import { useStore } from '../../store.ts'
export type SearchType = 'Auto' | 'Normal'
const searchTypeOptions: SelectProps['options'] = [
{ label: '自动', value: 'Auto' },
{ label: '视频', value: 'Normal' },
]
const store = useStore()
const message = useMessage()
const searchInput = ref<string>('')
const searching = ref<boolean>(false)
const searchTypeSelected = ref<SearchType>('Auto')
const searchResult = ref<SearchResult>()
const searchLabel = computed(() => {
if (searchTypeSelected.value === 'Normal') {
return '链接 / av... / BV...'
}
return '链接 / av... / BV... '
})
async function search(input: string, searchType: SearchType) {
if (store.currentNavName === undefined) {
return
}
store.currentNavName = 'search'
searchInput.value = input.trim()
searchTypeSelected.value = searchType
const isUrl = input.startsWith('http')
searching.value = true
if (searchType === 'Auto') {
await searchAuto(input, isUrl)
} else if (searchType === 'Normal') {
await searchNormal(input, isUrl)
} else {
message.error('未知的搜索类型')
}
searching.value = false
}
async function searchAuto(input: string, isUrl: boolean) {
let params: SearchParams | undefined
if (isUrl) {
const bvid = extractBvid(input)
const aid = extractAid(input)
if (bvid !== undefined) {
params = { Normal: { Bvid: bvid } }
} else if (aid !== undefined) {
params = { Normal: { Aid: aid } }
}
} else if (input.toLowerCase().startsWith('bv')) {
params = { Normal: { Bvid: input } }
} else if (input.toLowerCase().startsWith('av')) {
const aid = parseInt(input.substring(2), 10)
if (!isNaN(aid)) {
params = { Normal: { Aid: aid } }
}
}
if (params === undefined) {
message.error('解析输入失败,请输入正确的链接或ID(如 av... / BV...)')
return
}
const result = await commands.search(params)
if (result.status === 'error') {
console.error(result.error)
return
}
searchResult.value = result.data
}
async function searchNormal(input: string, isUrl: boolean) {
let params: GetNormalInfoParams | undefined
if (isUrl) {
const bvid = extractBvid(input)
const aid = extractAid(input)
if (bvid !== undefined) {
params = { Bvid: bvid }
} else if (aid !== undefined) {
params = { Aid: aid }
}
} else if (input.toLowerCase().startsWith('bv')) {
params = { Bvid: input }
} else if (input.toLowerCase().startsWith('av')) {
const aid = parseInt(input.substring(2), 10)
if (!isNaN(aid)) {
params = { Aid: aid }
}
}
if (params === undefined) {
message.error('解析输入失败,请输入正确的链接或ID(如 av... / BV...)')
return
}
const result = await commands.search({ Normal: params })
if (result.status === 'error') {
console.error(result.error)
return
}
searchResult.value = result.data
}
defineExpose({ search })
</script>
<template>
<div class="h-full flex flex-col">
<n-input-group class="box-border px-2 pt-2">
<FloatLabelInput
:label="searchLabel"
size="small"
v-model:value="searchInput"
clearable
@keydown.enter="search(searchInput.trim(), searchTypeSelected)" />
<n-select
class="w-15%"
v-model:value="searchTypeSelected"
:options="searchTypeOptions"
:show-checkmark="false"
size="small" />
<n-button
:loading="searching"
type="primary"
size="small"
class="w-10%"
@click="search(searchInput.trim(), searchTypeSelected)">
<template #icon>
<n-icon size="22">
<PhMagnifyingGlass weight="bold" />
</n-icon>
</template>
</n-button>
</n-input-group>
<div class="overflow-auto h-full" v-if="searchResult !== undefined">
<NormalSeasonPanel
v-if="'Normal' in searchResult && searchResult.Normal.ugc_season !== null"
:normal-result="searchResult.Normal"
:ugc-season="searchResult.Normal.ugc_season" />
<NormalSinglePanel v-else-if="'Normal' in searchResult" :normal-result="searchResult.Normal" />
</div>
</div>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import UpInfoBadge from '../../../components/UpInfoBadge.vue'
import { ensureHttps } from '../../../utils.tsx'
defineProps<{
title: string
description?: string
cover: string
upName?: string
upAvatar?: string
upUid?: number
}>()
</script>
<template>
<div class="flex m-2 rounded-lg">
<img class="object-contain mr-2 rounded-lg h-25" :src="`${ensureHttps(cover)}@450w`" alt="" draggable="false" />
<div class="flex flex-col w-full break-words overflow-auto">
<span class="font-bold text-lg line-clamp-1">{{ title }}</span>
<span class="line-clamp-2">{{ description }}</span>
<UpInfoBadge
v-if="upName !== undefined && upAvatar !== undefined && upUid !== undefined"
class="mt-auto ml-auto"
:upName="upName"
:upAvatar="upAvatar"
:upUid="upUid" />
</div>
</div>
</template>
@@ -0,0 +1,208 @@
<script setup lang="tsx">
import { computed, inject, onMounted, onUpdated, ref } from 'vue'
import { EpInNormal, NormalInfo, NormalSearchResult } from '../../../bindings.ts'
import SimpleCheckbox from '../../../components/SimpleCheckbox.vue'
import { PhDownloadSimple, PhGoogleChromeLogo, PhQueue, PhMagnifyingGlass } from '@phosphor-icons/vue'
import { useDialog } from 'naive-ui'
import PartsDialogContent from './PartsDialogContent.vue'
import { ensureHttps, isElementInViewport, playTaskToQueueAnimation } from '../../../utils.tsx'
import { navDownloadButtonRefKey } from '../../../injection_keys.ts'
import { SearchType } from '../SearchPane.vue'
onMounted(() => console.log('EpisodeCard mounted'))
onUpdated(() => console.log('EpisodeCard updated'))
const dialog = useDialog()
export type EpisodeInfo = {
episodeType: 'Normal' | 'Bangumi' | 'Cheese'
aid: number
bvid?: string
epId?: number
href?: string
cover: string
title: string
upName: string
upUid: number
pubTime: number
favTime?: number
}
const props = defineProps<{
searchResult: NormalSearchResult
episode: NormalInfo | EpInNormal
episodeType: 'NormalSingle' | 'NormalSeason'
downloadEpisode?: (episodeInfo: EpisodeInfo) => Promise<void>
checkboxChecked?: (episodeInfo: EpisodeInfo) => boolean
handleCheckboxClick?: (episodeInfo: EpisodeInfo) => void
handleContextMenu?: (episodeInfo: EpisodeInfo) => void
search?: (input: string, searchType: SearchType) => void
}>()
const navDownloadButtonRef = inject(navDownloadButtonRefKey)
const rootDivRef = ref<HTMLDivElement>()
const downloadButtonRef = ref<HTMLDivElement>()
const episodeInfo = computed<EpisodeInfo>(() => {
if (props.episodeType === 'NormalSingle') {
const episode = props.episode as NormalInfo
return {
episodeType: 'Normal',
aid: episode.aid,
bvid: episode.bvid,
href: `https://www.bilibili.com/video/${episode.bvid}/`,
cover: episode.pic,
title: episode.title,
upName: episode.owner.name,
upUid: episode.owner.mid,
pubTime: episode.pubdate,
}
} else if (props.episodeType === 'NormalSeason') {
const episode = props.episode as EpInNormal
return {
episodeType: 'Normal',
aid: episode.aid,
bvid: episode.bvid,
href: `https://www.bilibili.com/video/${episode.bvid}/`,
cover: episode.arc.pic,
title: episode.arc.title,
upName: episode.arc.author.name,
upUid: episode.arc.author.mid,
pubTime: episode.arc.pubdate,
}
}
throw new Error(`错误的 episodeType: ${props.episodeType}`)
})
const partsButtonShowing = computed<boolean>(() => {
if (props.episodeType !== 'NormalSingle' && props.episodeType !== 'NormalSeason') {
return false
}
const episode = props.episode as NormalInfo | EpInNormal
return episode.pages.length > 1
})
function handlePartsButtonClick() {
if (props.episodeType !== 'NormalSingle' && props.episodeType !== 'NormalSeason') {
return
}
const episode = props.episode as NormalInfo | EpInNormal
const info = props.searchResult as NormalSearchResult
dialog.create({
title: '分P',
showIcon: false,
content: () => (
<PartsDialogContent
info={info}
pages={episode.pages}
episodeInfo={episodeInfo.value}
downloadButtonRef={navDownloadButtonRef?.value}
/>
),
})
}
async function handleDownloadClick() {
if (props.downloadEpisode === undefined) {
return
}
await props.downloadEpisode(episodeInfo.value)
playDownloadAnimation()
}
function playDownloadAnimation() {
if (rootDivRef.value === undefined) {
return
}
const from = downloadButtonRef.value
const to = navDownloadButtonRef?.value
if (from instanceof Element && to !== undefined) {
if (isElementInViewport(rootDivRef.value)) {
// 只有卡片在视口内才播放动画
playTaskToQueueAnimation(from, to)
}
}
}
defineExpose({ playDownloadAnimation, episodeInfo })
</script>
<template>
<div
class="flex flex-col w-200px relative p-3 rounded-lg"
@contextmenu="() => handleContextMenu?.(episodeInfo)"
ref="rootDivRef">
<SimpleCheckbox
v-if="handleCheckboxClick !== undefined && checkboxChecked !== undefined"
class="absolute top-6 left-6 z-1 backdrop-blur-2"
:checked="checkboxChecked(episodeInfo)"
:on-click="() => handleCheckboxClick?.(episodeInfo)" />
<img
class="w-200px h-125px rounded-lg object-cover lazyload"
:data-src="`${ensureHttps(episodeInfo.cover)}@672w_378h_1c.webp`"
:key="episodeInfo.cover"
alt=""
draggable="false" />
<div class="w-full flex flex-col h-45px mt-2">
<span class="line-clamp-2" :title="episodeInfo.title">{{ episodeInfo.title }}</span>
</div>
<div class="flex items-center whitespace-nowrap text-gray text-12px w-full overflow-hidden">
<a
class="min-w-0 color-inherit no-underline hover:text-sky-5 mr-1"
:href="`https://space.bilibili.com/${episodeInfo.upUid}`"
target="_blank"
draggable="false">
<div class="truncate text-ellipsis" :title="episodeInfo.upName">{{ episodeInfo.upName }}</div>
</a>
<span v-if="episodeInfo.favTime !== undefined" class="ml-auto flex-shrink-0" title="收藏时间">
<n-time unix type="date" :time="episodeInfo.favTime" />
</span>
<span v-else-if="episodeInfo.pubTime !== 0" class="ml-auto flex-shrink-0" title="发布时间">
<n-time unix type="date" :time="episodeInfo.pubTime" />
</span>
</div>
<div class="flex gap-1 items-center">
<a
v-if="episodeInfo.href !== undefined"
:href="episodeInfo.href"
target="_blank"
draggable="false"
title="在浏览器中打开"
class="p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6">
<PhGoogleChromeLogo :size="24" />
</a>
<div
v-if="partsButtonShowing"
title="查看分P"
class="cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
@click="handlePartsButtonClick">
<PhQueue :size="24" />
</div>
<div
v-if="search !== undefined && episodeInfo.bvid !== undefined"
title="在下载器内搜索"
class="cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
@click="search(episodeInfo.bvid, 'Normal')">
<PhMagnifyingGlass :size="24" />
</div>
<div
ref="downloadButtonRef"
v-if="downloadEpisode !== undefined"
title="一键下载"
class="ml-auto cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
@click="handleDownloadClick">
<PhDownloadSimple :size="24" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,213 @@
<script setup lang="tsx">
import { commands, NormalSearchResult, UgcSeason, SectionInNormal } from '../../../bindings.ts'
import { SelectionArea } from '@viselect/vue'
import { ref, nextTick, computed, watch } from 'vue'
import CollectionCard from './CollectionCard.vue'
import { useEpisodeCard, useEpisodeDropdown, useEpisodeSelection } from '../../../utils.tsx'
import { TabsInst } from 'naive-ui'
import EpisodeCard, { EpisodeInfo } from './EpisodeCard.vue'
const props = defineProps<{
ugcSeason: UgcSeason
normalResult: NormalSearchResult
}>()
const collectionCardShowing = ref<boolean>(false)
const tabsInstRef = ref<TabsInst>()
const { selectedIds, updateSelectedIds, unselectAll } = useEpisodeSelection()
const selectionAreaRef = ref<InstanceType<typeof SelectionArea>>()
const checkedIds = ref<Set<number>>(new Set())
const rootDivRef = ref<HTMLDivElement>()
const episodeCardRefs = ref<InstanceType<typeof EpisodeCard>[]>([])
const episodeCardRefsMap = computed<Map<number, InstanceType<typeof EpisodeCard>>>(() => {
const map = new Map<number, InstanceType<typeof EpisodeCard>>()
episodeCardRefs.value.forEach((card) => map.set(card.episodeInfo.aid, card))
return map
})
const { dropdownX, dropdownY, dropdownShowing, dropdownOptions, showDropdown } = useEpisodeDropdown(
() => {
selectedIds.value.forEach((aid) => checkedIds.value.add(aid))
dropdownShowing.value = false
},
() => {
selectedIds.value.forEach((aid) => checkedIds.value.delete(aid))
dropdownShowing.value = false
},
() => {
currentSection.value.episodes.forEach((ep) => selectedIds.value.add(ep.aid))
dropdownShowing.value = false
},
)
const { downloadEpisode, checkboxChecked, handleCheckboxClick, handleContextMenu } = useEpisodeCard(
async (episodeInfo: EpisodeInfo) => {
await commands.createDownloadTasks({
Normal: {
info: props.normalResult,
aid_cid_pairs: [[episodeInfo.aid, null]],
},
})
},
(episodeInfo: EpisodeInfo) => {
return checkedIds.value.has(episodeInfo.aid)
},
(episodeInfo: EpisodeInfo) => {
const checked = checkedIds.value.has(episodeInfo.aid)
if (checked) {
checkedIds.value.delete(episodeInfo.aid)
} else {
checkedIds.value.add(episodeInfo.aid)
}
},
(episodeInfo: EpisodeInfo) => {
if (selectedIds.value.has(episodeInfo.aid)) {
return
}
selectedIds.value.clear()
selectedIds.value.add(episodeInfo.aid)
const selection = selectionAreaRef.value?.selection
if (selection) {
selection.clearSelection()
selection.select(`[data-key="${episodeInfo.aid}"]`)
}
},
)
const currentSectionIndex = ref<number>(0)
const currentSection = computed<SectionInNormal>(() => {
return props.ugcSeason.sections[currentSectionIndex.value]
})
watch(currentSectionIndex, () => {
selectionAreaRef.value?.$el.scrollTo({ top: 0, behavior: 'instant' })
})
watch(
() => props.normalResult,
async () => {
currentSectionIndex.value = props.ugcSeason.sections.findIndex((s) =>
s.episodes.some((ep) => ep.aid === props.normalResult.aid),
)
selectedIds.value = new Set([props.normalResult.aid])
checkedIds.value = new Set([props.normalResult.aid])
const selection = selectionAreaRef.value?.selection
if (selection) {
selection.clearSelection()
selection.select(`[data-key="${props.normalResult.aid}"]`)
}
await nextTick()
tabsInstRef.value?.syncBarPosition()
if (rootDivRef.value === undefined) {
return
}
const targetElement = rootDivRef.value.querySelector(`[data-key="${props.normalResult.aid}"]`)
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
},
{ immediate: true },
)
async function downloadCheckedEpisodes() {
const currentSectionAids = new Set(currentSection.value.episodes.map((ep) => ep.aid))
const aidsToDownload = Array.from(checkedIds.value).filter((aid) => currentSectionAids.has(aid))
// 创建下载任务
await commands.createDownloadTasks({
Normal: {
info: props.normalResult,
aid_cid_pairs: aidsToDownload.map((aid) => [aid, null]),
},
})
// 播放下载动画
for (const aid of aidsToDownload) {
const card = episodeCardRefsMap.value.get(aid)
if (card !== undefined) {
card.playDownloadAnimation()
}
}
}
</script>
<template>
<div class="flex flex-col h-full select-none" ref="rootDivRef">
<SelectionArea
ref="selectionAreaRef"
class="selection-container flex flex-col flex-1 px-2 pt-0 overflow-auto"
:options="{ selectables: '.selectable', features: { deselectOnBlur: true } }"
@contextmenu="showDropdown"
@move="updateSelectedIds"
@start="unselectAll">
<div class="animate-pulse text-violet">左键拖动进行框选右键打开菜单滚轮可以滚动底部的标签</div>
<div class="flex flex-wrap gap-2">
<EpisodeCard
ref="episodeCardRefs"
v-for="ep in currentSection.episodes"
:key="ep.aid"
:data-key="ep.aid"
:class="['selectable', selectedIds.has(ep.aid) ? 'selected shadow-md' : 'hover:bg-gray-1']"
:search-result="normalResult"
:episode="ep"
:episode-type="'NormalSeason'"
:download-episode="downloadEpisode"
:checkbox-checked="checkboxChecked"
:handle-checkbox-click="handleCheckboxClick"
:handle-context-menu="handleContextMenu" />
</div>
</SelectionArea>
<n-tabs
ref="tabsInstRef"
type="line"
class="select-none mt-2"
v-model:value="currentSectionIndex"
size="small"
placement="bottom">
<n-tab v-for="(section, index) in ugcSeason.sections" :key="index" :name="index" :tab="section.title" />
<template #suffix>
<n-button class="ml-auto mb-2" size="small" @click="collectionCardShowing = !collectionCardShowing">
{{ collectionCardShowing ? '隐藏合集' : '显示合集' }}
</n-button>
<n-button class="mx-2 mb-2" size="small" type="primary" @click="downloadCheckedEpisodes">下载勾选视频</n-button>
</template>
</n-tabs>
<n-collapse-transition :show="collectionCardShowing">
<CollectionCard
class="mt-0"
:title="ugcSeason.title"
:description="ugcSeason.intro"
:cover="ugcSeason.cover"
:up-name="normalResult.owner.name"
:up-avatar="normalResult.owner.face"
:up-uid="normalResult.owner.mid" />
</n-collapse-transition>
<n-dropdown
placement="bottom-start"
trigger="manual"
:x="dropdownX"
:y="dropdownY"
:options="dropdownOptions"
:show="dropdownShowing"
:on-clickoutside="() => (dropdownShowing = false)" />
</div>
</template>
<style scoped>
.selection-container .selected {
@apply bg-[rgb(204,232,255)];
}
:deep(.n-tabs-nav__suffix) {
@apply important-border-0;
}
</style>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { commands, NormalSearchResult } from '../../../bindings.ts'
import CollectionCard from './CollectionCard.vue'
import EpisodeCard from './EpisodeCard.vue'
const props = defineProps<{
normalResult: NormalSearchResult
}>()
async function downloadEpisode() {
await commands.createDownloadTasks({
Normal: {
info: props.normalResult,
aid_cid_pairs: [[props.normalResult.aid, null]],
},
})
}
</script>
<template>
<div class="flex flex-col h-full gap-2 select-none">
<div class="px-2 mt-2">
<EpisodeCard
class="border border-solid border-gray-2"
:search-result="normalResult"
:episode="normalResult"
:episode-type="'NormalSingle'"
:download-episode="downloadEpisode" />
</div>
<CollectionCard
class="mt-auto"
:title="normalResult.title"
:description="normalResult.desc"
:cover="normalResult.pic"
:up-name="normalResult.owner.name"
:up-avatar="normalResult.owner.face"
:up-uid="normalResult.owner.mid" />
</div>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import ColorfulTag from '../../../components/ColorfulTag.vue'
import { commands, NormalInfo, PageInNormalEp, PageInNormal } from '../../../bindings.ts'
import { EpisodeInfo } from './EpisodeCard.vue'
import { ensureHttps, playTaskToQueueAnimation } from '../../../utils.tsx'
const props = defineProps<{
info: NormalInfo
pages: PageInNormalEp[] | PageInNormal[]
episodeInfo: EpisodeInfo
downloadButtonRef?: HTMLDivElement
}>()
function handleDownloadClick(aid: number, cid: number, event: MouseEvent) {
const from = event.currentTarget
const to = props.downloadButtonRef
commands.createDownloadTasks({ Normal: { info: props.info, aid_cid_pairs: [[aid, cid]] } })
if (from instanceof Element && to !== undefined) {
playTaskToQueueAnimation(from, to)
}
}
</script>
<template>
<div v-if="pages !== undefined" class="flex flex-col max-h-60vh overflow-auto">
<div class="flex flex-shrink-0 items-start">
<img
class="w-200px h-125px rounded-lg object-cover lazyload"
:data-src="`${ensureHttps(episodeInfo.cover)}@672w_378h_1c.webp`"
:key="episodeInfo.cover"
alt=""
draggable="false" />
<div class="ml-2 font-bold text-base line-clamp-5">{{ episodeInfo.title }}</div>
</div>
<div class="flex flex-col gap-1 overflow-auto mt-2">
<div v-for="page in pages" class="whitespace-nowrap flex" :key="page.cid">
<ColorfulTag class="bg-violet-2 truncate" :title="page.part" color="violet">
P{{ page.page }} {{ page.part }}
</ColorfulTag>
<n-button
class="ml-2"
size="tiny"
type="primary"
@click="handleDownloadClick(episodeInfo.aid, page.cid, $event)">
下载该分P
</n-button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,3 @@
<template>
<div>WatchLaterPane</div>
</template>
+3 -1
View File
@@ -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<Config>()
const userInfo = ref<UserInfo>()
const currentNavName = ref<CurrentNavName>('search')
return { config, userInfo }
return { config, userInfo, currentNavName }
})
+177
View File
@@ -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<number>(0)
const dropdownY = ref<number>(0)
const dropdownShowing = ref<boolean>(false)
const dropdownOptions: DropdownOption[] = [
{
label: '勾选',
key: 'check',
icon: () => (
<NIcon size="20">
<PhCheck />
</NIcon>
),
props: {
onClick: onCheck,
},
},
{
label: '取消勾选',
key: 'uncheck',
icon: () => (
<NIcon size="20">
<PhX />
</NIcon>
),
props: {
onClick: onUncheck,
},
},
{
label: '全选',
key: 'select-all',
icon: () => (
<NIcon size="20">
<PhChecks />
</NIcon>
),
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<Set<number>>(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<void>,
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://')