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`
# 尽可能的使用简体中文
+175 -82
View File
@@ -6,18 +6,11 @@ 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')
export class TaskRoute implements VanComponent {
element: HTMLElement
/** 包含视频播放器的模态框 */
playerModalComp = new PlayerModalComp()
loading = van.state(false)
taskList: State<(TaskInDB & {
type TaskItem = TaskInDB & {
/** 音频下载进度百分比 */
audioProgress: State<number>
/** 视频下载进度百分比 */
@@ -30,10 +23,39 @@ export class TaskRoute implements VanComponent {
opening: State<boolean>
/** 是否正在删除 */
deleting: State<boolean>
})[]> = van.state([])
/** 是否选中 */
selected: State<boolean>
}
export class TaskRoute implements VanComponent {
element: HTMLElement
/** 包含视频播放器的模态框 */
playerModalComp = new PlayerModalComp()
loading = van.state(false)
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,16 +66,143 @@ 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 => {
() => 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))
)
)
)
},
async onFirst() {
if (!await checkLogin()) return
},
async onLoad() {
if (!GLOBAL_HAS_LOGIN.val) return goto('login')
_that.loading.val = true
getTaskList(0, 360).then(taskList => {
if (!taskList) return
_that.taskList.val = taskList.map(task => ({
...task,
audioProgress: van.state(1),
videoProgress: van.state(1),
mergeProgress: van.state(1),
statusState: van.state(task.status),
opening: van.state(false),
deleting: van.state(false),
selected: van.state(false)
}))
const refresh = async () => {
const activeTaskList = await getActiveTask()
if (!activeTaskList) return false
setTimeout(() => {
_that.loading.val = false
}, 200)
_that.taskList.val.forEach(taskInDB => {
activeTaskList.forEach(task => {
if (taskInDB.id == task.id) {
taskInDB.audioProgress.val = task.audioProgress
taskInDB.videoProgress.val = task.videoProgress
taskInDB.mergeProgress.val = task.mergeProgress
taskInDB.statusState.val = task.status
}
})
})
if (activeTaskList.filter(task => task.status == 'running').length == 0) {
clearInterval(timer)
clearInterval(helper)
}
return true
}
refresh()
let timer = setInterval(() => {
refresh()
}, 1000)
let helper = setInterval(() => {
if (now.val.split('/')[0] != 'task') {
clearInterval(helper)
clearInterval(timer)
}
})
})
},
})
}
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-3',
class: 'vstack gap-2 py-2 px-2 flex-fill',
style: `cursor: pointer;`,
onclick() {
const src = `/api/downloadVideo?path=${encodeURIComponent(
@@ -116,15 +265,17 @@ export class TaskRoute implements VanComponent {
}),
)
),
// 操作按钮
div({
class: 'me-4',
hidden: task.statusState.val != 'done'
|| task.opening.val // 正在打开文件位置时,不应该显示删除按钮
|| task.deleting.val // 正在删除时,不应该显示删除按钮
|| task.opening.val
|| task.deleting.val
},
div({
class: 'hover-btn', title: '打开文件位置',
onclick() {
onclick(event: Event) {
event.stopPropagation()
showFile(`${task.folder}\\${filename}`)
task.opening.val = true
setTimeout(() => {
@@ -139,16 +290,18 @@ export class TaskRoute implements VanComponent {
class: 'me-4',
hidden: task.statusState.val != 'done'
&& task.statusState.val != 'error'
|| task.opening.val // 正在打开文件位置时,不应该显示删除按钮
|| task.deleting.val // 正在删除时,不应该显示删除按钮
|| task.opening.val
|| task.deleting.val
},
div({
class: 'hover-btn', title: '删除视频',
onclick() {
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)
})
}
@@ -162,7 +315,8 @@ export class TaskRoute implements VanComponent {
},
div({
class: 'hover-btn text-danger', title: '取消任务',
onclick() {
onclick(event: Event) {
event.stopPropagation()
if (!confirm('确定要取消该任务吗?')) return
cancelTask(task.id).then(() => {
task.statusState.val = 'error'
@@ -175,67 +329,6 @@ export class TaskRoute implements VanComponent {
)
),
)
})
)
)
},
async onFirst() {
if (!await checkLogin()) return
},
async onLoad() {
if (!GLOBAL_HAS_LOGIN.val) return goto('login')
_that.loading.val = true
getTaskList(0, 360).then(taskList => {
if (!taskList) return
_that.taskList.val = taskList.map(task => ({
...task,
audioProgress: van.state(1),
videoProgress: van.state(1),
mergeProgress: van.state(1),
statusState: van.state(task.status),
opening: van.state(false),
deleting: van.state(false)
}))
const refresh = async () => {
const activeTaskList = await getActiveTask()
if (!activeTaskList) return false
setTimeout(() => {
_that.loading.val = false
}, 200)
_that.taskList.val.forEach(taskInDB => {
activeTaskList.forEach(task => {
if (taskInDB.id == task.id) {
taskInDB.audioProgress.val = task.audioProgress
taskInDB.videoProgress.val = task.videoProgress
taskInDB.mergeProgress.val = task.mergeProgress
taskInDB.statusState.val = task.status
}
})
})
if (activeTaskList.filter(task => task.status == 'running').length == 0) {
clearInterval(timer)
clearInterval(helper)
}
return true
}
refresh()
let timer = setInterval(() => {
refresh()
}, 1000)
let helper = setInterval(() => {
if (now.val.split('/')[0] != 'task') {
clearInterval(helper)
clearInterval(timer)
}
})
})
},
})
}
DeleteSVG() {