feat: add batch select and operations for task list

- Add checkbox for each task item
- Add select all checkbox in toolbar
- Add batch cancel button for selected running/waiting tasks
- Add batch delete button for selected completed/failed tasks
- Show selected count in toolbar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 17:05:48 +08:00
parent 8fb1aa36ca
commit aa59a4559f
4 changed files with 421 additions and 149 deletions
+242 -149
View File
@@ -6,10 +6,27 @@ import { TaskInDB, TaskStatus } from '../work/type'
import { LoadingBox } from '../view'
import { PlayerModalComp } from './playerModal'
const { div, span } = van.tags
const { div, span, input, button } = van.tags
const { svg, path } = van.tags('http://www.w3.org/2000/svg')
type TaskItem = TaskInDB & {
/** 音频下载进度百分比 */
audioProgress: State<number>
/** 视频下载进度百分比 */
videoProgress: State<number>
/** 合并进度百分比 */
mergeProgress: State<number>
/** 任务状态 */
statusState: State<TaskStatus>
/** 是否正在打开 */
opening: State<boolean>
/** 是否正在删除 */
deleting: State<boolean>
/** 是否选中 */
selected: State<boolean>
}
export class TaskRoute implements VanComponent {
element: HTMLElement
/** 包含视频播放器的模态框 */
@@ -17,23 +34,28 @@ export class TaskRoute implements VanComponent {
loading = van.state(false)
taskList: State<(TaskInDB & {
/** 音频下载进度百分比 */
audioProgress: State<number>
/** 视频下载进度百分比 */
videoProgress: State<number>
/** 合并进度百分比 */
mergeProgress: State<number>
/** 任务状态 */
statusState: State<TaskStatus>
/** 是否正在打开 */
opening: State<boolean>
/** 是否正在删除 */
deleting: State<boolean>
})[]> = van.state([])
taskList: State<TaskItem[]> = van.state([])
/** 是否全选 */
allSelected = van.derive(() => {
const list = this.taskList.val.filter(t => !t.deleting.val)
return list.length > 0 && list.every(t => t.selected.val)
})
/** 选中的任务数量 */
selectedCount = van.derive(() => this.taskList.val.filter(t => t.selected.val && !t.deleting.val).length)
/** 选中的可删除任务 */
selectedDeletable = van.derive(() => this.taskList.val.filter(t =>
t.selected.val && !t.deleting.val && (t.statusState.val == 'done' || t.statusState.val == 'error')
))
/** 选中的可取消任务 */
selectedCancellable = van.derive(() => this.taskList.val.filter(t =>
t.selected.val && !t.deleting.val && (t.statusState.val == 'waiting' || t.statusState.val == 'running')
))
constructor() {
this.element = this.Root()
}
@@ -44,138 +66,57 @@ export class TaskRoute implements VanComponent {
Loader() {
return div(
() => _that.loading.val ? LoadingBox() : '',
() => div({ class: 'list-group', hidden: _that.loading.val },
_that.taskList.val.map(task => {
const ext = task.downloadType === 'audio' ? '.m4a' : '.mp4'
const filename = `${task.title} ${btoa(task.id.toString()).replace(/=/g, '')}${ext}`
return div({
class: () => `list-group-item p-0 hstack user-select-none ${task.statusState.val != 'done' && task.statusState.val != 'error' || task.opening.val ? 'disabled' : ''}`,
hidden: task.deleting,
},
div({
class: 'vstack gap-2 py-2 px-3',
style: `cursor: pointer;`,
onclick() {
const src = `/api/downloadVideo?path=${encodeURIComponent(
`${task.folder}\\${filename}`
)}`
if (task.statusState.val != 'done') return
_that.playerModalComp.open(src, task.title, task.downloadType === 'audio' ? 'audio' : 'video')
}
},
div({
class: () => `
${task.statusState.val == 'error' ? 'text-danger' : ''}
${task.statusState.val == 'waiting' || task.statusState.val == 'running'
? 'text-primary' : ''}`
},
() => {
if (task.opening.val) return '正在打开文件位置...'
return div(
span({
class: `me-2 badge ${task.downloadType === 'audio' ? 'bg-success' : 'bg-primary'}`,
title: task.downloadType === 'audio' ? '音频' : '视频'
}, task.downloadType === 'audio' ? 'A' : 'V'),
span({}, filename),
)
}),
div({ class: 'text-secondary small' },
() => {
if (task.statusState.val == 'waiting') return '等待下载'
if (task.statusState.val == 'error') return '下载失败'
if (task.statusState.val == 'done') return task.folder
if (task.videoProgress.val == 0) {
return `正在下载音频 (${(task.audioProgress.val * 100).toFixed(2)}%)`
} else if (task.mergeProgress.val == 0) {
return `正在下载视频 (${(task.videoProgress.val * 100).toFixed(2)}%)`
} else if (task.statusState.val == 'running') {
return `正在合并音视频 (${(task.mergeProgress.val * 100).toFixed(2)}%)`
} else {
return task.folder
}
}
),
div({
class: `progress`,
style: `height: 5px`,
hidden: () => task.statusState.val == 'done' || task.statusState.val == 'error'
},
div({
class: () => `progress-bar progress-bar-striped progress-bar-animated bg-${(() => {
if (task.videoProgress.val == 0) return 'primary'
if (task.mergeProgress.val == 0) return 'success'
else return 'info'
})()}`,
style: () => {
let width = 0
if (task.videoProgress.val == 0) width = task.audioProgress.val * 100
else if (task.mergeProgress.val == 0) width = task.videoProgress.val * 100
else width = task.mergeProgress.val * 100
return `width: ${width}%`
}
}),
)
),
div({
class: 'me-4',
hidden: task.statusState.val != 'done'
|| task.opening.val // 正在打开文件位置时,不应该显示删除按钮
|| task.deleting.val // 正在删除时,不应该显示删除按钮
},
div({
class: 'hover-btn', title: '打开文件位置',
onclick() {
showFile(`${task.folder}\\${filename}`)
task.opening.val = true
setTimeout(() => {
task.opening.val = false
}, 3000)
}
},
_that.FolderSVG()
)
),
div({
class: 'me-4',
hidden: task.statusState.val != 'done'
&& task.statusState.val != 'error'
|| task.opening.val // 正在打开文件位置时,不应该显示删除按钮
|| task.deleting.val // 正在删除时,不应该显示删除按钮
},
div({
class: 'hover-btn', title: '删除视频',
onclick() {
task.deleting.val = true
deleteTask(task.id).then(() => {
_that.taskList.val = _that.taskList.val.filter(taskInDB => taskInDB.id != task.id)
}).catch(error => {
alert(error.message)
})
}
},
_that.DeleteSVG()
)
),
div({
class: 'me-4',
hidden: task.statusState.val != 'waiting' && task.statusState.val != 'running'
},
div({
class: 'hover-btn text-danger', title: '取消任务',
onclick() {
if (!confirm('确定要取消该任务吗?')) return
cancelTask(task.id).then(() => {
task.statusState.val = 'error'
}).catch(error => {
alert(error.message)
})
}
},
_that.CancelSVG()
)
),
)
})
() => div({ class: 'vstack gap-3', hidden: _that.loading.val },
// 批量操作工具栏
div({ class: 'hstack gap-2', hidden: () => _that.taskList.val.length == 0 },
input({
class: 'form-check-input',
type: 'checkbox',
checked: _that.allSelected,
onclick() {
const checked = !_that.allSelected.val
_that.taskList.val.forEach(t => {
if (!t.deleting.val) t.selected.val = checked
})
}
}),
span({ class: 'text-secondary' }, () => `已选择 ${_that.selectedCount.val}`),
button({
class: 'btn btn-sm btn-outline-danger',
hidden: () => _that.selectedCancellable.val.length == 0,
onclick() {
if (!confirm(`确定要取消选中的 ${_that.selectedCancellable.val.length} 个任务吗?`)) return
_that.selectedCancellable.val.forEach(t => {
cancelTask(t.id).then(() => {
t.statusState.val = 'error'
t.selected.val = false
}).catch(error => {
alert(error.message)
})
})
}
}, () => `批量取消 (${_that.selectedCancellable.val.length})`),
button({
class: 'btn btn-sm btn-outline-secondary',
hidden: () => _that.selectedDeletable.val.length == 0,
onclick() {
if (!confirm(`确定要删除选中的 ${_that.selectedDeletable.val.length} 个任务吗?`)) return
_that.selectedDeletable.val.forEach(t => {
t.deleting.val = true
deleteTask(t.id).then(() => {
_that.taskList.val = _that.taskList.val.filter(taskInDB => taskInDB.id != t.id)
}).catch(error => {
t.deleting.val = false
alert(error.message)
})
})
}
}, () => `批量删除 (${_that.selectedDeletable.val.length})`),
),
// 任务列表
div({ class: 'list-group' },
_that.taskList.val.map(task => _that.TaskItem(task))
)
)
)
},
@@ -195,7 +136,8 @@ export class TaskRoute implements VanComponent {
mergeProgress: van.state(1),
statusState: van.state(task.status),
opening: van.state(false),
deleting: van.state(false)
deleting: van.state(false),
selected: van.state(false)
}))
const refresh = async () => {
@@ -238,6 +180,157 @@ export class TaskRoute implements VanComponent {
})
}
TaskItem(task: TaskItem) {
const _that = this
const ext = task.downloadType === 'audio' ? '.m4a' : '.mp4'
const filename = `${task.title} ${btoa(task.id.toString()).replace(/=/g, '')}${ext}`
return div({
class: () => `list-group-item p-0 hstack user-select-none ${task.statusState.val != 'done' && task.statusState.val != 'error' || task.opening.val ? 'disabled' : ''}`,
hidden: task.deleting,
},
// 复选框
div({ class: 'px-3' },
input({
class: 'form-check-input',
type: 'checkbox',
checked: task.selected,
onclick(event: Event) {
event.stopPropagation()
}
})
),
// 任务内容
div({
class: 'vstack gap-2 py-2 px-2 flex-fill',
style: `cursor: pointer;`,
onclick() {
const src = `/api/downloadVideo?path=${encodeURIComponent(
`${task.folder}\\${filename}`
)}`
if (task.statusState.val != 'done') return
_that.playerModalComp.open(src, task.title, task.downloadType === 'audio' ? 'audio' : 'video')
}
},
div({
class: () => `
${task.statusState.val == 'error' ? 'text-danger' : ''}
${task.statusState.val == 'waiting' || task.statusState.val == 'running'
? 'text-primary' : ''}`
},
() => {
if (task.opening.val) return '正在打开文件位置...'
return div(
span({
class: `me-2 badge ${task.downloadType === 'audio' ? 'bg-success' : 'bg-primary'}`,
title: task.downloadType === 'audio' ? '音频' : '视频'
}, task.downloadType === 'audio' ? 'A' : 'V'),
span({}, filename),
)
}),
div({ class: 'text-secondary small' },
() => {
if (task.statusState.val == 'waiting') return '等待下载'
if (task.statusState.val == 'error') return '下载失败'
if (task.statusState.val == 'done') return task.folder
if (task.videoProgress.val == 0) {
return `正在下载音频 (${(task.audioProgress.val * 100).toFixed(2)}%)`
} else if (task.mergeProgress.val == 0) {
return `正在下载视频 (${(task.videoProgress.val * 100).toFixed(2)}%)`
} else if (task.statusState.val == 'running') {
return `正在合并音视频 (${(task.mergeProgress.val * 100).toFixed(2)}%)`
} else {
return task.folder
}
}
),
div({
class: `progress`,
style: `height: 5px`,
hidden: () => task.statusState.val == 'done' || task.statusState.val == 'error'
},
div({
class: () => `progress-bar progress-bar-striped progress-bar-animated bg-${(() => {
if (task.videoProgress.val == 0) return 'primary'
if (task.mergeProgress.val == 0) return 'success'
else return 'info'
})()}`,
style: () => {
let width = 0
if (task.videoProgress.val == 0) width = task.audioProgress.val * 100
else if (task.mergeProgress.val == 0) width = task.videoProgress.val * 100
else width = task.mergeProgress.val * 100
return `width: ${width}%`
}
}),
)
),
// 操作按钮
div({
class: 'me-4',
hidden: task.statusState.val != 'done'
|| task.opening.val
|| task.deleting.val
},
div({
class: 'hover-btn', title: '打开文件位置',
onclick(event: Event) {
event.stopPropagation()
showFile(`${task.folder}\\${filename}`)
task.opening.val = true
setTimeout(() => {
task.opening.val = false
}, 3000)
}
},
_that.FolderSVG()
)
),
div({
class: 'me-4',
hidden: task.statusState.val != 'done'
&& task.statusState.val != 'error'
|| task.opening.val
|| task.deleting.val
},
div({
class: 'hover-btn', title: '删除视频',
onclick(event: Event) {
event.stopPropagation()
task.deleting.val = true
deleteTask(task.id).then(() => {
_that.taskList.val = _that.taskList.val.filter(taskInDB => taskInDB.id != task.id)
}).catch(error => {
task.deleting.val = false
alert(error.message)
})
}
},
_that.DeleteSVG()
)
),
div({
class: 'me-4',
hidden: task.statusState.val != 'waiting' && task.statusState.val != 'running'
},
div({
class: 'hover-btn text-danger', title: '取消任务',
onclick(event: Event) {
event.stopPropagation()
if (!confirm('确定要取消该任务吗?')) return
cancelTask(task.id).then(() => {
task.statusState.val = 'error'
}).catch(error => {
alert(error.message)
})
}
},
_that.CancelSVG()
)
),
)
}
DeleteSVG() {
return svg({ style: `width: 1em; height: 1em`, fill: "currentColor", class: "bi bi-trash3", viewBox: "0 0 16 16" },
path({ "d": "M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5" }),