feat: 回收站、批量编辑、合并导入与标签治理
- 数据:Task 增加 deletedAt,schema v2→v3;删除改为软删除,保留 30 天后自动清理 - 回收站:支持恢复、永久删除、二次确认清空;导航栏入口带数量角标 - 批量编辑:多选后统一移动列、改优先级、标签增删、设置截止日、归档 - 合并导入:按 id 匹配、updatedAt 新者胜;覆盖/合并二选一,执行前自动快照 - 标签治理:全局重命名、合并、删除;回收站任务不参与改写 - 看板语义读取统一改用 activeTasks,避免已删除任务泄漏到统计、今日与标签下拉 - UI:回收站图标与备份统一尺寸;批量编辑条统一控件高度并分组 - 测试:705 个单测通过,补充迁移、回收站、批量编辑、合并与标签用例
This commit is contained in:
+77
-8
@@ -12,7 +12,7 @@ import { useFilterStore } from '@/stores/filters'
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { COLUMNS, type ColumnId, type MoveDirection } from '@/types/task'
|
||||
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type MoveDirection, type Priority } from '@/types/task'
|
||||
import { findArchivableTasks } from '@/utils/archive'
|
||||
import { filterTasks } from '@/utils/filter'
|
||||
import { groupByColumn } from '@/utils/sortOrder'
|
||||
@@ -25,7 +25,9 @@ const { filter, includeArchived } = storeToRefs(filters)
|
||||
|
||||
/** 详情抽屉当前编辑的任务 id;任务被删除后自动变为 null,抽屉随之关闭 */
|
||||
const editingId = ref<string | null>(null)
|
||||
const editingTask = computed(() => store.tasks.find((task) => task.id === editingId.value) ?? null)
|
||||
const editingTask = computed(
|
||||
() => store.activeTasks.find((task) => task.id === editingId.value) ?? null,
|
||||
)
|
||||
|
||||
/** 批量添加面板 */
|
||||
const batchOpen = ref(false)
|
||||
@@ -43,7 +45,7 @@ function setColumnRef(element: unknown, index: number): void {
|
||||
}
|
||||
|
||||
/** 实际渲染的看板:先按条件筛选,再按列分组,列计数随之反映结果 */
|
||||
const visibleColumns = computed(() => groupByColumn(filterTasks(store.tasks, filter.value)))
|
||||
const visibleColumns = computed(() => groupByColumn(filterTasks(store.activeTasks, filter.value)))
|
||||
|
||||
/**
|
||||
* 每列被隐藏的数量(被筛选掉 + 已归档)。
|
||||
@@ -70,7 +72,7 @@ const hiddenTotal = computed(() =>
|
||||
*/
|
||||
const archivedHiddenTotal = computed(() => {
|
||||
if (includeArchived.value) return 0
|
||||
const archived = store.tasks.filter((task) => task.archivedAt !== null)
|
||||
const archived = store.activeTasks.filter((task) => task.archivedAt !== null)
|
||||
// filter.includeArchived 为 false 时会把归档任务直接排除,这里显式放开
|
||||
return filterTasks(archived, { ...filter.value, includeArchived: true }).length
|
||||
})
|
||||
@@ -94,7 +96,7 @@ const hiddenNote = computed(() => {
|
||||
return `已隐藏 ${hiddenTotal.value} 个${detail}`
|
||||
})
|
||||
|
||||
const archivableTasks = computed(() => findArchivableTasks(store.tasks))
|
||||
const archivableTasks = computed(() => findArchivableTasks(store.activeTasks))
|
||||
|
||||
function archiveCompleted(): void {
|
||||
const targets = archivableTasks.value
|
||||
@@ -127,7 +129,7 @@ useKeyboardShortcuts({
|
||||
})
|
||||
|
||||
function toggleTask(id: string): void {
|
||||
const task = store.tasks.find((item) => item.id === id)
|
||||
const task = store.activeTasks.find((item) => item.id === id)
|
||||
if (!task) return
|
||||
store.updateTask(id, { status: task.status === 'done' ? 'todo' : 'done' })
|
||||
}
|
||||
@@ -152,7 +154,7 @@ function handleMove(payload: { id: string; toColumn: ColumnId; toIndex: number }
|
||||
* 上下调顺序则要求该列渲染的是全量,否则下标没有意义。
|
||||
*/
|
||||
function handleKeyMove(payload: { id: string; direction: MoveDirection }): void {
|
||||
const task = store.tasks.find((item) => item.id === payload.id)
|
||||
const task = store.activeTasks.find((item) => item.id === payload.id)
|
||||
if (!task) return
|
||||
|
||||
if (payload.direction === 'left' || payload.direction === 'right') {
|
||||
@@ -183,7 +185,9 @@ const visibleTaskIds = computed(() =>
|
||||
)
|
||||
|
||||
/** 真正存在的已选任务:跨标签页同步等情况下可能出现已不存在的 id */
|
||||
const selectedTasks = computed(() => store.tasks.filter((task) => selectedIds.value.has(task.id)))
|
||||
const selectedTasks = computed(() =>
|
||||
store.activeTasks.filter((task) => selectedIds.value.has(task.id)),
|
||||
)
|
||||
const selectedCount = computed(() => selectedTasks.value.length)
|
||||
|
||||
function toggleSelectMode(): void {
|
||||
@@ -226,6 +230,63 @@ function removeSelected(): void {
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 批量编辑 ---------- */
|
||||
|
||||
/** 一次批量操作只写一次仓库;返回实际更新数量便于提示 */
|
||||
function selectedTaskIds(): string[] {
|
||||
return selectedTasks.value.map((task) => task.id)
|
||||
}
|
||||
|
||||
function batchMove(status: ColumnId): void {
|
||||
const count = store.updateTasks(selectedTaskIds(), { status })
|
||||
if (count === 0) return
|
||||
const title = COLUMNS.find((column) => column.id === status)?.title ?? status
|
||||
toast.show({ message: `已把 ${count} 个任务移动到「${title}」` })
|
||||
}
|
||||
|
||||
function batchPriority(priority: Priority): void {
|
||||
const count = store.updateTasks(selectedTaskIds(), { priority })
|
||||
if (count === 0) return
|
||||
toast.show({ message: `已将 ${count} 个任务设为「${PRIORITY_LABELS[priority]}」优先级` })
|
||||
}
|
||||
|
||||
function batchAddTag(tag: string): void {
|
||||
const count = store.updateTasks(selectedTaskIds(), { addTags: [tag] })
|
||||
if (count === 0) return
|
||||
toast.show({ message: `已为 ${count} 个任务添加标签「${tag}」` })
|
||||
}
|
||||
|
||||
function batchRemoveTag(tag: string): void {
|
||||
const count = store.updateTasks(selectedTaskIds(), { removeTags: [tag] })
|
||||
if (count === 0) return
|
||||
toast.show({ message: `已从 ${count} 个任务移除标签「${tag}」` })
|
||||
}
|
||||
|
||||
function batchDue(date: string | null): void {
|
||||
const count = store.updateTasks(selectedTaskIds(), { dueDate: date })
|
||||
if (count === 0) return
|
||||
toast.show({
|
||||
message: date
|
||||
? `已将 ${count} 个任务的截止日期设为 ${date}`
|
||||
: `已清除 ${count} 个任务的截止日期`,
|
||||
})
|
||||
}
|
||||
|
||||
/** 批量归档后卡片会从看板消失,因此退出多选并给一次撤销入口 */
|
||||
function batchArchive(): void {
|
||||
const ids = selectedTaskIds()
|
||||
if (ids.length === 0) return
|
||||
|
||||
const count = store.archiveTasks(ids)
|
||||
if (count === 0) return
|
||||
|
||||
exitSelection()
|
||||
toast.show({
|
||||
message: `已归档 ${count} 个任务`,
|
||||
action: { label: '撤销', run: () => store.unarchiveTasks(ids) },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选条件一变就清空选择:选择必须始终对应「眼前看得到的卡片」,
|
||||
* 否则会出现「删掉了看不见的任务」这种难以预料的结果。
|
||||
@@ -244,16 +305,24 @@ watch(filter, () => {
|
||||
@archive="archiveCompleted"
|
||||
@batch-add="batchOpen = true"
|
||||
@toggle-select="toggleSelectMode"
|
||||
@manage-tags="ui.openTags()"
|
||||
/>
|
||||
|
||||
<BatchSelectionBar
|
||||
v-if="selecting"
|
||||
:count="selectedCount"
|
||||
:total="visibleTaskIds.length"
|
||||
:tags="store.allTags"
|
||||
@select-all="selectAllVisible"
|
||||
@clear="clearSelection"
|
||||
@remove="removeSelected"
|
||||
@done="exitSelection"
|
||||
@move="batchMove"
|
||||
@priority="batchPriority"
|
||||
@add-tag="batchAddTag"
|
||||
@remove-tag="batchRemoveTag"
|
||||
@due="batchDue"
|
||||
@archive="batchArchive"
|
||||
/>
|
||||
|
||||
<!--
|
||||
|
||||
Reference in New Issue
Block a user