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
+15
View File
@@ -0,0 +1,15 @@
# 我是简体中文用户
# 我是简体中文用户
# 我是简体中文用户
# 这是windows系统,不是linux,也不是wsl
# 这是windows系统,不是linux,也不是wsl
# 这是windows系统,不是linux,也不是wsl
# 涉及到各种图的画法,使用`mermaid`
# 代码注释使用中文。utf-8编码
@configs/git-commit.md
@configs/env.md
+35
View File
@@ -0,0 +1,35 @@
# environment
**The current system is Windows.**
## go env:
**C:\Program Files\Go\bin**
## git env:
**C:\Program Files\PortableGit\bin**
## python env:
**C:\Program Files\Python314**
**C:\Program Files\Python314\Scripts**
**Please locate the venv or .venv directory in your project to create a Python virtual environment.**
## node env
**C:\Program Files\nodejs**
## nmap env:
**C:\Program Files (x86)\Nmap**
## sqliet3 env:
**C:\Program Files\sqlite-tools-win-x64-3510300**
## curl env:
**C:\Program Files\curl-8.19.0_2-win64-mingw\bin**
+129
View File
@@ -0,0 +1,129 @@
# 我是中文简体用户
# Commit Message Format
We have very precise rules over how our Git commit messages must be formatted.
This format leads to **easier to read commit history** and makes it analyzable for changelog generation.
Each commit message consists of a **header**, a **body**, and a **footer**.
```
<header>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>
```
The `header` is mandatory and must conform to the [Commit Message Header](#commit-header) format.
The `body` is mandatory for all commits except for those of type "docs".
When the body is present it must be at least 20 characters long and must conform to the [Commit Message Body](#commit-body) format.
The `footer` is optional. The [Commit Message Footer](#commit-footer) format describes what the footer is used for and the structure it must have.
## Commit Message Header
```
<type>(<scope>): <short summary>
│ │ │
│ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
│ │
│ └─⫸ Commit Scope: anything module
└─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
```
The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
### Type
Must be one of the following:
| Type | Description |
| ------------ | --------------------------------------------------------------------------------------------------- |
| **build** | Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) |
| **ci** | Changes to our CI configuration files and scripts (examples: Github Actions, SauceLabs) |
| **docs** | Documentation only changes |
| **feat** | A new feature |
| **fix** | A bug fix |
| **perf** | A code change that improves performance |
| **refactor** | A code change that neither fixes a bug nor adds a feature |
| **test** | Adding missing tests or correcting existing tests |
### Scope
The scope should be the name of the module affected (as perceived by the person reading the changelog generated from commit messages).
There are currently a few exceptions to the "use package name" rule:
- `dev-infra`: used for dev-infra related changes within the directories /scripts and /tools
- `docs-infra`: used for infrastructure changes to the docs-app (angular.dev) within the `/adev` directory, such as application code, tooling, or configuration. **For modifications to documentation content (e.g., editing a `.md` file), use `docs:` without a scope instead.**
- `migrations`: used for changes to the `ng update` migrations.
- none/empty string: useful for `test` and `refactor` changes that are done across all packages (e.g. `test: add missing unit tests`) and for docs changes that are not related to a specific package (e.g. `docs: fix typo in tutorial`).
### Summary
Use the summary field to provide a succinct description of the change:
- use the imperative, present tense: "change" not "changed" nor "changes"
- don't capitalize the first letter
- no dot (.) at the end
## Commit Message Body
Just as in the summary, use the imperative, present tense: "fix" not "fixed" nor "fixes".
Explain the motivation for the change in the commit message body. This commit message should explain _why_ you are making the change.
You can include a comparison of the previous behavior with the new behavior in order to illustrate the impact of the change.
## Commit Message Footer
The footer can contain information about breaking changes and deprecations and is also the place to reference GitHub issues and other PRs that this commit closes or is related to.
For example:
```
BREAKING CHANGE: <breaking change summary>
<BLANK LINE>
<breaking change description + migration instructions>
<BLANK LINE>
<BLANK LINE>
Fixes #<issue number>
```
or
```
DEPRECATED: <what is deprecated>
<BLANK LINE>
<deprecation description + recommended update path>
<BLANK LINE>
<BLANK LINE>
Closes #<pr number>
```
Breaking Change section should start with the phrase `BREAKING CHANGE: ` followed by a _brief_ summary of the breaking change, a blank line, and a detailed description of the breaking change that also includes migration instructions.
Similarly, a Deprecation section should start with `DEPRECATED: ` followed by a short description of what is deprecated, a blank line, and a detailed description of the deprecation that also mentions the recommended update path.
## Revert commits
If the commit reverts a previous commit, it should begin with `revert: `, followed by the header of the reverted commit.
The content of the commit message body should contain:
- information about the SHA of the commit being reverted in the following format: `This reverts commit <SHA>`,
- a clear description of the reason for reverting the commit message.
## 在Body中最后一行添加`Co-Authored-By: AI`
# 尽可能的使用简体中文
+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" }),