feat: 回收站、批量编辑、合并导入与标签治理

- 数据:Task 增加 deletedAt,schema v2→v3;删除改为软删除,保留 30 天后自动清理
- 回收站:支持恢复、永久删除、二次确认清空;导航栏入口带数量角标
- 批量编辑:多选后统一移动列、改优先级、标签增删、设置截止日、归档
- 合并导入:按 id 匹配、updatedAt 新者胜;覆盖/合并二选一,执行前自动快照
- 标签治理:全局重命名、合并、删除;回收站任务不参与改写
- 看板语义读取统一改用 activeTasks,避免已删除任务泄漏到统计、今日与标签下拉
- UI:回收站图标与备份统一尺寸;批量编辑条统一控件高度并分组
- 测试:705 个单测通过,补充迁移、回收站、批量编辑、合并与标签用例
This commit is contained in:
chenshoulin
2026-10-19 18:32:42 +08:00
parent 43e813b93a
commit 65e98199ba
39 changed files with 2734 additions and 143 deletions
+4
View File
@@ -7,7 +7,9 @@ import AppNav from '@/components/AppNav.vue'
import BackupPanel from '@/components/BackupPanel.vue'
import PwaUpdatePrompt from '@/components/PwaUpdatePrompt.vue'
import SideDrawer from '@/components/SideDrawer.vue'
import TagManagerPanel from '@/components/TagManagerPanel.vue'
import ToastHost from '@/components/ToastHost.vue'
import TrashPanel from '@/components/TrashPanel.vue'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useTaskStore } from '@/stores/tasks'
import { useThemeStore } from '@/stores/theme'
@@ -68,6 +70,8 @@ useKeyboardShortcuts({
</div>
<BackupPanel :open="ui.backupOpen" @close="ui.closeBackup()" />
<TrashPanel :open="ui.trashOpen" @close="ui.closeTrash()" />
<TagManagerPanel :open="ui.tagsOpen" @close="ui.closeTags()" />
<SideDrawer :open="ui.helpOpen" title="快捷键" @close="ui.closeHelp()">
<h3 class="mb-2 text-xs font-medium tracking-wide text-slate-400 uppercase dark:text-zinc-500">
+36 -1
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { useTaskStore } from '@/stores/tasks'
import { useThemeStore, THEME_LABELS } from '@/stores/theme'
import { useUiStore } from '@/stores/ui'
const ui = useUiStore()
const theme = useThemeStore()
const store = useTaskStore()
const LINKS = [
{ to: '/', label: '看板' },
@@ -134,12 +136,45 @@ const LINKS = [
</svg>
</button>
<button
type="button"
class="relative flex items-center gap-1.5 rounded-md border border-slate-200 px-2.5 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 dark:border-zinc-700 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
:aria-label="`回收站(${store.trashTasks.length} 个任务)`"
@click="ui.openTrash()"
>
<svg viewBox="0 0 20 20" class="size-4" fill="none" aria-hidden="true">
<path d="M3.5 5.5h13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path
d="M8 5.5v-2h4v2"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="m5.5 5.5.9 11h7.2l.9-11"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M8.5 8.5v5M11.5 8.5v5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
<span class="hidden sm:inline">回收站</span>
<span
v-if="store.trashTasks.length > 0"
class="rounded-full bg-slate-200 px-1.5 text-[11px] leading-4 font-medium text-slate-700 dark:bg-zinc-700 dark:text-zinc-200"
>
{{ store.trashTasks.length }}
</span>
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded-md border border-slate-200 px-2.5 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 dark:border-zinc-700 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
@click="ui.openBackup()"
>
<svg viewBox="0 0 20 20" class="size-3.5" fill="none" aria-hidden="true">
<svg viewBox="0 0 20 20" class="size-4" fill="none" aria-hidden="true">
<path
d="M10 13V3.5m0 0L6.5 7M10 3.5 13.5 7"
stroke="currentColor"
+104
View File
@@ -157,6 +157,110 @@ describe('BackupPanel 导入', () => {
})
})
describe('BackupPanel 合并导入', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('预览显示新增 / 更新 / 保持数量', async () => {
const { store, wrapper } = await setup()
const local = store.tasks[0]
const text = JSON.stringify({
schemaVersion: 3,
tasks: [
{
id: local?.id,
title: '更新后的任务',
status: 'todo',
priority: 2,
sortOrder: 1024,
createdAt: local?.createdAt,
updatedAt: '2099-01-01T00:00:00.000Z',
},
{
id: 'new-1',
title: '新增任务',
status: 'todo',
priority: 2,
sortOrder: 2048,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
},
],
})
await chooseFile(wrapper, text)
await wrapper.find('input[value="merge"]').setValue(true)
expect(wrapper.text()).toContain('新增')
expect(wrapper.text()).toContain('更新')
expect(wrapper.text()).toContain('保持')
// 默认仍是覆盖模式,不会静默改成合并
expect(buttonByText(wrapper, '确认合并导入')).toBeDefined()
})
it('确认合并后更新同 id 并新增其余任务', async () => {
const { store, wrapper } = await setup()
const local = store.tasks[0]
const text = JSON.stringify({
schemaVersion: 3,
tasks: [
{
id: local?.id,
title: '更新后的任务',
status: 'todo',
priority: 2,
sortOrder: 1024,
createdAt: local?.createdAt,
updatedAt: '2099-01-01T00:00:00.000Z',
},
{
id: 'new-1',
title: '新增任务',
status: 'todo',
priority: 2,
sortOrder: 2048,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
},
],
})
await chooseFile(wrapper, text)
await wrapper.find('input[value="merge"]').setValue(true)
await buttonByText(wrapper, '确认合并导入')?.trigger('click')
expect(store.activeTasks.map((task) => task.title).sort()).toEqual(
['更新后的任务', '新增任务'].sort(),
)
expect(wrapper.text()).toContain('已合并')
expect(wrapper.text()).toContain('新增 1 个,更新 1 个')
})
it('合并不删除本地独有的任务', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, makePayload(['文件任务']))
await wrapper.find('input[value="merge"]').setValue(true)
await buttonByText(wrapper, '确认合并导入')?.trigger('click')
expect(store.activeTasks.map((task) => task.title).sort()).toEqual(
['原有任务', '文件任务'].sort(),
)
})
it('合并前自动写快照', async () => {
const { store, wrapper } = await setup()
expect(store.snapshots).toHaveLength(0)
await chooseFile(wrapper, makePayload(['文件任务']))
await wrapper.find('input[value="merge"]').setValue(true)
await buttonByText(wrapper, '确认合并导入')?.trigger('click')
expect(store.snapshots).toHaveLength(1)
})
})
describe('BackupPanel 自动备份', () => {
beforeEach(() => {
setActivePinia(createPinia())
+72 -5
View File
@@ -6,6 +6,7 @@ import { ref } from 'vue'
import { parseImportJson } from '@/data/schema'
import { useTaskStore } from '@/stores/tasks'
import { downloadText, exportFileName } from '@/utils/exportFile'
import { mergeTasks, type MergeResult } from '@/utils/merge'
import SideDrawer from './SideDrawer.vue'
@@ -19,8 +20,16 @@ const { snapshots } = storeToRefs(store)
const message = ref<string | null>(null)
const error = ref<string | null>(null)
/** 导入模式:覆盖会替换全部数据,合并只按 id 更新 / 新增 */
const importMode = ref<'replace' | 'merge'>('replace')
/** 已选好文件但还没确认的导入(覆盖式操作需要二次确认) */
const pending = ref<{ text: string; fileName: string; count: number } | null>(null)
const pending = ref<{
text: string
fileName: string
count: number
preview: MergeResult
} | null>(null)
function exportNow(): void {
const fileName = exportFileName()
@@ -43,12 +52,19 @@ async function onFileChange(event: Event): Promise<void> {
message.value = null
error.value = null
// 每次重新选文件都回到默认的覆盖模式,避免沿用上一次的选择误覆盖
importMode.value = 'replace'
try {
const text = await file.text()
// 先做一次纯校验,确认合法后才让用户确认覆盖
// 先做一次纯校验,确认合法后才让用户确认导入
const tasks = parseImportJson(text)
pending.value = { text, fileName: file.name, count: tasks.length }
pending.value = {
text,
fileName: file.name,
count: tasks.length,
preview: mergeTasks(store.tasks, tasks),
}
} catch (cause) {
pending.value = null
error.value = cause instanceof Error ? cause.message : '导入文件无法解析'
@@ -63,9 +79,17 @@ function confirmImport(): void {
const beforeCount = store.tasks.length
try {
if (importMode.value === 'replace') {
const count = store.importJson(target.text)
const backupNote = beforeCount > 0 ? ',导入前的数据已自动备份' : ''
message.value = `已从 ${target.fileName} 导入 ${count} 个任务${backupNote}`
} else {
const { added, updated, skipped } = store.mergeJson(target.text)
const backupNote = beforeCount > 0 ? ',导入前的数据已自动备份' : ''
message.value =
`已合并 ${target.fileName}:新增 ${added} 个,更新 ${updated} 个,跳过 ${skipped} 个` +
backupNote
}
error.value = null
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '导入失败'
@@ -142,18 +166,61 @@ function formatTime(iso: string): string {
v-if="pending"
class="flex flex-col gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 dark:border-amber-800 dark:bg-amber-950/60"
>
<p class="text-sm text-amber-900 dark:text-amber-200">
<fieldset class="flex flex-col gap-1.5">
<legend class="sr-only">导入方式</legend>
<label class="flex items-start gap-2 text-sm text-amber-900 dark:text-amber-200">
<input
v-model="importMode"
type="radio"
name="import-mode"
value="replace"
class="mt-0.5"
/>
<span>
覆盖导入 —— 替换当前全部 {{ store.tasks.length }} 个任务
</span>
</label>
<label class="flex items-start gap-2 text-sm text-amber-900 dark:text-amber-200">
<input
v-model="importMode"
type="radio"
name="import-mode"
value="merge"
class="mt-0.5"
/>
<span>合并导入 —— 更新同 id 任务,新增其余任务</span>
</label>
</fieldset>
<p
v-if="importMode === 'replace'"
class="border-t border-amber-200 pt-2 text-sm text-amber-900 dark:border-amber-800/60 dark:text-amber-200"
>
将从「{{ pending.fileName }}」导入 {{ pending.count }} 个任务,
<strong>并覆盖当前全部 {{ store.tasks.length }} 个任务</strong>。 覆盖前会自动把当前数据
存一份到下方「自动备份」,选错文件也能从那里恢复。
</p>
<p
v-else
class="border-t border-amber-200 pt-2 text-sm text-amber-900 dark:border-amber-800/60 dark:text-amber-200"
>
「{{ pending.fileName }}」共 {{ pending.count }} 个任务:新增
<strong>{{ pending.preview.added }}</strong> 个 / 更新
<strong>{{ pending.preview.updated }}</strong> 个 / 保持
<strong>{{ Math.max(0, store.tasks.length - pending.preview.updated) }}</strong> 个。
<span v-if="pending.preview.skipped > 0">
其中 {{ pending.preview.skipped }} 个同 id 任务与本地相同或更旧,已跳过。
</span>
合并前会自动把当前数据存一份到下方「自动备份」。
</p>
<div class="flex gap-2">
<button
type="button"
class="rounded-md bg-rose-600 px-3 py-1 text-sm font-medium text-white transition hover:bg-rose-700 dark:hover:bg-rose-500"
@click="confirmImport"
>
确认覆盖导入
{{ importMode === 'replace' ? '确认覆盖导入' : '确认合并导入' }}
</button>
<button
type="button"
+1 -1
View File
@@ -129,6 +129,6 @@ describe('BatchAddDrawer 提交', () => {
expect(action?.label).toBe('撤销')
action?.run()
expect(store.tasks).toHaveLength(0)
expect(store.activeTasks).toHaveLength(0)
})
})
+69
View File
@@ -75,3 +75,72 @@ describe('BatchSelectionBar 禁用态', () => {
expect(buttonByText(setup(0, 0), '全选')?.attributes('disabled')).toBeDefined()
})
})
describe('BatchSelectionBar 批量编辑', () => {
it('未选中任务时不渲染编辑器', () => {
expect(setup(0, 3).find('select[aria-label="移动到"]').exists()).toBe(false)
})
it('选中后出现编辑器', () => {
const bar = setup(2, 3)
expect(bar.find('select[aria-label="移动到"]').exists()).toBe(true)
expect(bar.find('button[aria-label="设为高优先级"]').exists()).toBe(true)
expect(bar.find('input[aria-label="标签"]').exists()).toBe(true)
})
it('移动下拉 emit move,并重置为占位项', async () => {
const bar = setup(2, 3)
const select = bar.find('select[aria-label="移动到"]')
await select.setValue('doing')
expect(bar.emitted('move')?.[0]).toEqual(['doing'])
expect((select.element as HTMLSelectElement).value).toBe('')
})
it('优先级按钮 emit priority', async () => {
const bar = setup(1, 3)
await bar.find('button[aria-label="设为高优先级"]').trigger('click')
expect(bar.emitted('priority')?.[0]).toEqual([3])
})
it('添加标签后清空输入框', async () => {
const bar = setup(2, 3)
const input = bar.find('input[aria-label="标签"]')
await input.setValue(' 前端 ')
await buttonByText(bar, '添加')?.trigger('click')
expect(bar.emitted('add-tag')?.[0]).toEqual(['前端'])
expect((input.element as HTMLInputElement).value).toBe('')
})
it('移除标签 emit remove-tag', async () => {
const bar = setup(2, 3)
await bar.find('input[aria-label="标签"]').setValue('前端')
await buttonByText(bar, '移除')?.trigger('click')
expect(bar.emitted('remove-tag')?.[0]).toEqual(['前端'])
})
it('空标签不会 emit', async () => {
const bar = setup(2, 3)
await bar.find('input[aria-label="标签"]').setValue(' ')
const add = buttonByText(bar, '添加')
expect(add?.attributes('disabled')).toBeDefined()
})
it('截止日期弹层选择后 emit due', async () => {
const bar = setup(2, 3)
await bar.find('button[aria-label="设置截止日期"]').trigger('click')
const dateInput = bar.find('input[type="date"]')
await dateInput.setValue('2026-11-01')
expect(bar.emitted('due')?.[0]).toEqual(['2026-11-01'])
})
it('归档 emit archive', async () => {
const bar = setup(2, 3)
await buttonByText(bar, '归档')?.trigger('click')
expect(bar.emitted('archive')).toHaveLength(1)
})
})
+191 -6
View File
@@ -2,32 +2,83 @@
/**
* 多选操作条
*
* 只在多选模式出现,负责「选了多少 / 全选 / 清空 / 删除」。
* 删除不做二次确认:与单条删除一致,靠 Toast 的「撤销」兜底,
* 误删的代价远低于每次批量操作都弹窗确认。
* 第一行负责「选了多少 / 全选 / 清空 / 完成 / 删除」;
* 第二行是批量编辑器:移动、优先级、标签增删、截止日期、归档。
*
* 删除与归档保留 Toast 撤销(软删除 / 归档本身可逆);
* 批量改属性不做撤销 —— 与详情编辑一致,只提示「已更新 N 个任务」。
*
* 标签刻意是「添加 / 移除」而不是「覆盖」:批量加一个标签时,
* 不应该把每条任务原有的其它标签一起清掉。
*/
defineProps<{
import { ref } from 'vue'
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority } from '@/types/task'
import DatePicker from './DatePicker.vue'
const props = withDefaults(
defineProps<{
/** 已选数量 */
count: number
/** 当前可见(筛选后)的任务总数 */
total: number
}>()
/** 已有标签,作为输入提示 */
tags?: readonly string[]
}>(),
{ tags: () => [] },
)
const emit = defineEmits<{
'select-all': []
clear: []
remove: []
done: []
move: [status: ColumnId]
priority: [priority: Priority]
'add-tag': [tag: string]
'remove-tag': [tag: string]
due: [date: string | null]
archive: []
}>()
const PRIORITIES: readonly Priority[] = [1, 2, 3]
const tagDraft = ref('')
const dueOpen = ref(false)
const batchDue = ref<string | null>(null)
function submitTag(action: 'add-tag' | 'remove-tag'): void {
const tag = tagDraft.value.trim()
if (!tag || props.count === 0) return
if (action === 'add-tag') emit('add-tag', tag)
else emit('remove-tag', tag)
tagDraft.value = ''
}
function onMove(event: Event): void {
const select = event.target as HTMLSelectElement
const value = select.value
select.value = ''
if (!value) return
emit('move', value as ColumnId)
}
function onDueChange(value: string | null): void {
batchDue.value = value
emit('due', value)
dueOpen.value = false
}
</script>
<template>
<div
class="flex flex-wrap items-center gap-x-2 gap-y-1.5 rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-900 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-200"
class="flex flex-col gap-2 rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-900 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-200"
role="toolbar"
aria-label="批量操作"
>
<div class="flex flex-wrap items-center gap-x-2 gap-y-1.5">
<span>
已选
<strong class="font-semibold">{{ count }}</strong>
@@ -71,4 +122,138 @@ const emit = defineEmits<{
</button>
</div>
</div>
<!-- 批量编辑器:没有选中时收起,避免一排灰按钮占满工具栏 -->
<div
v-if="count > 0"
class="flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-sky-200/70 pt-2.5 dark:border-sky-900"
role="group"
aria-label="批量编辑"
>
<div class="flex items-center gap-1.5">
<span class="text-[11px] font-medium whitespace-nowrap text-sky-700/70 dark:text-sky-300/70">
移动到
</span>
<select
class="h-8 rounded-md border border-sky-200 bg-white/85 px-2 text-xs text-sky-800 transition hover:bg-white focus:border-sky-400 focus:outline-none dark:border-sky-800 dark:bg-sky-900/40 dark:text-sky-200 dark:hover:bg-sky-900/70 dark:focus:border-sky-600"
aria-label="移动到"
@change="onMove"
>
<option value="">选择列…</option>
<option v-for="column in COLUMNS" :key="column.id" :value="column.id">
{{ column.title }}
</option>
</select>
</div>
<span
class="hidden h-5 w-px shrink-0 bg-sky-200/80 sm:block dark:bg-sky-800"
aria-hidden="true"
/>
<div class="flex items-center gap-1.5" role="group" aria-label="设置优先级">
<span class="text-[11px] font-medium whitespace-nowrap text-sky-700/70 dark:text-sky-300/70">
优先级
</span>
<div
class="flex h-8 overflow-hidden rounded-md border border-sky-200 bg-white/85 dark:border-sky-800 dark:bg-sky-900/40"
>
<button
v-for="value in PRIORITIES"
:key="value"
type="button"
class="border-r border-sky-200 px-3 text-xs text-sky-800 transition last:border-r-0 hover:bg-sky-50 dark:border-sky-800 dark:text-sky-200 dark:hover:bg-sky-900/70"
:aria-label="`设为${PRIORITY_LABELS[value]}优先级`"
@click="emit('priority', value)"
>
{{ PRIORITY_LABELS[value] }}
</button>
</div>
</div>
<span
class="hidden h-5 w-px shrink-0 bg-sky-200/80 sm:block dark:bg-sky-800"
aria-hidden="true"
/>
<div class="flex items-center gap-1.5">
<span class="text-[11px] font-medium whitespace-nowrap text-sky-700/70 dark:text-sky-300/70">
标签
</span>
<input
v-model="tagDraft"
type="text"
list="batch-tag-suggestions"
class="h-8 w-28 rounded-md border border-sky-200 bg-white/85 px-2.5 text-xs text-sky-800 transition outline-none placeholder:text-sky-700/40 focus:border-sky-400 dark:border-sky-800 dark:bg-sky-900/40 dark:text-sky-200 dark:placeholder:text-sky-300/40 dark:focus:border-sky-600"
placeholder="输入标签"
aria-label="标签"
@keydown.enter.prevent="submitTag('add-tag')"
/>
<datalist id="batch-tag-suggestions">
<option v-for="tag in props.tags" :key="tag" :value="tag" />
</datalist>
<div
class="flex h-8 overflow-hidden rounded-md border border-sky-200 bg-white/85 dark:border-sky-800 dark:bg-sky-900/40"
>
<button
type="button"
class="border-r border-sky-200 px-2.5 text-xs text-sky-800 transition hover:bg-sky-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:border-sky-800 dark:text-sky-200 dark:hover:bg-sky-900/70"
:disabled="tagDraft.trim() === ''"
@click="submitTag('add-tag')"
>
添加
</button>
<button
type="button"
class="px-2.5 text-xs text-sky-800 transition hover:bg-sky-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:text-sky-200 dark:hover:bg-sky-900/70"
:disabled="tagDraft.trim() === ''"
@click="submitTag('remove-tag')"
>
移除
</button>
</div>
</div>
<span
class="hidden h-5 w-px shrink-0 bg-sky-200/80 sm:block dark:bg-sky-800"
aria-hidden="true"
/>
<div class="flex items-center gap-1.5">
<span class="text-[11px] font-medium whitespace-nowrap text-sky-700/70 dark:text-sky-300/70">
截止
</span>
<div class="relative">
<button
type="button"
class="h-8 rounded-md border border-sky-200 bg-white/85 px-2.5 text-xs text-sky-800 transition hover:bg-white dark:border-sky-800 dark:bg-sky-900/40 dark:text-sky-200 dark:hover:bg-sky-900/70"
:aria-expanded="dueOpen"
aria-label="设置截止日期"
@click="dueOpen = !dueOpen"
>
{{ batchDue ?? '选择日期' }}
</button>
<div
v-if="dueOpen"
class="absolute top-full left-0 z-30 mt-1.5 w-56 rounded-lg border border-slate-200 bg-white p-2 shadow-pop dark:border-zinc-700 dark:bg-zinc-900"
>
<DatePicker
:model-value="batchDue"
label="批量设置截止日期"
@update:model-value="onDueChange"
/>
</div>
</div>
</div>
<button
type="button"
class="ml-auto h-8 rounded-md border border-sky-200 bg-white/85 px-3 text-xs text-sky-800 transition hover:bg-white dark:border-sky-800 dark:bg-sky-900/40 dark:text-sky-200 dark:hover:bg-sky-900/70"
@click="emit('archive')"
>
归档
</button>
</div>
</div>
</template>
+1
View File
@@ -43,6 +43,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
+11 -5
View File
@@ -12,7 +12,12 @@ import TagFilter from './TagFilter.vue'
defineProps<{ hiddenNote?: string; selecting?: boolean }>()
const emit = defineEmits<{ archive: []; 'batch-add': []; 'toggle-select': [] }>()
const emit = defineEmits<{
archive: []
'batch-add': []
'toggle-select': []
'manage-tags': []
}>()
const store = useTaskStore()
const filters = useFilterStore()
@@ -24,15 +29,15 @@ const searchRef = ref<HTMLInputElement | null>(null)
const PRIORITY_ORDER: readonly Priority[] = [3, 2, 1]
const dueTodayCount = computed(
() => store.tasks.filter((task) => isDueToday(task.dueDate, task.status)).length,
() => store.activeTasks.filter((task) => isDueToday(task.dueDate, task.status)).length,
)
const overdueCount = computed(
() => store.tasks.filter((task) => isOverdue(task.dueDate, task.status)).length,
() => store.activeTasks.filter((task) => isOverdue(task.dueDate, task.status)).length,
)
/** 当前可归档(已完成且超过保留天数)的任务数量 */
const archivableCount = computed(() => findArchivableTasks(store.tasks).length)
const archivableCount = computed(() => findArchivableTasks(store.activeTasks).length)
function focusSearch(): void {
searchRef.value?.focus()
@@ -91,6 +96,7 @@ defineExpose({ focusSearch })
:selected="tags"
@toggle="filters.toggleTag($event)"
@clear="filters.clearTags()"
@manage="emit('manage-tags')"
/>
<div class="ml-auto flex items-center gap-1.5">
@@ -129,7 +135,7 @@ defineExpose({ focusSearch })
</div>
<div class="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-xs text-slate-500 dark:text-zinc-400">
<span>共 {{ store.tasks.length }} 个任务</span>
<span>共 {{ store.activeTasks.length }} 个任务</span>
<span v-if="hiddenNote" class="text-slate-400 dark:text-zinc-500">{{ hiddenNote }}</span>
<div class="ml-auto flex items-center gap-1.5">
+1 -1
View File
@@ -197,6 +197,6 @@ describe('QuickAddTask 批量粘贴', () => {
expect(action?.label).toBe('撤销')
action?.run()
expect(store.tasks).toHaveLength(0)
expect(store.activeTasks).toHaveLength(0)
})
})
+15 -1
View File
@@ -10,7 +10,7 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
defineProps<{ tags: readonly string[]; selected: readonly string[] }>()
const emit = defineEmits<{ toggle: [tag: string]; clear: [] }>()
const emit = defineEmits<{ toggle: [tag: string]; clear: []; manage: [] }>()
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
@@ -29,6 +29,12 @@ function onDocumentKeydown(event: KeyboardEvent): void {
open.value = false
}
/** 交给应用级面板处理;先收起下拉,避免两层浮层叠在一起 */
function openManager(): void {
open.value = false
emit('manage')
}
onMounted(() => {
document.addEventListener('click', onDocumentClick)
document.addEventListener('keydown', onDocumentKeydown, true)
@@ -110,6 +116,14 @@ onBeforeUnmount(() => {
>
清除标签筛选
</button>
<button
type="button"
class="mt-1 w-full rounded-md border-t border-slate-100 px-2 py-1.5 text-left text-xs text-slate-500 transition hover:bg-slate-50 hover:text-slate-700 dark:border-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
@click="openManager"
>
管理标签…
</button>
</div>
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import TagManagerPanel from './TagManagerPanel.vue'
function setup(prepare?: (store: ReturnType<typeof useTaskStore>) => void) {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
prepare?.(store)
const wrapper = mount(TagManagerPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
return { store, wrapper }
}
function buttonByText(wrapper: VueWrapper, text: string) {
return wrapper.findAll('button').find((node) => node.text() === text)
}
describe('TagManagerPanel 展示', () => {
beforeEach(() => setActivePinia(createPinia()))
it('没有标签时显示空状态', () => {
const { wrapper } = setup()
expect(wrapper.text()).toContain('还没有任何标签')
})
it('列出标签与使用数量', () => {
const { wrapper } = setup((store) => {
store.createTask({ title: 'A', tags: ['前端'] })
store.createTask({ title: 'B', tags: ['前端'] })
})
expect(wrapper.text()).toContain('#前端')
expect(wrapper.text()).toContain('2 个任务')
})
})
describe('TagManagerPanel 重命名', () => {
beforeEach(() => setActivePinia(createPinia()))
it('重命名后所有任务同步', async () => {
const { store, wrapper } = setup((store) => {
store.createTask({ title: 'A', tags: ['前端'] })
store.createTask({ title: 'B', tags: ['前端', '设计'] })
})
const input = wrapper.find('input[aria-label="重命名标签 前端"]')
await input.setValue('Frontend')
await buttonByText(wrapper, '保存')?.trigger('click')
expect([...store.allTags].sort()).toEqual(['Frontend', '设计'].sort())
expect(wrapper.text()).toContain('#Frontend')
expect(wrapper.text()).not.toContain('#前端')
})
it('清空输入后保存不改动标签', async () => {
const { store, wrapper } = setup((store) => {
store.createTask({ title: 'A', tags: ['前端'] })
})
await wrapper.find('input[aria-label="重命名标签 前端"]').setValue(' ')
await buttonByText(wrapper, '保存')?.trigger('click')
expect(store.allTags).toEqual(['前端'])
})
})
describe('TagManagerPanel 合并与删除', () => {
beforeEach(() => setActivePinia(createPinia()))
it('合并两个标签后去重', async () => {
const { store, wrapper } = setup((store) => {
store.createTask({ title: 'A', tags: ['前端', '设计'] })
store.createTask({ title: 'B', tags: ['设计'] })
})
await wrapper.find('select[aria-label="把标签 前端 合并到"]').setValue('设计')
await buttonByText(wrapper, '合并')?.trigger('click')
expect(store.allTags).toEqual(['设计'])
expect(store.activeTasks.every((task) => !task.tags.includes('前端'))).toBe(true)
})
it('删除标签需要二次确认,且不删除任务', async () => {
const { store, wrapper } = setup((store) => {
store.createTask({ title: 'A', tags: ['前端'] })
})
await buttonByText(wrapper, '删除')?.trigger('click')
expect(store.allTags).toEqual(['前端'])
await buttonByText(wrapper, '确认删除')?.trigger('click')
expect(store.allTags).toEqual([])
expect(store.activeTasks).toHaveLength(1)
})
it('取消删除保留标签', async () => {
const { store, wrapper } = setup((store) => {
store.createTask({ title: 'A', tags: ['前端'] })
})
await buttonByText(wrapper, '删除')?.trigger('click')
await buttonByText(wrapper, '取消')?.trigger('click')
expect(store.allTags).toEqual(['前端'])
expect(wrapper.text()).not.toContain('确认删除')
})
})
+182
View File
@@ -0,0 +1,182 @@
<script setup lang="ts">
/**
* 标签管理
*
* 标签是任务上的字符串,没有独立实体。这里把「重命名 / 合并 / 删除」
* 三种全局操作集中到一个面板,避免让用户逐个任务点开改。
*/
import { computed, ref } from 'vue'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import { collectTagStats } from '@/utils/tags'
import SideDrawer from './SideDrawer.vue'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const store = useTaskStore()
const toast = useToastStore()
/** 每行的重命名草稿;没有输入时以原标签为准 */
const drafts = ref<Record<string, string>>({})
/** 每行选择的「合并到」目标 */
const mergeTargets = ref<Record<string, string>>({})
/** 二次确认中的删除标签 */
const confirmingRemove = ref<string | null>(null)
const stats = computed(() => collectTagStats(store.activeTasks))
function draftFor(tag: string): string {
return drafts.value[tag] ?? tag
}
function setDraft(tag: string, value: string): void {
drafts.value = { ...drafts.value, [tag]: value }
}
function setMergeTarget(tag: string, value: string): void {
mergeTargets.value = { ...mergeTargets.value, [tag]: value }
}
/** 合并目标候选:排除自己 */
function otherTags(tag: string): { tag: string; count: number }[] {
return stats.value.filter((item) => item.tag !== tag)
}
function saveRename(tag: string): void {
const target = draftFor(tag).trim()
if (!target || target === tag) return
const count = store.renameTag(tag, target)
if (count === 0) {
toast.show({ message: `没有任务使用「${tag}」` })
return
}
// 该标签已不存在,清掉本行草稿,避免旧值继续显示
const next = { ...drafts.value }
delete next[tag]
drafts.value = next
toast.show({ message: `已将「${tag}」重命名为「${target}」,影响 ${count} 个任务` })
}
function merge(tag: string): void {
const target = mergeTargets.value[tag]
if (!target) return
const count = store.mergeTags([tag], target)
if (count === 0) {
toast.show({ message: `没有任务使用「${tag}」` })
return
}
toast.show({ message: `已将「${tag}」合并到「${target}」,影响 ${count} 个任务` })
}
function remove(tag: string): void {
const count = store.removeTag(tag)
confirmingRemove.value = null
if (count === 0) return
toast.show({ message: `已从 ${count} 个任务上移除「${tag}」` })
}
</script>
<template>
<SideDrawer :open="props.open" title="标签管理" @close="emit('close')">
<div class="flex flex-col gap-4">
<p class="text-xs leading-relaxed text-slate-500 dark:text-zinc-400">
统计只包含未归档、未删除的任务。重命名与合并会同步改写所有任务,删除只移除标签、不删除任务。
</p>
<p
v-if="stats.length === 0"
class="rounded-xl border border-dashed border-slate-300 px-4 py-12 text-center text-sm text-slate-400 dark:border-zinc-700 dark:text-zinc-500"
>
还没有任何标签
</p>
<ul v-else class="flex flex-col gap-3">
<li
v-for="item in stats"
:key="item.tag"
class="flex flex-col gap-2 rounded-md border border-slate-200 px-3 py-2.5 dark:border-zinc-700"
>
<div class="flex items-baseline justify-between gap-2">
<span class="text-sm font-medium text-slate-700 dark:text-zinc-200">
#{{ item.tag }}
</span>
<span class="text-xs text-slate-400 dark:text-zinc-500">{{ item.count }} 个任务</span>
</div>
<div class="flex items-center gap-1.5">
<input
type="text"
class="min-w-0 flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm text-slate-700 outline-none focus:border-sky-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:focus:border-sky-600"
:value="draftFor(item.tag)"
:aria-label="`重命名标签 ${item.tag}`"
@input="setDraft(item.tag, ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="saveRename(item.tag)"
/>
<button
type="button"
class="shrink-0 rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-600 transition hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
@click="saveRename(item.tag)"
>
保存
</button>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<select
class="min-w-0 flex-1 rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
:value="mergeTargets[item.tag] ?? ''"
:aria-label="`把标签 ${item.tag} 合并到`"
@change="setMergeTarget(item.tag, ($event.target as HTMLSelectElement).value)"
>
<option value="">合并到…</option>
<option v-for="other in otherTags(item.tag)" :key="other.tag" :value="other.tag">
#{{ other.tag }}
</option>
</select>
<button
type="button"
class="shrink-0 rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
:disabled="!mergeTargets[item.tag]"
@click="merge(item.tag)"
>
合并
</button>
<button
v-if="confirmingRemove !== item.tag"
type="button"
class="shrink-0 rounded-md border border-rose-300 px-2 py-1 text-xs text-rose-600 transition hover:bg-rose-50 dark:border-rose-800 dark:text-rose-400 dark:hover:bg-rose-950"
@click="confirmingRemove = item.tag"
>
删除
</button>
<template v-else>
<button
type="button"
class="shrink-0 rounded-md bg-rose-600 px-2 py-1 text-xs font-medium text-white transition hover:bg-rose-700 dark:hover:bg-rose-500"
@click="remove(item.tag)"
>
确认删除
</button>
<button
type="button"
class="shrink-0 rounded-md px-2 py-1 text-xs text-slate-500 transition hover:bg-slate-50 dark:text-zinc-400 dark:hover:bg-zinc-800"
@click="confirmingRemove = null"
>
取消
</button>
</template>
</div>
</li>
</ul>
</div>
</SideDrawer>
</template>
+1
View File
@@ -21,6 +21,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
+2 -1
View File
@@ -231,7 +231,8 @@ describe('TaskDetailDrawer 删除与撤销', () => {
await buttonByText(wrapper, '删除')?.trigger('click')
expect(store.tasks).toHaveLength(0)
expect(store.activeTasks).toHaveLength(0)
expect(store.trashTasks).toHaveLength(1)
expect(wrapper.emitted('close')).toHaveLength(1)
expect(toast.toasts).toHaveLength(1)
expect(toast.toasts[0]?.action?.label).toBe('撤销')
+117
View File
@@ -0,0 +1,117 @@
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import TrashPanel from './TrashPanel.vue'
function setup(prepare?: (store: ReturnType<typeof useTaskStore>) => void) {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
prepare?.(store)
const wrapper = mount(TrashPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
return { store, wrapper }
}
function buttonByText(wrapper: VueWrapper, text: string) {
return wrapper.findAll('button').find((node) => node.text().includes(text))
}
describe('TrashPanel 展示', () => {
beforeEach(() => setActivePinia(createPinia()))
it('回收站为空时显示空状态', () => {
const { wrapper } = setup()
expect(wrapper.text()).toContain('回收站是空的')
})
it('列出标题、删除时间与原列,并统计数量', () => {
const { wrapper } = setup((store) => {
const task = store.createTask({ title: '被删除的任务', status: 'doing' })
store.removeTask(task?.id ?? '')
})
expect(wrapper.text()).toContain('被删除的任务')
expect(wrapper.text()).toContain('原列 进行中')
expect(wrapper.text()).toContain('删除于')
expect(wrapper.text()).toContain('共 1 个任务')
})
it('已归档的删除项带「归档」徽标', () => {
const { wrapper } = setup((store) => {
const task = store.createTask({ title: '归档任务', status: 'done' })
store.archiveTasks([task?.id ?? ''])
store.removeTask(task?.id ?? '')
})
expect(wrapper.text()).toContain('归档')
})
})
describe('TrashPanel 操作', () => {
beforeEach(() => setActivePinia(createPinia()))
it('恢复后回到看板,回收站变空', async () => {
const { store, wrapper } = setup((store) => {
const task = store.createTask({ title: '可恢复' })
store.removeTask(task?.id ?? '')
})
await buttonByText(wrapper, '恢复')?.trigger('click')
expect(store.activeTasks).toHaveLength(1)
expect(store.trashTasks).toHaveLength(0)
expect(wrapper.text()).toContain('回收站是空的')
})
it('永久删除后数据真正消失', async () => {
const { store, wrapper } = setup((store) => {
const task = store.createTask({ title: '永久删除' })
store.removeTask(task?.id ?? '')
})
await buttonByText(wrapper, '永久删除')?.trigger('click')
expect(store.tasks).toHaveLength(0)
expect(wrapper.text()).toContain('回收站是空的')
})
it('清空回收站需要二次确认', async () => {
const { store, wrapper } = setup((store) => {
const a = store.createTask({ title: 'A' })
const b = store.createTask({ title: 'B' })
store.removeTasks([a?.id ?? '', b?.id ?? ''])
})
await buttonByText(wrapper, '清空回收站')?.trigger('click')
expect(wrapper.text()).toContain('此操作不可撤销')
expect(store.tasks).toHaveLength(2)
await buttonByText(wrapper, '确认清空')?.trigger('click')
expect(store.tasks).toHaveLength(0)
expect(wrapper.text()).toContain('回收站是空的')
})
it('取消清空不会删除数据', async () => {
const { store, wrapper } = setup((store) => {
const task = store.createTask({ title: 'A' })
store.removeTask(task?.id ?? '')
})
await buttonByText(wrapper, '清空回收站')?.trigger('click')
await buttonByText(wrapper, '取消')?.trigger('click')
expect(store.trashTasks).toHaveLength(1)
expect(wrapper.text()).not.toContain('此操作不可撤销')
})
})
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
/**
* 回收站
*
* 删除不再是「5 秒内可撤销,否则碰运气找快照」,而是软删除进回收站。
* 这里的「永久删除」是真正的物理删除,因此走二次确认;
* 恢复则保留原 sortOrder 与归档状态,回到原来的位置。
*/
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import { COLUMNS, type Task } from '@/types/task'
import SideDrawer from './SideDrawer.vue'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const store = useTaskStore()
const toast = useToastStore()
/** 「清空回收站」的二次确认态 */
const confirmingClear = ref(false)
const columnTitles = Object.fromEntries(COLUMNS.map((column) => [column.id, column.title]))
const count = computed(() => store.trashTasks.length)
function formatTime(iso: string): string {
return dayjs(iso).format('YYYY-MM-DD HH:mm')
}
function restore(task: Task): void {
store.restoreTask(task)
toast.show({ message: `已恢复「${task.title}」` })
}
function purge(task: Task): void {
if (!store.purgeTask(task.id)) return
toast.show({ message: `已永久删除「${task.title}」` })
}
function clearAll(): void {
const removed = store.emptyTrash()
confirmingClear.value = false
if (removed > 0) toast.show({ message: `已永久删除 ${removed} 个任务` })
}
// 每次重新打开都回到「未确认」状态,避免上一次的确认态残留
watch(
() => props.open,
(open) => {
if (open) confirmingClear.value = false
},
)
</script>
<template>
<SideDrawer :open="props.open" title="回收站" @close="emit('close')">
<div class="flex flex-col gap-4">
<p
class="rounded-md bg-slate-50 px-3 py-2 text-xs leading-relaxed text-slate-500 dark:bg-zinc-800/60 dark:text-zinc-400"
>
删除的任务会在这里保留 30 天,之后在下次打开时自动清理。恢复后会回到原来的列与顺序。
</p>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-slate-500 dark:text-zinc-400">共 {{ count }} 个任务</span>
<button
v-if="count > 0 && !confirmingClear"
type="button"
class="rounded-md border border-rose-300 px-2.5 py-1 text-xs text-rose-600 transition hover:bg-rose-50 dark:border-rose-800 dark:text-rose-400 dark:hover:bg-rose-950"
@click="confirmingClear = true"
>
清空回收站
</button>
</div>
<div
v-if="confirmingClear"
class="flex flex-col gap-2 rounded-md border border-rose-300 bg-rose-50 px-3 py-2 dark:border-rose-800 dark:bg-rose-950/60"
role="alert"
>
<p class="text-sm text-rose-700 dark:text-rose-300">
将永久删除回收站中的 {{ count }} 个任务,<strong>此操作不可撤销</strong>。
</p>
<div class="flex gap-2">
<button
type="button"
class="rounded-md bg-rose-600 px-3 py-1 text-sm font-medium text-white transition hover:bg-rose-700 dark:hover:bg-rose-500"
@click="clearAll"
>
确认清空
</button>
<button
type="button"
class="rounded-md border border-slate-300 px-3 py-1 text-sm text-slate-600 transition hover:bg-white dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
@click="confirmingClear = false"
>
取消
</button>
</div>
</div>
<p
v-if="count === 0"
class="rounded-xl border border-dashed border-slate-300 px-4 py-12 text-center text-sm text-slate-400 dark:border-zinc-700 dark:text-zinc-500"
>
回收站是空的
</p>
<ul v-else class="flex flex-col gap-2">
<li
v-for="task in store.trashTasks"
:key="task.id"
class="flex flex-col gap-1.5 rounded-md border border-slate-200 px-3 py-2 dark:border-zinc-700"
>
<div class="flex items-start justify-between gap-2">
<span class="min-w-0 flex-1 text-sm break-words text-slate-700 dark:text-zinc-200">
{{ task.title }}
</span>
<span
v-if="task.archivedAt !== null"
class="shrink-0 rounded-full border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[11px] text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
>
归档
</span>
</div>
<p class="text-xs text-slate-400 dark:text-zinc-500">
删除于 {{ formatTime(task.deletedAt ?? task.updatedAt) }} · 原列
{{ columnTitles[task.status] }}
</p>
<div class="flex gap-2">
<button
type="button"
class="rounded-md px-2 py-0.5 text-sm text-sky-700 transition hover:bg-sky-50 dark:text-sky-400 dark:hover:bg-sky-950"
@click="restore(task)"
>
恢复
</button>
<button
type="button"
class="rounded-md px-2 py-0.5 text-sm text-rose-600 transition hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-950"
@click="purge(task)"
>
永久删除
</button>
</div>
</li>
</ul>
</div>
</SideDrawer>
</template>
+58
View File
@@ -26,6 +26,7 @@ function validTask(overrides: Record<string, unknown> = {}): Record<string, unkn
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
@@ -106,6 +107,18 @@ describe('coerceTask', () => {
expect(coerceTask(validTask({ archivedAt: null }))?.archivedAt).toBeNull()
})
it('非法 deletedAt 收敛为 null', () => {
expect(coerceTask(validTask({ deletedAt: 123 }))?.deletedAt).toBeNull()
expect(coerceTask(validTask({ deletedAt: null }))?.deletedAt).toBeNull()
expect(coerceTask(validTask({ deletedAt: undefined }))?.deletedAt).toBeNull()
})
it('保留合法的 deletedAt', () => {
expect(coerceTask(validTask({ deletedAt: '2026-10-19T00:00:00.000Z' }))?.deletedAt).toBe(
'2026-10-19T00:00:00.000Z',
)
})
it('保留合法的 archivedAt', () => {
expect(coerceTask(validTask({ archivedAt: '2026-10-01T00:00:00.000Z' }))?.archivedAt).toBe(
'2026-10-01T00:00:00.000Z',
@@ -226,6 +239,51 @@ describe('版本迁移', () => {
const state = parsePersistedState(v1)
expect(state.tasks.map((task) => task.archivedAt)).toEqual([null, null])
})
it('v2 数据迁移到 v3 时补上 deletedAt,其余字段不变', () => {
const v2 = JSON.stringify({
schemaVersion: 2,
tasks: [
{
id: 'legacy',
title: '归档任务',
status: 'done',
priority: 2,
sortOrder: 1024,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
completedAt: '2026-01-02T00:00:00.000Z',
archivedAt: '2026-01-03T00:00:00.000Z',
},
],
})
const state = parsePersistedState(v2)
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
expect(state.tasks[0]?.deletedAt).toBeNull()
expect(state.tasks[0]?.archivedAt).toBe('2026-01-03T00:00:00.000Z')
})
it('v0/v1/v2 都能一路迁移到 v3', () => {
const builds = [
() => ({ tasks: [{ id: 'a', title: 'A', status: 'todo', priority: 2 }] }),
() => ({ schemaVersion: 1, tasks: [{ id: 'a', title: 'A', status: 'todo', priority: 2 }] }),
() => ({ schemaVersion: 2, tasks: [{ id: 'a', title: 'A', status: 'todo', priority: 2 }] }),
]
for (const build of builds) {
const state = parsePersistedState(JSON.stringify(build()))
expect(state.schemaVersion).toBe(3)
expect(state.tasks[0]?.deletedAt).toBeNull()
}
})
it('删除状态在序列化往返后保留', () => {
const tasks = coerceTaskList([validTask({ deletedAt: '2026-10-19T00:00:00.000Z' })])
const roundTrip = parsePersistedState(serializeState(tasks))
expect(roundTrip.tasks[0]?.deletedAt).toBe('2026-10-19T00:00:00.000Z')
})
})
describe('序列化往返', () => {
+9 -1
View File
@@ -9,7 +9,7 @@
import { isColumnId, isPriority, type Task } from '@/types/task'
export const SCHEMA_VERSION = 2
export const SCHEMA_VERSION = 3
/** 主数据在 localStorage 中的键名 */
export const STORAGE_KEY = 'kanban.tasks'
@@ -74,6 +74,8 @@ export function coerceTask(value: unknown): Task | null {
const updatedAt = readString(value, 'updatedAt') ?? createdAt
const completedAt = readString(value, 'completedAt')
const archivedAt = readString(value, 'archivedAt')
// 与 archivedAt 一致:非字符串(含 undefined / 数字 / 对象)一律收敛为 null
const deletedAt = readString(value, 'deletedAt')
return {
id,
@@ -88,6 +90,7 @@ export function coerceTask(value: unknown): Task | null {
updatedAt,
completedAt,
archivedAt,
deletedAt,
}
}
@@ -113,6 +116,7 @@ type Migration = (state: PersistedState) => PersistedState
* 版本迁移表,键为「迁移前的版本号」。
* - v0:早期数据没有 schemaVersion 字段,任务结构与 v1 一致
* - v1 -> v2:新增 archivedAt 字段(归档功能)
* - v2 -> v3:新增 deletedAt 字段(回收站 / 软删除)
* 未来新增字段时,在此登记 n -> n+1 的转换,并同步提升 SCHEMA_VERSION。
*/
const MIGRATIONS: Record<number, Migration> = {
@@ -121,6 +125,10 @@ const MIGRATIONS: Record<number, Migration> = {
schemaVersion: 2,
tasks: state.tasks.map((task) => ({ ...task, archivedAt: null })),
}),
2: (state) => ({
schemaVersion: 3,
tasks: state.tasks.map((task) => ({ ...task, deletedAt: null })),
}),
}
/**
+1
View File
@@ -44,6 +44,7 @@ function makeTask(id: string): Task {
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
}
}
+1
View File
@@ -43,6 +43,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
+435 -2
View File
@@ -37,6 +37,7 @@ describe('初始化', () => {
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
},
])
const store = freshStore(repository)
@@ -251,7 +252,8 @@ describe('删除与撤销', () => {
const store = freshStore()
const task = store.createTask({ title: 'A' })
expect(store.removeTask(task?.id ?? '')?.title).toBe('A')
expect(store.tasks).toHaveLength(0)
expect(store.activeTasks).toHaveLength(0)
expect(store.trashTasks.map((item) => item.title)).toEqual(['A'])
})
it('撤销后连原顺序一起恢复', () => {
@@ -412,7 +414,8 @@ describe('持久化', () => {
first.removeTask(task?.id ?? '')
const second = freshStore(repository)
expect(second.tasks).toHaveLength(0)
expect(second.activeTasks).toHaveLength(0)
expect(second.trashTasks).toHaveLength(1)
})
it('写入失败时标记为非持久化并提示,恢复后自动清除', () => {
@@ -534,6 +537,7 @@ describe('导出与导入', () => {
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
}
vi.setSystemTime(new Date('2026-10-19T00:00:01.000Z'))
@@ -676,3 +680,432 @@ describe('规模与稳定性', () => {
expect(store.tasks).toHaveLength(60)
})
})
function makePersistedTask(overrides: Partial<Task> = {}): Task {
return {
id: 'persisted',
title: '持久化任务',
description: '',
status: 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
describe('回收站', () => {
it('软删除后从看板消失,但保留在回收站', () => {
const store = freshStore()
const a = store.createTask({ title: 'A' })
store.createTask({ title: 'B' })
store.removeTask(a?.id ?? '')
expect(titles(store.byColumn.todo)).toEqual(['B'])
expect(store.counts.todo).toBe(1)
expect(store.trashTasks.map((task) => task.title)).toEqual(['A'])
expect(store.trashTasks[0]?.deletedAt).not.toBeNull()
// 数据本身仍在,导出可带走
expect(store.tasks).toHaveLength(2)
expect(store.exportJson()).toContain('"deletedAt"')
})
it('恢复后回到原列原序,deletedAt 清空', () => {
const store = freshStore()
store.createTask({ title: 'A' })
const middle = store.createTask({ title: 'B' })
store.createTask({ title: 'C' })
expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A'])
const removed = store.removeTask(middle?.id ?? '')
store.restoreTask(removed!)
expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A'])
expect(store.tasks.find((task) => task.id === middle?.id)?.deletedAt).toBeNull()
expect(store.trashTasks).toHaveLength(0)
})
it('删除已归档任务后,恢复仍保留归档态', () => {
const store = freshStore()
const task = store.createTask({ title: '归档过的', status: 'done' })
store.archiveTasks([task?.id ?? ''])
const removed = store.removeTask(task?.id ?? '')
expect(removed?.archivedAt).not.toBeNull()
store.restoreTask(removed!)
const restored = store.tasks.find((item) => item.id === task?.id)
expect(restored?.archivedAt).not.toBeNull()
expect(restored?.deletedAt).toBeNull()
})
it('回收站按删除时间倒序', () => {
vi.useFakeTimers()
try {
const store = freshStore()
vi.setSystemTime(new Date('2026-10-19T00:00:00.000Z'))
const first = store.createTask({ title: '先删的' })
store.removeTask(first?.id ?? '')
vi.setSystemTime(new Date('2026-10-19T00:00:01.000Z'))
const second = store.createTask({ title: '后删的' })
store.removeTask(second?.id ?? '')
expect(store.trashTasks.map((task) => task.title)).toEqual(['后删的', '先删的'])
} finally {
vi.useRealTimers()
}
})
it('永久删除会真正移除,导出里也不再有', () => {
const store = freshStore()
const task = store.createTask({ title: '要永久删除' })
const id = task?.id ?? ''
store.removeTask(id)
expect(store.purgeTask(id)).toBe(true)
expect(store.tasks).toHaveLength(0)
expect(store.exportJson()).not.toContain('要永久删除')
})
it('只能永久删除回收站里的任务,未删除的不受影响', () => {
const store = freshStore()
const task = store.createTask({ title: '还在用' })
expect(store.purgeTask(task?.id ?? '')).toBe(false)
expect(store.purgeTasks([task?.id ?? ''])).toBe(0)
expect(store.tasks).toHaveLength(1)
})
it('批量永久删除返回实际数量', () => {
const store = freshStore()
const a = store.createTask({ title: 'A' })
const b = store.createTask({ title: 'B' })
store.removeTasks([a?.id ?? '', b?.id ?? ''])
expect(store.purgeTasks([a?.id ?? '', b?.id ?? '', 'missing'])).toBe(2)
expect(store.tasks).toHaveLength(0)
})
it('清空回收站只删已删除的任务', () => {
const store = freshStore()
const kept = store.createTask({ title: '保留' })
const doomed = store.createTask({ title: '删除' })
store.removeTask(doomed?.id ?? '')
expect(store.emptyTrash()).toBe(1)
expect(store.activeTasks.map((task) => task.id)).toEqual([kept?.id])
})
it('31 天前的软删除项在下次启动时被自动清理', () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-10-19T00:00:00.000Z'))
const old = makePersistedTask({
id: 'old',
deletedAt: '2026-09-01T00:00:00.000Z',
})
const recent = makePersistedTask({
id: 'recent',
deletedAt: '2026-10-18T00:00:00.000Z',
})
const store = freshStore(createMemoryTaskRepository([old, recent]))
expect(store.tasks.map((task) => task.id)).toEqual(['recent'])
} finally {
vi.useRealTimers()
}
})
it('未超过保留期的任务不会被清理', () => {
const store = freshStore(
createMemoryTaskRepository([
makePersistedTask({ id: 'recent', deletedAt: '2026-10-18T00:00:00.000Z' }),
]),
)
expect(store.purgeExpiredTrash(30, new Date('2026-10-19T00:00:00.000Z'))).toBe(0)
expect(store.trashTasks).toHaveLength(1)
})
it('已删除任务不影响统计、今日分组与标签下拉', () => {
const store = freshStore()
const active = store.createTask({ title: '活跃', tags: ['共用'] })
const archived = store.createTask({ title: '归档', tags: ['归档标签'] })
const deleted = store.createTask({ title: '删除', tags: ['删除标签'] })
store.archiveTasks([archived?.id ?? ''])
store.removeTask(deleted?.id ?? '')
expect(store.allTags).toEqual(['共用'])
// 归档任务仍属于看板数据(只是默认隐藏),已删除的必须彻底消失
const todoIds = store.byColumn.todo.map((task) => task.id)
expect(todoIds).toEqual(expect.arrayContaining([active?.id, archived?.id]))
expect(todoIds).not.toContain(deleted?.id)
expect(store.counts.todo).toBe(2)
})
it('批量删除后撤销只恢复被删的那些', () => {
const store = freshStore()
const created = store.createTasks([{ title: 'A' }, { title: 'B' }, { title: 'C' }])
const removed = store.removeTasks([created[0]?.id ?? '', created[2]?.id ?? ''])
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['B'])
store.restoreTasks(removed)
expect(titles(store.byColumn.todo)).toEqual(['A', 'B', 'C'])
})
})
describe('批量编辑', () => {
it('批量设置优先级且只落盘一次', () => {
const repository = createMemoryTaskRepository()
const save = vi.spyOn(repository, 'save')
const store = freshStore(repository)
const created = store.createTasks([{ title: 'A' }, { title: 'B' }, { title: 'C' }])
save.mockClear()
expect(store.updateTasks(created.map((task) => task.id), { priority: 3 })).toBe(3)
expect(store.activeTasks.every((task) => task.priority === 3)).toBe(true)
expect(save).toHaveBeenCalledTimes(1)
})
it('批量移动到目标列末尾并清空 completedAt', () => {
const store = freshStore()
store.createTask({ title: '已在做', status: 'doing' })
const done1 = store.createTask({ title: 'D1', status: 'done' })
const done2 = store.createTask({ title: 'D2', status: 'done' })
expect(store.updateTasks([done1?.id ?? '', done2?.id ?? ''], { status: 'doing' })).toBe(2)
// 目标列末尾追加,且同批多条的相对顺序沿用它们在原列里的视觉顺序
expect(store.byColumn.doing.map((task) => task.title)).toEqual(['已在做', 'D2', 'D1'])
for (const task of store.byColumn.doing) expect(task.completedAt).toBeNull()
})
it('批量移入已完成会写入 completedAt', () => {
const store = freshStore()
const a = store.createTask({ title: 'A' })
store.updateTasks([a?.id ?? ''], { status: 'done' })
expect(store.byColumn.done[0]?.completedAt).not.toBeNull()
})
it('批量加标签不影响原有标签', () => {
const store = freshStore()
const a = store.createTask({ title: 'A', tags: ['x', 'y'] })
const b = store.createTask({ title: 'B', tags: ['y'] })
store.updateTasks([a?.id ?? '', b?.id ?? ''], { addTags: ['z'] })
expect(store.tasks.find((task) => task.id === a?.id)?.tags).toEqual(['x', 'y', 'z'])
expect(store.tasks.find((task) => task.id === b?.id)?.tags).toEqual(['y', 'z'])
})
it('批量移标签只移除指定标签', () => {
const store = freshStore()
const a = store.createTask({ title: 'A', tags: ['x', 'y'] })
const b = store.createTask({ title: 'B', tags: ['x', 'z'] })
store.updateTasks([a?.id ?? '', b?.id ?? ''], { removeTags: ['x'] })
expect(store.tasks.find((task) => task.id === a?.id)?.tags).toEqual(['y'])
expect(store.tasks.find((task) => task.id === b?.id)?.tags).toEqual(['z'])
})
it('批量设置与清除截止日期', () => {
const store = freshStore()
const a = store.createTask({ title: 'A' })
const b = store.createTask({ title: 'B' })
const ids = [a?.id ?? '', b?.id ?? '']
store.updateTasks(ids, { dueDate: '2026-11-01' })
expect(store.activeTasks.every((task) => task.dueDate === '2026-11-01')).toBe(true)
store.updateTasks(ids, { dueDate: null })
expect(store.activeTasks.every((task) => task.dueDate === null)).toBe(true)
})
it('批量归档只处理未删除任务', () => {
const store = freshStore()
const a = store.createTask({ title: 'A', status: 'done' })
const deleted = store.createTask({ title: 'B' })
store.removeTask(deleted?.id ?? '')
expect(store.updateTasks([a?.id ?? '', deleted?.id ?? ''], { archived: true })).toBe(1)
expect(store.tasks.find((task) => task.id === a?.id)?.archivedAt).not.toBeNull()
expect(store.tasks.find((task) => task.id === deleted?.id)?.archivedAt).toBeNull()
})
it('空选择或空 patch 不写仓库', () => {
const repository = createMemoryTaskRepository()
const save = vi.spyOn(repository, 'save')
const store = freshStore(repository)
const task = store.createTask({ title: 'A' })
save.mockClear()
expect(store.updateTasks([], { priority: 1 })).toBe(0)
expect(store.updateTasks([task?.id ?? ''], {})).toBe(0)
expect(save).not.toHaveBeenCalled()
})
})
describe('合并导入', () => {
function payload(tasks: Record<string, unknown>[]): string {
return JSON.stringify({ schemaVersion: 3, tasks })
}
function payloadTask(overrides: Record<string, unknown>): Record<string, unknown> {
return {
id: 'incoming',
title: '导入任务',
status: 'todo',
priority: 2,
sortOrder: 1024,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
...overrides,
}
}
it('同 id 新者胜、缺失的新增、本地独有保留', () => {
const store = freshStore()
const localA = store.createTask({ title: '本地 A' })
store.createTask({ title: '本地独有' })
const local = store.tasks.find((task) => task.id === localA?.id)
const result = store.mergeJson(
payload([
payloadTask({
id: localA?.id,
title: '更新后的 A',
createdAt: local?.createdAt,
updatedAt: '2099-01-01T00:00:00.000Z',
}),
payloadTask({ id: 'new-b', title: '文件新增' }),
]),
)
expect(result).toEqual({ added: 1, updated: 1, skipped: 0 })
expect(store.activeTasks).toHaveLength(3)
expect(titles(store.activeTasks).sort()).toEqual(['更新后的 A', '文件新增', '本地独有'].sort())
const added = store.activeTasks.find((task) => task.id === 'new-b')
const others = store.activeTasks.filter((task) => task.status === 'todo' && task.id !== 'new-b')
expect(added?.sortOrder).toBeGreaterThan(Math.max(...others.map((task) => task.sortOrder)))
})
it('同 id 且 updatedAt 相同则跳过', () => {
const store = freshStore()
const local = store.createTask({ title: '本地' })
const current = store.tasks.find((task) => task.id === local?.id)
const result = store.mergeJson(
payload([
payloadTask({
id: local?.id,
title: '文件',
createdAt: current?.createdAt,
updatedAt: current?.updatedAt,
}),
]),
)
expect(result).toEqual({ added: 0, updated: 0, skipped: 1 })
expect(store.activeTasks[0]?.title).toBe('本地')
})
it('合并不删除本地独有任务,也不产生重复 id', () => {
const store = freshStore()
store.createTasks([{ title: 'A' }, { title: 'B' }])
store.mergeJson(payload([payloadTask({ id: 'c', title: 'C' })]))
const ids = store.activeTasks.map((task) => task.id)
expect(new Set(ids).size).toBe(ids.length)
expect(store.activeTasks).toHaveLength(3)
})
it('合并前自动写入快照', () => {
const store = freshStore()
store.createTask({ title: '合并前' })
expect(store.snapshots).toHaveLength(0)
store.mergeJson(payload([payloadTask({ id: 'x', title: 'X' })]))
expect(store.snapshots).toHaveLength(1)
})
it('非法文件不改变现有数据,也不写快照', () => {
const store = freshStore()
store.createTask({ title: '保留' })
expect(() => store.mergeJson('{"tasks":[{"id":"broken"}]}')).toThrow()
expect(store.activeTasks).toHaveLength(1)
expect(store.snapshots).toHaveLength(0)
})
it('合并后重建 store 结果不变', () => {
const repository = createMemoryTaskRepository()
const first = freshStore(repository)
first.createTask({ title: '本地' })
first.mergeJson(payload([payloadTask({ id: 'new', title: '新增' })]))
const second = freshStore(repository)
expect(titles(second.activeTasks).sort()).toEqual(['新增', '本地'].sort())
})
})
describe('标签治理', () => {
it('重命名同步全部任务并返回受影响数量', () => {
const store = freshStore()
store.createTask({ title: 'A', tags: ['前端', '设计'] })
store.createTask({ title: 'B', tags: ['前端'] })
store.createTask({ title: 'C', tags: ['后端'] })
expect(store.renameTag('前端', 'Frontend')).toBe(2)
expect(store.allTags).toEqual(['Frontend', '后端', '设计'].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')))
expect(store.tasks.find((task) => task.title === 'A')?.tags).toEqual(['Frontend', '设计'])
})
it('合并标签后去重且计数正确', () => {
const store = freshStore()
store.createTask({ title: 'A', tags: ['前端', 'Frontend'] })
store.createTask({ title: 'B', tags: ['Frontend'] })
expect(store.mergeTags(['前端'], 'Frontend')).toBe(1)
expect(store.allTags).toEqual(['Frontend'])
expect(store.tasks.find((task) => task.title === 'A')?.tags).toEqual(['Frontend'])
})
it('删除标签不删除任务', () => {
const store = freshStore()
store.createTask({ title: 'A', tags: ['前端'] })
store.createTask({ title: 'B', tags: ['前端', '设计'] })
expect(store.removeTag('前端')).toBe(2)
expect(store.activeTasks).toHaveLength(2)
expect(store.allTags).toEqual(['设计'])
})
it('重命名不存在的标签不改动任何任务', () => {
const store = freshStore()
store.createTask({ title: 'A', tags: ['前端'] })
expect(store.renameTag('不存在', 'X')).toBe(0)
expect(store.allTags).toEqual(['前端'])
})
it('标签操作不修改回收站里的任务', () => {
const store = freshStore()
const task = store.createTask({ title: 'A', tags: ['前端'] })
store.removeTask(task?.id ?? '')
expect(store.renameTag('前端', 'Frontend')).toBe(0)
expect(store.trashTasks[0]?.tags).toEqual(['前端'])
})
})
+295 -64
View File
@@ -17,14 +17,28 @@ import {
type SnapshotInfo,
type TaskRepository,
} from '@/data/taskRepository'
import { DEFAULT_PRIORITY, type ColumnId, type Priority, type Task } from '@/types/task'
import { COLUMN_IDS, DEFAULT_PRIORITY, type ColumnId, type Priority, type Task } from '@/types/task'
import { createId } from '@/utils/id'
import { mergeTasks as mergeTaskLists } from '@/utils/merge'
import {
groupByColumn,
moveWithinColumn,
sortByOrder,
sortOrderForPosition,
} from '@/utils/sortOrder'
import {
mergeTags as mergeTagsPure,
normalizeTags,
removeTag as removeTagPure,
renameTag as renameTagPure,
} from '@/utils/tags'
import {
applyTaskPatch,
isBatchPatchEmpty,
tagsAfterBatchPatch,
type BatchPatch,
type TaskPatch,
} from '@/utils/taskPatch'
/** 跨标签页同步使用的频道名 */
export const SYNC_CHANNEL_NAME = 'kanban.sync'
@@ -38,7 +52,7 @@ export interface CreateTaskInput {
status?: ColumnId
}
export type TaskPatch = Partial<Omit<Task, 'id' | 'createdAt'>>
export type { BatchPatch, TaskPatch } from '@/utils/taskPatch'
export interface InitOptions {
/** 注入仓库;不传则使用 localStorage,不可用时降级为内存仓库 */
@@ -59,18 +73,6 @@ function isSyncMessage(value: unknown): value is SyncMessage {
)
}
function normalizeTags(tags: readonly string[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const tag of tags) {
const value = tag.trim()
if (!value || seen.has(value)) continue
seen.add(value)
result.push(value)
}
return result
}
export const useTaskStore = defineStore('tasks', () => {
const tasks = ref<Task[]>([])
const ready = ref(false)
@@ -84,7 +86,22 @@ export const useTaskStore = defineStore('tasks', () => {
let repository: TaskRepository | null = null
let channel: BroadcastChannel | null = null
const byColumn = computed(() => groupByColumn(tasks.value))
/**
* 未删除的任务。所有「看板语义」的读取都必须走它:
* 列、计数、标签、统计、今日视图、归档候选……
* 回收站不是筛选维度,而是独立的数据状态,因此在数据层剔除,
* 不交给 matchesFilter 处理。
*/
const activeTasks = computed(() => tasks.value.filter((task) => task.deletedAt === null))
/** 回收站:已软删除的任务,按删除时间倒序(最近删的在最上面) */
const trashTasks = computed(() =>
tasks.value
.filter((task) => task.deletedAt !== null)
.sort((a, b) => (b.deletedAt ?? '').localeCompare(a.deletedAt ?? '')),
)
const byColumn = computed(() => groupByColumn(activeTasks.value))
const counts = computed(() => {
const result = {} as Record<ColumnId, number>
@@ -96,11 +113,13 @@ export const useTaskStore = defineStore('tasks', () => {
/**
* 现有任务里出现过的全部标签,按中文排序。
* 筛选下拉和编辑任务时的标签候选都用它,避免两处各算一份、结果不一致。
* 只统计未删除、未归档的任务:否则筛选下拉里会出现
* 「只有回收站/归档任务才用过」的标签,选中后一条都筛不出来。
*/
const allTags = computed(() => {
const collected = new Set<string>()
for (const task of tasks.value) {
for (const task of activeTasks.value) {
if (task.archivedAt !== null) continue
for (const tag of task.tags) collected.add(tag)
}
return [...collected].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
@@ -238,6 +257,8 @@ export const useTaskStore = defineStore('tasks', () => {
ready.value = true
// 先清理过期回收站再写启动快照,避免快照里又留下一份本该消失的数据
purgeExpiredTrash()
writeStartupSnapshot()
if (options.crossTab !== false) setupChannel()
@@ -260,7 +281,7 @@ export const useTaskStore = defineStore('tasks', () => {
const now = new Date().toISOString()
/** 同列工作副本:含本批已创建的任务,供中点插入取前后邻居 */
const working = groupByColumn(tasks.value)
const working = groupByColumn(activeTasks.value)
const created: Task[] = []
for (let index = drafts.length - 1; index >= 0; index -= 1) {
@@ -282,6 +303,7 @@ export const useTaskStore = defineStore('tasks', () => {
updatedAt: now,
completedAt: status === 'done' ? now : null,
archivedAt: null,
deletedAt: null,
}
working[status] = [task, ...working[status]]
@@ -299,41 +321,21 @@ export const useTaskStore = defineStore('tasks', () => {
}
function updateTask(id: string, patch: TaskPatch): Task | null {
const index = tasks.value.findIndex((task) => task.id === id)
const index = tasks.value.findIndex((task) => task.id === id && task.deletedAt === null)
const current = tasks.value[index]
if (!current) return null
const now = new Date().toISOString()
const nextStatus = patch.status ?? current.status
let completedAt = current.completedAt
if (patch.completedAt !== undefined) {
completedAt = patch.completedAt
} else if (nextStatus === 'done' && current.status !== 'done') {
completedAt = now
} else if (nextStatus !== 'done' && current.status === 'done') {
completedAt = null
}
// 状态变更且未显式指定顺序时,追加到目标列末尾
let sortOrder = patch.sortOrder ?? current.sortOrder
if (nextStatus !== current.status && patch.sortOrder === undefined) {
const target = groupByColumn(tasks.value)[nextStatus].filter((task) => task.id !== id)
const target = byColumn.value[nextStatus].filter((task) => task.id !== id)
sortOrder = sortOrderForPosition(target, target.length)
}
const title = patch.title === undefined ? current.title : patch.title.trim() || current.title
const updated: Task = {
...current,
...patch,
title,
status: nextStatus,
sortOrder,
tags: patch.tags ? normalizeTags(patch.tags) : current.tags,
completedAt,
updatedAt: now,
}
const updated = applyTaskPatch(current, { ...patch, sortOrder }, now)
const next = [...tasks.value]
next[index] = updated
@@ -342,65 +344,227 @@ export const useTaskStore = defineStore('tasks', () => {
return updated
}
/** 删除任务,返回被删除的任务本身,供撤销使用(T12) */
function removeTask(id: string): Task | null {
const task = tasks.value.find((item) => item.id === id)
if (!task) return null
tasks.value = tasks.value.filter((item) => item.id !== id)
commit()
return task
/**
* 批量更新(一次 map + 一次 commit)。
*
* 与单条编辑共用 applyTaskPatch,因此 completedAt、updatedAt、标签去重
* 的状态流转规则只有一份。标签按「增 / 删」处理,避免批量加标签时
* 把每条任务原有的其它标签抹掉。
*
* @returns 实际更新的任务数
*/
function updateTasks(ids: readonly string[], patch: BatchPatch): number {
const idSet = new Set(ids)
if (idSet.size === 0 || isBatchPatchEmpty(patch)) return 0
const now = new Date().toISOString()
/** 目标列工作副本:同批多条移入同一列时,按看板上的视觉顺序依次追加到末尾 */
const columns = groupByColumn(activeTasks.value)
/** 按列顺序 + 列内 sortOrder 展开,避免按 tasks 数组的创建顺序处理 */
const inBoardOrder = COLUMN_IDS.flatMap((columnId) => columns[columnId])
const ordered = inBoardOrder.filter((task) => idSet.has(task.id))
const updates = new Map<string, Task>()
let count = 0
for (const task of ordered) {
const taskPatch: TaskPatch = {}
if (patch.status !== undefined) taskPatch.status = patch.status
if (patch.priority !== undefined) taskPatch.priority = patch.priority
if (patch.dueDate !== undefined) taskPatch.dueDate = patch.dueDate
if (patch.addTags !== undefined || patch.removeTags !== undefined) {
const tags = tagsAfterBatchPatch(task.tags, patch)
const unchanged =
tags.length === task.tags.length && tags.every((tag, i) => tag === task.tags[i])
if (!unchanged) taskPatch.tags = tags
}
/** 撤销删除:连原 sortOrder 一起恢复,因此位置也会还原 */
if (patch.status !== undefined && patch.status !== task.status) {
const target = columns[patch.status].filter((item) => item.id !== task.id)
taskPatch.sortOrder = sortOrderForPosition(target, target.length)
}
let updated = applyTaskPatch(task, taskPatch, now)
if (patch.archived !== undefined) {
const archivedAt = patch.archived ? (task.archivedAt ?? now) : null
if (archivedAt !== updated.archivedAt) updated = { ...updated, archivedAt }
}
if (updated.status === task.status) {
// 没有换列:原位替换,保持工作副本的列内顺序不变
columns[task.status] = columns[task.status].map((item) =>
item.id === updated.id ? updated : item,
)
} else {
columns[task.status] = columns[task.status].filter((item) => item.id !== task.id)
columns[updated.status] = [
...columns[updated.status].filter((item) => item.id !== updated.id),
updated,
]
}
updates.set(updated.id, updated)
count += 1
}
if (count === 0) return 0
tasks.value = tasks.value.map((task) => updates.get(task.id) ?? task)
commit()
return count
}
/**
* 删除任务 —— 软删除:写入 deletedAt,任务进入回收站。
* 返回的仍是任务本身(带 deletedAt),供 Toast 撤销使用。
*/
function removeTask(id: string): Task | null {
const task = tasks.value.find((item) => item.id === id && item.deletedAt === null)
if (!task) return null
const now = new Date().toISOString()
const removed: Task = { ...task, deletedAt: now, updatedAt: now }
tasks.value = tasks.value.map((item) => (item.id === id ? removed : item))
commit()
return removed
}
/** 撤销删除 / 从回收站恢复:清除 deletedAt,原 sortOrder 与 archivedAt 一起保留 */
function restoreTask(task: Task): void {
if (tasks.value.some((item) => item.id === task.id)) return
tasks.value = [...tasks.value, task]
const existing = tasks.value.find((item) => item.id === task.id)
if (!existing) {
tasks.value = [...tasks.value, { ...task, deletedAt: null }]
commit()
return
}
if (existing.deletedAt === null) return
tasks.value = tasks.value.map((item) =>
item.id === task.id ? { ...item, deletedAt: null } : item,
)
commit()
}
/**
* 批量删除,返回被删除的任务(保持原有顺序)。
* 批量软删除,返回被删除的任务(保持原有顺序)。
* 供「批量添加」的撤销入口使用:一次操作删掉整批,而不是逐条点删除。
*/
function removeTasks(ids: readonly string[]): Task[] {
const idSet = new Set(ids)
const removed = tasks.value.filter((task) => idSet.has(task.id))
if (removed.length === 0) return []
const now = new Date().toISOString()
const removed: Task[] = []
tasks.value = tasks.value.filter((task) => !idSet.has(task.id))
tasks.value = tasks.value.map((task) => {
if (!idSet.has(task.id) || task.deletedAt !== null) return task
const next: Task = { ...task, deletedAt: now, updatedAt: now }
removed.push(next)
return next
})
if (removed.length === 0) return []
commit()
return removed
}
/** 批量撤销删除:原 sortOrder 一起恢复,位置不变;已存在的不重复插入 */
function restoreTasks(items: readonly Task[]): void {
const existing = new Set(tasks.value.map((task) => task.id))
const restored = items.filter((task) => !existing.has(task.id))
if (restored.length === 0) return
const byId = new Map(tasks.value.map((task) => [task.id, task]))
let changed = false
tasks.value = [...tasks.value, ...restored]
for (const item of items) {
const existing = byId.get(item.id)
if (existing) {
if (existing.deletedAt === null) continue
byId.set(item.id, { ...existing, deletedAt: null })
} else {
byId.set(item.id, { ...item, deletedAt: null })
}
changed = true
}
if (!changed) return
tasks.value = tasks.value.map((task) => byId.get(task.id) ?? task)
// 新插入的(已不在 tasks 里的)需要补进来
const existingIds = new Set(tasks.value.map((task) => task.id))
const appended = [...byId.values()].filter((task) => !existingIds.has(task.id))
if (appended.length > 0) tasks.value = [...tasks.value, ...appended]
commit()
}
/** 永久删除单个任务(物理删除,不可撤销);只允许删除回收站里的任务 */
function purgeTask(id: string): boolean {
const task = tasks.value.find((item) => item.id === id && item.deletedAt !== null)
if (!task) return false
tasks.value = tasks.value.filter((item) => item.id !== id)
commit()
return true
}
/** 永久删除多个任务;返回实际删除数量 */
function purgeTasks(ids: readonly string[]): number {
const idSet = new Set(ids)
const remaining = tasks.value.filter(
(task) => !(idSet.has(task.id) && task.deletedAt !== null),
)
const removed = tasks.value.length - remaining.length
if (removed === 0) return 0
tasks.value = remaining
commit()
return removed
}
/** 永久删除 + 清空回收站 */
function emptyTrash(): number {
return purgeTasks(trashTasks.value.map((task) => task.id))
}
/**
* 清理回收站中超过保留期的任务(默认 30 天)。
* 删除时间无法解析时保留而非删除 —— 宁可多占一点配额,
* 也不因为一条脏数据把用户还可能需要的任务清掉。
*
* @returns 实际清理的数量
*/
function purgeExpiredTrash(days = 30, now: Date = new Date()): number {
const cutoff = now.getTime() - days * 24 * 60 * 60 * 1000
const expiredIds = new Set<string>()
for (const task of tasks.value) {
if (task.deletedAt === null) continue
const deletedAt = new Date(task.deletedAt).getTime()
if (Number.isFinite(deletedAt) && deletedAt < cutoff) expiredIds.add(task.id)
}
if (expiredIds.size === 0) return 0
tasks.value = tasks.value.filter((task) => !expiredIds.has(task.id))
commit()
return expiredIds.size
}
/**
* 拖拽移动(T13)。toIndex 为移动完成后在目标列中的最终下标。
* status 与 sortOrder 在同一次赋值中更新,不存在中间态。
*/
function moveTask(id: string, toStatus: ColumnId, toIndex: number): void {
const task = tasks.value.find((item) => item.id === id)
const task = tasks.value.find((item) => item.id === id && item.deletedAt === null)
if (!task) return
const now = new Date().toISOString()
if (task.status === toStatus) {
const column = groupByColumn(tasks.value)[toStatus]
const column = byColumn.value[toStatus]
const fromIndex = column.findIndex((item) => item.id === id)
if (fromIndex === -1) return
const moved = moveWithinColumn(column, fromIndex, toIndex)
const byId = new Map(moved.map((item) => [item.id, item]))
tasks.value = tasks.value.map((item) => byId.get(item.id) ?? item)
} else {
const target = groupByColumn(tasks.value)[toStatus].filter((item) => item.id !== id)
const target = byColumn.value[toStatus].filter((item) => item.id !== id)
const sortOrder = sortOrderForPosition(target, toIndex)
tasks.value = tasks.value.map((item) =>
item.id === id
@@ -437,6 +601,20 @@ export const useTaskStore = defineStore('tasks', () => {
return imported.length
}
/**
* 合并式导入:同 id 以 updatedAt 较新的一方为准,其余新增到列尾。
* 与覆盖导入一样,执行前先留一份快照。
*/
function mergeJson(raw: string): { added: number; updated: number; skipped: number } {
const incoming = parseImportJson(raw)
snapshotBeforeOverwrite()
const result = mergeTaskLists(tasks.value, incoming)
tasks.value = sortByOrder(result.tasks)
commit()
return { added: result.added, updated: result.updated, skipped: result.skipped }
}
/** 覆盖全部数据(供导入后的自定义处理或测试使用) */
function replaceAll(next: readonly Task[]): void {
tasks.value = sortByOrder(next)
@@ -453,7 +631,7 @@ export const useTaskStore = defineStore('tasks', () => {
let count = 0
tasks.value = tasks.value.map((task) => {
if (!idSet.has(task.id) || task.archivedAt !== null) return task
if (!idSet.has(task.id) || task.deletedAt !== null || task.archivedAt !== null) return task
count += 1
return { ...task, archivedAt: now, updatedAt: now }
})
@@ -469,7 +647,7 @@ export const useTaskStore = defineStore('tasks', () => {
let count = 0
tasks.value = tasks.value.map((task) => {
if (!idSet.has(task.id) || task.archivedAt === null) return task
if (!idSet.has(task.id) || task.deletedAt !== null || task.archivedAt === null) return task
count += 1
return { ...task, archivedAt: null, updatedAt: now }
})
@@ -478,6 +656,48 @@ export const useTaskStore = defineStore('tasks', () => {
return count
}
/**
* 标签治理:对未删除的任务应用一次纯函数变换,只刷新真正变化的那些任务。
* 回收站里的任务不改 —— 它们不在任何常规视图里,改了也看不见,
* 反而会让「恢复后标签变了」显得莫名其妙。
*
* @returns 受影响的任务数
*/
function applyTagTransform(transform: (tasks: readonly Task[]) => Task[]): number {
const active = activeTasks.value
const transformed = transform(active)
const now = new Date().toISOString()
const changed = new Map<string, Task>()
let count = 0
transformed.forEach((task, index) => {
if (task === active[index]) return
count += 1
changed.set(task.id, { ...task, updatedAt: now })
})
if (count === 0) return 0
tasks.value = tasks.value.map((task) => changed.get(task.id) ?? task)
commit()
return count
}
/** 重命名标签,返回受影响的任务数 */
function renameTag(from: string, to: string): number {
return applyTagTransform((tasks) => renameTagPure(tasks, from, to))
}
/** 把多个标签合并为 into,返回受影响的任务数 */
function mergeTags(from: readonly string[], into: string): number {
return applyTagTransform((tasks) => mergeTagsPure(tasks, from, into))
}
/** 从所有任务上删除某个标签(不删除任务),返回受影响的任务数 */
function removeTag(tag: string): number {
return applyTagTransform((tasks) => removeTagPure(tasks, tag))
}
/** 释放跨标签页频道;主要供测试使用 */
function dispose(): void {
channel?.close()
@@ -487,6 +707,8 @@ export const useTaskStore = defineStore('tasks', () => {
return {
tasks,
activeTasks,
trashTasks,
ready,
persistent,
notice,
@@ -498,16 +720,25 @@ export const useTaskStore = defineStore('tasks', () => {
createTask,
createTasks,
updateTask,
updateTasks,
removeTask,
restoreTask,
removeTasks,
restoreTasks,
purgeTask,
purgeTasks,
emptyTrash,
purgeExpiredTrash,
moveTask,
exportJson,
importJson,
mergeJson,
replaceAll,
archiveTasks,
unarchiveTasks,
renameTag,
mergeTags,
removeTag,
refreshSnapshots,
restoreSnapshot,
dispose,
+31 -3
View File
@@ -1,9 +1,9 @@
/**
* 应用级面板状态
*
* 备份面板与快捷键帮助都是**应用级**操作(在哪个视图都该能用),
* 备份、回收站、标签管理、快捷键帮助都是**应用级**操作(在哪个视图都该能用),
* 因此状态放在这里,由 App.vue 统一渲染,
* 导航栏负责打开,看板负责在它们打开时让出快捷键。
* 导航栏 / 工具栏负责打开,看板负责在它们打开时让出快捷键。
*/
import { defineStore } from 'pinia'
@@ -12,8 +12,12 @@ import { computed, ref } from 'vue'
export const useUiStore = defineStore('ui', () => {
const backupOpen = ref(false)
const helpOpen = ref(false)
const trashOpen = ref(false)
const tagsOpen = ref(false)
const anyPanelOpen = computed(() => backupOpen.value || helpOpen.value)
const anyPanelOpen = computed(
() => backupOpen.value || helpOpen.value || trashOpen.value || tagsOpen.value,
)
function openBackup(): void {
backupOpen.value = true
@@ -31,19 +35,43 @@ export const useUiStore = defineStore('ui', () => {
helpOpen.value = false
}
function openTrash(): void {
trashOpen.value = true
}
function closeTrash(): void {
trashOpen.value = false
}
function openTags(): void {
tagsOpen.value = true
}
function closeTags(): void {
tagsOpen.value = false
}
function closeAll(): void {
backupOpen.value = false
helpOpen.value = false
trashOpen.value = false
tagsOpen.value = false
}
return {
backupOpen,
helpOpen,
trashOpen,
tagsOpen,
anyPanelOpen,
openBackup,
closeBackup,
openHelp,
closeHelp,
openTrash,
closeTrash,
openTags,
closeTags,
closeAll,
}
})
+7
View File
@@ -35,6 +35,13 @@ export interface Task {
completedAt: string | null
/** 归档时间;null 表示未归档。归档任务默认不在看板上显示 */
archivedAt: string | null
/**
* 删除时间;null 表示未删除。
*
* 删除是「软删除」:任务仍留在数据里,只是从看板、统计、标签等
* 所有常规视图里剔除,直到用户在回收站里恢复或永久删除。
*/
deletedAt: string | null
}
export interface ColumnDef {
+1
View File
@@ -30,6 +30,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
updatedAt: '2026-10-01T00:00:00.000Z',
completedAt: ago(10 * DAY_MS),
archivedAt: null,
deletedAt: null,
...overrides,
}
}
+1
View File
@@ -19,6 +19,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import { mergeTasks } from './merge'
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: 'task-1',
title: '任务',
description: '',
status: 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
describe('mergeTasks 冲突规则', () => {
it('同 id 以 updatedAt 较新的一方为准', () => {
const local = makeTask({ id: 'a', title: '旧标题' })
const incoming = makeTask({
id: 'a',
title: '新标题',
updatedAt: '2026-10-10T00:00:00.000Z',
})
const result = mergeTasks([local], [incoming])
expect(result.updated).toBe(1)
expect(result.added).toBe(0)
expect(result.tasks.find((task) => task.id === 'a')?.title).toBe('新标题')
})
it('本地更新时保留本地版本并计入 skipped', () => {
const local = makeTask({ id: 'a', title: '本地新', updatedAt: '2026-10-10T00:00:00.000Z' })
const incoming = makeTask({ id: 'a', title: '文件旧' })
const result = mergeTasks([local], [incoming])
expect(result.updated).toBe(0)
expect(result.skipped).toBe(1)
expect(result.tasks[0]?.title).toBe('本地新')
})
it('updatedAt 相同视为同一条,跳过', () => {
const local = makeTask({ id: 'a', title: '本地' })
const incoming = makeTask({ id: 'a', title: '文件' })
const result = mergeTasks([local], [incoming])
expect(result.skipped).toBe(1)
expect(result.tasks[0]?.title).toBe('本地')
})
it('文件新增的任务追加到对应列末尾', () => {
const local = makeTask({ id: 'a', sortOrder: 1024 })
const incoming = makeTask({ id: 'b', title: '新增', sortOrder: 1 })
const result = mergeTasks([local], [incoming])
const added = result.tasks.find((task) => task.id === 'b')
expect(result.added).toBe(1)
expect(added?.sortOrder).toBeGreaterThan(local.sortOrder)
})
it('本地独有(文件里没有)的任务保留', () => {
const local = makeTask({ id: 'local-only', title: '本地保留' })
const result = mergeTasks([local], [])
expect(result.added).toBe(0)
expect(result.tasks.map((task) => task.title)).toEqual(['本地保留'])
})
it('归档与软删除状态跟随较新的一方', () => {
const local = makeTask({ id: 'a' })
const incoming = makeTask({
id: 'a',
archivedAt: '2026-10-10T00:00:00.000Z',
deletedAt: '2026-10-11T00:00:00.000Z',
updatedAt: '2026-10-10T00:00:00.000Z',
})
const result = mergeTasks([local], [incoming])
const merged = result.tasks.find((task) => task.id === 'a')
expect(merged?.archivedAt).toBe('2026-10-10T00:00:00.000Z')
expect(merged?.deletedAt).toBe('2026-10-11T00:00:00.000Z')
})
it('不修改入参数组', () => {
const local = [makeTask({ id: 'a' })]
const incoming = [makeTask({ id: 'b' })]
mergeTasks(local, incoming)
expect(local).toHaveLength(1)
expect(incoming).toHaveLength(1)
})
})
+75
View File
@@ -0,0 +1,75 @@
/**
* 合并导入
*
* 覆盖导入适合「换设备后整体恢复」,但多设备、多次导出之后,
* 更常见的诉求是把两份数据拼起来。合并规则刻意做得简单可预期:
*
* - 按 id 匹配;同 id 比较 updatedAt,新者胜
* - 文件里有、本地没有 → 新增,追加到对应列末尾
* - 本地有、文件里没有 → 保留(合并 ≠ 同步,不删本地数据)
* - updatedAt 相同 → 视为同一条,跳过
*
* 不做全局 sortOrder normalize:新增项只要各自落到列尾即可,
* 已经有冲突的旧数据不该被一次导入悄悄重排。
*/
import type { Task } from '@/types/task'
import { groupByColumn, sortOrderForPosition } from '@/utils/sortOrder'
export interface MergeResult {
tasks: Task[]
/** 文件里新增的任务数 */
added: number
/** 覆盖了本地较旧版本的任务数 */
updated: number
/** 文件内同 id 但本地更新(或完全相同)而跳过的任务数 */
skipped: number
}
/**
* ISO 时间戳是定长 UTC 字符串,字典序比较与时间先后一致。
* 用字符串比较而不是 Date.parse,避免非法时间被解析成 NaN 后静默胜出。
*/
function isNewer(incoming: Task, current: Task): boolean {
return incoming.updatedAt > current.updatedAt
}
export function mergeTasks(current: readonly Task[], incoming: readonly Task[]): MergeResult {
let tasks = [...current]
const byId = new Map(tasks.map((task) => [task.id, task]))
const added: Task[] = []
let updated = 0
let skipped = 0
for (const item of incoming) {
const existing = byId.get(item.id)
if (!existing) {
added.push(item)
// 先登记,避免文件内重复 id(严格校验已拦截,这里只是兜底)
byId.set(item.id, item)
continue
}
if (isNewer(item, existing)) {
tasks = tasks.map((task) => (task.id === item.id ? item : task))
byId.set(item.id, item)
updated += 1
} else {
skipped += 1
}
}
if (added.length > 0) {
const columns = groupByColumn(tasks)
for (const task of added) {
const column = columns[task.status]
const placed: Task = { ...task, sortOrder: sortOrderForPosition(column, column.length) }
columns[task.status] = [...column, placed]
tasks = [...tasks, placed]
byId.set(placed.id, placed)
}
}
return { tasks, added: added.length, updated, skipped }
}
+1
View File
@@ -31,6 +31,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
+1
View File
@@ -27,6 +27,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
updatedAt: '2026-10-01T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import { collectTagStats, mergeTags, normalizeTags, removeTag, renameTag } from './tags'
function makeTask(id: string, tags: string[]): Task {
return {
id,
title: id,
description: '',
status: 'todo',
priority: 2,
tags,
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
}
}
describe('normalizeTags', () => {
it('去空白、丢弃空串并保持首次出现顺序', () => {
expect(normalizeTags([' 前端 ', '', '设计', '前端'])).toEqual(['前端', '设计'])
})
})
describe('renameTag', () => {
it('同步改写所有任务并保持其它标签', () => {
const tasks = [makeTask('a', ['前端', '设计']), makeTask('b', ['前端'])]
const next = renameTag(tasks, '前端', 'Frontend')
expect(next[0]?.tags).toEqual(['Frontend', '设计'])
expect(next[1]?.tags).toEqual(['Frontend'])
})
it('目标标签已存在时自动去重', () => {
const next = renameTag([makeTask('a', ['前端', 'Frontend'])], '前端', 'Frontend')
expect(next[0]?.tags).toEqual(['Frontend'])
})
it('目标为空或与原标签相同时不改动,且返回原对象', () => {
const task = makeTask('a', ['前端'])
expect(renameTag([task], '前端', ' ')[0]).toBe(task)
expect(renameTag([task], '前端', '前端')[0]).toBe(task)
})
it('不影响没有该标签的任务', () => {
const task = makeTask('a', ['设计'])
expect(renameTag([task], '前端', 'Frontend')[0]).toBe(task)
})
})
describe('mergeTags', () => {
it('把多个标签合并为一个并去重', () => {
const next = mergeTags(
[makeTask('a', ['前端', 'Frontend', '设计'])],
['前端', 'Frontend'],
'FE',
)
expect(next[0]?.tags).toEqual(['FE', '设计'])
})
it('目标是源标签之一时也能正确去重', () => {
const next = mergeTags([makeTask('a', ['前端', '设计'])], ['前端', '设计'], '设计')
expect(next[0]?.tags).toEqual(['设计'])
})
it('来源为空时不改动', () => {
const task = makeTask('a', ['前端'])
expect(mergeTags([task], [], 'X')[0]).toBe(task)
})
})
describe('removeTag', () => {
it('只移除标签,不删除任务', () => {
const tasks = [makeTask('a', ['前端', '设计']), makeTask('b', ['前端'])]
const next = removeTag(tasks, '前端')
expect(next).toHaveLength(2)
expect(next[0]?.tags).toEqual(['设计'])
expect(next[1]?.tags).toEqual([])
})
it('空标签名不产生改动', () => {
const task = makeTask('a', ['前端'])
expect(removeTag([task], ' ')[0]).toBe(task)
})
})
describe('collectTagStats', () => {
it('统计每个标签的使用次数', () => {
const stats = collectTagStats([
makeTask('a', ['设计', '前端']),
makeTask('b', ['前端']),
makeTask('c', ['Bug']),
])
expect(Object.fromEntries(stats.map((item) => [item.tag, item.count]))).toEqual({
Bug: 1,
前端: 2,
设计: 1,
})
})
it('按中文排序', () => {
const tags = ['设计', 'Bug', '前端']
const stats = collectTagStats(tags.map((tag, index) => makeTask(`t${index}`, [tag])))
const expected = [...tags].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
expect(stats.map((item) => item.tag)).toEqual(expected)
})
it('空列表返回空数组', () => {
expect(collectTagStats([])).toEqual([])
})
})
+117
View File
@@ -0,0 +1,117 @@
/**
* 标签治理纯函数
*
* 标签是任务上的字符串数组,没有独立的标签表。重命名 / 合并 / 删除
* 本质上都是「批量改写任务上的数组」,因此统一放在这里,store 只负责落盘。
*
* 约定:
* - 全部 trim,空串丢弃,去重
* - 保持原有标签顺序:被替换的位置就地替换,新标签追加到末尾
* - 无需改动时返回**原任务对象**(引用不变),调用方据此统计影响数量
*/
import type { Task } from '@/types/task'
/** 去空白、去重,保持首次出现的顺序 */
export function normalizeTags(tags: readonly string[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const tag of tags) {
const value = tag.trim()
if (!value || seen.has(value)) continue
seen.add(value)
result.push(value)
}
return result
}
/** 在已有标签上追加一些、移除一些,返回规范化后的新数组 */
export function applyTagChanges(
current: readonly string[],
add: readonly string[],
remove: readonly string[],
): string[] {
const removed = new Set(remove.map((tag) => tag.trim()).filter((tag) => tag !== ''))
return normalizeTags([...current.filter((tag) => !removed.has(tag)), ...add])
}
function sameTags(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((tag, index) => tag === right[index])
}
/** 用新的标签数组生成任务;内容不变时返回原对象 */
function withTags(task: Task, tags: readonly string[]): Task {
const next = normalizeTags(tags)
return sameTags(task.tags, next) ? task : { ...task, tags: next }
}
/**
* 把标签 from 重命名为 to。
* to 为空或与 from 相同(去空白后)时不改动任何任务。
*/
export function renameTag(tasks: readonly Task[], from: string, to: string): Task[] {
const source = from.trim()
const target = to.trim()
if (!source || !target || source === target) return [...tasks]
return tasks.map((task) =>
task.tags.includes(source)
? withTags(
task,
task.tags.map((tag) => (tag === source ? target : tag)),
)
: task,
)
}
/**
* 把多个标签合并为 into。
* into 自身也在 from 里时会自然去重;from 为空时不做改动。
*/
export function mergeTags(tasks: readonly Task[], from: readonly string[], into: string): Task[] {
const target = into.trim()
const sources = new Set(from.map((tag) => tag.trim()).filter((tag) => tag !== ''))
if (!target || sources.size === 0) return [...tasks]
return tasks.map((task) => {
if (!task.tags.some((tag) => sources.has(tag))) return task
return withTags(
task,
task.tags.map((tag) => (sources.has(tag) ? target : tag)),
)
})
}
/** 从所有任务上移除某个标签(不删除任务本身) */
export function removeTag(tasks: readonly Task[], tag: string): Task[] {
const target = tag.trim()
if (!target) return [...tasks]
return tasks.map((task) =>
task.tags.includes(target)
? withTags(
task,
task.tags.filter((item) => item !== target),
)
: task,
)
}
export interface TagStat {
tag: string
count: number
}
/** 统计每个标签被多少任务使用,按中文排序 */
export function collectTagStats(tasks: readonly Task[]): TagStat[] {
const counts = new Map<string, number>()
for (const task of tasks) {
for (const tag of task.tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1)
}
}
return [...counts.entries()]
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => a.tag.localeCompare(b.tag, 'zh-Hans-CN'))
}
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import { applyTaskPatch, isBatchPatchEmpty, tagsAfterBatchPatch } from './taskPatch'
const NOW = '2026-10-19T10:00:00.000Z'
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: 'task-1',
title: '写方案',
description: '',
status: 'todo',
priority: 2,
tags: ['设计'],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
describe('applyTaskPatch 状态流转', () => {
it('移入已完成时写入 completedAt', () => {
const next = applyTaskPatch(makeTask(), { status: 'done' }, NOW)
expect(next.status).toBe('done')
expect(next.completedAt).toBe(NOW)
})
it('移出已完成时清空 completedAt', () => {
const next = applyTaskPatch(
makeTask({ status: 'done', completedAt: '2026-10-02T00:00:00.000Z' }),
{ status: 'todo' },
NOW,
)
expect(next.completedAt).toBeNull()
})
it('显式传入 completedAt 时以它为准', () => {
const explicit = '2026-09-01T00:00:00.000Z'
const next = applyTaskPatch(makeTask(), { status: 'done', completedAt: explicit }, NOW)
expect(next.completedAt).toBe(explicit)
})
it('状态不变时保留原 completedAt', () => {
const completedAt = '2026-10-02T00:00:00.000Z'
const next = applyTaskPatch(makeTask({ status: 'done', completedAt }), { title: '新标题' }, NOW)
expect(next.completedAt).toBe(completedAt)
})
})
describe('applyTaskPatch 字段规则', () => {
it('刷新 updatedAt 且不修改入参', () => {
const current = makeTask()
const next = applyTaskPatch(current, { title: '新标题' }, NOW)
expect(next.updatedAt).toBe(NOW)
expect(current.title).toBe('写方案')
expect(current.updatedAt).toBe('2026-10-01T00:00:00.000Z')
})
it('标题被清空时保留原标题', () => {
expect(applyTaskPatch(makeTask(), { title: ' ' }, NOW).title).toBe('写方案')
})
it('标签去空白并去重', () => {
const next = applyTaskPatch(makeTask(), { tags: [' 前端 ', '前端', '', '设计'] }, NOW)
expect(next.tags).toEqual(['前端', '设计'])
})
it('未传 tags 时保留原标签', () => {
expect(applyTaskPatch(makeTask(), { priority: 3 }, NOW).tags).toEqual(['设计'])
})
it('patch 里的 sortOrder 会被采用', () => {
expect(applyTaskPatch(makeTask(), { sortOrder: 4096 }, NOW).sortOrder).toBe(4096)
})
})
describe('批量 patch 辅助', () => {
it('标签按增删计算,不影响其它标签', () => {
expect(tagsAfterBatchPatch(['a', 'b'], { addTags: ['c'], removeTags: ['a'] })).toEqual([
'b',
'c',
])
})
it('添加已存在的标签不会重复', () => {
expect(tagsAfterBatchPatch(['a'], { addTags: ['a'] })).toEqual(['a'])
})
it('没有标签字段时原样返回', () => {
expect(tagsAfterBatchPatch(['a'], { priority: 3 })).toEqual(['a'])
})
it('空 patch 判定', () => {
expect(isBatchPatchEmpty({})).toBe(true)
expect(isBatchPatchEmpty({ addTags: [] })).toBe(true)
expect(isBatchPatchEmpty({ priority: 1 })).toBe(false)
expect(isBatchPatchEmpty({ archived: false })).toBe(false)
})
})
+85
View File
@@ -0,0 +1,85 @@
/**
* 任务变更的状态流转规则
*
* 单条编辑(详情抽屉)与批量编辑(多选操作条)必须共享同一套规则,
* 否则很容易出现「详情里改状态会写 completedAt,批量改却不会」这类不一致。
* 因此把「按 patch 生成新任务」抽成纯函数,store 只负责定位任务、算位置与落盘。
*/
import { normalizeTags, applyTagChanges } from '@/utils/tags'
import type { ColumnId, Priority, Task } from '@/types/task'
/** 单条更新允许改的字段:id 与 createdAt 不可变 */
export type TaskPatch = Partial<Omit<Task, 'id' | 'createdAt'>>
/**
* 批量更新使用的 patch。
*
* 标签刻意采用「增 / 删」而不是「覆盖」:多选后统一加一个标签时,
* 不应该把每条任务原有的其它标签一起抹掉。
*/
export interface BatchPatch {
status?: ColumnId
priority?: Priority
/** null 表示清空截止日期 */
dueDate?: string | null
addTags?: readonly string[]
removeTags?: readonly string[]
/** true 归档,false 取消归档 */
archived?: boolean
}
/**
* 应用一次 patch,返回新任务对象(不修改入参)。
*
* 职责:
* - 状态进出「已完成」时写入 / 清除 completedAt
* - 刷新 updatedAt
* - 标签去重、trim
* - 标题被清空时保留原标题
*
* 不负责 sortOrder 的换列计算 —— 那需要目标列的全部任务,
* 由调用方(store)算好放进 patch。
*/
export function applyTaskPatch(current: Task, patch: TaskPatch, now: string): Task {
const nextStatus = patch.status ?? current.status
let completedAt = current.completedAt
if (patch.completedAt !== undefined) {
completedAt = patch.completedAt
} else if (nextStatus === 'done' && current.status !== 'done') {
completedAt = now
} else if (nextStatus !== 'done' && current.status === 'done') {
completedAt = null
}
const title = patch.title === undefined ? current.title : patch.title.trim() || current.title
return {
...current,
...patch,
title,
status: nextStatus,
tags: patch.tags ? normalizeTags(patch.tags) : current.tags,
completedAt,
updatedAt: now,
}
}
/** 把批量 patch 中与字段无关的「标签增删」折算成新的标签数组 */
export function tagsAfterBatchPatch(current: readonly string[], patch: BatchPatch): string[] {
if (!patch.addTags && !patch.removeTags) return [...current]
return applyTagChanges(current, patch.addTags ?? [], patch.removeTags ?? [])
}
/** 批量 patch 是否真的包含需要写入的字段 */
export function isBatchPatchEmpty(patch: BatchPatch): boolean {
return (
patch.status === undefined &&
patch.priority === undefined &&
patch.dueDate === undefined &&
!patch.addTags?.length &&
!patch.removeTags?.length &&
patch.archived === undefined
)
}
+1
View File
@@ -20,6 +20,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
updatedAt: '2026-10-01T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
+75 -2
View File
@@ -205,7 +205,7 @@ describe('BoardView 详情抽屉', () => {
await deleteButton?.trigger('click')
await nextTick()
expect(store.tasks).toHaveLength(0)
expect(store.activeTasks).toHaveLength(0)
expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false)
})
})
@@ -698,7 +698,7 @@ describe('BoardView 多选与批量删除', () => {
?.trigger('click')
await nextTick()
expect(store.tasks.map((item) => item.title)).toEqual(['B'])
expect(store.activeTasks.map((item) => item.title)).toEqual(['B'])
// 删除后自动退出多选
expect(wrapper.text()).not.toContain('已选')
@@ -780,3 +780,76 @@ describe('BoardView 多选与批量删除', () => {
expect(wrapper.text()).toContain('已选 0 个任务')
})
})
describe('BoardView 批量编辑', () => {
function exactButton(wrapper: ReturnType<typeof mountBoard>, text: string) {
return wrapper.findAll('button').find((node) => node.text() === text)
}
async function enterSelectionAndSelectAll(wrapper: ReturnType<typeof mountBoard>) {
await exactButton(wrapper, '多选')?.trigger('click')
await nextTick()
await wrapper
.findAll('button')
.find((node) => node.text().includes('全选'))
?.trigger('click')
await nextTick()
}
it('批量设置优先级后全部生效并提示数量', async () => {
const { pinia, store } = setup()
store.createTasks([{ title: 'A' }, { title: 'B' }])
const wrapper = mountBoard(pinia)
await enterSelectionAndSelectAll(wrapper)
await wrapper.find('button[aria-label="设为高优先级"]').trigger('click')
await nextTick()
expect(store.activeTasks.every((task) => task.priority === 3)).toBe(true)
expect(useToastStore().toasts.at(-1)?.message).toBe('已将 2 个任务设为「高」优先级')
})
it('批量移动到进行中,卡片换列', async () => {
const { pinia, store } = setup()
store.createTasks([{ title: 'A' }, { title: 'B' }])
const wrapper = mountBoard(pinia)
await enterSelectionAndSelectAll(wrapper)
await wrapper.find('select[aria-label="移动到"]').setValue('doing')
await nextTick()
expect(store.byColumn.todo).toHaveLength(0)
expect(store.byColumn.doing.map((task) => task.title)).toEqual(['A', 'B'])
})
it('批量添加标签不会清空原有标签', async () => {
const { pinia, store } = setup()
store.createTask({ title: 'A', tags: ['已有'] })
const wrapper = mountBoard(pinia)
await enterSelectionAndSelectAll(wrapper)
await wrapper.find('input[aria-label="标签"]').setValue('前端')
await exactButton(wrapper, '添加')?.trigger('click')
await nextTick()
expect(store.activeTasks[0]?.tags).toEqual(['已有', '前端'])
})
it('批量归档后卡片消失,撤销可恢复', async () => {
const { pinia, store } = setup()
store.createTasks([{ title: 'A' }, { title: 'B' }])
const wrapper = mountBoard(pinia)
await enterSelectionAndSelectAll(wrapper)
await exactButton(wrapper, '归档')?.trigger('click')
await nextTick()
expect(store.activeTasks.every((task) => task.archivedAt !== null)).toBe(true)
expect(wrapper.text()).not.toContain('已选')
const toast = useToastStore()
expect(toast.toasts.at(-1)?.message).toBe('已归档 2 个任务')
toast.toasts.at(-1)?.action?.run()
expect(store.activeTasks.every((task) => task.archivedAt === null)).toBe(true)
})
})
+77 -8
View File
@@ -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"
/>
<!--
+8 -6
View File
@@ -37,15 +37,15 @@ const TONES = {
} as const
const store = useTaskStore()
const { tasks } = storeToRefs(store)
const { activeTasks } = storeToRefs(store)
const todayLabel = computed(() => {
const now = dayjs()
return `${now.format('YYYY年M月D日')} ${WEEKDAYS[now.day()]}`
})
const buckets = computed(() => buildTodayBuckets(tasks.value))
const stats = computed(() => computeStats(tasks.value))
const buckets = computed(() => buildTodayBuckets(activeTasks.value))
const stats = computed(() => computeStats(activeTasks.value))
const todayCount = computed(() => countTodayTasks(buckets.value))
/**
@@ -97,10 +97,12 @@ const sections = computed(() =>
)
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,
)
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' })
}
@@ -108,7 +110,7 @@ function toggleTask(id: string): void {
/** 今日视图没有列概念,左右方向键不适用;上下仍可调 order */
function handleKeyMove(payload: { id: string; direction: MoveDirection }): void {
if (payload.direction === 'left' || payload.direction === 'right') return
const task = store.tasks.find((item) => item.id === payload.id)
const task = store.activeTasks.find((item) => item.id === payload.id)
if (!task) return
const column = store.byColumn[task.status]
const currentIndex = column.findIndex((item) => item.id === payload.id)