feat: 控制和管理下载任务

This commit is contained in:
lanyeeee
2025-08-09 06:27:33 +08:00
parent b83d13c7e1
commit ae57231577
14 changed files with 1142 additions and 7 deletions

3
components.d.ts vendored
View File

@@ -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']

View File

@@ -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
View File

@@ -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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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"
]
}

View File

@@ -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())

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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' },

View File

@@ -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 }
}