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

- 数据:Task 增加 deletedAt,schema v2→v3;删除改为软删除,保留 30 天后自动清理
- 回收站:支持恢复、永久删除、二次确认清空;导航栏入口带数量角标
- 批量编辑:多选后统一移动列、改优先级、标签增删、设置截止日、归档
- 合并导入:按 id 匹配、updatedAt 新者胜;覆盖/合并二选一,执行前自动快照
- 标签治理:全局重命名、合并、删除;回收站任务不参与改写
- 看板语义读取统一改用 activeTasks,避免已删除任务泄漏到统计、今日与标签下拉
- UI:回收站图标与备份统一尺寸;批量编辑条统一控件高度并分组
- 测试:705 个单测通过,补充迁移、回收站、批量编辑、合并与标签用例
This commit is contained in:
chenshoulin
2026-10-19 18:32:42 +08:00
parent 43e813b93a
commit 65e98199ba
39 changed files with 2734 additions and 143 deletions
+36 -1
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { useTaskStore } from '@/stores/tasks'
import { useThemeStore, THEME_LABELS } from '@/stores/theme'
import { useUiStore } from '@/stores/ui'
const ui = useUiStore()
const theme = useThemeStore()
const store = useTaskStore()
const LINKS = [
{ to: '/', label: '看板' },
@@ -134,12 +136,45 @@ const LINKS = [
</svg>
</button>
<button
type="button"
class="relative flex items-center gap-1.5 rounded-md border border-slate-200 px-2.5 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 dark:border-zinc-700 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
:aria-label="`回收站(${store.trashTasks.length} 个任务)`"
@click="ui.openTrash()"
>
<svg viewBox="0 0 20 20" class="size-4" fill="none" aria-hidden="true">
<path d="M3.5 5.5h13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path
d="M8 5.5v-2h4v2"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="m5.5 5.5.9 11h7.2l.9-11"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M8.5 8.5v5M11.5 8.5v5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
<span class="hidden sm:inline">回收站</span>
<span
v-if="store.trashTasks.length > 0"
class="rounded-full bg-slate-200 px-1.5 text-[11px] leading-4 font-medium text-slate-700 dark:bg-zinc-700 dark:text-zinc-200"
>
{{ store.trashTasks.length }}
</span>
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded-md border border-slate-200 px-2.5 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 dark:border-zinc-700 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
@click="ui.openBackup()"
>
<svg viewBox="0 0 20 20" class="size-3.5" fill="none" aria-hidden="true">
<svg viewBox="0 0 20 20" class="size-4" fill="none" aria-hidden="true">
<path
d="M10 13V3.5m0 0L6.5 7M10 3.5 13.5 7"
stroke="currentColor"
+104
View File
@@ -157,6 +157,110 @@ describe('BackupPanel 导入', () => {
})
})
describe('BackupPanel 合并导入', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('预览显示新增 / 更新 / 保持数量', async () => {
const { store, wrapper } = await setup()
const local = store.tasks[0]
const text = JSON.stringify({
schemaVersion: 3,
tasks: [
{
id: local?.id,
title: '更新后的任务',
status: 'todo',
priority: 2,
sortOrder: 1024,
createdAt: local?.createdAt,
updatedAt: '2099-01-01T00:00:00.000Z',
},
{
id: 'new-1',
title: '新增任务',
status: 'todo',
priority: 2,
sortOrder: 2048,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
},
],
})
await chooseFile(wrapper, text)
await wrapper.find('input[value="merge"]').setValue(true)
expect(wrapper.text()).toContain('新增')
expect(wrapper.text()).toContain('更新')
expect(wrapper.text()).toContain('保持')
// 默认仍是覆盖模式,不会静默改成合并
expect(buttonByText(wrapper, '确认合并导入')).toBeDefined()
})
it('确认合并后更新同 id 并新增其余任务', async () => {
const { store, wrapper } = await setup()
const local = store.tasks[0]
const text = JSON.stringify({
schemaVersion: 3,
tasks: [
{
id: local?.id,
title: '更新后的任务',
status: 'todo',
priority: 2,
sortOrder: 1024,
createdAt: local?.createdAt,
updatedAt: '2099-01-01T00:00:00.000Z',
},
{
id: 'new-1',
title: '新增任务',
status: 'todo',
priority: 2,
sortOrder: 2048,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
},
],
})
await chooseFile(wrapper, text)
await wrapper.find('input[value="merge"]').setValue(true)
await buttonByText(wrapper, '确认合并导入')?.trigger('click')
expect(store.activeTasks.map((task) => task.title).sort()).toEqual(
['更新后的任务', '新增任务'].sort(),
)
expect(wrapper.text()).toContain('已合并')
expect(wrapper.text()).toContain('新增 1 个,更新 1 个')
})
it('合并不删除本地独有的任务', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, makePayload(['文件任务']))
await wrapper.find('input[value="merge"]').setValue(true)
await buttonByText(wrapper, '确认合并导入')?.trigger('click')
expect(store.activeTasks.map((task) => task.title).sort()).toEqual(
['原有任务', '文件任务'].sort(),
)
})
it('合并前自动写快照', async () => {
const { store, wrapper } = await setup()
expect(store.snapshots).toHaveLength(0)
await chooseFile(wrapper, makePayload(['文件任务']))
await wrapper.find('input[value="merge"]').setValue(true)
await buttonByText(wrapper, '确认合并导入')?.trigger('click')
expect(store.snapshots).toHaveLength(1)
})
})
describe('BackupPanel 自动备份', () => {
beforeEach(() => {
setActivePinia(createPinia())
+75 -8
View File
@@ -6,6 +6,7 @@ import { ref } from 'vue'
import { parseImportJson } from '@/data/schema'
import { useTaskStore } from '@/stores/tasks'
import { downloadText, exportFileName } from '@/utils/exportFile'
import { mergeTasks, type MergeResult } from '@/utils/merge'
import SideDrawer from './SideDrawer.vue'
@@ -19,8 +20,16 @@ const { snapshots } = storeToRefs(store)
const message = ref<string | null>(null)
const error = ref<string | null>(null)
/** 导入模式:覆盖会替换全部数据,合并只按 id 更新 / 新增 */
const importMode = ref<'replace' | 'merge'>('replace')
/** 已选好文件但还没确认的导入(覆盖式操作需要二次确认) */
const pending = ref<{ text: string; fileName: string; count: number } | null>(null)
const pending = ref<{
text: string
fileName: string
count: number
preview: MergeResult
} | null>(null)
function exportNow(): void {
const fileName = exportFileName()
@@ -43,12 +52,19 @@ async function onFileChange(event: Event): Promise<void> {
message.value = null
error.value = null
// 每次重新选文件都回到默认的覆盖模式,避免沿用上一次的选择误覆盖
importMode.value = 'replace'
try {
const text = await file.text()
// 先做一次纯校验,确认合法后才让用户确认覆盖
// 先做一次纯校验,确认合法后才让用户确认导入
const tasks = parseImportJson(text)
pending.value = { text, fileName: file.name, count: tasks.length }
pending.value = {
text,
fileName: file.name,
count: tasks.length,
preview: mergeTasks(store.tasks, tasks),
}
} catch (cause) {
pending.value = null
error.value = cause instanceof Error ? cause.message : '导入文件无法解析'
@@ -63,9 +79,17 @@ function confirmImport(): void {
const beforeCount = store.tasks.length
try {
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"
+1 -1
View File
@@ -129,6 +129,6 @@ describe('BatchAddDrawer 提交', () => {
expect(action?.label).toBe('撤销')
action?.run()
expect(store.tasks).toHaveLength(0)
expect(store.activeTasks).toHaveLength(0)
})
})
+69
View File
@@ -75,3 +75,72 @@ describe('BatchSelectionBar 禁用态', () => {
expect(buttonByText(setup(0, 0), '全选')?.attributes('disabled')).toBeDefined()
})
})
describe('BatchSelectionBar 批量编辑', () => {
it('未选中任务时不渲染编辑器', () => {
expect(setup(0, 3).find('select[aria-label="移动到"]').exists()).toBe(false)
})
it('选中后出现编辑器', () => {
const bar = setup(2, 3)
expect(bar.find('select[aria-label="移动到"]').exists()).toBe(true)
expect(bar.find('button[aria-label="设为高优先级"]').exists()).toBe(true)
expect(bar.find('input[aria-label="标签"]').exists()).toBe(true)
})
it('移动下拉 emit move,并重置为占位项', async () => {
const bar = setup(2, 3)
const select = bar.find('select[aria-label="移动到"]')
await select.setValue('doing')
expect(bar.emitted('move')?.[0]).toEqual(['doing'])
expect((select.element as HTMLSelectElement).value).toBe('')
})
it('优先级按钮 emit priority', async () => {
const bar = setup(1, 3)
await bar.find('button[aria-label="设为高优先级"]').trigger('click')
expect(bar.emitted('priority')?.[0]).toEqual([3])
})
it('添加标签后清空输入框', async () => {
const bar = setup(2, 3)
const input = bar.find('input[aria-label="标签"]')
await input.setValue(' 前端 ')
await buttonByText(bar, '添加')?.trigger('click')
expect(bar.emitted('add-tag')?.[0]).toEqual(['前端'])
expect((input.element as HTMLInputElement).value).toBe('')
})
it('移除标签 emit remove-tag', async () => {
const bar = setup(2, 3)
await bar.find('input[aria-label="标签"]').setValue('前端')
await buttonByText(bar, '移除')?.trigger('click')
expect(bar.emitted('remove-tag')?.[0]).toEqual(['前端'])
})
it('空标签不会 emit', async () => {
const bar = setup(2, 3)
await bar.find('input[aria-label="标签"]').setValue(' ')
const add = buttonByText(bar, '添加')
expect(add?.attributes('disabled')).toBeDefined()
})
it('截止日期弹层选择后 emit due', async () => {
const bar = setup(2, 3)
await bar.find('button[aria-label="设置截止日期"]').trigger('click')
const dateInput = bar.find('input[type="date"]')
await dateInput.setValue('2026-11-01')
expect(bar.emitted('due')?.[0]).toEqual(['2026-11-01'])
})
it('归档 emit archive', async () => {
const bar = setup(2, 3)
await buttonByText(bar, '归档')?.trigger('click')
expect(bar.emitted('archive')).toHaveLength(1)
})
})
+225 -40
View File
@@ -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>
+1
View File
@@ -43,6 +43,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
+11 -5
View File
@@ -12,7 +12,12 @@ import TagFilter from './TagFilter.vue'
defineProps<{ hiddenNote?: string; selecting?: boolean }>()
const emit = defineEmits<{ archive: []; 'batch-add': []; 'toggle-select': [] }>()
const emit = defineEmits<{
archive: []
'batch-add': []
'toggle-select': []
'manage-tags': []
}>()
const store = useTaskStore()
const filters = useFilterStore()
@@ -24,15 +29,15 @@ const searchRef = ref<HTMLInputElement | null>(null)
const PRIORITY_ORDER: readonly Priority[] = [3, 2, 1]
const dueTodayCount = computed(
() => store.tasks.filter((task) => isDueToday(task.dueDate, task.status)).length,
() => store.activeTasks.filter((task) => isDueToday(task.dueDate, task.status)).length,
)
const overdueCount = computed(
() => store.tasks.filter((task) => isOverdue(task.dueDate, task.status)).length,
() => store.activeTasks.filter((task) => isOverdue(task.dueDate, task.status)).length,
)
/** 当前可归档(已完成且超过保留天数)的任务数量 */
const archivableCount = computed(() => findArchivableTasks(store.tasks).length)
const archivableCount = computed(() => findArchivableTasks(store.activeTasks).length)
function focusSearch(): void {
searchRef.value?.focus()
@@ -91,6 +96,7 @@ defineExpose({ focusSearch })
:selected="tags"
@toggle="filters.toggleTag($event)"
@clear="filters.clearTags()"
@manage="emit('manage-tags')"
/>
<div class="ml-auto flex items-center gap-1.5">
@@ -129,7 +135,7 @@ defineExpose({ focusSearch })
</div>
<div class="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-xs text-slate-500 dark:text-zinc-400">
<span>共 {{ store.tasks.length }} 个任务</span>
<span>共 {{ store.activeTasks.length }} 个任务</span>
<span v-if="hiddenNote" class="text-slate-400 dark:text-zinc-500">{{ hiddenNote }}</span>
<div class="ml-auto flex items-center gap-1.5">
+1 -1
View File
@@ -197,6 +197,6 @@ describe('QuickAddTask 批量粘贴', () => {
expect(action?.label).toBe('撤销')
action?.run()
expect(store.tasks).toHaveLength(0)
expect(store.activeTasks).toHaveLength(0)
})
})
+15 -1
View File
@@ -10,7 +10,7 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
defineProps<{ tags: readonly string[]; selected: readonly string[] }>()
const emit = defineEmits<{ toggle: [tag: string]; clear: [] }>()
const emit = defineEmits<{ toggle: [tag: string]; clear: []; manage: [] }>()
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
@@ -29,6 +29,12 @@ function onDocumentKeydown(event: KeyboardEvent): void {
open.value = false
}
/** 交给应用级面板处理;先收起下拉,避免两层浮层叠在一起 */
function openManager(): void {
open.value = false
emit('manage')
}
onMounted(() => {
document.addEventListener('click', onDocumentClick)
document.addEventListener('keydown', onDocumentKeydown, true)
@@ -110,6 +116,14 @@ onBeforeUnmount(() => {
>
清除标签筛选
</button>
<button
type="button"
class="mt-1 w-full rounded-md border-t border-slate-100 px-2 py-1.5 text-left text-xs text-slate-500 transition hover:bg-slate-50 hover:text-slate-700 dark:border-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
@click="openManager"
>
管理标签…
</button>
</div>
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import TagManagerPanel from './TagManagerPanel.vue'
function setup(prepare?: (store: ReturnType<typeof useTaskStore>) => void) {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
prepare?.(store)
const wrapper = mount(TagManagerPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
return { store, wrapper }
}
function buttonByText(wrapper: VueWrapper, text: string) {
return wrapper.findAll('button').find((node) => node.text() === text)
}
describe('TagManagerPanel 展示', () => {
beforeEach(() => setActivePinia(createPinia()))
it('没有标签时显示空状态', () => {
const { wrapper } = setup()
expect(wrapper.text()).toContain('还没有任何标签')
})
it('列出标签与使用数量', () => {
const { wrapper } = setup((store) => {
store.createTask({ title: 'A', tags: ['前端'] })
store.createTask({ title: 'B', tags: ['前端'] })
})
expect(wrapper.text()).toContain('#前端')
expect(wrapper.text()).toContain('2 个任务')
})
})
describe('TagManagerPanel 重命名', () => {
beforeEach(() => setActivePinia(createPinia()))
it('重命名后所有任务同步', async () => {
const { store, wrapper } = setup((store) => {
store.createTask({ title: 'A', tags: ['前端'] })
store.createTask({ title: 'B', tags: ['前端', '设计'] })
})
const input = wrapper.find('input[aria-label="重命名标签 前端"]')
await input.setValue('Frontend')
await buttonByText(wrapper, '保存')?.trigger('click')
expect([...store.allTags].sort()).toEqual(['Frontend', '设计'].sort())
expect(wrapper.text()).toContain('#Frontend')
expect(wrapper.text()).not.toContain('#前端')
})
it('清空输入后保存不改动标签', async () => {
const { store, wrapper } = setup((store) => {
store.createTask({ title: 'A', tags: ['前端'] })
})
await wrapper.find('input[aria-label="重命名标签 前端"]').setValue(' ')
await buttonByText(wrapper, '保存')?.trigger('click')
expect(store.allTags).toEqual(['前端'])
})
})
describe('TagManagerPanel 合并与删除', () => {
beforeEach(() => setActivePinia(createPinia()))
it('合并两个标签后去重', async () => {
const { store, wrapper } = setup((store) => {
store.createTask({ title: 'A', tags: ['前端', '设计'] })
store.createTask({ title: 'B', tags: ['设计'] })
})
await wrapper.find('select[aria-label="把标签 前端 合并到"]').setValue('设计')
await buttonByText(wrapper, '合并')?.trigger('click')
expect(store.allTags).toEqual(['设计'])
expect(store.activeTasks.every((task) => !task.tags.includes('前端'))).toBe(true)
})
it('删除标签需要二次确认,且不删除任务', async () => {
const { store, wrapper } = setup((store) => {
store.createTask({ title: 'A', tags: ['前端'] })
})
await buttonByText(wrapper, '删除')?.trigger('click')
expect(store.allTags).toEqual(['前端'])
await buttonByText(wrapper, '确认删除')?.trigger('click')
expect(store.allTags).toEqual([])
expect(store.activeTasks).toHaveLength(1)
})
it('取消删除保留标签', async () => {
const { store, wrapper } = setup((store) => {
store.createTask({ title: 'A', tags: ['前端'] })
})
await buttonByText(wrapper, '删除')?.trigger('click')
await buttonByText(wrapper, '取消')?.trigger('click')
expect(store.allTags).toEqual(['前端'])
expect(wrapper.text()).not.toContain('确认删除')
})
})
+182
View File
@@ -0,0 +1,182 @@
<script setup lang="ts">
/**
* 标签管理
*
* 标签是任务上的字符串,没有独立实体。这里把「重命名 / 合并 / 删除」
* 三种全局操作集中到一个面板,避免让用户逐个任务点开改。
*/
import { computed, ref } from 'vue'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import { collectTagStats } from '@/utils/tags'
import SideDrawer from './SideDrawer.vue'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const store = useTaskStore()
const toast = useToastStore()
/** 每行的重命名草稿;没有输入时以原标签为准 */
const drafts = ref<Record<string, string>>({})
/** 每行选择的「合并到」目标 */
const mergeTargets = ref<Record<string, string>>({})
/** 二次确认中的删除标签 */
const confirmingRemove = ref<string | null>(null)
const stats = computed(() => collectTagStats(store.activeTasks))
function draftFor(tag: string): string {
return drafts.value[tag] ?? tag
}
function setDraft(tag: string, value: string): void {
drafts.value = { ...drafts.value, [tag]: value }
}
function setMergeTarget(tag: string, value: string): void {
mergeTargets.value = { ...mergeTargets.value, [tag]: value }
}
/** 合并目标候选:排除自己 */
function otherTags(tag: string): { tag: string; count: number }[] {
return stats.value.filter((item) => item.tag !== tag)
}
function saveRename(tag: string): void {
const target = draftFor(tag).trim()
if (!target || target === tag) return
const count = store.renameTag(tag, target)
if (count === 0) {
toast.show({ message: `没有任务使用「${tag}」` })
return
}
// 该标签已不存在,清掉本行草稿,避免旧值继续显示
const next = { ...drafts.value }
delete next[tag]
drafts.value = next
toast.show({ message: `已将「${tag}」重命名为「${target}」,影响 ${count} 个任务` })
}
function merge(tag: string): void {
const target = mergeTargets.value[tag]
if (!target) return
const count = store.mergeTags([tag], target)
if (count === 0) {
toast.show({ message: `没有任务使用「${tag}」` })
return
}
toast.show({ message: `已将「${tag}」合并到「${target}」,影响 ${count} 个任务` })
}
function remove(tag: string): void {
const count = store.removeTag(tag)
confirmingRemove.value = null
if (count === 0) return
toast.show({ message: `已从 ${count} 个任务上移除「${tag}」` })
}
</script>
<template>
<SideDrawer :open="props.open" title="标签管理" @close="emit('close')">
<div class="flex flex-col gap-4">
<p class="text-xs leading-relaxed text-slate-500 dark:text-zinc-400">
统计只包含未归档、未删除的任务。重命名与合并会同步改写所有任务,删除只移除标签、不删除任务。
</p>
<p
v-if="stats.length === 0"
class="rounded-xl border border-dashed border-slate-300 px-4 py-12 text-center text-sm text-slate-400 dark:border-zinc-700 dark:text-zinc-500"
>
还没有任何标签
</p>
<ul v-else class="flex flex-col gap-3">
<li
v-for="item in stats"
:key="item.tag"
class="flex flex-col gap-2 rounded-md border border-slate-200 px-3 py-2.5 dark:border-zinc-700"
>
<div class="flex items-baseline justify-between gap-2">
<span class="text-sm font-medium text-slate-700 dark:text-zinc-200">
#{{ item.tag }}
</span>
<span class="text-xs text-slate-400 dark:text-zinc-500">{{ item.count }} 个任务</span>
</div>
<div class="flex items-center gap-1.5">
<input
type="text"
class="min-w-0 flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm text-slate-700 outline-none focus:border-sky-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:focus:border-sky-600"
:value="draftFor(item.tag)"
:aria-label="`重命名标签 ${item.tag}`"
@input="setDraft(item.tag, ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="saveRename(item.tag)"
/>
<button
type="button"
class="shrink-0 rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-600 transition hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
@click="saveRename(item.tag)"
>
保存
</button>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<select
class="min-w-0 flex-1 rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
:value="mergeTargets[item.tag] ?? ''"
:aria-label="`把标签 ${item.tag} 合并到`"
@change="setMergeTarget(item.tag, ($event.target as HTMLSelectElement).value)"
>
<option value="">合并到…</option>
<option v-for="other in otherTags(item.tag)" :key="other.tag" :value="other.tag">
#{{ other.tag }}
</option>
</select>
<button
type="button"
class="shrink-0 rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
:disabled="!mergeTargets[item.tag]"
@click="merge(item.tag)"
>
合并
</button>
<button
v-if="confirmingRemove !== item.tag"
type="button"
class="shrink-0 rounded-md border border-rose-300 px-2 py-1 text-xs text-rose-600 transition hover:bg-rose-50 dark:border-rose-800 dark:text-rose-400 dark:hover:bg-rose-950"
@click="confirmingRemove = item.tag"
>
删除
</button>
<template v-else>
<button
type="button"
class="shrink-0 rounded-md bg-rose-600 px-2 py-1 text-xs font-medium text-white transition hover:bg-rose-700 dark:hover:bg-rose-500"
@click="remove(item.tag)"
>
确认删除
</button>
<button
type="button"
class="shrink-0 rounded-md px-2 py-1 text-xs text-slate-500 transition hover:bg-slate-50 dark:text-zinc-400 dark:hover:bg-zinc-800"
@click="confirmingRemove = null"
>
取消
</button>
</template>
</div>
</li>
</ul>
</div>
</SideDrawer>
</template>
+1
View File
@@ -21,6 +21,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
+2 -1
View File
@@ -231,7 +231,8 @@ describe('TaskDetailDrawer 删除与撤销', () => {
await buttonByText(wrapper, '删除')?.trigger('click')
expect(store.tasks).toHaveLength(0)
expect(store.activeTasks).toHaveLength(0)
expect(store.trashTasks).toHaveLength(1)
expect(wrapper.emitted('close')).toHaveLength(1)
expect(toast.toasts).toHaveLength(1)
expect(toast.toasts[0]?.action?.label).toBe('撤销')
+117
View File
@@ -0,0 +1,117 @@
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import TrashPanel from './TrashPanel.vue'
function setup(prepare?: (store: ReturnType<typeof useTaskStore>) => void) {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
prepare?.(store)
const wrapper = mount(TrashPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
return { store, wrapper }
}
function buttonByText(wrapper: VueWrapper, text: string) {
return wrapper.findAll('button').find((node) => node.text().includes(text))
}
describe('TrashPanel 展示', () => {
beforeEach(() => setActivePinia(createPinia()))
it('回收站为空时显示空状态', () => {
const { wrapper } = setup()
expect(wrapper.text()).toContain('回收站是空的')
})
it('列出标题、删除时间与原列,并统计数量', () => {
const { wrapper } = setup((store) => {
const task = store.createTask({ title: '被删除的任务', status: 'doing' })
store.removeTask(task?.id ?? '')
})
expect(wrapper.text()).toContain('被删除的任务')
expect(wrapper.text()).toContain('原列 进行中')
expect(wrapper.text()).toContain('删除于')
expect(wrapper.text()).toContain('共 1 个任务')
})
it('已归档的删除项带「归档」徽标', () => {
const { wrapper } = setup((store) => {
const task = store.createTask({ title: '归档任务', status: 'done' })
store.archiveTasks([task?.id ?? ''])
store.removeTask(task?.id ?? '')
})
expect(wrapper.text()).toContain('归档')
})
})
describe('TrashPanel 操作', () => {
beforeEach(() => setActivePinia(createPinia()))
it('恢复后回到看板,回收站变空', async () => {
const { store, wrapper } = setup((store) => {
const task = store.createTask({ title: '可恢复' })
store.removeTask(task?.id ?? '')
})
await buttonByText(wrapper, '恢复')?.trigger('click')
expect(store.activeTasks).toHaveLength(1)
expect(store.trashTasks).toHaveLength(0)
expect(wrapper.text()).toContain('回收站是空的')
})
it('永久删除后数据真正消失', async () => {
const { store, wrapper } = setup((store) => {
const task = store.createTask({ title: '永久删除' })
store.removeTask(task?.id ?? '')
})
await buttonByText(wrapper, '永久删除')?.trigger('click')
expect(store.tasks).toHaveLength(0)
expect(wrapper.text()).toContain('回收站是空的')
})
it('清空回收站需要二次确认', async () => {
const { store, wrapper } = setup((store) => {
const a = store.createTask({ title: 'A' })
const b = store.createTask({ title: 'B' })
store.removeTasks([a?.id ?? '', b?.id ?? ''])
})
await buttonByText(wrapper, '清空回收站')?.trigger('click')
expect(wrapper.text()).toContain('此操作不可撤销')
expect(store.tasks).toHaveLength(2)
await buttonByText(wrapper, '确认清空')?.trigger('click')
expect(store.tasks).toHaveLength(0)
expect(wrapper.text()).toContain('回收站是空的')
})
it('取消清空不会删除数据', async () => {
const { store, wrapper } = setup((store) => {
const task = store.createTask({ title: 'A' })
store.removeTask(task?.id ?? '')
})
await buttonByText(wrapper, '清空回收站')?.trigger('click')
await buttonByText(wrapper, '取消')?.trigger('click')
expect(store.trashTasks).toHaveLength(1)
expect(wrapper.text()).not.toContain('此操作不可撤销')
})
})
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
/**
* 回收站
*
* 删除不再是「5 秒内可撤销,否则碰运气找快照」,而是软删除进回收站。
* 这里的「永久删除」是真正的物理删除,因此走二次确认;
* 恢复则保留原 sortOrder 与归档状态,回到原来的位置。
*/
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import { COLUMNS, type Task } from '@/types/task'
import SideDrawer from './SideDrawer.vue'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const store = useTaskStore()
const toast = useToastStore()
/** 「清空回收站」的二次确认态 */
const confirmingClear = ref(false)
const columnTitles = Object.fromEntries(COLUMNS.map((column) => [column.id, column.title]))
const count = computed(() => store.trashTasks.length)
function formatTime(iso: string): string {
return dayjs(iso).format('YYYY-MM-DD HH:mm')
}
function restore(task: Task): void {
store.restoreTask(task)
toast.show({ message: `已恢复「${task.title}」` })
}
function purge(task: Task): void {
if (!store.purgeTask(task.id)) return
toast.show({ message: `已永久删除「${task.title}」` })
}
function clearAll(): void {
const removed = store.emptyTrash()
confirmingClear.value = false
if (removed > 0) toast.show({ message: `已永久删除 ${removed} 个任务` })
}
// 每次重新打开都回到「未确认」状态,避免上一次的确认态残留
watch(
() => props.open,
(open) => {
if (open) confirmingClear.value = false
},
)
</script>
<template>
<SideDrawer :open="props.open" title="回收站" @close="emit('close')">
<div class="flex flex-col gap-4">
<p
class="rounded-md bg-slate-50 px-3 py-2 text-xs leading-relaxed text-slate-500 dark:bg-zinc-800/60 dark:text-zinc-400"
>
删除的任务会在这里保留 30 天,之后在下次打开时自动清理。恢复后会回到原来的列与顺序。
</p>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-slate-500 dark:text-zinc-400">共 {{ count }} 个任务</span>
<button
v-if="count > 0 && !confirmingClear"
type="button"
class="rounded-md border border-rose-300 px-2.5 py-1 text-xs text-rose-600 transition hover:bg-rose-50 dark:border-rose-800 dark:text-rose-400 dark:hover:bg-rose-950"
@click="confirmingClear = true"
>
清空回收站
</button>
</div>
<div
v-if="confirmingClear"
class="flex flex-col gap-2 rounded-md border border-rose-300 bg-rose-50 px-3 py-2 dark:border-rose-800 dark:bg-rose-950/60"
role="alert"
>
<p class="text-sm text-rose-700 dark:text-rose-300">
将永久删除回收站中的 {{ count }} 个任务,<strong>此操作不可撤销</strong>。
</p>
<div class="flex gap-2">
<button
type="button"
class="rounded-md bg-rose-600 px-3 py-1 text-sm font-medium text-white transition hover:bg-rose-700 dark:hover:bg-rose-500"
@click="clearAll"
>
确认清空
</button>
<button
type="button"
class="rounded-md border border-slate-300 px-3 py-1 text-sm text-slate-600 transition hover:bg-white dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
@click="confirmingClear = false"
>
取消
</button>
</div>
</div>
<p
v-if="count === 0"
class="rounded-xl border border-dashed border-slate-300 px-4 py-12 text-center text-sm text-slate-400 dark:border-zinc-700 dark:text-zinc-500"
>
回收站是空的
</p>
<ul v-else class="flex flex-col gap-2">
<li
v-for="task in store.trashTasks"
:key="task.id"
class="flex flex-col gap-1.5 rounded-md border border-slate-200 px-3 py-2 dark:border-zinc-700"
>
<div class="flex items-start justify-between gap-2">
<span class="min-w-0 flex-1 text-sm break-words text-slate-700 dark:text-zinc-200">
{{ task.title }}
</span>
<span
v-if="task.archivedAt !== null"
class="shrink-0 rounded-full border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[11px] text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
>
归档
</span>
</div>
<p class="text-xs text-slate-400 dark:text-zinc-500">
删除于 {{ formatTime(task.deletedAt ?? task.updatedAt) }} · 原列
{{ columnTitles[task.status] }}
</p>
<div class="flex gap-2">
<button
type="button"
class="rounded-md px-2 py-0.5 text-sm text-sky-700 transition hover:bg-sky-50 dark:text-sky-400 dark:hover:bg-sky-950"
@click="restore(task)"
>
恢复
</button>
<button
type="button"
class="rounded-md px-2 py-0.5 text-sm text-rose-600 transition hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-950"
@click="purge(task)"
>
永久删除
</button>
</div>
</li>
</ul>
</div>
</SideDrawer>
</template>