mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-09-05 07:27:42 +08:00
feat: 课程搜索
This commit is contained in:
@@ -3,19 +3,28 @@ 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, GetBangumiInfoParams, GetNormalInfoParams, SearchParams, SearchResult } from '../../bindings.ts'
|
||||
import {
|
||||
commands,
|
||||
GetBangumiInfoParams,
|
||||
GetCheeseInfoParams,
|
||||
GetNormalInfoParams,
|
||||
SearchParams,
|
||||
SearchResult,
|
||||
} from '../../bindings.ts'
|
||||
import NormalSeasonPanel from './components/NormalSeasonPanel.vue'
|
||||
import NormalSinglePanel from './components/NormalSinglePanel.vue'
|
||||
import { extractBvid, extractAid, extractEpId, extractSeasonId } from '../../utils.tsx'
|
||||
import { useStore } from '../../store.ts'
|
||||
import BangumiPanel from './components/BangumiPanel.vue'
|
||||
import CheesePanel from './components/CheesePanel.vue'
|
||||
|
||||
export type SearchType = 'Auto' | 'Normal' | 'Bangumi'
|
||||
export type SearchType = 'Auto' | 'Normal' | 'Bangumi' | 'Cheese'
|
||||
|
||||
const searchTypeOptions: SelectProps['options'] = [
|
||||
{ label: '自动', value: 'Auto' },
|
||||
{ label: '视频', value: 'Normal' },
|
||||
{ label: '番剧', value: 'Bangumi' },
|
||||
{ label: '课程', value: 'Cheese' },
|
||||
]
|
||||
|
||||
const store = useStore()
|
||||
@@ -32,6 +41,8 @@ const searchLabel = computed(() => {
|
||||
return '链接 / av... / BV...'
|
||||
} else if (searchTypeSelected.value === 'Bangumi') {
|
||||
return '链接 / ep... / ss...'
|
||||
} else if (searchTypeSelected.value === 'Cheese') {
|
||||
return '链接 / ep... / ss...'
|
||||
}
|
||||
return '链接 / av... / BV... / ep... / ss...'
|
||||
})
|
||||
@@ -54,6 +65,8 @@ async function search(input: string, searchType: SearchType) {
|
||||
await searchNormal(input, isUrl)
|
||||
} else if (searchType === 'Bangumi') {
|
||||
await searchBangumi(input, isUrl)
|
||||
} else if (searchType === 'Cheese') {
|
||||
await searchCheese(input, isUrl)
|
||||
} else {
|
||||
message.error('未知的搜索类型')
|
||||
}
|
||||
@@ -179,6 +192,42 @@ async function searchBangumi(input: string, isUrl: boolean) {
|
||||
searchResult.value = result.data
|
||||
}
|
||||
|
||||
async function searchCheese(input: string, isUrl: boolean) {
|
||||
let params: GetCheeseInfoParams | undefined
|
||||
|
||||
if (isUrl) {
|
||||
const epId = extractEpId(input)
|
||||
const seasonId = extractSeasonId(input)
|
||||
if (epId !== undefined) {
|
||||
params = { EpId: epId }
|
||||
} else if (seasonId !== undefined) {
|
||||
params = { SeasonId: seasonId }
|
||||
}
|
||||
} else if (input.toLowerCase().startsWith('ep')) {
|
||||
const epId = parseInt(input.substring(2), 10)
|
||||
if (!isNaN(epId)) {
|
||||
params = { EpId: epId }
|
||||
}
|
||||
} else if (input.toLowerCase().startsWith('ss')) {
|
||||
const seasonId = parseInt(input.substring(2), 10)
|
||||
if (!isNaN(seasonId)) {
|
||||
params = { SeasonId: seasonId }
|
||||
}
|
||||
}
|
||||
|
||||
if (params === undefined) {
|
||||
message.error('解析输入失败,请输入正确的链接或ID(如 ep... / ss...)')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await commands.search({ Cheese: params })
|
||||
if (result.status === 'error') {
|
||||
console.error(result.error)
|
||||
return
|
||||
}
|
||||
searchResult.value = result.data
|
||||
}
|
||||
|
||||
defineExpose({ search })
|
||||
</script>
|
||||
|
||||
@@ -217,6 +266,7 @@ defineExpose({ search })
|
||||
:ugc-season="searchResult.Normal.ugc_season" />
|
||||
<NormalSinglePanel v-else-if="'Normal' in searchResult" :normal-result="searchResult.Normal" />
|
||||
<BangumiPanel v-else-if="'Bangumi' in searchResult" :bangumi-result="searchResult.Bangumi" />
|
||||
<CheesePanel v-else-if="'Cheese' in searchResult" :cheese-result="searchResult.Cheese" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="tsx">
|
||||
import { CheeseSearchResult, commands } from '../../../bindings.ts'
|
||||
import { SelectionArea } from '@viselect/vue'
|
||||
import { ref, nextTick, watch, computed } from 'vue'
|
||||
import CollectionCard from './CollectionCard.vue'
|
||||
import { useEpisodeCard, useEpisodeDropdown, useEpisodeSelection } from '../../../utils.tsx'
|
||||
import EpisodeCard, { EpisodeInfo } from './EpisodeCard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
cheeseResult: CheeseSearchResult
|
||||
}>()
|
||||
|
||||
const collectionCardShowing = ref<boolean>(false)
|
||||
|
||||
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) => {
|
||||
if (card.episodeInfo.epId !== undefined) {
|
||||
map.set(card.episodeInfo.epId, card)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const { dropdownX, dropdownY, dropdownShowing, dropdownOptions, showDropdown } = useEpisodeDropdown(
|
||||
() => {
|
||||
selectedIds.value.forEach((epId) => checkedIds.value.add(epId))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
() => {
|
||||
selectedIds.value.forEach((epId) => checkedIds.value.delete(epId))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
() => {
|
||||
props.cheeseResult.info.episodes.forEach((ep) => selectedIds.value.add(ep.id))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
)
|
||||
|
||||
const { downloadEpisode, checkboxChecked, handleCheckboxClick, handleContextMenu } = useEpisodeCard(
|
||||
async (episodeInfo: EpisodeInfo) => {
|
||||
if (episodeInfo.epId === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
await commands.createDownloadTasks({ Cheese: { ep_ids: [episodeInfo.epId], info: props.cheeseResult.info } })
|
||||
},
|
||||
(episodeInfo: EpisodeInfo) => {
|
||||
if (episodeInfo.epId === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
return checkedIds.value.has(episodeInfo.epId)
|
||||
},
|
||||
(episodeInfo: EpisodeInfo) => {
|
||||
if (episodeInfo.epId === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const checked = checkedIds.value.has(episodeInfo.epId)
|
||||
if (checked) {
|
||||
checkedIds.value.delete(episodeInfo.epId)
|
||||
} else {
|
||||
checkedIds.value.add(episodeInfo.epId)
|
||||
}
|
||||
},
|
||||
(episodeInfo: EpisodeInfo) => {
|
||||
if (episodeInfo.epId === undefined || selectedIds.value.has(episodeInfo.epId)) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedIds.value.clear()
|
||||
selectedIds.value.add(episodeInfo.epId)
|
||||
const selection = selectionAreaRef.value?.selection
|
||||
if (selection) {
|
||||
selection.clearSelection()
|
||||
selection.select(`[data-key="${episodeInfo.epId}"]`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.cheeseResult,
|
||||
async () => {
|
||||
const ep = props.cheeseResult.ep
|
||||
if (ep === null) {
|
||||
return
|
||||
}
|
||||
selectedIds.value = new Set([ep.id])
|
||||
checkedIds.value = new Set([ep.id])
|
||||
await nextTick()
|
||||
|
||||
if (rootDivRef.value === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetElement = rootDivRef.value.querySelector(`[data-key="${ep.id}"]`)
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function downloadCheckedEpisodes() {
|
||||
// 创建下载任务
|
||||
const epIdsToDownload = Array.from(checkedIds.value)
|
||||
await commands.createDownloadTasks({
|
||||
Cheese: {
|
||||
ep_ids: epIdsToDownload,
|
||||
info: props.cheeseResult.info,
|
||||
},
|
||||
})
|
||||
// 播放下载动画
|
||||
for (const epId of epIdsToDownload) {
|
||||
const card = episodeCardRefsMap.value.get(epId)
|
||||
console.log(card)
|
||||
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 cheeseResult.info.episodes"
|
||||
:key="ep.id"
|
||||
:data-key="ep.id"
|
||||
:class="['selectable', selectedIds.has(ep.id) ? 'selected shadow-md' : 'hover:bg-gray-1']"
|
||||
:search-result="cheeseResult"
|
||||
:episode="ep"
|
||||
:episode-type="'Cheese'"
|
||||
:download-episode="downloadEpisode"
|
||||
:checkbox-checked="checkboxChecked"
|
||||
:handle-checkbox-click="handleCheckboxClick"
|
||||
:handle-context-menu="handleContextMenu" />
|
||||
</div>
|
||||
</SelectionArea>
|
||||
|
||||
<div class="p-2 ml-auto">
|
||||
<n-button class="mr-2" size="small" @click="collectionCardShowing = !collectionCardShowing">
|
||||
{{ collectionCardShowing ? '隐藏合集' : '显示合集' }}
|
||||
</n-button>
|
||||
<n-button size="small" type="primary" @click="downloadCheckedEpisodes">下载勾选视频</n-button>
|
||||
</div>
|
||||
<n-collapse-transition :show="collectionCardShowing">
|
||||
<CollectionCard
|
||||
class="mt-0"
|
||||
:title="cheeseResult.info.title"
|
||||
:description="cheeseResult.info.subtitle"
|
||||
:cover="cheeseResult.info.cover"
|
||||
:up-name="cheeseResult.info.up_info.uname"
|
||||
:up-avatar="cheeseResult.info.up_info.avatar"
|
||||
:up-uid="cheeseResult.info.up_info.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)];
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,14 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, inject, onMounted, onUpdated, ref } from 'vue'
|
||||
import { BangumiSearchResult, EpInBangumi, EpInNormal, NormalInfo, NormalSearchResult } from '../../../bindings.ts'
|
||||
import {
|
||||
BangumiSearchResult,
|
||||
CheeseSearchResult,
|
||||
EpInBangumi,
|
||||
EpInCheese,
|
||||
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'
|
||||
@@ -29,9 +37,9 @@ export type EpisodeInfo = {
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
searchResult: NormalSearchResult | BangumiSearchResult
|
||||
episode: NormalInfo | EpInNormal | EpInBangumi
|
||||
episodeType: 'NormalSingle' | 'NormalSeason' | 'Bangumi'
|
||||
searchResult: NormalSearchResult | BangumiSearchResult | CheeseSearchResult
|
||||
episode: NormalInfo | EpInNormal | EpInBangumi | EpInCheese
|
||||
episodeType: 'NormalSingle' | 'NormalSeason' | 'Bangumi' | 'Cheese'
|
||||
downloadEpisode?: (episodeInfo: EpisodeInfo) => Promise<void>
|
||||
checkboxChecked?: (episodeInfo: EpisodeInfo) => boolean
|
||||
handleCheckboxClick?: (episodeInfo: EpisodeInfo) => void
|
||||
@@ -88,6 +96,20 @@ const episodeInfo = computed<EpisodeInfo>(() => {
|
||||
upUid: searchResult.info.up_info?.mid ?? 0,
|
||||
pubTime: episode.pub_time,
|
||||
}
|
||||
} else if (props.episodeType === 'Cheese') {
|
||||
const episode = props.episode as EpInCheese
|
||||
const searchResult = props.searchResult as CheeseSearchResult
|
||||
return {
|
||||
episodeType: 'Cheese',
|
||||
aid: episode.aid,
|
||||
epId: episode.id,
|
||||
href: `https://www.bilibili.com/cheese/play/ep${episode.id}`,
|
||||
cover: episode.cover,
|
||||
title: episode.title,
|
||||
upName: searchResult.info.up_info.uname,
|
||||
upUid: searchResult.info.up_info.mid,
|
||||
pubTime: episode.release_date,
|
||||
}
|
||||
}
|
||||
throw new Error(`错误的 episodeType: ${props.episodeType}`)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user