feat(tasks): 任务列表支持批量修改分类
勾选多个任务后可一次性移动到目标分类,或选「未分类」把它们移出当前分类。 - db: 新增 SetTasksCategory,在单个事务里校验分类存在性并 UPDATE ... WHERE id=ANY(...),返回真正被更新的 id 和刷新后的分类(含 task_count)。分类检查 放在事务内,避免并发删除把 UPDATE 变成外键冲突。单批上限 100。 - server: 新增 POST /api/tasks/category/batch。写入原子,响应里的逐任务失败 只可能是勾选后又被删掉的任务;顺带把 category_id 的解析(必填但可为 null) 抽成 parseCategoryIDField 与单任务接口共用。 - server: 把批量 id 的 trim/归一/去重逻辑从 controlTasksBatch 抽成 normalizeBatchTaskIDs,两个批量端点共用同一套重复判定。 - manager: SetTasksCategory 在 taskStateMu 内完成 DB 写入与运行中任务 DTO 刷新,防止与单任务更新交错后留下过期的 category_name。 - web: 选中工具栏新增「修改分类」对话框(需确认目标,避免误点批量改动); 「未分类」哨兵值统一提取为 UNCATEGORIZED_VALUE。 验证: go build / vet / gofmt、db 与 server 新增测试连本地 PG 实跑通过、 tsc --noEmit、biome check、next build 均通过。
This commit is contained in:
1 parent
3eca390089
commit
fea78b2d6f
12 files changed
+607
-47
No files matched your search
@@ -812,6 +812,49 @@ func (m *Manager) SetTaskCategory(taskID string, categoryID *int64) (*pgdb.TaskC
|
||||
return category, nil
|
||||
}
|
||||
|
||||
// SetTasksCategory applies one category change to several tasks at once. The
|
||||
// database write and the in-memory refresh share taskStateMu, so a concurrent
|
||||
// single-task update cannot interleave and leave a live DTO stale. The returned
|
||||
// set holds the ids that were actually moved; callers report the rest as gone.
|
||||
func (m *Manager) SetTasksCategory(taskIDs []string, categoryID *int64) (map[string]bool, *pgdb.TaskCategory, error) {
|
||||
m.taskStateMu.Lock()
|
||||
defer m.taskStateMu.Unlock()
|
||||
numeric := make([]int64, 0, len(taskIDs))
|
||||
for _, taskID := range taskIDs {
|
||||
id, err := strconv.ParseInt(taskID, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return nil, nil, pgdb.ErrTaskCategoryTaskNotFound
|
||||
}
|
||||
numeric = append(numeric, id)
|
||||
}
|
||||
updatedIDs, category, err := m.pg.SetTasksCategory(numeric, categoryID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
categoryName := ""
|
||||
if category != nil {
|
||||
categoryName = category.Name
|
||||
}
|
||||
updated := make(map[string]bool, len(updatedIDs))
|
||||
m.mu.RLock()
|
||||
tasks := make([]*Task, 0, len(updatedIDs))
|
||||
for _, id := range updatedIDs {
|
||||
taskID := strconv.FormatInt(id, 10)
|
||||
updated[taskID] = true
|
||||
if task := m.tasks[taskID]; task != nil {
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
for _, task := range tasks {
|
||||
task.updateLifecycle(func(state *taskLifecycleState) {
|
||||
state.CategoryID = cloneInt64Ptr(categoryID)
|
||||
state.CategoryName = categoryName
|
||||
})
|
||||
}
|
||||
return updated, category, nil
|
||||
}
|
||||
|
||||
// DeleteCompanyWithAssets keeps the database cascade and live task handles in
|
||||
// one manager-level critical section. This closes the gap where a task could
|
||||
// commit its company scope immediately before registration and miss the
|
||||
|
||||
Reference in new issue
Block a user