feat: UP投稿搜索

This commit is contained in:
lanyeeee
2025-08-12 06:55:57 +08:00
parent fa57a47d80
commit 2ab7c707d5
4 changed files with 252 additions and 7 deletions
+53 -4
View File
@@ -8,23 +8,26 @@ import {
GetBangumiInfoParams,
GetCheeseInfoParams,
GetNormalInfoParams,
GetUserVideoInfoParams,
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 { extractBvid, extractAid, extractEpId, extractSeasonId, extractUid } from '../../utils.tsx'
import { useStore } from '../../store.ts'
import BangumiPanel from './components/BangumiPanel.vue'
import CheesePanel from './components/CheesePanel.vue'
import UserVideoPanel from './components/UserVideoPanel.vue'
export type SearchType = 'Auto' | 'Normal' | 'Bangumi' | 'Cheese'
export type SearchType = 'Auto' | 'Normal' | 'Bangumi' | 'Cheese' | 'UserVideo'
const searchTypeOptions: SelectProps['options'] = [
{ label: '自动', value: 'Auto' },
{ label: '视频', value: 'Normal' },
{ label: '番剧', value: 'Bangumi' },
{ label: '课程', value: 'Cheese' },
{ label: 'UP投稿', value: 'UserVideo' },
]
const store = useStore()
@@ -43,8 +46,10 @@ const searchLabel = computed(() => {
return '链接 / ep... / ss...'
} else if (searchTypeSelected.value === 'Cheese') {
return '链接 / ep... / ss...'
} else if (searchTypeSelected.value === 'UserVideo') {
return '个人空间链接 / uid...'
}
return '链接 / av... / BV... / ep... / ss...'
return '链接 / av... / BV... / ep... / ss... / uid...'
})
async function search(input: string, searchType: SearchType) {
@@ -67,6 +72,8 @@ async function search(input: string, searchType: SearchType) {
await searchBangumi(input, isUrl)
} else if (searchType === 'Cheese') {
await searchCheese(input, isUrl)
} else if (searchType === 'UserVideo') {
await searchUserVideo(input, isUrl)
} else {
message.error('未知的搜索类型')
}
@@ -81,6 +88,7 @@ async function searchAuto(input: string, isUrl: boolean) {
const aid = extractAid(input)
const epId = extractEpId(input)
const seasonId = extractSeasonId(input)
const uid = extractUid(input)
if (bvid !== undefined) {
params = { Normal: { Bvid: bvid } }
@@ -90,6 +98,8 @@ async function searchAuto(input: string, isUrl: boolean) {
params = { Bangumi: { EpId: epId } }
} else if (seasonId !== undefined) {
params = { Bangumi: { SeasonId: seasonId } }
} else if (uid !== undefined) {
params = { UserVideo: { mid: uid, pn: 1 } }
}
} else if (input.toLowerCase().startsWith('bv')) {
params = { Normal: { Bvid: input } }
@@ -108,10 +118,15 @@ async function searchAuto(input: string, isUrl: boolean) {
if (!isNaN(seasonId)) {
params = { Bangumi: { SeasonId: seasonId } }
}
} else if (input.toLowerCase().startsWith('uid')) {
const uid = parseInt(input.substring(3), 10)
if (!isNaN(uid)) {
params = { UserVideo: { mid: uid, pn: 1 } }
}
}
if (params === undefined) {
message.error('解析输入失败,请输入正确的链接或ID(如 av... / BV... / ep... / ss...)')
message.error('解析输入失败,请输入正确的链接或ID(如 av... / BV... / ep... / ss... / uid...)')
return
}
@@ -228,6 +243,39 @@ async function searchCheese(input: string, isUrl: boolean) {
searchResult.value = result.data
}
async function searchUserVideo(input: string, isUrl: boolean) {
let params: GetUserVideoInfoParams | undefined
if (isUrl) {
const uid = extractUid(input)
if (uid !== undefined) {
params = { mid: uid, pn: 1 }
}
} else if (input.toLowerCase().startsWith('uid')) {
const uid = parseInt(input.substring(3), 10)
if (!isNaN(uid)) {
params = { mid: uid, pn: 1 }
}
} else {
const uid = parseInt(input, 10)
if (!isNaN(uid)) {
params = { mid: uid, pn: 1 }
}
}
if (params === undefined) {
message.error('解析输入失败,请输入正确的个人空间链接或ID(如 uid...)')
return
}
const result = await commands.search({ UserVideo: params })
if (result.status === 'error') {
console.error(result.error)
return
}
searchResult.value = result.data
}
defineExpose({ search })
</script>
@@ -267,6 +315,7 @@ defineExpose({ search })
<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" />
<UserVideoPanel v-else-if="'UserVideo' in searchResult" v-model:user-video-result="searchResult.UserVideo" />
</div>
</div>
</template>
@@ -6,8 +6,10 @@ import {
EpInBangumi,
EpInCheese,
EpInNormal,
EpInUserVideo,
NormalInfo,
NormalSearchResult,
UserVideoSearchResult,
} from '../../../bindings.ts'
import SimpleCheckbox from '../../../components/SimpleCheckbox.vue'
import { PhDownloadSimple, PhGoogleChromeLogo, PhQueue, PhMagnifyingGlass } from '@phosphor-icons/vue'
@@ -37,9 +39,9 @@ export type EpisodeInfo = {
}
const props = defineProps<{
searchResult: NormalSearchResult | BangumiSearchResult | CheeseSearchResult
episode: NormalInfo | EpInNormal | EpInBangumi | EpInCheese
episodeType: 'NormalSingle' | 'NormalSeason' | 'Bangumi' | 'Cheese'
searchResult: NormalSearchResult | BangumiSearchResult | CheeseSearchResult | UserVideoSearchResult
episode: NormalInfo | EpInNormal | EpInBangumi | EpInCheese | EpInUserVideo
episodeType: 'NormalSingle' | 'NormalSeason' | 'Bangumi' | 'Cheese' | 'UserVideo'
downloadEpisode?: (episodeInfo: EpisodeInfo) => Promise<void>
checkboxChecked?: (episodeInfo: EpisodeInfo) => boolean
handleCheckboxClick?: (episodeInfo: EpisodeInfo) => void
@@ -110,6 +112,19 @@ const episodeInfo = computed<EpisodeInfo>(() => {
upUid: searchResult.info.up_info.mid,
pubTime: episode.release_date,
}
} else if (props.episodeType === 'UserVideo') {
const episode = props.episode as EpInUserVideo
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.author,
upUid: episode.mid,
pubTime: episode.created,
}
}
throw new Error(`错误的 episodeType: ${props.episodeType}`)
})
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { commands, GetUserVideoInfoParams, UserVideoSearchResult } from '../../../bindings.ts'
import { computed, inject, ref, watch } from 'vue'
import EpisodeCard, { EpisodeInfo } from './EpisodeCard.vue'
import { useEpisodeCard, useEpisodeDropdown, useEpisodeSelection } from '../../../utils.tsx'
import { SelectionArea } from '@viselect/vue'
import { searchPaneRefKey } from '../../../injection_keys.ts'
const userVideoResult = defineModel<UserVideoSearchResult>('userVideoResult', { required: true })
const searchPaneRef = inject(searchPaneRefKey)
const currentPage = ref<number>(1)
const pageCount = computed<number>(() => {
const count = userVideoResult.value.page.count
const ps = userVideoResult.value.page.ps
return Math.ceil(count / ps)
})
const { selectedIds, updateSelectedIds, unselectAll } = useEpisodeSelection()
const selectionAreaRef = ref<InstanceType<typeof SelectionArea>>()
const checkedIds = ref<Set<number>>(new Set())
watch(userVideoResult, () => {
currentPage.value = userVideoResult.value.page.pn
selectedIds.value.clear()
checkedIds.value.clear()
selectionAreaRef.value?.selection?.clearSelection()
selectionAreaRef.value?.$el.scrollTo({ top: 0, behavior: 'instant' })
})
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
},
() => {
userVideoResult.value.list.vlist.forEach((ep) => selectedIds.value.add(ep.aid))
dropdownShowing.value = false
},
)
const { downloadEpisode, checkboxChecked, handleCheckboxClick, handleContextMenu } = useEpisodeCard(
async (episodeInfo: EpisodeInfo) => {
await downloadNormalEpisode(episodeInfo.aid)
},
(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}"]`)
}
},
)
async function downloadNormalEpisode(aid: number) {
// 获取普通视频信息,用于创建下载任务
const getNormalInfoResult = await commands.getNormalInfo({ Aid: aid })
if (getNormalInfoResult.status === 'error') {
console.error(getNormalInfoResult.error)
return
}
// 创建下载任务
await commands.createDownloadTasks({ Normal: { info: getNormalInfoResult.data, aid_cid_pairs: [[aid, null]] } })
}
async function downloadCheckedEpisodes() {
for (const aid of checkedIds.value) {
// 创建下载任务
await downloadNormalEpisode(aid)
// 播放下载动画
const card = episodeCardRefsMap.value.get(aid)
if (card !== undefined) {
card.playDownloadAnimation()
}
await new Promise((resolve) => setTimeout(resolve, 200))
}
}
async function getUserVideoInfo(page: number) {
currentPage.value = page
const mid = userVideoResult.value.list.vlist[0].mid
const params: GetUserVideoInfoParams = { mid, pn: page }
const result = await commands.getUserVideoInfo(params)
if (result.status === 'error') {
console.error(result.error)
return
}
userVideoResult.value = result.data
}
</script>
<template>
<div class="flex flex-col h-full select-none overflow-auto">
<SelectionArea
ref="selectionAreaRef"
class="selection-container flex flex-col flex-1 px-2 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 userVideoResult.list.vlist"
:key="ep.aid"
:data-key="ep.aid"
:class="['selectable', selectedIds.has(ep.aid) ? 'selected shadow-md' : 'hover:bg-gray-1']"
:search-result="userVideoResult"
:episode="ep"
:episode-type="'UserVideo'"
:download-episode="downloadEpisode"
:checkbox-checked="checkboxChecked"
:handle-checkbox-click="handleCheckboxClick"
:handle-context-menu="handleContextMenu"
:search="searchPaneRef?.search" />
</div>
</SelectionArea>
<div class="flex gap-2 m-2 box-border">
<n-pagination :page-count="pageCount" :page="currentPage" @update:page="getUserVideoInfo($event)" />
<n-button class="ml-auto" size="small" type="primary" @click="downloadCheckedEpisodes">下载勾选视频</n-button>
</div>
<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>
+12
View File
@@ -61,6 +61,18 @@ export function extractSeasonId(url: string): number | undefined {
}
}
export function extractUid(url: string): number | undefined {
const parsedUrl = new URL(url)
if (parsedUrl.hostname === 'space.bilibili.com') {
const segments = parsedUrl.pathname.split('/')
const uidSegment = segments[1]
const uid = parseInt(uidSegment, 10)
if (!isNaN(uid)) {
return uid
}
}
}
export function useEpisodeDropdown(onCheck: () => void, onUncheck: () => void, onSelectAll: () => void) {
const dropdownX = ref<number>(0)
const dropdownY = ref<number>(0)