feat: 回收站、批量编辑、合并导入与标签治理
- 数据:Task 增加 deletedAt,schema v2→v3;删除改为软删除,保留 30 天后自动清理 - 回收站:支持恢复、永久删除、二次确认清空;导航栏入口带数量角标 - 批量编辑:多选后统一移动列、改优先级、标签增删、设置截止日、归档 - 合并导入:按 id 匹配、updatedAt 新者胜;覆盖/合并二选一,执行前自动快照 - 标签治理:全局重命名、合并、删除;回收站任务不参与改写 - 看板语义读取统一改用 activeTasks,避免已删除任务泄漏到统计、今日与标签下拉 - UI:回收站图标与备份统一尺寸;批量编辑条统一控件高度并分组 - 测试:705 个单测通过,补充迁移、回收站、批量编辑、合并与标签用例
This commit is contained in:
@@ -7,7 +7,9 @@ import AppNav from '@/components/AppNav.vue'
|
|||||||
import BackupPanel from '@/components/BackupPanel.vue'
|
import BackupPanel from '@/components/BackupPanel.vue'
|
||||||
import PwaUpdatePrompt from '@/components/PwaUpdatePrompt.vue'
|
import PwaUpdatePrompt from '@/components/PwaUpdatePrompt.vue'
|
||||||
import SideDrawer from '@/components/SideDrawer.vue'
|
import SideDrawer from '@/components/SideDrawer.vue'
|
||||||
|
import TagManagerPanel from '@/components/TagManagerPanel.vue'
|
||||||
import ToastHost from '@/components/ToastHost.vue'
|
import ToastHost from '@/components/ToastHost.vue'
|
||||||
|
import TrashPanel from '@/components/TrashPanel.vue'
|
||||||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||||
import { useTaskStore } from '@/stores/tasks'
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
@@ -68,6 +70,8 @@ useKeyboardShortcuts({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BackupPanel :open="ui.backupOpen" @close="ui.closeBackup()" />
|
<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()">
|
<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">
|
<h3 class="mb-2 text-xs font-medium tracking-wide text-slate-400 uppercase dark:text-zinc-500">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
import { useThemeStore, THEME_LABELS } from '@/stores/theme'
|
import { useThemeStore, THEME_LABELS } from '@/stores/theme'
|
||||||
import { useUiStore } from '@/stores/ui'
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
const theme = useThemeStore()
|
const theme = useThemeStore()
|
||||||
|
const store = useTaskStore()
|
||||||
|
|
||||||
const LINKS = [
|
const LINKS = [
|
||||||
{ to: '/', label: '看板' },
|
{ to: '/', label: '看板' },
|
||||||
@@ -134,12 +136,45 @@ const LINKS = [
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="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"
|
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()"
|
@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
|
<path
|
||||||
d="M10 13V3.5m0 0L6.5 7M10 3.5 13.5 7"
|
d="M10 13V3.5m0 0L6.5 7M10 3.5 13.5 7"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
|||||||
@@ -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 自动备份', () => {
|
describe('BackupPanel 自动备份', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ref } from 'vue'
|
|||||||
import { parseImportJson } from '@/data/schema'
|
import { parseImportJson } from '@/data/schema'
|
||||||
import { useTaskStore } from '@/stores/tasks'
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
import { downloadText, exportFileName } from '@/utils/exportFile'
|
import { downloadText, exportFileName } from '@/utils/exportFile'
|
||||||
|
import { mergeTasks, type MergeResult } from '@/utils/merge'
|
||||||
|
|
||||||
import SideDrawer from './SideDrawer.vue'
|
import SideDrawer from './SideDrawer.vue'
|
||||||
|
|
||||||
@@ -19,8 +20,16 @@ const { snapshots } = storeToRefs(store)
|
|||||||
const message = ref<string | null>(null)
|
const message = ref<string | null>(null)
|
||||||
const error = 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 {
|
function exportNow(): void {
|
||||||
const fileName = exportFileName()
|
const fileName = exportFileName()
|
||||||
@@ -43,12 +52,19 @@ async function onFileChange(event: Event): Promise<void> {
|
|||||||
|
|
||||||
message.value = null
|
message.value = null
|
||||||
error.value = null
|
error.value = null
|
||||||
|
// 每次重新选文件都回到默认的覆盖模式,避免沿用上一次的选择误覆盖
|
||||||
|
importMode.value = 'replace'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await file.text()
|
const text = await file.text()
|
||||||
// 先做一次纯校验,确认合法后才让用户确认覆盖
|
// 先做一次纯校验,确认合法后才让用户确认导入
|
||||||
const tasks = parseImportJson(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) {
|
} catch (cause) {
|
||||||
pending.value = null
|
pending.value = null
|
||||||
error.value = cause instanceof Error ? cause.message : '导入文件无法解析'
|
error.value = cause instanceof Error ? cause.message : '导入文件无法解析'
|
||||||
@@ -63,9 +79,17 @@ function confirmImport(): void {
|
|||||||
const beforeCount = store.tasks.length
|
const beforeCount = store.tasks.length
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const count = store.importJson(target.text)
|
if (importMode.value === 'replace') {
|
||||||
const backupNote = beforeCount > 0 ? ',导入前的数据已自动备份' : ''
|
const count = store.importJson(target.text)
|
||||||
message.value = `已从 ${target.fileName} 导入 ${count} 个任务${backupNote}`
|
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
|
error.value = null
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
error.value = cause instanceof Error ? cause.message : '导入失败'
|
error.value = cause instanceof Error ? cause.message : '导入失败'
|
||||||
@@ -142,18 +166,61 @@ function formatTime(iso: string): string {
|
|||||||
v-if="pending"
|
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"
|
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 }} 个任务,
|
将从「{{ pending.fileName }}」导入 {{ pending.count }} 个任务,
|
||||||
<strong>并覆盖当前全部 {{ store.tasks.length }} 个任务</strong>。 覆盖前会自动把当前数据
|
<strong>并覆盖当前全部 {{ store.tasks.length }} 个任务</strong>。 覆盖前会自动把当前数据
|
||||||
存一份到下方「自动备份」,选错文件也能从那里恢复。
|
存一份到下方「自动备份」,选错文件也能从那里恢复。
|
||||||
</p>
|
</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">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
@click="confirmImport"
|
||||||
>
|
>
|
||||||
确认覆盖导入
|
{{ importMode === 'replace' ? '确认覆盖导入' : '确认合并导入' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -129,6 +129,6 @@ describe('BatchAddDrawer 提交', () => {
|
|||||||
expect(action?.label).toBe('撤销')
|
expect(action?.label).toBe('撤销')
|
||||||
action?.run()
|
action?.run()
|
||||||
|
|
||||||
expect(store.tasks).toHaveLength(0)
|
expect(store.activeTasks).toHaveLength(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,3 +75,72 @@ describe('BatchSelectionBar 禁用态', () => {
|
|||||||
expect(buttonByText(setup(0, 0), '全选')?.attributes('disabled')).toBeDefined()
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,72 +2,257 @@
|
|||||||
/**
|
/**
|
||||||
* 多选操作条
|
* 多选操作条
|
||||||
*
|
*
|
||||||
* 只在多选模式出现,负责「选了多少 / 全选 / 清空 / 删除」。
|
* 第一行负责「选了多少 / 全选 / 清空 / 完成 / 删除」;
|
||||||
* 删除不做二次确认:与单条删除一致,靠 Toast 的「撤销」兜底,
|
* 第二行是批量编辑器:移动、优先级、标签增删、截止日期、归档。
|
||||||
* 误删的代价远低于每次批量操作都弹窗确认。
|
*
|
||||||
|
* 删除与归档保留 Toast 撤销(软删除 / 归档本身可逆);
|
||||||
|
* 批量改属性不做撤销 —— 与详情编辑一致,只提示「已更新 N 个任务」。
|
||||||
|
*
|
||||||
|
* 标签刻意是「添加 / 移除」而不是「覆盖」:批量加一个标签时,
|
||||||
|
* 不应该把每条任务原有的其它标签一起清掉。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
defineProps<{
|
import { ref } from 'vue'
|
||||||
/** 已选数量 */
|
|
||||||
count: number
|
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority } from '@/types/task'
|
||||||
/** 当前可见(筛选后)的任务总数 */
|
|
||||||
total: number
|
import DatePicker from './DatePicker.vue'
|
||||||
}>()
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 已选数量 */
|
||||||
|
count: number
|
||||||
|
/** 当前可见(筛选后)的任务总数 */
|
||||||
|
total: number
|
||||||
|
/** 已有标签,作为输入提示 */
|
||||||
|
tags?: readonly string[]
|
||||||
|
}>(),
|
||||||
|
{ tags: () => [] },
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'select-all': []
|
'select-all': []
|
||||||
clear: []
|
clear: []
|
||||||
remove: []
|
remove: []
|
||||||
done: []
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<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"
|
role="toolbar"
|
||||||
aria-label="批量操作"
|
aria-label="批量操作"
|
||||||
>
|
>
|
||||||
<span>
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1.5">
|
||||||
已选
|
<span>
|
||||||
<strong class="font-semibold">{{ count }}</strong>
|
已选
|
||||||
个任务
|
<strong class="font-semibold">{{ count }}</strong>
|
||||||
</span>
|
个任务
|
||||||
|
</span>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded border border-sky-200 bg-white/70 px-2 py-1 text-sky-700 transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-40 dark:border-sky-800 dark:bg-sky-900/40 dark:text-sky-200 dark:hover:bg-sky-900/70"
|
|
||||||
:disabled="total === 0 || count === total"
|
|
||||||
@click="emit('select-all')"
|
|
||||||
>
|
|
||||||
全选 {{ total }} 个
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded px-2 py-1 text-sky-700 underline-offset-2 transition hover:underline disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:no-underline dark:text-sky-200"
|
|
||||||
:disabled="count === 0"
|
|
||||||
@click="emit('clear')"
|
|
||||||
>
|
|
||||||
清空选择
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-1.5">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md border border-sky-200 bg-white/70 px-2.5 py-1 text-sky-700 transition hover:bg-white dark:border-sky-800 dark:bg-sky-900/40 dark:text-sky-200 dark:hover:bg-sky-900/70"
|
class="rounded border border-sky-200 bg-white/70 px-2 py-1 text-sky-700 transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-40 dark:border-sky-800 dark:bg-sky-900/40 dark:text-sky-200 dark:hover:bg-sky-900/70"
|
||||||
@click="emit('done')"
|
:disabled="total === 0 || count === total"
|
||||||
|
@click="emit('select-all')"
|
||||||
>
|
>
|
||||||
完成
|
全选 {{ total }} 个
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md border border-rose-300 bg-white px-2.5 py-1 font-medium text-rose-600 transition hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white dark:border-rose-800 dark:bg-zinc-900 dark:text-rose-400 dark:hover:bg-rose-950"
|
class="rounded px-2 py-1 text-sky-700 underline-offset-2 transition hover:underline disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:no-underline dark:text-sky-200"
|
||||||
:disabled="count === 0"
|
:disabled="count === 0"
|
||||||
@click="emit('remove')"
|
@click="emit('clear')"
|
||||||
>
|
>
|
||||||
删除 {{ count }} 个
|
清空选择
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-sky-200 bg-white/70 px-2.5 py-1 text-sky-700 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('done')"
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-rose-300 bg-white px-2.5 py-1 font-medium text-rose-600 transition hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white dark:border-rose-800 dark:bg-zinc-900 dark:text-rose-400 dark:hover:bg-rose-950"
|
||||||
|
:disabled="count === 0"
|
||||||
|
@click="emit('remove')"
|
||||||
|
>
|
||||||
|
删除 {{ count }} 个
|
||||||
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
|||||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import TagFilter from './TagFilter.vue'
|
|||||||
|
|
||||||
defineProps<{ hiddenNote?: string; selecting?: boolean }>()
|
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 store = useTaskStore()
|
||||||
const filters = useFilterStore()
|
const filters = useFilterStore()
|
||||||
@@ -24,15 +29,15 @@ const searchRef = ref<HTMLInputElement | null>(null)
|
|||||||
const PRIORITY_ORDER: readonly Priority[] = [3, 2, 1]
|
const PRIORITY_ORDER: readonly Priority[] = [3, 2, 1]
|
||||||
|
|
||||||
const dueTodayCount = computed(
|
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(
|
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 {
|
function focusSearch(): void {
|
||||||
searchRef.value?.focus()
|
searchRef.value?.focus()
|
||||||
@@ -91,6 +96,7 @@ defineExpose({ focusSearch })
|
|||||||
:selected="tags"
|
:selected="tags"
|
||||||
@toggle="filters.toggleTag($event)"
|
@toggle="filters.toggleTag($event)"
|
||||||
@clear="filters.clearTags()"
|
@clear="filters.clearTags()"
|
||||||
|
@manage="emit('manage-tags')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-1.5">
|
<div class="ml-auto flex items-center gap-1.5">
|
||||||
@@ -129,7 +135,7 @@ defineExpose({ focusSearch })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-xs text-slate-500 dark:text-zinc-400">
|
<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>
|
<span v-if="hiddenNote" class="text-slate-400 dark:text-zinc-500">{{ hiddenNote }}</span>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-1.5">
|
<div class="ml-auto flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -197,6 +197,6 @@ describe('QuickAddTask 批量粘贴', () => {
|
|||||||
expect(action?.label).toBe('撤销')
|
expect(action?.label).toBe('撤销')
|
||||||
action?.run()
|
action?.run()
|
||||||
|
|
||||||
expect(store.tasks).toHaveLength(0)
|
expect(store.activeTasks).toHaveLength(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
|
|||||||
|
|
||||||
defineProps<{ tags: readonly string[]; selected: readonly string[] }>()
|
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 open = ref(false)
|
||||||
const rootRef = ref<HTMLElement | null>(null)
|
const rootRef = ref<HTMLElement | null>(null)
|
||||||
@@ -29,6 +29,12 @@ function onDocumentKeydown(event: KeyboardEvent): void {
|
|||||||
open.value = false
|
open.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 交给应用级面板处理;先收起下拉,避免两层浮层叠在一起 */
|
||||||
|
function openManager(): void {
|
||||||
|
open.value = false
|
||||||
|
emit('manage')
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', onDocumentClick)
|
document.addEventListener('click', onDocumentClick)
|
||||||
document.addEventListener('keydown', onDocumentKeydown, true)
|
document.addEventListener('keydown', onDocumentKeydown, true)
|
||||||
@@ -110,6 +116,14 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
清除标签筛选
|
清除标签筛选
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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('确认删除')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -21,6 +21,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
|||||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,7 +231,8 @@ describe('TaskDetailDrawer 删除与撤销', () => {
|
|||||||
|
|
||||||
await buttonByText(wrapper, '删除')?.trigger('click')
|
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(wrapper.emitted('close')).toHaveLength(1)
|
||||||
expect(toast.toasts).toHaveLength(1)
|
expect(toast.toasts).toHaveLength(1)
|
||||||
expect(toast.toasts[0]?.action?.label).toBe('撤销')
|
expect(toast.toasts[0]?.action?.label).toBe('撤销')
|
||||||
|
|||||||
@@ -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('此操作不可撤销')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -26,6 +26,7 @@ function validTask(overrides: Record<string, unknown> = {}): Record<string, unkn
|
|||||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +107,18 @@ describe('coerceTask', () => {
|
|||||||
expect(coerceTask(validTask({ archivedAt: null }))?.archivedAt).toBeNull()
|
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', () => {
|
it('保留合法的 archivedAt', () => {
|
||||||
expect(coerceTask(validTask({ archivedAt: '2026-10-01T00:00:00.000Z' }))?.archivedAt).toBe(
|
expect(coerceTask(validTask({ archivedAt: '2026-10-01T00:00:00.000Z' }))?.archivedAt).toBe(
|
||||||
'2026-10-01T00:00:00.000Z',
|
'2026-10-01T00:00:00.000Z',
|
||||||
@@ -226,6 +239,51 @@ describe('版本迁移', () => {
|
|||||||
const state = parsePersistedState(v1)
|
const state = parsePersistedState(v1)
|
||||||
expect(state.tasks.map((task) => task.archivedAt)).toEqual([null, null])
|
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('序列化往返', () => {
|
describe('序列化往返', () => {
|
||||||
|
|||||||
+9
-1
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { isColumnId, isPriority, type Task } from '@/types/task'
|
import { isColumnId, isPriority, type Task } from '@/types/task'
|
||||||
|
|
||||||
export const SCHEMA_VERSION = 2
|
export const SCHEMA_VERSION = 3
|
||||||
|
|
||||||
/** 主数据在 localStorage 中的键名 */
|
/** 主数据在 localStorage 中的键名 */
|
||||||
export const STORAGE_KEY = 'kanban.tasks'
|
export const STORAGE_KEY = 'kanban.tasks'
|
||||||
@@ -74,6 +74,8 @@ export function coerceTask(value: unknown): Task | null {
|
|||||||
const updatedAt = readString(value, 'updatedAt') ?? createdAt
|
const updatedAt = readString(value, 'updatedAt') ?? createdAt
|
||||||
const completedAt = readString(value, 'completedAt')
|
const completedAt = readString(value, 'completedAt')
|
||||||
const archivedAt = readString(value, 'archivedAt')
|
const archivedAt = readString(value, 'archivedAt')
|
||||||
|
// 与 archivedAt 一致:非字符串(含 undefined / 数字 / 对象)一律收敛为 null
|
||||||
|
const deletedAt = readString(value, 'deletedAt')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -88,6 +90,7 @@ export function coerceTask(value: unknown): Task | null {
|
|||||||
updatedAt,
|
updatedAt,
|
||||||
completedAt,
|
completedAt,
|
||||||
archivedAt,
|
archivedAt,
|
||||||
|
deletedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +116,7 @@ type Migration = (state: PersistedState) => PersistedState
|
|||||||
* 版本迁移表,键为「迁移前的版本号」。
|
* 版本迁移表,键为「迁移前的版本号」。
|
||||||
* - v0:早期数据没有 schemaVersion 字段,任务结构与 v1 一致
|
* - v0:早期数据没有 schemaVersion 字段,任务结构与 v1 一致
|
||||||
* - v1 -> v2:新增 archivedAt 字段(归档功能)
|
* - v1 -> v2:新增 archivedAt 字段(归档功能)
|
||||||
|
* - v2 -> v3:新增 deletedAt 字段(回收站 / 软删除)
|
||||||
* 未来新增字段时,在此登记 n -> n+1 的转换,并同步提升 SCHEMA_VERSION。
|
* 未来新增字段时,在此登记 n -> n+1 的转换,并同步提升 SCHEMA_VERSION。
|
||||||
*/
|
*/
|
||||||
const MIGRATIONS: Record<number, Migration> = {
|
const MIGRATIONS: Record<number, Migration> = {
|
||||||
@@ -121,6 +125,10 @@ const MIGRATIONS: Record<number, Migration> = {
|
|||||||
schemaVersion: 2,
|
schemaVersion: 2,
|
||||||
tasks: state.tasks.map((task) => ({ ...task, archivedAt: null })),
|
tasks: state.tasks.map((task) => ({ ...task, archivedAt: null })),
|
||||||
}),
|
}),
|
||||||
|
2: (state) => ({
|
||||||
|
schemaVersion: 3,
|
||||||
|
tasks: state.tasks.map((task) => ({ ...task, deletedAt: null })),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function makeTask(id: string): Task {
|
|||||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
|||||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+435
-2
@@ -37,6 +37,7 @@ describe('初始化', () => {
|
|||||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const store = freshStore(repository)
|
const store = freshStore(repository)
|
||||||
@@ -251,7 +252,8 @@ describe('删除与撤销', () => {
|
|||||||
const store = freshStore()
|
const store = freshStore()
|
||||||
const task = store.createTask({ title: 'A' })
|
const task = store.createTask({ title: 'A' })
|
||||||
expect(store.removeTask(task?.id ?? '')?.title).toBe('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('撤销后连原顺序一起恢复', () => {
|
it('撤销后连原顺序一起恢复', () => {
|
||||||
@@ -412,7 +414,8 @@ describe('持久化', () => {
|
|||||||
first.removeTask(task?.id ?? '')
|
first.removeTask(task?.id ?? '')
|
||||||
|
|
||||||
const second = freshStore(repository)
|
const second = freshStore(repository)
|
||||||
expect(second.tasks).toHaveLength(0)
|
expect(second.activeTasks).toHaveLength(0)
|
||||||
|
expect(second.trashTasks).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('写入失败时标记为非持久化并提示,恢复后自动清除', () => {
|
it('写入失败时标记为非持久化并提示,恢复后自动清除', () => {
|
||||||
@@ -534,6 +537,7 @@ describe('导出与导入', () => {
|
|||||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.setSystemTime(new Date('2026-10-19T00:00:01.000Z'))
|
vi.setSystemTime(new Date('2026-10-19T00:00:01.000Z'))
|
||||||
@@ -676,3 +680,432 @@ describe('规模与稳定性', () => {
|
|||||||
expect(store.tasks).toHaveLength(60)
|
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(['前端'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+294
-63
@@ -17,14 +17,28 @@ import {
|
|||||||
type SnapshotInfo,
|
type SnapshotInfo,
|
||||||
type TaskRepository,
|
type TaskRepository,
|
||||||
} from '@/data/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 { createId } from '@/utils/id'
|
||||||
|
import { mergeTasks as mergeTaskLists } from '@/utils/merge'
|
||||||
import {
|
import {
|
||||||
groupByColumn,
|
groupByColumn,
|
||||||
moveWithinColumn,
|
moveWithinColumn,
|
||||||
sortByOrder,
|
sortByOrder,
|
||||||
sortOrderForPosition,
|
sortOrderForPosition,
|
||||||
} from '@/utils/sortOrder'
|
} 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'
|
export const SYNC_CHANNEL_NAME = 'kanban.sync'
|
||||||
@@ -38,7 +52,7 @@ export interface CreateTaskInput {
|
|||||||
status?: ColumnId
|
status?: ColumnId
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskPatch = Partial<Omit<Task, 'id' | 'createdAt'>>
|
export type { BatchPatch, TaskPatch } from '@/utils/taskPatch'
|
||||||
|
|
||||||
export interface InitOptions {
|
export interface InitOptions {
|
||||||
/** 注入仓库;不传则使用 localStorage,不可用时降级为内存仓库 */
|
/** 注入仓库;不传则使用 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', () => {
|
export const useTaskStore = defineStore('tasks', () => {
|
||||||
const tasks = ref<Task[]>([])
|
const tasks = ref<Task[]>([])
|
||||||
const ready = ref(false)
|
const ready = ref(false)
|
||||||
@@ -84,7 +86,22 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
let repository: TaskRepository | null = null
|
let repository: TaskRepository | null = null
|
||||||
let channel: BroadcastChannel | 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 counts = computed(() => {
|
||||||
const result = {} as Record<ColumnId, number>
|
const result = {} as Record<ColumnId, number>
|
||||||
@@ -96,11 +113,13 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 现有任务里出现过的全部标签,按中文排序。
|
* 现有任务里出现过的全部标签,按中文排序。
|
||||||
* 筛选下拉和编辑任务时的标签候选都用它,避免两处各算一份、结果不一致。
|
* 只统计未删除、未归档的任务:否则筛选下拉里会出现
|
||||||
|
* 「只有回收站/归档任务才用过」的标签,选中后一条都筛不出来。
|
||||||
*/
|
*/
|
||||||
const allTags = computed(() => {
|
const allTags = computed(() => {
|
||||||
const collected = new Set<string>()
|
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)
|
for (const tag of task.tags) collected.add(tag)
|
||||||
}
|
}
|
||||||
return [...collected].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
|
return [...collected].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
|
||||||
@@ -238,6 +257,8 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
|
|
||||||
ready.value = true
|
ready.value = true
|
||||||
|
|
||||||
|
// 先清理过期回收站再写启动快照,避免快照里又留下一份本该消失的数据
|
||||||
|
purgeExpiredTrash()
|
||||||
writeStartupSnapshot()
|
writeStartupSnapshot()
|
||||||
|
|
||||||
if (options.crossTab !== false) setupChannel()
|
if (options.crossTab !== false) setupChannel()
|
||||||
@@ -260,7 +281,7 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
/** 同列工作副本:含本批已创建的任务,供中点插入取前后邻居 */
|
/** 同列工作副本:含本批已创建的任务,供中点插入取前后邻居 */
|
||||||
const working = groupByColumn(tasks.value)
|
const working = groupByColumn(activeTasks.value)
|
||||||
const created: Task[] = []
|
const created: Task[] = []
|
||||||
|
|
||||||
for (let index = drafts.length - 1; index >= 0; index -= 1) {
|
for (let index = drafts.length - 1; index >= 0; index -= 1) {
|
||||||
@@ -282,6 +303,7 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
completedAt: status === 'done' ? now : null,
|
completedAt: status === 'done' ? now : null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
working[status] = [task, ...working[status]]
|
working[status] = [task, ...working[status]]
|
||||||
@@ -299,41 +321,21 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateTask(id: string, patch: TaskPatch): Task | null {
|
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]
|
const current = tasks.value[index]
|
||||||
if (!current) return null
|
if (!current) return null
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const nextStatus = patch.status ?? current.status
|
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
|
let sortOrder = patch.sortOrder ?? current.sortOrder
|
||||||
if (nextStatus !== current.status && patch.sortOrder === undefined) {
|
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)
|
sortOrder = sortOrderForPosition(target, target.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = patch.title === undefined ? current.title : patch.title.trim() || current.title
|
const updated = applyTaskPatch(current, { ...patch, sortOrder }, now)
|
||||||
|
|
||||||
const updated: Task = {
|
|
||||||
...current,
|
|
||||||
...patch,
|
|
||||||
title,
|
|
||||||
status: nextStatus,
|
|
||||||
sortOrder,
|
|
||||||
tags: patch.tags ? normalizeTags(patch.tags) : current.tags,
|
|
||||||
completedAt,
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = [...tasks.value]
|
const next = [...tasks.value]
|
||||||
next[index] = updated
|
next[index] = updated
|
||||||
@@ -342,65 +344,227 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除任务,返回被删除的任务本身,供撤销使用(T12) */
|
/**
|
||||||
function removeTask(id: string): Task | null {
|
* 批量更新(一次 map + 一次 commit)。
|
||||||
const task = tasks.value.find((item) => item.id === id)
|
*
|
||||||
if (!task) return null
|
* 与单条编辑共用 applyTaskPatch,因此 completedAt、updatedAt、标签去重
|
||||||
tasks.value = tasks.value.filter((item) => item.id !== id)
|
* 的状态流转规则只有一份。标签按「增 / 删」处理,避免批量加标签时
|
||||||
|
* 把每条任务原有的其它标签抹掉。
|
||||||
|
*
|
||||||
|
* @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
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
commit()
|
||||||
return task
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 撤销删除:连原 sortOrder 一起恢复,因此位置也会还原 */
|
/**
|
||||||
|
* 删除任务 —— 软删除:写入 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 {
|
function restoreTask(task: Task): void {
|
||||||
if (tasks.value.some((item) => item.id === task.id)) return
|
const existing = tasks.value.find((item) => item.id === task.id)
|
||||||
tasks.value = [...tasks.value, task]
|
|
||||||
|
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()
|
commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量删除,返回被删除的任务(保持原有顺序)。
|
* 批量软删除,返回被删除的任务(保持原有顺序)。
|
||||||
* 供「批量添加」的撤销入口使用:一次操作删掉整批,而不是逐条点删除。
|
* 供「批量添加」的撤销入口使用:一次操作删掉整批,而不是逐条点删除。
|
||||||
*/
|
*/
|
||||||
function removeTasks(ids: readonly string[]): Task[] {
|
function removeTasks(ids: readonly string[]): Task[] {
|
||||||
const idSet = new Set(ids)
|
const idSet = new Set(ids)
|
||||||
const removed = tasks.value.filter((task) => idSet.has(task.id))
|
const now = new Date().toISOString()
|
||||||
if (removed.length === 0) return []
|
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()
|
commit()
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 批量撤销删除:原 sortOrder 一起恢复,位置不变;已存在的不重复插入 */
|
/** 批量撤销删除:原 sortOrder 一起恢复,位置不变;已存在的不重复插入 */
|
||||||
function restoreTasks(items: readonly Task[]): void {
|
function restoreTasks(items: readonly Task[]): void {
|
||||||
const existing = new Set(tasks.value.map((task) => task.id))
|
const byId = new Map(tasks.value.map((task) => [task.id, task]))
|
||||||
const restored = items.filter((task) => !existing.has(task.id))
|
let changed = false
|
||||||
if (restored.length === 0) return
|
|
||||||
|
|
||||||
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()
|
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 为移动完成后在目标列中的最终下标。
|
* 拖拽移动(T13)。toIndex 为移动完成后在目标列中的最终下标。
|
||||||
* status 与 sortOrder 在同一次赋值中更新,不存在中间态。
|
* status 与 sortOrder 在同一次赋值中更新,不存在中间态。
|
||||||
*/
|
*/
|
||||||
function moveTask(id: string, toStatus: ColumnId, toIndex: number): void {
|
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
|
if (!task) return
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
if (task.status === toStatus) {
|
if (task.status === toStatus) {
|
||||||
const column = groupByColumn(tasks.value)[toStatus]
|
const column = byColumn.value[toStatus]
|
||||||
const fromIndex = column.findIndex((item) => item.id === id)
|
const fromIndex = column.findIndex((item) => item.id === id)
|
||||||
if (fromIndex === -1) return
|
if (fromIndex === -1) return
|
||||||
const moved = moveWithinColumn(column, fromIndex, toIndex)
|
const moved = moveWithinColumn(column, fromIndex, toIndex)
|
||||||
const byId = new Map(moved.map((item) => [item.id, item]))
|
const byId = new Map(moved.map((item) => [item.id, item]))
|
||||||
tasks.value = tasks.value.map((item) => byId.get(item.id) ?? item)
|
tasks.value = tasks.value.map((item) => byId.get(item.id) ?? item)
|
||||||
} else {
|
} 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)
|
const sortOrder = sortOrderForPosition(target, toIndex)
|
||||||
tasks.value = tasks.value.map((item) =>
|
tasks.value = tasks.value.map((item) =>
|
||||||
item.id === id
|
item.id === id
|
||||||
@@ -437,6 +601,20 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
return imported.length
|
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 {
|
function replaceAll(next: readonly Task[]): void {
|
||||||
tasks.value = sortByOrder(next)
|
tasks.value = sortByOrder(next)
|
||||||
@@ -453,7 +631,7 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
let count = 0
|
let count = 0
|
||||||
|
|
||||||
tasks.value = tasks.value.map((task) => {
|
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
|
count += 1
|
||||||
return { ...task, archivedAt: now, updatedAt: now }
|
return { ...task, archivedAt: now, updatedAt: now }
|
||||||
})
|
})
|
||||||
@@ -469,7 +647,7 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
let count = 0
|
let count = 0
|
||||||
|
|
||||||
tasks.value = tasks.value.map((task) => {
|
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
|
count += 1
|
||||||
return { ...task, archivedAt: null, updatedAt: now }
|
return { ...task, archivedAt: null, updatedAt: now }
|
||||||
})
|
})
|
||||||
@@ -478,6 +656,48 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
return count
|
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 {
|
function dispose(): void {
|
||||||
channel?.close()
|
channel?.close()
|
||||||
@@ -487,6 +707,8 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
tasks,
|
tasks,
|
||||||
|
activeTasks,
|
||||||
|
trashTasks,
|
||||||
ready,
|
ready,
|
||||||
persistent,
|
persistent,
|
||||||
notice,
|
notice,
|
||||||
@@ -498,16 +720,25 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
createTask,
|
createTask,
|
||||||
createTasks,
|
createTasks,
|
||||||
updateTask,
|
updateTask,
|
||||||
|
updateTasks,
|
||||||
removeTask,
|
removeTask,
|
||||||
restoreTask,
|
restoreTask,
|
||||||
removeTasks,
|
removeTasks,
|
||||||
restoreTasks,
|
restoreTasks,
|
||||||
|
purgeTask,
|
||||||
|
purgeTasks,
|
||||||
|
emptyTrash,
|
||||||
|
purgeExpiredTrash,
|
||||||
moveTask,
|
moveTask,
|
||||||
exportJson,
|
exportJson,
|
||||||
importJson,
|
importJson,
|
||||||
|
mergeJson,
|
||||||
replaceAll,
|
replaceAll,
|
||||||
archiveTasks,
|
archiveTasks,
|
||||||
unarchiveTasks,
|
unarchiveTasks,
|
||||||
|
renameTag,
|
||||||
|
mergeTags,
|
||||||
|
removeTag,
|
||||||
refreshSnapshots,
|
refreshSnapshots,
|
||||||
restoreSnapshot,
|
restoreSnapshot,
|
||||||
dispose,
|
dispose,
|
||||||
|
|||||||
+31
-3
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* 应用级面板状态
|
* 应用级面板状态
|
||||||
*
|
*
|
||||||
* 备份面板与快捷键帮助都是**应用级**操作(在哪个视图都该能用),
|
* 备份、回收站、标签管理、快捷键帮助都是**应用级**操作(在哪个视图都该能用),
|
||||||
* 因此状态放在这里,由 App.vue 统一渲染,
|
* 因此状态放在这里,由 App.vue 统一渲染,
|
||||||
* 导航栏负责打开,看板负责在它们打开时让出快捷键。
|
* 导航栏 / 工具栏负责打开,看板负责在它们打开时让出快捷键。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
@@ -12,8 +12,12 @@ import { computed, ref } from 'vue'
|
|||||||
export const useUiStore = defineStore('ui', () => {
|
export const useUiStore = defineStore('ui', () => {
|
||||||
const backupOpen = ref(false)
|
const backupOpen = ref(false)
|
||||||
const helpOpen = 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 {
|
function openBackup(): void {
|
||||||
backupOpen.value = true
|
backupOpen.value = true
|
||||||
@@ -31,19 +35,43 @@ export const useUiStore = defineStore('ui', () => {
|
|||||||
helpOpen.value = false
|
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 {
|
function closeAll(): void {
|
||||||
backupOpen.value = false
|
backupOpen.value = false
|
||||||
helpOpen.value = false
|
helpOpen.value = false
|
||||||
|
trashOpen.value = false
|
||||||
|
tagsOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
backupOpen,
|
backupOpen,
|
||||||
helpOpen,
|
helpOpen,
|
||||||
|
trashOpen,
|
||||||
|
tagsOpen,
|
||||||
anyPanelOpen,
|
anyPanelOpen,
|
||||||
openBackup,
|
openBackup,
|
||||||
closeBackup,
|
closeBackup,
|
||||||
openHelp,
|
openHelp,
|
||||||
closeHelp,
|
closeHelp,
|
||||||
|
openTrash,
|
||||||
|
closeTrash,
|
||||||
|
openTags,
|
||||||
|
closeTags,
|
||||||
closeAll,
|
closeAll,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ export interface Task {
|
|||||||
completedAt: string | null
|
completedAt: string | null
|
||||||
/** 归档时间;null 表示未归档。归档任务默认不在看板上显示 */
|
/** 归档时间;null 表示未归档。归档任务默认不在看板上显示 */
|
||||||
archivedAt: string | null
|
archivedAt: string | null
|
||||||
|
/**
|
||||||
|
* 删除时间;null 表示未删除。
|
||||||
|
*
|
||||||
|
* 删除是「软删除」:任务仍留在数据里,只是从看板、统计、标签等
|
||||||
|
* 所有常规视图里剔除,直到用户在回收站里恢复或永久删除。
|
||||||
|
*/
|
||||||
|
deletedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ColumnDef {
|
export interface ColumnDef {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
|||||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||||
completedAt: ago(10 * DAY_MS),
|
completedAt: ago(10 * DAY_MS),
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
|||||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
|||||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
|||||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
|||||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ describe('BoardView 详情抽屉', () => {
|
|||||||
await deleteButton?.trigger('click')
|
await deleteButton?.trigger('click')
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(store.tasks).toHaveLength(0)
|
expect(store.activeTasks).toHaveLength(0)
|
||||||
expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false)
|
expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -698,7 +698,7 @@ describe('BoardView 多选与批量删除', () => {
|
|||||||
?.trigger('click')
|
?.trigger('click')
|
||||||
await nextTick()
|
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('已选')
|
expect(wrapper.text()).not.toContain('已选')
|
||||||
|
|
||||||
@@ -780,3 +780,76 @@ describe('BoardView 多选与批量删除', () => {
|
|||||||
expect(wrapper.text()).toContain('已选 0 个任务')
|
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
@@ -12,7 +12,7 @@ import { useFilterStore } from '@/stores/filters'
|
|||||||
import { useTaskStore } from '@/stores/tasks'
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useUiStore } from '@/stores/ui'
|
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 { findArchivableTasks } from '@/utils/archive'
|
||||||
import { filterTasks } from '@/utils/filter'
|
import { filterTasks } from '@/utils/filter'
|
||||||
import { groupByColumn } from '@/utils/sortOrder'
|
import { groupByColumn } from '@/utils/sortOrder'
|
||||||
@@ -25,7 +25,9 @@ const { filter, includeArchived } = storeToRefs(filters)
|
|||||||
|
|
||||||
/** 详情抽屉当前编辑的任务 id;任务被删除后自动变为 null,抽屉随之关闭 */
|
/** 详情抽屉当前编辑的任务 id;任务被删除后自动变为 null,抽屉随之关闭 */
|
||||||
const editingId = ref<string | null>(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)
|
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(() => {
|
const archivedHiddenTotal = computed(() => {
|
||||||
if (includeArchived.value) return 0
|
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 时会把归档任务直接排除,这里显式放开
|
// filter.includeArchived 为 false 时会把归档任务直接排除,这里显式放开
|
||||||
return filterTasks(archived, { ...filter.value, includeArchived: true }).length
|
return filterTasks(archived, { ...filter.value, includeArchived: true }).length
|
||||||
})
|
})
|
||||||
@@ -94,7 +96,7 @@ const hiddenNote = computed(() => {
|
|||||||
return `已隐藏 ${hiddenTotal.value} 个${detail}`
|
return `已隐藏 ${hiddenTotal.value} 个${detail}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const archivableTasks = computed(() => findArchivableTasks(store.tasks))
|
const archivableTasks = computed(() => findArchivableTasks(store.activeTasks))
|
||||||
|
|
||||||
function archiveCompleted(): void {
|
function archiveCompleted(): void {
|
||||||
const targets = archivableTasks.value
|
const targets = archivableTasks.value
|
||||||
@@ -127,7 +129,7 @@ useKeyboardShortcuts({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function toggleTask(id: string): void {
|
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
|
if (!task) return
|
||||||
store.updateTask(id, { status: task.status === 'done' ? 'todo' : 'done' })
|
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 {
|
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 (!task) return
|
||||||
|
|
||||||
if (payload.direction === 'left' || payload.direction === 'right') {
|
if (payload.direction === 'left' || payload.direction === 'right') {
|
||||||
@@ -183,7 +185,9 @@ const visibleTaskIds = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
/** 真正存在的已选任务:跨标签页同步等情况下可能出现已不存在的 id */
|
/** 真正存在的已选任务:跨标签页同步等情况下可能出现已不存在的 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)
|
const selectedCount = computed(() => selectedTasks.value.length)
|
||||||
|
|
||||||
function toggleSelectMode(): void {
|
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"
|
@archive="archiveCompleted"
|
||||||
@batch-add="batchOpen = true"
|
@batch-add="batchOpen = true"
|
||||||
@toggle-select="toggleSelectMode"
|
@toggle-select="toggleSelectMode"
|
||||||
|
@manage-tags="ui.openTags()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BatchSelectionBar
|
<BatchSelectionBar
|
||||||
v-if="selecting"
|
v-if="selecting"
|
||||||
:count="selectedCount"
|
:count="selectedCount"
|
||||||
:total="visibleTaskIds.length"
|
:total="visibleTaskIds.length"
|
||||||
|
:tags="store.allTags"
|
||||||
@select-all="selectAllVisible"
|
@select-all="selectAllVisible"
|
||||||
@clear="clearSelection"
|
@clear="clearSelection"
|
||||||
@remove="removeSelected"
|
@remove="removeSelected"
|
||||||
@done="exitSelection"
|
@done="exitSelection"
|
||||||
|
@move="batchMove"
|
||||||
|
@priority="batchPriority"
|
||||||
|
@add-tag="batchAddTag"
|
||||||
|
@remove-tag="batchRemoveTag"
|
||||||
|
@due="batchDue"
|
||||||
|
@archive="batchArchive"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -37,15 +37,15 @@ const TONES = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
const store = useTaskStore()
|
const store = useTaskStore()
|
||||||
const { tasks } = storeToRefs(store)
|
const { activeTasks } = storeToRefs(store)
|
||||||
|
|
||||||
const todayLabel = computed(() => {
|
const todayLabel = computed(() => {
|
||||||
const now = dayjs()
|
const now = dayjs()
|
||||||
return `${now.format('YYYY年M月D日')} ${WEEKDAYS[now.day()]}`
|
return `${now.format('YYYY年M月D日')} ${WEEKDAYS[now.day()]}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const buckets = computed(() => buildTodayBuckets(tasks.value))
|
const buckets = computed(() => buildTodayBuckets(activeTasks.value))
|
||||||
const stats = computed(() => computeStats(tasks.value))
|
const stats = computed(() => computeStats(activeTasks.value))
|
||||||
const todayCount = computed(() => countTodayTasks(buckets.value))
|
const todayCount = computed(() => countTodayTasks(buckets.value))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,10 +97,12 @@ const sections = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const editingId = ref<string | null>(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,
|
||||||
|
)
|
||||||
|
|
||||||
function toggleTask(id: string): void {
|
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
|
if (!task) return
|
||||||
store.updateTask(id, { status: task.status === 'done' ? 'todo' : 'done' })
|
store.updateTask(id, { status: task.status === 'done' ? 'todo' : 'done' })
|
||||||
}
|
}
|
||||||
@@ -108,7 +110,7 @@ function toggleTask(id: string): void {
|
|||||||
/** 今日视图没有列概念,左右方向键不适用;上下仍可调 order */
|
/** 今日视图没有列概念,左右方向键不适用;上下仍可调 order */
|
||||||
function handleKeyMove(payload: { id: string; direction: MoveDirection }): void {
|
function handleKeyMove(payload: { id: string; direction: MoveDirection }): void {
|
||||||
if (payload.direction === 'left' || payload.direction === 'right') return
|
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
|
if (!task) return
|
||||||
const column = store.byColumn[task.status]
|
const column = store.byColumn[task.status]
|
||||||
const currentIndex = column.findIndex((item) => item.id === payload.id)
|
const currentIndex = column.findIndex((item) => item.id === payload.id)
|
||||||
|
|||||||
Reference in New Issue
Block a user