feat: 回收站、批量编辑、合并导入与标签治理
- 数据:Task 增加 deletedAt,schema v2→v3;删除改为软删除,保留 30 天后自动清理 - 回收站:支持恢复、永久删除、二次确认清空;导航栏入口带数量角标 - 批量编辑:多选后统一移动列、改优先级、标签增删、设置截止日、归档 - 合并导入:按 id 匹配、updatedAt 新者胜;覆盖/合并二选一,执行前自动快照 - 标签治理:全局重命名、合并、删除;回收站任务不参与改写 - 看板语义读取统一改用 activeTasks,避免已删除任务泄漏到统计、今日与标签下拉 - UI:回收站图标与备份统一尺寸;批量编辑条统一控件高度并分组 - 测试:705 个单测通过,补充迁移、回收站、批量编辑、合并与标签用例
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import { useThemeStore, THEME_LABELS } from '@/stores/theme'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
|
||||
const ui = useUiStore()
|
||||
const theme = useThemeStore()
|
||||
const store = useTaskStore()
|
||||
|
||||
const LINKS = [
|
||||
{ to: '/', label: '看板' },
|
||||
@@ -134,12 +136,45 @@ const LINKS = [
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex items-center gap-1.5 rounded-md border border-slate-200 px-2.5 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 dark:border-zinc-700 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
|
||||
:aria-label="`回收站(${store.trashTasks.length} 个任务)`"
|
||||
@click="ui.openTrash()"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" class="size-4" fill="none" aria-hidden="true">
|
||||
<path d="M3.5 5.5h13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
<path
|
||||
d="M8 5.5v-2h4v2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="m5.5 5.5.9 11h7.2l.9-11"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M8.5 8.5v5M11.5 8.5v5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">回收站</span>
|
||||
<span
|
||||
v-if="store.trashTasks.length > 0"
|
||||
class="rounded-full bg-slate-200 px-1.5 text-[11px] leading-4 font-medium text-slate-700 dark:bg-zinc-700 dark:text-zinc-200"
|
||||
>
|
||||
{{ store.trashTasks.length }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded-md border border-slate-200 px-2.5 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 dark:border-zinc-700 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
|
||||
@click="ui.openBackup()"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" class="size-3.5" fill="none" aria-hidden="true">
|
||||
<svg viewBox="0 0 20 20" class="size-4" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M10 13V3.5m0 0L6.5 7M10 3.5 13.5 7"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -157,6 +157,110 @@ describe('BackupPanel 导入', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BackupPanel 合并导入', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(downloadText).mockClear()
|
||||
})
|
||||
|
||||
it('预览显示新增 / 更新 / 保持数量', async () => {
|
||||
const { store, wrapper } = await setup()
|
||||
const local = store.tasks[0]
|
||||
const text = JSON.stringify({
|
||||
schemaVersion: 3,
|
||||
tasks: [
|
||||
{
|
||||
id: local?.id,
|
||||
title: '更新后的任务',
|
||||
status: 'todo',
|
||||
priority: 2,
|
||||
sortOrder: 1024,
|
||||
createdAt: local?.createdAt,
|
||||
updatedAt: '2099-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'new-1',
|
||||
title: '新增任务',
|
||||
status: 'todo',
|
||||
priority: 2,
|
||||
sortOrder: 2048,
|
||||
createdAt: '2026-10-01T00:00:00.000Z',
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await chooseFile(wrapper, text)
|
||||
await wrapper.find('input[value="merge"]').setValue(true)
|
||||
|
||||
expect(wrapper.text()).toContain('新增')
|
||||
expect(wrapper.text()).toContain('更新')
|
||||
expect(wrapper.text()).toContain('保持')
|
||||
// 默认仍是覆盖模式,不会静默改成合并
|
||||
expect(buttonByText(wrapper, '确认合并导入')).toBeDefined()
|
||||
})
|
||||
|
||||
it('确认合并后更新同 id 并新增其余任务', async () => {
|
||||
const { store, wrapper } = await setup()
|
||||
const local = store.tasks[0]
|
||||
const text = JSON.stringify({
|
||||
schemaVersion: 3,
|
||||
tasks: [
|
||||
{
|
||||
id: local?.id,
|
||||
title: '更新后的任务',
|
||||
status: 'todo',
|
||||
priority: 2,
|
||||
sortOrder: 1024,
|
||||
createdAt: local?.createdAt,
|
||||
updatedAt: '2099-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'new-1',
|
||||
title: '新增任务',
|
||||
status: 'todo',
|
||||
priority: 2,
|
||||
sortOrder: 2048,
|
||||
createdAt: '2026-10-01T00:00:00.000Z',
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await chooseFile(wrapper, text)
|
||||
await wrapper.find('input[value="merge"]').setValue(true)
|
||||
await buttonByText(wrapper, '确认合并导入')?.trigger('click')
|
||||
|
||||
expect(store.activeTasks.map((task) => task.title).sort()).toEqual(
|
||||
['更新后的任务', '新增任务'].sort(),
|
||||
)
|
||||
expect(wrapper.text()).toContain('已合并')
|
||||
expect(wrapper.text()).toContain('新增 1 个,更新 1 个')
|
||||
})
|
||||
|
||||
it('合并不删除本地独有的任务', async () => {
|
||||
const { store, wrapper } = await setup()
|
||||
await chooseFile(wrapper, makePayload(['文件任务']))
|
||||
await wrapper.find('input[value="merge"]').setValue(true)
|
||||
await buttonByText(wrapper, '确认合并导入')?.trigger('click')
|
||||
|
||||
expect(store.activeTasks.map((task) => task.title).sort()).toEqual(
|
||||
['原有任务', '文件任务'].sort(),
|
||||
)
|
||||
})
|
||||
|
||||
it('合并前自动写快照', async () => {
|
||||
const { store, wrapper } = await setup()
|
||||
expect(store.snapshots).toHaveLength(0)
|
||||
|
||||
await chooseFile(wrapper, makePayload(['文件任务']))
|
||||
await wrapper.find('input[value="merge"]').setValue(true)
|
||||
await buttonByText(wrapper, '确认合并导入')?.trigger('click')
|
||||
|
||||
expect(store.snapshots).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BackupPanel 自动备份', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ref } from 'vue'
|
||||
import { parseImportJson } from '@/data/schema'
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import { downloadText, exportFileName } from '@/utils/exportFile'
|
||||
import { mergeTasks, type MergeResult } from '@/utils/merge'
|
||||
|
||||
import SideDrawer from './SideDrawer.vue'
|
||||
|
||||
@@ -19,8 +20,16 @@ const { snapshots } = storeToRefs(store)
|
||||
const message = ref<string | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/** 导入模式:覆盖会替换全部数据,合并只按 id 更新 / 新增 */
|
||||
const importMode = ref<'replace' | 'merge'>('replace')
|
||||
|
||||
/** 已选好文件但还没确认的导入(覆盖式操作需要二次确认) */
|
||||
const pending = ref<{ text: string; fileName: string; count: number } | null>(null)
|
||||
const pending = ref<{
|
||||
text: string
|
||||
fileName: string
|
||||
count: number
|
||||
preview: MergeResult
|
||||
} | null>(null)
|
||||
|
||||
function exportNow(): void {
|
||||
const fileName = exportFileName()
|
||||
@@ -43,12 +52,19 @@ async function onFileChange(event: Event): Promise<void> {
|
||||
|
||||
message.value = null
|
||||
error.value = null
|
||||
// 每次重新选文件都回到默认的覆盖模式,避免沿用上一次的选择误覆盖
|
||||
importMode.value = 'replace'
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
// 先做一次纯校验,确认合法后才让用户确认覆盖
|
||||
// 先做一次纯校验,确认合法后才让用户确认导入
|
||||
const tasks = parseImportJson(text)
|
||||
pending.value = { text, fileName: file.name, count: tasks.length }
|
||||
pending.value = {
|
||||
text,
|
||||
fileName: file.name,
|
||||
count: tasks.length,
|
||||
preview: mergeTasks(store.tasks, tasks),
|
||||
}
|
||||
} catch (cause) {
|
||||
pending.value = null
|
||||
error.value = cause instanceof Error ? cause.message : '导入文件无法解析'
|
||||
@@ -63,9 +79,17 @@ function confirmImport(): void {
|
||||
const beforeCount = store.tasks.length
|
||||
|
||||
try {
|
||||
const count = store.importJson(target.text)
|
||||
const backupNote = beforeCount > 0 ? ',导入前的数据已自动备份' : ''
|
||||
message.value = `已从 ${target.fileName} 导入 ${count} 个任务${backupNote}`
|
||||
if (importMode.value === 'replace') {
|
||||
const count = store.importJson(target.text)
|
||||
const backupNote = beforeCount > 0 ? ',导入前的数据已自动备份' : ''
|
||||
message.value = `已从 ${target.fileName} 导入 ${count} 个任务${backupNote}`
|
||||
} else {
|
||||
const { added, updated, skipped } = store.mergeJson(target.text)
|
||||
const backupNote = beforeCount > 0 ? ',导入前的数据已自动备份' : ''
|
||||
message.value =
|
||||
`已合并 ${target.fileName}:新增 ${added} 个,更新 ${updated} 个,跳过 ${skipped} 个` +
|
||||
backupNote
|
||||
}
|
||||
error.value = null
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : '导入失败'
|
||||
@@ -142,18 +166,61 @@ function formatTime(iso: string): string {
|
||||
v-if="pending"
|
||||
class="flex flex-col gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 dark:border-amber-800 dark:bg-amber-950/60"
|
||||
>
|
||||
<p class="text-sm text-amber-900 dark:text-amber-200">
|
||||
<fieldset class="flex flex-col gap-1.5">
|
||||
<legend class="sr-only">导入方式</legend>
|
||||
<label class="flex items-start gap-2 text-sm text-amber-900 dark:text-amber-200">
|
||||
<input
|
||||
v-model="importMode"
|
||||
type="radio"
|
||||
name="import-mode"
|
||||
value="replace"
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
覆盖导入 —— 替换当前全部 {{ store.tasks.length }} 个任务
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-2 text-sm text-amber-900 dark:text-amber-200">
|
||||
<input
|
||||
v-model="importMode"
|
||||
type="radio"
|
||||
name="import-mode"
|
||||
value="merge"
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<span>合并导入 —— 更新同 id 任务,新增其余任务</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<p
|
||||
v-if="importMode === 'replace'"
|
||||
class="border-t border-amber-200 pt-2 text-sm text-amber-900 dark:border-amber-800/60 dark:text-amber-200"
|
||||
>
|
||||
将从「{{ pending.fileName }}」导入 {{ pending.count }} 个任务,
|
||||
<strong>并覆盖当前全部 {{ store.tasks.length }} 个任务</strong>。 覆盖前会自动把当前数据
|
||||
存一份到下方「自动备份」,选错文件也能从那里恢复。
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="border-t border-amber-200 pt-2 text-sm text-amber-900 dark:border-amber-800/60 dark:text-amber-200"
|
||||
>
|
||||
「{{ pending.fileName }}」共 {{ pending.count }} 个任务:新增
|
||||
<strong>{{ pending.preview.added }}</strong> 个 / 更新
|
||||
<strong>{{ pending.preview.updated }}</strong> 个 / 保持
|
||||
<strong>{{ Math.max(0, store.tasks.length - pending.preview.updated) }}</strong> 个。
|
||||
<span v-if="pending.preview.skipped > 0">
|
||||
其中 {{ pending.preview.skipped }} 个同 id 任务与本地相同或更旧,已跳过。
|
||||
</span>
|
||||
合并前会自动把当前数据存一份到下方「自动备份」。
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-rose-600 px-3 py-1 text-sm font-medium text-white transition hover:bg-rose-700 dark:hover:bg-rose-500"
|
||||
@click="confirmImport"
|
||||
>
|
||||
确认覆盖导入
|
||||
{{ importMode === 'replace' ? '确认覆盖导入' : '确认合并导入' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -129,6 +129,6 @@ describe('BatchAddDrawer 提交', () => {
|
||||
expect(action?.label).toBe('撤销')
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
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<{
|
||||
/** 已选数量 */
|
||||
count: number
|
||||
/** 当前可见(筛选后)的任务总数 */
|
||||
total: number
|
||||
}>()
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority } from '@/types/task'
|
||||
|
||||
import DatePicker from './DatePicker.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 已选数量 */
|
||||
count: number
|
||||
/** 当前可见(筛选后)的任务总数 */
|
||||
total: number
|
||||
/** 已有标签,作为输入提示 */
|
||||
tags?: readonly string[]
|
||||
}>(),
|
||||
{ tags: () => [] },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select-all': []
|
||||
clear: []
|
||||
remove: []
|
||||
done: []
|
||||
move: [status: ColumnId]
|
||||
priority: [priority: Priority]
|
||||
'add-tag': [tag: string]
|
||||
'remove-tag': [tag: string]
|
||||
due: [date: string | null]
|
||||
archive: []
|
||||
}>()
|
||||
|
||||
const PRIORITIES: readonly Priority[] = [1, 2, 3]
|
||||
|
||||
const tagDraft = ref('')
|
||||
const dueOpen = ref(false)
|
||||
const batchDue = ref<string | null>(null)
|
||||
|
||||
function submitTag(action: 'add-tag' | 'remove-tag'): void {
|
||||
const tag = tagDraft.value.trim()
|
||||
if (!tag || props.count === 0) return
|
||||
if (action === 'add-tag') emit('add-tag', tag)
|
||||
else emit('remove-tag', tag)
|
||||
tagDraft.value = ''
|
||||
}
|
||||
|
||||
function onMove(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement
|
||||
const value = select.value
|
||||
select.value = ''
|
||||
if (!value) return
|
||||
emit('move', value as ColumnId)
|
||||
}
|
||||
|
||||
function onDueChange(value: string | null): void {
|
||||
batchDue.value = value
|
||||
emit('due', value)
|
||||
dueOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-2 gap-y-1.5 rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-900 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-200"
|
||||
class="flex flex-col gap-2 rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-900 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-200"
|
||||
role="toolbar"
|
||||
aria-label="批量操作"
|
||||
>
|
||||
<span>
|
||||
已选
|
||||
<strong class="font-semibold">{{ count }}</strong>
|
||||
个任务
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1.5">
|
||||
<span>
|
||||
已选
|
||||
<strong class="font-semibold">{{ count }}</strong>
|
||||
个任务
|
||||
</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
|
||||
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')"
|
||||
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-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"
|
||||
@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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||
completedAt: null,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,12 @@ import TagFilter from './TagFilter.vue'
|
||||
|
||||
defineProps<{ hiddenNote?: string; selecting?: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ archive: []; 'batch-add': []; 'toggle-select': [] }>()
|
||||
const emit = defineEmits<{
|
||||
archive: []
|
||||
'batch-add': []
|
||||
'toggle-select': []
|
||||
'manage-tags': []
|
||||
}>()
|
||||
|
||||
const store = useTaskStore()
|
||||
const filters = useFilterStore()
|
||||
@@ -24,15 +29,15 @@ const searchRef = ref<HTMLInputElement | null>(null)
|
||||
const PRIORITY_ORDER: readonly Priority[] = [3, 2, 1]
|
||||
|
||||
const dueTodayCount = computed(
|
||||
() => store.tasks.filter((task) => isDueToday(task.dueDate, task.status)).length,
|
||||
() => store.activeTasks.filter((task) => isDueToday(task.dueDate, task.status)).length,
|
||||
)
|
||||
|
||||
const overdueCount = computed(
|
||||
() => store.tasks.filter((task) => isOverdue(task.dueDate, task.status)).length,
|
||||
() => store.activeTasks.filter((task) => isOverdue(task.dueDate, task.status)).length,
|
||||
)
|
||||
|
||||
/** 当前可归档(已完成且超过保留天数)的任务数量 */
|
||||
const archivableCount = computed(() => findArchivableTasks(store.tasks).length)
|
||||
const archivableCount = computed(() => findArchivableTasks(store.activeTasks).length)
|
||||
|
||||
function focusSearch(): void {
|
||||
searchRef.value?.focus()
|
||||
@@ -91,6 +96,7 @@ defineExpose({ focusSearch })
|
||||
:selected="tags"
|
||||
@toggle="filters.toggleTag($event)"
|
||||
@clear="filters.clearTags()"
|
||||
@manage="emit('manage-tags')"
|
||||
/>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
@@ -129,7 +135,7 @@ defineExpose({ focusSearch })
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-xs text-slate-500 dark:text-zinc-400">
|
||||
<span>共 {{ store.tasks.length }} 个任务</span>
|
||||
<span>共 {{ store.activeTasks.length }} 个任务</span>
|
||||
<span v-if="hiddenNote" class="text-slate-400 dark:text-zinc-500">{{ hiddenNote }}</span>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
|
||||
@@ -197,6 +197,6 @@ describe('QuickAddTask 批量粘贴', () => {
|
||||
expect(action?.label).toBe('撤销')
|
||||
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[] }>()
|
||||
|
||||
const emit = defineEmits<{ toggle: [tag: string]; clear: [] }>()
|
||||
const emit = defineEmits<{ toggle: [tag: string]; clear: []; manage: [] }>()
|
||||
|
||||
const open = ref(false)
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
@@ -29,6 +29,12 @@ function onDocumentKeydown(event: KeyboardEvent): void {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
/** 交给应用级面板处理;先收起下拉,避免两层浮层叠在一起 */
|
||||
function openManager(): void {
|
||||
open.value = false
|
||||
emit('manage')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
document.addEventListener('keydown', onDocumentKeydown, true)
|
||||
@@ -110,6 +116,14 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
清除标签筛选
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mt-1 w-full rounded-md border-t border-slate-100 px-2 py-1.5 text-left text-xs text-slate-500 transition hover:bg-slate-50 hover:text-slate-700 dark:border-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
|
||||
@click="openManager"
|
||||
>
|
||||
管理标签…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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',
|
||||
completedAt: null,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,8 @@ describe('TaskDetailDrawer 删除与撤销', () => {
|
||||
|
||||
await buttonByText(wrapper, '删除')?.trigger('click')
|
||||
|
||||
expect(store.tasks).toHaveLength(0)
|
||||
expect(store.activeTasks).toHaveLength(0)
|
||||
expect(store.trashTasks).toHaveLength(1)
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
expect(toast.toasts).toHaveLength(1)
|
||||
expect(toast.toasts[0]?.action?.label).toBe('撤销')
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user