mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-05-06 20:02:57 +08:00
feat: 控制和管理下载任务
This commit is contained in:
3
components.d.ts
vendored
3
components.d.ts
vendored
@@ -11,6 +11,7 @@ declare module 'vue' {
|
||||
ColorfulTag: typeof import('./src/components/ColorfulTag.vue')['default']
|
||||
FloatLabelInput: typeof import('./src/components/FloatLabelInput.vue')['default']
|
||||
NA: typeof import('naive-ui')['NA']
|
||||
NBadge: typeof import('naive-ui')['NBadge']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
|
||||
@@ -28,6 +29,8 @@ declare module 'vue' {
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NModalProvider: typeof import('naive-ui')['NModalProvider']
|
||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||
NPagination: typeof import('naive-ui')['NPagination']
|
||||
NProgress: typeof import('naive-ui')['NProgress']
|
||||
NQrCode: typeof import('naive-ui')['NQrCode']
|
||||
NRadioButton: typeof import('naive-ui')['NRadioButton']
|
||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@phosphor-icons/vue": "^2.2.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.3.0",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-os": "^2.3.0",
|
||||
"@viselect/vue": "^3.9.0",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2
|
||||
version: 2.6.0
|
||||
'@tauri-apps/plugin-dialog':
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
'@tauri-apps/plugin-opener':
|
||||
specifier: ^2
|
||||
version: 2.4.0
|
||||
@@ -711,6 +714,9 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.3.0':
|
||||
resolution: {integrity: sha512-ylSBvYYShpGlKKh732ZuaHyJ5Ie1JR71QCXewCtsRLqGdc8Is4xWdz6t43rzXyvkItM9syNPMvFVcvjgEy+/GA==}
|
||||
|
||||
'@tauri-apps/plugin-opener@2.4.0':
|
||||
resolution: {integrity: sha512-43VyN8JJtvKWJY72WI/KNZszTpDpzHULFxQs0CJBIYUdCRowQ6Q1feWTDb979N7nldqSuDOaBupZ6wz2nvuWwQ==}
|
||||
|
||||
@@ -2546,6 +2552,10 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.6.2
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.6.2
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.3.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.6.0
|
||||
|
||||
'@tauri-apps/plugin-opener@2.4.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.6.0
|
||||
|
||||
132
src-tauri/Cargo.lock
generated
132
src-tauri/Cargo.lock
generated
@@ -68,6 +68,24 @@ version = "1.0.98"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.9.1",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"url",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -289,6 +307,7 @@ dependencies = [
|
||||
"strfmt",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-os",
|
||||
"tauri-specta",
|
||||
@@ -793,6 +812,18 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a0d569e003ff27784e0e14e4a594048698e0c0f0b66cabcb51511be55a7caa0"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"block2 0.6.1",
|
||||
"libc",
|
||||
"objc2 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.0"
|
||||
@@ -2481,7 +2512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"dispatch2",
|
||||
"dispatch2 0.3.0",
|
||||
"objc2 0.6.1",
|
||||
]
|
||||
|
||||
@@ -2492,7 +2523,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"dispatch2",
|
||||
"dispatch2 0.3.0",
|
||||
"objc2 0.6.1",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-surface",
|
||||
@@ -3196,6 +3227,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -3216,6 +3257,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -3234,6 +3285,15 @@ dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
|
||||
dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -3421,6 +3481,31 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80c844748fdc82aae252ee4594a89b6e7ebef1063de7951545564cbc4e57075d"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"block2 0.6.1",
|
||||
"dispatch2 0.2.0",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.1",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.1",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.25"
|
||||
@@ -4257,6 +4342,46 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aefb14219b492afb30b12647b5b1247cadd2c0603467310c36e0f7ae1698c28"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.12",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c341290d31991dbca38b31d412c73dfbdb070bb11536784f19dd2211d13b778f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.12",
|
||||
"toml",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.4.0"
|
||||
@@ -4556,6 +4681,7 @@ dependencies = [
|
||||
"slab",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
@@ -5796,6 +5922,7 @@ dependencies = [
|
||||
"ordered-stream",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"windows-sys 0.59.0",
|
||||
@@ -5921,6 +6048,7 @@ dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"url",
|
||||
"winnow 0.7.11",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
|
||||
@@ -21,6 +21,8 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-os = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close"
|
||||
"core:window:allow-close",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
@@ -75,6 +75,7 @@ pub fn run() {
|
||||
.expect("Failed to export typescript bindings");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(builder.invoke_handler())
|
||||
|
||||
@@ -1,3 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { commands, DownloadProgress, DownloadTaskState, events } from '../../bindings.ts'
|
||||
import { useStore } from '../../store.ts'
|
||||
import UncompletedProgresses from './components/UncompletedProgresses.vue'
|
||||
import CompletedProgresses from './components/CompletedProgresses.vue'
|
||||
import DownloadDirInput from './components/DownloadDirInput.vue'
|
||||
import { PhCheckCircle, PhCloudArrowDown } from '@phosphor-icons/vue'
|
||||
|
||||
export type ProgressData = DownloadProgress & {
|
||||
state: DownloadTaskState
|
||||
percentage: number
|
||||
stateIndicator: string
|
||||
taskIndicator: string
|
||||
}
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const currentTabName = ref<'uncompleted' | 'completed'>('uncompleted')
|
||||
|
||||
const uncompletedProgressesRef = ref<InstanceType<typeof UncompletedProgresses>>()
|
||||
const completedProgressesRef = ref<InstanceType<typeof CompletedProgresses>>()
|
||||
|
||||
onMounted(async () => {
|
||||
await events.downloadEvent.listen(({ payload: { event, data } }) => {
|
||||
if (event === 'Speed') {
|
||||
store.downloadSpeed = data.speed
|
||||
} else if (event === 'TaskCreate') {
|
||||
const { progress, state } = data
|
||||
const taskId = progress.task_id
|
||||
const progressData: ProgressData = {
|
||||
...progress,
|
||||
state,
|
||||
percentage: 0,
|
||||
stateIndicator: '',
|
||||
taskIndicator: '',
|
||||
}
|
||||
store.updateProgresses((progresses) => {
|
||||
progresses.set(taskId, progressData)
|
||||
})
|
||||
} else if (event === 'TaskStateUpdate') {
|
||||
const { task_id, state } = data
|
||||
|
||||
store.updateProgresses((progresses) => {
|
||||
const progressData = progresses.get(task_id)
|
||||
if (progressData === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
let stateIndicator = ''
|
||||
if (state === 'Pending') {
|
||||
stateIndicator = '排队中'
|
||||
} else if (state === 'Downloading') {
|
||||
stateIndicator = '下载中'
|
||||
} else if (state === 'Paused') {
|
||||
stateIndicator = '已暂停'
|
||||
} else if (state === 'Completed') {
|
||||
stateIndicator = '下载完成'
|
||||
} else if (state === 'Failed') {
|
||||
stateIndicator = '下载失败'
|
||||
}
|
||||
|
||||
progressData.state = state
|
||||
progressData.stateIndicator = stateIndicator
|
||||
})
|
||||
} else if (event === 'TaskSleeping') {
|
||||
const { task_id, remaining_sec } = data
|
||||
store.updateProgresses((progresses) => {
|
||||
const progressData = progresses.get(task_id)
|
||||
if (progressData !== undefined) {
|
||||
progressData.taskIndicator = `将在${remaining_sec}秒后继续下载`
|
||||
}
|
||||
})
|
||||
} else if (event === 'TaskDelete') {
|
||||
const { task_id } = data
|
||||
store.updateProgresses((progresses) => {
|
||||
progresses.delete(task_id)
|
||||
})
|
||||
} else if (event === 'ProgressPreparing') {
|
||||
const { task_id } = data
|
||||
store.updateProgresses((progresses) => {
|
||||
const progressData = progresses.get(task_id)
|
||||
if (progressData !== undefined) {
|
||||
progressData.taskIndicator = '正在准备下载'
|
||||
}
|
||||
})
|
||||
} else if (event === 'ProgressUpdate') {
|
||||
const progress = data.progress
|
||||
store.updateProgresses((progresses) => {
|
||||
const progressData = progresses.get(progress.task_id)
|
||||
if (progressData === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
Object.assign(progressData, progress)
|
||||
|
||||
const videoTask = progressData.video_task
|
||||
const audioTask = progressData.audio_task
|
||||
const mergeTask = progressData.merge_task
|
||||
const danmakuTask = progressData.danmaku_task
|
||||
const subtitleTask = progressData.subtitle_task
|
||||
const coverTask = progressData.cover_task
|
||||
const nfoTask = progressData.nfo_task
|
||||
const jsonTask = progressData.json_task
|
||||
|
||||
const danmakuSelected = danmakuTask.xml_selected || danmakuTask.ass_selected || danmakuTask.json_selected
|
||||
|
||||
if (videoTask.selected && !videoTask.completed && videoTask.content_length > 0) {
|
||||
const chunkCount = progressData.video_task.chunks.length
|
||||
const completedChunks = progressData.video_task.chunks.filter((chunk) => chunk.completed).length
|
||||
progressData.percentage = (completedChunks / chunkCount) * 100
|
||||
progressData.taskIndicator = `视频分片 ${completedChunks}/${chunkCount}`
|
||||
} else if (audioTask.selected && !audioTask.completed && audioTask.content_length > 0) {
|
||||
const chunkCount = progressData.audio_task.chunks.length
|
||||
const completedChunks = progressData.audio_task.chunks.filter((chunk) => chunk.completed).length
|
||||
progressData.percentage = (completedChunks / chunkCount) * 100
|
||||
progressData.taskIndicator = `音频分片 ${completedChunks}/${chunkCount}`
|
||||
} else if (mergeTask.selected && !mergeTask.completed) {
|
||||
progressData.percentage = 100
|
||||
progressData.taskIndicator = '合并视频和音频'
|
||||
} else if (danmakuSelected && !danmakuTask.completed) {
|
||||
progressData.percentage = 100
|
||||
progressData.taskIndicator = '弹幕'
|
||||
} else if (subtitleTask.selected && !subtitleTask.completed) {
|
||||
progressData.percentage = 100
|
||||
progressData.taskIndicator = '字幕'
|
||||
} else if (coverTask.selected && !coverTask.completed) {
|
||||
progressData.percentage = 100
|
||||
progressData.taskIndicator = '封面'
|
||||
} else if (nfoTask.selected && !nfoTask.completed) {
|
||||
progressData.percentage = 100
|
||||
progressData.taskIndicator = 'nfo元数据'
|
||||
} else if (jsonTask.selected && !jsonTask.completed) {
|
||||
progressData.percentage = 100
|
||||
progressData.taskIndicator = 'json元数据'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const result = await commands.restoreDownloadTasks()
|
||||
if (result.status === 'error') {
|
||||
console.error(result.error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>DownloadPane</div>
|
||||
<div class="h-full flex flex-col overflow-auto" v-if="store.config !== undefined">
|
||||
<div class="flex items-center m-2 mb-0">
|
||||
<DownloadDirInput />
|
||||
<span class="ml-2 whitespace-nowrap">下载速度:{{ store.downloadSpeed }}</span>
|
||||
</div>
|
||||
<n-tabs
|
||||
class="h-full overflow-auto"
|
||||
type="line"
|
||||
v-model:value="currentTabName"
|
||||
animated
|
||||
size="small"
|
||||
placement="bottom">
|
||||
<n-tab-pane class="h-full p-0! overflow-auto flex flex-col" name="uncompleted">
|
||||
<template #tab>
|
||||
<n-badge :value="store.uncompletedProgressesCount" :offset="[5, -3]" class="tab-badge-wrapper">
|
||||
<PhCloudArrowDown :weight="currentTabName === 'uncompleted' ? 'fill' : 'regular'" size="20" />
|
||||
<span class="ml-1">下载中</span>
|
||||
</n-badge>
|
||||
</template>
|
||||
<UncompletedProgresses ref="uncompletedProgressesRef" />
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane class="h-full p-0! overflow-auto flex flex-col" name="completed">
|
||||
<template #tab>
|
||||
<PhCheckCircle :weight="currentTabName === 'completed' ? 'fill' : 'regular'" size="20" />
|
||||
<span class="ml-1">已完成</span>
|
||||
</template>
|
||||
|
||||
<CompletedProgresses ref="completedProgressesRef" />
|
||||
</n-tab-pane>
|
||||
|
||||
<template #suffix>
|
||||
<n-pagination
|
||||
v-if="currentTabName === 'uncompleted' && uncompletedProgressesRef"
|
||||
class="ml-auto mr-2"
|
||||
:page-count="uncompletedProgressesRef.pageCount"
|
||||
v-model:page="uncompletedProgressesRef.currentPage" />
|
||||
|
||||
<n-pagination
|
||||
v-else-if="currentTabName === 'completed' && completedProgressesRef"
|
||||
class="ml-auto mr-2"
|
||||
:page-count="completedProgressesRef.pageCount"
|
||||
v-model:page="completedProgressesRef.currentPage" />
|
||||
</template>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
:deep(.n-tabs-nav-scroll-wrapper) {
|
||||
@apply mt-2 h-9;
|
||||
}
|
||||
|
||||
:deep(.n-tabs-nav__suffix) {
|
||||
@apply important-border-0;
|
||||
}
|
||||
|
||||
:deep(.n-tabs-nav-scroll-wrapper) {
|
||||
@apply overflow-visible;
|
||||
}
|
||||
|
||||
:deep(.v-x-scroll) {
|
||||
@apply overflow-visible;
|
||||
}
|
||||
</style>
|
||||
|
||||
192
src/panes/DownloadPane/components/CompletedProgresses.vue
Normal file
192
src/panes/DownloadPane/components/CompletedProgresses.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup lang="tsx">
|
||||
import { ref, watchEffect, computed, nextTick, DeepReadonly, watch } from 'vue'
|
||||
import { SelectionArea, SelectionEvent } from '@viselect/vue'
|
||||
import { commands } from '../../../bindings.ts'
|
||||
import { DropdownOption, NIcon } from 'naive-ui'
|
||||
import { PhChecks, PhTrash, PhArrowClockwise } from '@phosphor-icons/vue'
|
||||
import { useStore } from '../../../store.ts'
|
||||
import DownloadProgress from './DownloadProgress.vue'
|
||||
import { ProgressData } from '../DownloadPane.vue'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
const selectionAreaRef = ref<InstanceType<typeof SelectionArea>>()
|
||||
const progressRefs = ref<InstanceType<typeof DownloadProgress>[]>([])
|
||||
const { dropdownX, dropdownY, dropdownShowing, dropdownOptions, showDropdown } = useDropdown()
|
||||
|
||||
const completedProgresses = computed<[string, DeepReadonly<ProgressData>][]>(() =>
|
||||
Array.from(store.progresses.entries())
|
||||
.filter(([, { state }]) => state === 'Completed')
|
||||
.sort((a, b) => (b[1].completed_ts ?? 0) - (a[1].completed_ts ?? 0)),
|
||||
)
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
const currentPage = ref<number>(1)
|
||||
const pageCount = computed<number>(() => {
|
||||
return Math.ceil(completedProgresses.value.length / PAGE_SIZE)
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (currentPage.value > pageCount.value) {
|
||||
currentPage.value = Math.max(1, pageCount.value)
|
||||
}
|
||||
})
|
||||
|
||||
const currentPageProgresses = computed<[string, DeepReadonly<ProgressData>][]>(() => {
|
||||
const start = (currentPage.value - 1) * PAGE_SIZE
|
||||
const end = start + PAGE_SIZE
|
||||
return completedProgresses.value.slice(start, end)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
// 保证selectedIds中的任务ID都在completedProgresses中
|
||||
const completedIds = new Set(completedProgresses.value.map(([taskId]) => taskId))
|
||||
for (const taskId of selectedIds.value) {
|
||||
if (!completedIds.has(taskId)) {
|
||||
selectedIds.value.delete(taskId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(currentPage, () => {
|
||||
selectionAreaRef.value?.$el.scrollTo({ top: 0, behavior: 'instant' })
|
||||
})
|
||||
|
||||
function extractIds(elements: Element[]): string[] {
|
||||
return elements
|
||||
.map((element) => element.getAttribute('data-key'))
|
||||
.filter(Boolean)
|
||||
.filter((id) => id !== null)
|
||||
}
|
||||
|
||||
function updateSelectedIds({
|
||||
store: {
|
||||
changed: { added, removed },
|
||||
},
|
||||
}: SelectionEvent) {
|
||||
extractIds(added).forEach((taskId) => selectedIds.value.add(taskId))
|
||||
extractIds(removed).forEach((taskId) => selectedIds.value.delete(taskId))
|
||||
}
|
||||
|
||||
function unselectAll({ event, selection }: SelectionEvent) {
|
||||
if (!event?.ctrlKey && !event?.metaKey) {
|
||||
selection.clearSelection()
|
||||
selectedIds.value.clear()
|
||||
}
|
||||
}
|
||||
|
||||
function useDropdown() {
|
||||
const dropdownX = ref<number>(0)
|
||||
const dropdownY = ref<number>(0)
|
||||
const dropdownShowing = ref<boolean>(false)
|
||||
const dropdownOptions: DropdownOption[] = [
|
||||
{
|
||||
label: '全选',
|
||||
key: 'check all',
|
||||
icon: () => (
|
||||
<NIcon size="20">
|
||||
<PhChecks />
|
||||
</NIcon>
|
||||
),
|
||||
props: {
|
||||
onClick: () => {
|
||||
completedProgresses.value.forEach(([taskId]) => selectedIds.value.add(taskId))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '重来',
|
||||
key: 'restart',
|
||||
icon: () => (
|
||||
<NIcon size="20">
|
||||
<PhArrowClockwise />
|
||||
</NIcon>
|
||||
),
|
||||
props: {
|
||||
onClick: () => {
|
||||
commands.restartDownloadTasks(Array.from(selectedIds.value))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
key: 'delete',
|
||||
icon: () => (
|
||||
<NIcon size="20">
|
||||
<PhTrash />
|
||||
</NIcon>
|
||||
),
|
||||
props: {
|
||||
onClick: () => {
|
||||
commands.deleteDownloadTasks(Array.from(selectedIds.value).reverse())
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
pageCount,
|
||||
currentPage,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-auto">
|
||||
<SelectionArea
|
||||
ref="selectionAreaRef"
|
||||
class="h-full flex flex-col selection-container px-2"
|
||||
:options="{ selectables: '.selectable', features: { deselectOnBlur: true } }"
|
||||
@contextmenu="showDropdown"
|
||||
@move="updateSelectedIds"
|
||||
@start="unselectAll">
|
||||
<div class="animate-pulse text-violet">左键拖动进行框选,右键打开菜单</div>
|
||||
|
||||
<DownloadProgress
|
||||
v-for="[taskId, p] in currentPageProgresses"
|
||||
:key="taskId"
|
||||
:p="p"
|
||||
v-model:selected-ids="selectedIds"
|
||||
ref="progressRefs"
|
||||
:data-key="taskId"
|
||||
:class="['selectable', selectedIds.has(taskId) ? 'selected shadow-md' : 'hover:bg-gray-1']" />
|
||||
<n-dropdown
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
:x="dropdownX"
|
||||
:y="dropdownY"
|
||||
:options="dropdownOptions"
|
||||
:show="dropdownShowing"
|
||||
:on-clickoutside="() => (dropdownShowing = false)" />
|
||||
</SelectionArea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.selection-container {
|
||||
@apply select-none overflow-auto;
|
||||
}
|
||||
|
||||
.selection-container .selected {
|
||||
@apply bg-[rgb(204,232,255)];
|
||||
}
|
||||
</style>
|
||||
46
src/panes/DownloadPane/components/DownloadDirInput.vue
Normal file
46
src/panes/DownloadPane/components/DownloadDirInput.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { PhFolderOpen } from '@phosphor-icons/vue'
|
||||
import { commands } from '../../../bindings.ts'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { useStore } from '../../../store.ts'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
async function showDownloadDirInFileManager() {
|
||||
if (store.config === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await commands.showPathInFileManager(store.config.download_dir)
|
||||
if (result.status === 'error') {
|
||||
console.error(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
async function selectDownloadDir() {
|
||||
if (store.config === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedDirPath = await open({ directory: true })
|
||||
if (selectedDirPath === null) {
|
||||
return
|
||||
}
|
||||
|
||||
store.config.download_dir = selectedDirPath
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-input-group class="box-border" v-if="store.config !== undefined">
|
||||
<n-input-group-label size="small">下载目录</n-input-group-label>
|
||||
<n-input v-model:value="store.config.download_dir" size="small" readonly @click="selectDownloadDir" />
|
||||
<n-button class="w-10" size="small" @click="showDownloadDirInFileManager">
|
||||
<template #icon>
|
||||
<n-icon size="20">
|
||||
<PhFolderOpen />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
</template>
|
||||
237
src/panes/DownloadPane/components/DownloadProgress.vue
Normal file
237
src/panes/DownloadPane/components/DownloadProgress.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup lang="tsx">
|
||||
import { path } from '@tauri-apps/api'
|
||||
import { commands, DownloadTaskState } from '../../../bindings.ts'
|
||||
import { useStore } from '../../../store.ts'
|
||||
import {
|
||||
PhWarningCircle,
|
||||
PhCloudArrowDown,
|
||||
PhPause,
|
||||
PhClock,
|
||||
PhGoogleChromeLogo,
|
||||
PhFolderOpen,
|
||||
PhFileVideo,
|
||||
PhMagnifyingGlass,
|
||||
} from '@phosphor-icons/vue'
|
||||
import { ProgressProps } from 'naive-ui'
|
||||
import UpInfoBadge from '../../../components/UpInfoBadge.vue'
|
||||
import { computed, DeepReadonly, inject } from 'vue'
|
||||
import ColorfulTag from '../../../components/ColorfulTag.vue'
|
||||
import { searchPaneRefKey } from '../../../injection_keys.ts'
|
||||
import { ProgressData } from '../DownloadPane.vue'
|
||||
import { ensureHttps } from '../../../utils.tsx'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const searchPaneRef = inject(searchPaneRefKey)
|
||||
|
||||
const props = defineProps<{
|
||||
p: DeepReadonly<ProgressData>
|
||||
}>()
|
||||
|
||||
const selectedIds = defineModel<Set<string>>('selectedIds', { required: true })
|
||||
|
||||
function handleProgressContextMenu() {
|
||||
const taskId = props.p.task_id
|
||||
if (selectedIds.value.has(taskId)) {
|
||||
return
|
||||
}
|
||||
selectedIds.value.clear()
|
||||
selectedIds.value.add(taskId)
|
||||
}
|
||||
|
||||
async function handleProgressDoubleClick() {
|
||||
const state = props.p.state
|
||||
const taskId = props.p.task_id
|
||||
|
||||
if (state === 'Downloading' || state === 'Pending') {
|
||||
await commands.pauseDownloadTasks([taskId])
|
||||
} else if (state === 'Failed' || state === 'Paused') {
|
||||
await commands.resumeDownloadTasks([taskId])
|
||||
}
|
||||
}
|
||||
|
||||
function stateToStatus(state: DownloadTaskState): ProgressProps['status'] {
|
||||
if (state === 'Completed') {
|
||||
return 'success'
|
||||
} else if (state === 'Paused') {
|
||||
return 'warning'
|
||||
} else if (state === 'Failed') {
|
||||
return 'error'
|
||||
} else {
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
function stateToColorClass(state: DownloadTaskState) {
|
||||
if (state === 'Downloading') {
|
||||
return 'text-blue-500'
|
||||
} else if (state === 'Pending') {
|
||||
return 'text-gray-500'
|
||||
} else if (state === 'Paused') {
|
||||
return 'text-yellow-500'
|
||||
} else if (state === 'Failed') {
|
||||
return 'text-red-500'
|
||||
} else if (state === 'Completed') {
|
||||
return 'text-green-500'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
async function showMp4InFileManager(episodeDir: string, filename: string) {
|
||||
if (store.config === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
let mp4filename = `${filename}.mp4`
|
||||
const mp4Path = await path.join(episodeDir, mp4filename)
|
||||
|
||||
const result = await commands.showPathInFileManager(mp4Path)
|
||||
if (result.status === 'error') {
|
||||
console.error(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
async function showEpisodeDirInFileManager(episodeDir: string) {
|
||||
if (store.config === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await commands.showPathInFileManager(episodeDir)
|
||||
if (result.status === 'error') {
|
||||
console.error(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
const href = computed<string>(() => {
|
||||
if (props.p.episode_type === 'Normal') {
|
||||
let href = `https://www.bilibili.com/video/${props.p.bvid}/`
|
||||
if (props.p.part_order !== null) {
|
||||
href += `?p=${props.p.part_order}`
|
||||
}
|
||||
return href
|
||||
} else if (props.p.episode_type === 'Bangumi') {
|
||||
return `https://www.bilibili.com/bangumi/play/ep${props.p.ep_id}`
|
||||
} else if (props.p.episode_type === 'Cheese') {
|
||||
return `https://www.bilibili.com/cheese/play/ep${props.p.ep_id}`
|
||||
}
|
||||
return 'https://www.bilibili.com/'
|
||||
})
|
||||
|
||||
function handleSearchClick() {
|
||||
if (props.p.episode_type === 'Normal' && props.p.bvid !== null) {
|
||||
searchPaneRef?.value?.search(props.p.bvid, 'Normal')
|
||||
} else if (props.p.episode_type === 'Bangumi' && props.p.ep_id !== null) {
|
||||
searchPaneRef?.value?.search(`ep${props.p.ep_id}`, 'Bangumi')
|
||||
} else if (props.p.episode_type === 'Cheese' && props.p.ep_id !== null) {
|
||||
searchPaneRef?.value?.search(`ep${props.p.ep_id}`, 'Cheese')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="p-2 mb-2 rounded-lg flex flex-col border border-solid border-gray-2"
|
||||
@contextmenu="handleProgressContextMenu"
|
||||
@dblclick="handleProgressDoubleClick">
|
||||
<div class="flex">
|
||||
<img
|
||||
class="w-224px h-140px rounded-lg object-cover lazyload"
|
||||
:data-src="`${ensureHttps(p.cover_task.url)}@672w_378h_1c.webp`"
|
||||
:key="p.cover_task.url"
|
||||
alt=""
|
||||
draggable="false" />
|
||||
|
||||
<div class="ml-2 flex flex-col w-full overflow-hidden">
|
||||
<div class="text-lg font-bold line-clamp-2" :title="p.episode_title">{{ p.episode_title }}</div>
|
||||
|
||||
<ColorfulTag
|
||||
color="violet"
|
||||
class="bg-violet-2 w-fit font-bold line-clamp-1"
|
||||
v-if="p.part_title !== null"
|
||||
:title="p.part_title">
|
||||
P{{ p.part_order }} {{ p.part_title }}
|
||||
</ColorfulTag>
|
||||
|
||||
<div class="mt-auto flex gap-1 flex-wrap" title="下载内容">
|
||||
<ColorfulTag v-if="p.video_task.selected" color="blue">
|
||||
视频(编码:{{ p.video_task.codec_type }} 画质:{{ p.video_task.video_quality }})
|
||||
</ColorfulTag>
|
||||
<ColorfulTag v-if="p.audio_task.selected" color="blue">
|
||||
音频(音质:{{ p.audio_task.audio_quality }})
|
||||
</ColorfulTag>
|
||||
<ColorfulTag v-if="p.merge_task.selected" color="blue">自动合并</ColorfulTag>
|
||||
|
||||
<ColorfulTag v-if="p.danmaku_task.xml_selected" color="purple">xml弹幕</ColorfulTag>
|
||||
<ColorfulTag v-if="p.danmaku_task.ass_selected" color="purple">ass弹幕</ColorfulTag>
|
||||
<ColorfulTag v-if="p.danmaku_task.json_selected" color="purple">json弹幕</ColorfulTag>
|
||||
|
||||
<ColorfulTag v-if="p.subtitle_task.selected" color="green">字幕</ColorfulTag>
|
||||
<ColorfulTag v-if="p.cover_task.selected" color="green">封面</ColorfulTag>
|
||||
|
||||
<ColorfulTag v-if="p.nfo_task.selected" color="amber">nfo元数据</ColorfulTag>
|
||||
<ColorfulTag v-if="p.json_task.selected" color="amber">json元数据</ColorfulTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex mt-2 gap-2">
|
||||
<UpInfoBadge
|
||||
class="w-40"
|
||||
v-if="p.up_name !== null && p.up_avatar !== null && p.up_uid !== null"
|
||||
:up-name="p.up_name"
|
||||
:up-avatar="p.up_avatar"
|
||||
:up-uid="p.up_uid" />
|
||||
|
||||
<div v-if="p.state !== 'Completed'" :class="[stateToColorClass(p.state), 'flex items-center w-100']">
|
||||
<n-icon :size="22">
|
||||
<PhCloudArrowDown v-if="p.state === 'Downloading'" />
|
||||
<PhClock v-else-if="p.state === 'Pending'" />
|
||||
<PhPause v-else-if="p.state === 'Paused'" />
|
||||
<PhWarningCircle v-else-if="p.state === 'Failed'" />
|
||||
</n-icon>
|
||||
<span class="whitespace-nowrap mr-2">{{ p.stateIndicator }}</span>
|
||||
<n-progress
|
||||
v-if="p.taskIndicator !== ''"
|
||||
:status="stateToStatus(p.state)"
|
||||
:percentage="p.percentage"
|
||||
:processing="p.state === 'Downloading'">
|
||||
{{ p.taskIndicator }}
|
||||
</n-progress>
|
||||
</div>
|
||||
<div v-else-if="p.completed_ts !== null" title="完成时间" class="flex items-center">
|
||||
<n-time class="font-bold" unix :time="p.completed_ts" />
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex gap-2 items-center">
|
||||
<div
|
||||
v-if="p.state === 'Completed' && p.video_task.selected"
|
||||
title="打开mp4目录"
|
||||
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="showMp4InFileManager(p.episode_dir, p.filename)">
|
||||
<PhFileVideo :size="24" />
|
||||
</div>
|
||||
<div
|
||||
v-if="p.state === 'Completed'"
|
||||
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="showEpisodeDirInFileManager(p.episode_dir)">
|
||||
<PhFolderOpen :size="24" />
|
||||
</div>
|
||||
<div
|
||||
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="handleSearchClick">
|
||||
<PhMagnifyingGlass :size="24" />
|
||||
</div>
|
||||
<a
|
||||
:href="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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
255
src/panes/DownloadPane/components/UncompletedProgresses.vue
Normal file
255
src/panes/DownloadPane/components/UncompletedProgresses.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<script setup lang="tsx">
|
||||
import { ref, watchEffect, computed, nextTick, DeepReadonly, watch } from 'vue'
|
||||
import { SelectionArea, SelectionEvent } from '@viselect/vue'
|
||||
import { commands } from '../../../bindings.ts'
|
||||
import { DropdownOption, NIcon } from 'naive-ui'
|
||||
import { PhPause, PhChecks, PhTrash, PhCaretRight, PhArrowClockwise } from '@phosphor-icons/vue'
|
||||
import { useStore } from '../../../store.ts'
|
||||
import DownloadProgress from './DownloadProgress.vue'
|
||||
import { ProgressData } from '../DownloadPane.vue'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
const selectionAreaRef = ref<InstanceType<typeof SelectionArea>>()
|
||||
const progressRefs = ref<InstanceType<typeof DownloadProgress>[]>([])
|
||||
const { dropdownX, dropdownY, dropdownShowing, dropdownOptions, showDropdown } = useDropdown()
|
||||
|
||||
const uncompletedProgresses = computed<[string, DeepReadonly<ProgressData>][]>(() =>
|
||||
Array.from(store.progresses.entries())
|
||||
.filter(([, { state }]) => state !== 'Completed')
|
||||
.sort((a, b) => {
|
||||
// 下载中的任务排在最前面
|
||||
if (a[1].state === 'Downloading' && b[1].state !== 'Downloading') {
|
||||
return -1
|
||||
}
|
||||
if (b[1].state === 'Downloading' && a[1].state !== 'Downloading') {
|
||||
return 1
|
||||
}
|
||||
// 其次是排队中的任务
|
||||
if (a[1].state === 'Pending' && b[1].state !== 'Pending') {
|
||||
return -1
|
||||
}
|
||||
if (b[1].state === 'Pending' && a[1].state !== 'Pending') {
|
||||
return 1
|
||||
}
|
||||
// 再其次是已开始的任务(即有进度的任务)
|
||||
if (a[1].taskIndicator !== '' && b[1].taskIndicator === '') {
|
||||
return -1
|
||||
}
|
||||
if (b[1].taskIndicator !== '' && a[1].taskIndicator === '') {
|
||||
return 1
|
||||
}
|
||||
// 如果任务都已开始(即有进度的任务),则按进度排序
|
||||
if (a[1].taskIndicator !== '' && b[1].taskIndicator !== '') {
|
||||
return b[1].percentage - a[1].percentage
|
||||
}
|
||||
// 上述条件都不满足时,按创建时间排序
|
||||
return b[1].create_ts - a[1].create_ts
|
||||
}),
|
||||
)
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
const currentPage = ref<number>(1)
|
||||
const pageCount = computed<number>(() => {
|
||||
return Math.ceil(uncompletedProgresses.value.length / PAGE_SIZE)
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (currentPage.value > pageCount.value) {
|
||||
currentPage.value = Math.max(1, pageCount.value)
|
||||
}
|
||||
})
|
||||
|
||||
const currentPageProgresses = computed<[string, DeepReadonly<ProgressData>][]>(() => {
|
||||
const start = (currentPage.value - 1) * PAGE_SIZE
|
||||
const end = start + PAGE_SIZE
|
||||
return uncompletedProgresses.value.slice(start, end)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
// 保证selectedIds中的任务ID都在uncompletedProgresses中
|
||||
const uncompletedIds = new Set(uncompletedProgresses.value.map(([taskId]) => taskId))
|
||||
for (const taskId of selectedIds.value) {
|
||||
if (!uncompletedIds.has(taskId)) {
|
||||
selectedIds.value.delete(taskId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(currentPage, () => {
|
||||
selectionAreaRef.value?.$el.scrollTo({ top: 0, behavior: 'instant' })
|
||||
})
|
||||
|
||||
function extractIds(elements: Element[]): string[] {
|
||||
return elements
|
||||
.map((element) => element.getAttribute('data-key'))
|
||||
.filter(Boolean)
|
||||
.filter((id) => id !== null)
|
||||
}
|
||||
|
||||
function updateSelectedIds({
|
||||
store: {
|
||||
changed: { added, removed },
|
||||
},
|
||||
}: SelectionEvent) {
|
||||
extractIds(added).forEach((taskId) => selectedIds.value.add(taskId))
|
||||
extractIds(removed).forEach((taskId) => selectedIds.value.delete(taskId))
|
||||
}
|
||||
|
||||
function unselectAll({ event, selection }: SelectionEvent) {
|
||||
if (!event?.ctrlKey && !event?.metaKey) {
|
||||
selection.clearSelection()
|
||||
selectedIds.value.clear()
|
||||
}
|
||||
}
|
||||
|
||||
function useDropdown() {
|
||||
const dropdownX = ref<number>(0)
|
||||
const dropdownY = ref<number>(0)
|
||||
const dropdownShowing = ref<boolean>(false)
|
||||
const dropdownOptions: DropdownOption[] = [
|
||||
{
|
||||
label: '全选',
|
||||
key: 'check all',
|
||||
icon: () => (
|
||||
<NIcon size="20">
|
||||
<PhChecks />
|
||||
</NIcon>
|
||||
),
|
||||
props: {
|
||||
onClick: () => {
|
||||
uncompletedProgresses.value.forEach(([taskId]) => selectedIds.value.add(taskId))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '继续',
|
||||
key: 'resume',
|
||||
icon: () => (
|
||||
<NIcon size="20">
|
||||
<PhCaretRight />
|
||||
</NIcon>
|
||||
),
|
||||
props: {
|
||||
onClick: () => {
|
||||
commands.resumeDownloadTasks(Array.from(selectedIds.value))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '暂停',
|
||||
key: 'pause',
|
||||
icon: () => (
|
||||
<NIcon size="20">
|
||||
<PhPause />
|
||||
</NIcon>
|
||||
),
|
||||
props: {
|
||||
onClick: () => {
|
||||
commands.pauseDownloadTasks(Array.from(selectedIds.value))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '重来',
|
||||
key: 'restart',
|
||||
icon: () => (
|
||||
<NIcon size="20">
|
||||
<PhArrowClockwise />
|
||||
</NIcon>
|
||||
),
|
||||
props: {
|
||||
onClick: () => {
|
||||
commands.restartDownloadTasks(Array.from(selectedIds.value).reverse())
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
key: 'delete',
|
||||
icon: () => (
|
||||
<NIcon size="20">
|
||||
<PhTrash />
|
||||
</NIcon>
|
||||
),
|
||||
props: {
|
||||
onClick: () => {
|
||||
commands.deleteDownloadTasks(Array.from(selectedIds.value))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
pageCount,
|
||||
currentPage,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-auto">
|
||||
<SelectionArea
|
||||
ref="selectionAreaRef"
|
||||
class="h-full flex flex-col selection-container px-2"
|
||||
:options="{ selectables: '.selectable', features: { deselectOnBlur: true } }"
|
||||
@contextmenu="showDropdown"
|
||||
@move="updateSelectedIds"
|
||||
@start="unselectAll">
|
||||
<div class="flex">
|
||||
<span class="animate-pulse text-violet">
|
||||
左键拖动进行框选,右键打开菜单,双击暂停/继续,失败的任务也可以继续
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DownloadProgress
|
||||
v-for="[taskId, p] in currentPageProgresses"
|
||||
:key="taskId"
|
||||
:p="p"
|
||||
v-model:selected-ids="selectedIds"
|
||||
ref="progressRefs"
|
||||
:data-key="taskId"
|
||||
:class="['selectable ', selectedIds.has(taskId) ? 'selected shadow-md' : 'hover:bg-gray-1']" />
|
||||
</SelectionArea>
|
||||
|
||||
<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 {
|
||||
@apply select-none overflow-auto;
|
||||
}
|
||||
|
||||
.selection-container .selected {
|
||||
@apply bg-[rgb(204,232,255)];
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@ import NormalSinglePanel from './components/NormalSinglePanel.vue'
|
||||
import { extractBvid, extractAid } from '../../utils.tsx'
|
||||
import { useStore } from '../../store.ts'
|
||||
|
||||
export type SearchType = 'Auto' | 'Normal'
|
||||
export type SearchType = 'Auto' | 'Normal' | 'Bangumi' | 'Cheese'
|
||||
|
||||
const searchTypeOptions: SelectProps['options'] = [
|
||||
{ label: '自动', value: 'Auto' },
|
||||
|
||||
54
src/store.ts
54
src/store.ts
@@ -1,12 +1,62 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { Config, UserInfo } from './bindings.ts'
|
||||
import { CurrentNavName } from './AppContent.vue'
|
||||
import { ProgressData } from './panes/DownloadPane/DownloadPane.vue'
|
||||
|
||||
export const useStore = defineStore('store', () => {
|
||||
const config = ref<Config>()
|
||||
const userInfo = ref<UserInfo>()
|
||||
const currentNavName = ref<CurrentNavName>('search')
|
||||
const downloadSpeed = ref<string>('')
|
||||
|
||||
return { config, userInfo, currentNavName }
|
||||
const { progresses, updateProgresses } = useProgresses()
|
||||
const uncompletedProgressesCount = computed<number>(() => {
|
||||
return Array.from(progresses.value.values()).filter(({ state }) => state !== 'Completed').length
|
||||
})
|
||||
|
||||
return {
|
||||
config,
|
||||
currentNavName,
|
||||
userInfo,
|
||||
progresses,
|
||||
updateProgresses,
|
||||
uncompletedProgressesCount,
|
||||
downloadSpeed,
|
||||
}
|
||||
})
|
||||
|
||||
function useProgresses() {
|
||||
// 内部的高频更新状态
|
||||
const _progresses = new Map<string, ProgressData>()
|
||||
// 对外暴露的响应式状态
|
||||
const progresses = ref<Map<string, ProgressData>>(new Map())
|
||||
|
||||
// 用于确保在同一渲染帧内只安排一次UI更新
|
||||
let isUpdateScheduled = false
|
||||
|
||||
// 将 `_progresses` 的内容更新到 `progresses` 中,并触发重新渲染
|
||||
const updateProgressesOnFrame = () => {
|
||||
const newProgressesMap = new Map<string, ProgressData>()
|
||||
for (const [key, value] of _progresses.entries()) {
|
||||
newProgressesMap.set(key, { ...value })
|
||||
}
|
||||
progresses.value = newProgressesMap
|
||||
|
||||
isUpdateScheduled = false
|
||||
}
|
||||
|
||||
const updateProgresses = (updateFn: (progresses: Map<string, ProgressData>) => void) => {
|
||||
// 使用传入的更新函数来修改 `_progresses`
|
||||
updateFn(_progresses)
|
||||
|
||||
if (!isUpdateScheduled) {
|
||||
// 如果没有安排过UI更新,则安排一次
|
||||
isUpdateScheduled = true
|
||||
// 使用 `requestAnimationFrame` 调度 UI 更新
|
||||
requestAnimationFrame(updateProgressesOnFrame)
|
||||
}
|
||||
}
|
||||
|
||||
return { progresses: readonly(progresses), updateProgresses }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user