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:
Vendored
+8
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+26
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,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)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div>DownloadPane</div>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div>FavPane</div>
|
||||
</template>
|
||||
@@ -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
@@ -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
@@ -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://')
|
||||
|
||||
Reference in New Issue
Block a user