feat(board): 支持批量添加与批量删除任务
- 批量添加面板:一行一条,整批共用目标列、优先级、标签与截止日期 - 快速新增支持粘贴多行文本,直接拆成多条任务 - 多选模式支持全选可见任务、整批删除,撤销时连原顺序一起还原 - 批量创建与删除走 store 的批量方法,一次落盘、一次广播 - 补充单元测试与 E2E 用例
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
|
||||
import BatchAddDrawer from './BatchAddDrawer.vue'
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function setup(open = true) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const store = useTaskStore()
|
||||
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
|
||||
|
||||
// 抽屉会被 Teleport 到 body,测试中改为就地渲染以便断言
|
||||
const wrapper = mount(BatchAddDrawer, {
|
||||
props: { open },
|
||||
global: { plugins: [pinia], stubs: { teleport: true } },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { store, wrapper }
|
||||
}
|
||||
|
||||
function buttonByText(wrapper: ReturnType<typeof setup>['wrapper'], text: string) {
|
||||
return wrapper.findAll('button').find((node) => node.text().includes(text))
|
||||
}
|
||||
|
||||
describe('BatchAddDrawer 录入', () => {
|
||||
it('打开后显示多行清单输入框', () => {
|
||||
const { wrapper } = setup()
|
||||
expect(wrapper.find('textarea[aria-label="批量任务清单"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('按行实时统计将添加的任务数', async () => {
|
||||
const { wrapper } = setup()
|
||||
await wrapper.find('textarea').setValue('第一条\n\n- 第二条\n第三条')
|
||||
|
||||
expect(buttonByText(wrapper, '个任务')?.text()).toContain('添加 3 个任务')
|
||||
})
|
||||
|
||||
it('清单为空时提交按钮不可用', () => {
|
||||
const { wrapper } = setup()
|
||||
const button = buttonByText(wrapper, '添加 0 个任务')
|
||||
|
||||
expect(button).toBeDefined()
|
||||
expect(button?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('只有空行时同样不可提交', async () => {
|
||||
const { wrapper } = setup()
|
||||
await wrapper.find('textarea').setValue(' \n\n')
|
||||
|
||||
expect(buttonByText(wrapper, '添加 0 个任务')?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BatchAddDrawer 提交', () => {
|
||||
it('按清单顺序创建任务,第一条在最上面', async () => {
|
||||
const { store, wrapper } = setup()
|
||||
await wrapper.find('textarea').setValue('第一条\n第二条\n第三条')
|
||||
|
||||
await buttonByText(wrapper, '添加 3 个任务')?.trigger('click')
|
||||
|
||||
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['第一条', '第二条', '第三条'])
|
||||
})
|
||||
|
||||
it('目标列、优先级、截止日期与标签整批共用', async () => {
|
||||
const { store, wrapper } = setup()
|
||||
await wrapper.find('textarea').setValue('第一条\n第二条')
|
||||
|
||||
await buttonByText(wrapper, '进行中')?.trigger('click')
|
||||
await buttonByText(wrapper, '高')?.trigger('click')
|
||||
await wrapper.find('input[type="date"]').setValue('2026-10-20')
|
||||
|
||||
const tagInput = wrapper.find('input[aria-label="添加标签"]')
|
||||
await tagInput.setValue('前端')
|
||||
await tagInput.trigger('keydown.enter')
|
||||
|
||||
await buttonByText(wrapper, '添加 2 个任务')?.trigger('click')
|
||||
|
||||
const created = store.byColumn.doing
|
||||
expect(created.map((task) => task.title)).toEqual(['第一条', '第二条'])
|
||||
expect(created[0]?.priority).toBe(3)
|
||||
expect(created[0]?.dueDate).toBe('2026-10-20')
|
||||
expect(created[0]?.tags).toEqual(['前端'])
|
||||
expect(created[1]?.tags).toEqual(['前端'])
|
||||
})
|
||||
|
||||
it('提交后请求关闭并提示数量', async () => {
|
||||
const { wrapper } = setup()
|
||||
await wrapper.find('textarea').setValue('A\nB')
|
||||
|
||||
await buttonByText(wrapper, '添加 2 个任务')?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
expect(useToastStore().toasts.at(-1)?.message).toBe('已添加 2 个任务')
|
||||
})
|
||||
|
||||
it('取消不创建任何任务', async () => {
|
||||
const { store, wrapper } = setup()
|
||||
await wrapper.find('textarea').setValue('A\nB')
|
||||
|
||||
await buttonByText(wrapper, '取消')?.trigger('click')
|
||||
|
||||
expect(store.tasks).toHaveLength(0)
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('再次打开时清空上一次的残留', async () => {
|
||||
const { wrapper } = setup()
|
||||
await wrapper.find('textarea').setValue('上一次的清单')
|
||||
|
||||
await wrapper.setProps({ open: false })
|
||||
await wrapper.setProps({ open: true })
|
||||
|
||||
expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('误提交时可以整批撤销', async () => {
|
||||
const { store, wrapper } = setup()
|
||||
await wrapper.find('textarea').setValue('A\nB')
|
||||
await buttonByText(wrapper, '添加 2 个任务')?.trigger('click')
|
||||
|
||||
const action = useToastStore().toasts.at(-1)?.action
|
||||
expect(action?.label).toBe('撤销')
|
||||
action?.run()
|
||||
|
||||
expect(store.tasks).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 批量添加任务
|
||||
*
|
||||
* 一行一条,整段粘贴即可录入一批 —— 从笔记、Markdown 清单、聊天记录里
|
||||
* 直接复制过来就能用,行首的「-」「1.」这类列表符号会被自动去掉。
|
||||
*
|
||||
* 目标列、优先级、标签、截止日期是这一批**共用**的属性:批量录入时逐个
|
||||
* 任务调字段不现实,而「先统一建出来、再单独改个别任务」正是看板的用法。
|
||||
*/
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority } from '@/types/task'
|
||||
import { parseTaskLines } from '@/utils/batch'
|
||||
|
||||
import DatePicker from './DatePicker.vue'
|
||||
import SideDrawer from './SideDrawer.vue'
|
||||
import TagInput from './TagInput.vue'
|
||||
|
||||
const props = defineProps<{ open: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const store = useTaskStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const PRIORITIES: readonly Priority[] = [1, 2, 3]
|
||||
|
||||
const text = ref('')
|
||||
const status = ref<ColumnId>('todo')
|
||||
const priority = ref<Priority>(2)
|
||||
const dueDate = ref<string | null>(null)
|
||||
const tags = ref<string[]>([])
|
||||
|
||||
const titles = computed(() => parseTaskLines(text.value))
|
||||
const canSubmit = computed(() => titles.value.length > 0)
|
||||
|
||||
/** 每次打开都回到干净状态,避免上一次的残留被误提交 */
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
text.value = ''
|
||||
status.value = 'todo'
|
||||
priority.value = 2
|
||||
dueDate.value = null
|
||||
tags.value = []
|
||||
},
|
||||
)
|
||||
|
||||
function submit(): void {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
const created = store.createTasks(
|
||||
titles.value.map((title) => ({
|
||||
title,
|
||||
status: status.value,
|
||||
priority: priority.value,
|
||||
dueDate: dueDate.value,
|
||||
tags: [...tags.value],
|
||||
})),
|
||||
)
|
||||
if (created.length === 0) return
|
||||
|
||||
const ids = created.map((task) => task.id)
|
||||
toast.show({
|
||||
message: `已添加 ${created.length} 个任务`,
|
||||
action: { label: '撤销', run: () => store.removeTasks(ids) },
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SideDrawer :open="open" title="批量添加" initial-focus="textarea" @close="emit('close')">
|
||||
<div class="flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">
|
||||
任务清单
|
||||
<span class="ml-1 text-xs font-normal text-slate-400 dark:text-zinc-500">
|
||||
每行一条,可直接粘贴
|
||||
</span>
|
||||
</span>
|
||||
<textarea
|
||||
v-model="text"
|
||||
rows="8"
|
||||
class="resize-y rounded-md border border-slate-300 px-2.5 py-1.5 text-sm leading-6 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"
|
||||
placeholder="写周报 评审 PR 回归测试"
|
||||
aria-label="批量任务清单"
|
||||
/>
|
||||
<span class="text-xs text-slate-400 dark:text-zinc-500">
|
||||
空行会被忽略;行首的「-」「*」「1.」等列表符号会自动去掉
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<fieldset class="flex flex-col gap-1.5">
|
||||
<legend class="mb-1.5 text-sm font-medium text-slate-600 dark:text-zinc-400">添加到</legend>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="column in COLUMNS"
|
||||
:key="column.id"
|
||||
type="button"
|
||||
class="rounded-md border px-3 py-1 text-sm transition"
|
||||
:class="
|
||||
status === column.id
|
||||
? 'border-sky-400 bg-sky-50 text-sky-700 dark:border-sky-600 dark:bg-sky-950 dark:text-sky-300'
|
||||
: 'border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-400 dark:hover:bg-zinc-800'
|
||||
"
|
||||
:aria-pressed="status === column.id"
|
||||
@click="status = column.id"
|
||||
>
|
||||
{{ column.title }}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="flex flex-col gap-1.5">
|
||||
<legend class="mb-1.5 text-sm font-medium text-slate-600 dark:text-zinc-400">优先级</legend>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="value in PRIORITIES"
|
||||
:key="value"
|
||||
type="button"
|
||||
class="rounded-md border px-3 py-1 text-sm transition"
|
||||
:class="
|
||||
priority === value
|
||||
? 'border-sky-400 bg-sky-50 text-sky-700 dark:border-sky-600 dark:bg-sky-950 dark:text-sky-300'
|
||||
: 'border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-400 dark:hover:bg-zinc-800'
|
||||
"
|
||||
:aria-pressed="priority === value"
|
||||
@click="priority = value"
|
||||
>
|
||||
{{ PRIORITY_LABELS[value] }}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">截止日期</span>
|
||||
<DatePicker v-model="dueDate" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">标签</span>
|
||||
<TagInput v-model="tags" :suggestions="store.allTags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-slate-500 dark:text-zinc-400">
|
||||
将添加
|
||||
<strong class="font-semibold text-slate-700 dark:text-zinc-200">
|
||||
{{ titles.length }}
|
||||
</strong>
|
||||
个任务
|
||||
</span>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
@click="emit('close')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-sky-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-sky-600 dark:hover:bg-sky-500"
|
||||
:disabled="!canSubmit"
|
||||
@click="submit"
|
||||
>
|
||||
添加 {{ titles.length }} 个任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SideDrawer>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import BatchSelectionBar from './BatchSelectionBar.vue'
|
||||
|
||||
function setup(count = 0, total = 0) {
|
||||
const wrapper = mount(BatchSelectionBar, { props: { count, total } })
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function buttonByText(wrapper: ReturnType<typeof setup>, text: string) {
|
||||
return wrapper.findAll('button').find((node) => node.text().includes(text))
|
||||
}
|
||||
|
||||
describe('BatchSelectionBar 展示', () => {
|
||||
it('显示已选数量', () => {
|
||||
expect(setup(2, 5).text()).toContain('已选 2 个任务')
|
||||
})
|
||||
|
||||
it('全选按钮带上可见任务总数', () => {
|
||||
expect(buttonByText(setup(0, 5), '全选')?.text()).toContain('全选 5 个')
|
||||
})
|
||||
|
||||
it('删除按钮带上已选数量', () => {
|
||||
expect(buttonByText(setup(3, 5), '删除')?.text()).toContain('删除 3 个')
|
||||
})
|
||||
|
||||
it('作为工具条暴露给读屏', () => {
|
||||
const bar = setup(1, 1)
|
||||
expect(bar.attributes('role')).toBe('toolbar')
|
||||
expect(bar.attributes('aria-label')).toBe('批量操作')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BatchSelectionBar 交互', () => {
|
||||
it('全选 emit select-all', async () => {
|
||||
const wrapper = setup(0, 3)
|
||||
await buttonByText(wrapper, '全选')?.trigger('click')
|
||||
expect(wrapper.emitted('select-all')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('清空选择 emit clear', async () => {
|
||||
const wrapper = setup(2, 3)
|
||||
await buttonByText(wrapper, '清空选择')?.trigger('click')
|
||||
expect(wrapper.emitted('clear')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('完成 emit done', async () => {
|
||||
const wrapper = setup(2, 3)
|
||||
await buttonByText(wrapper, '完成')?.trigger('click')
|
||||
expect(wrapper.emitted('done')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('删除 emit remove', async () => {
|
||||
const wrapper = setup(2, 3)
|
||||
await buttonByText(wrapper, '删除')?.trigger('click')
|
||||
expect(wrapper.emitted('remove')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BatchSelectionBar 禁用态', () => {
|
||||
it('未选中时不能删除', () => {
|
||||
expect(buttonByText(setup(0, 3), '删除')?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('未选中时不能清空', () => {
|
||||
expect(buttonByText(setup(0, 3), '清空选择')?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('已全选时全选按钮不可再点', () => {
|
||||
expect(buttonByText(setup(3, 3), '全选')?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('没有可见任务时全选不可用', () => {
|
||||
expect(buttonByText(setup(0, 0), '全选')?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 多选操作条
|
||||
*
|
||||
* 只在多选模式出现,负责「选了多少 / 全选 / 清空 / 删除」。
|
||||
* 删除不做二次确认:与单条删除一致,靠 Toast 的「撤销」兜底,
|
||||
* 误删的代价远低于每次批量操作都弹窗确认。
|
||||
*/
|
||||
|
||||
defineProps<{
|
||||
/** 已选数量 */
|
||||
count: number
|
||||
/** 当前可见(筛选后)的任务总数 */
|
||||
total: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select-all': []
|
||||
clear: []
|
||||
remove: []
|
||||
done: []
|
||||
}>()
|
||||
</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"
|
||||
role="toolbar"
|
||||
aria-label="批量操作"
|
||||
>
|
||||
<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')"
|
||||
>
|
||||
完成
|
||||
</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>
|
||||
</template>
|
||||
@@ -214,3 +214,47 @@ describe('BoardColumn 拖拽事件接线', () => {
|
||||
expect(options.filter).toBe('button')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BoardColumn 多选模式', () => {
|
||||
function mountSelectable(tasks: Task[], selectedIds: Set<string>) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const store = useTaskStore()
|
||||
store.init({ repository: createMemoryTaskRepository(tasks), crossTab: false })
|
||||
|
||||
const wrapper = mount(BoardColumn, {
|
||||
props: { column: TODO_COLUMN, tasks: store.byColumn.todo, selectable: true, selectedIds },
|
||||
global: { plugins: [pinia] },
|
||||
})
|
||||
return { store, wrapper }
|
||||
}
|
||||
|
||||
it('卡片变为可勾选,selectedIds 决定勾选态', () => {
|
||||
const { wrapper } = mountSelectable(
|
||||
[makeTask({ id: 'task-1' }), makeTask({ id: 'task-2' })],
|
||||
new Set(['task-2']),
|
||||
)
|
||||
|
||||
expect(wrapper.find('[data-task-id="task-1"]').attributes('aria-checked')).toBe('false')
|
||||
expect(wrapper.find('[data-task-id="task-2"]').attributes('aria-checked')).toBe('true')
|
||||
})
|
||||
|
||||
it('点击卡片 emit select 并带上 id', async () => {
|
||||
const { wrapper } = mountSelectable([makeTask({ id: 'task-1' })], new Set())
|
||||
|
||||
await wrapper.find('article').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('select')).toEqual([['task-1']])
|
||||
expect(wrapper.emitted('open')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('多选时禁止拖拽(同列与跨列都不允许)', () => {
|
||||
const { wrapper } = mountSelectable([makeTask()], new Set())
|
||||
const same = wrapper.find('[data-column-id="todo"]').element
|
||||
const other = document.createElement('div')
|
||||
|
||||
const onMove = lastDragOptions().onMove
|
||||
expect(onMove?.({ from: same, to: same }, new Event('move'))).toBe(false)
|
||||
expect(onMove?.({ from: same, to: other }, new Event('move'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,13 +8,21 @@ import { resolveDrop, shouldAllowMove } from '@/utils/drag'
|
||||
import QuickAddTask from './QuickAddTask.vue'
|
||||
import TaskCard from './TaskCard.vue'
|
||||
|
||||
const props = defineProps<{ column: ColumnDef; tasks: Task[]; sortDisabled?: boolean }>()
|
||||
const props = defineProps<{
|
||||
column: ColumnDef
|
||||
tasks: Task[]
|
||||
sortDisabled?: boolean
|
||||
/** 多选模式:卡片改为可勾选,并且整体禁用拖拽 */
|
||||
selectable?: boolean
|
||||
selectedIds?: ReadonlySet<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [id: string]
|
||||
open: [id: string]
|
||||
move: [payload: { id: string; toColumn: ColumnId; toIndex: number }]
|
||||
keymove: [payload: { id: string; direction: MoveDirection }]
|
||||
select: [id: string]
|
||||
}>()
|
||||
|
||||
/** 每列一个色点:三列在余光里也能区分开 */
|
||||
@@ -46,7 +54,9 @@ useDraggable(listRef, {
|
||||
ghostClass: 'opacity-40',
|
||||
// 在完成勾选按钮上按下时不启动拖拽
|
||||
filter: 'button',
|
||||
onMove: (event) => shouldAllowMove(event.from, event.to, props.sortDisabled === true),
|
||||
onMove: (event) =>
|
||||
props.selectable !== true &&
|
||||
shouldAllowMove(event.from, event.to, props.sortDisabled === true),
|
||||
onEnd: (event) => {
|
||||
const drop = resolveDrop(event)
|
||||
if (!drop) return
|
||||
@@ -81,9 +91,12 @@ useDraggable(listRef, {
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:selectable="selectable === true"
|
||||
:selected="selectedIds?.has(task.id) ?? false"
|
||||
@toggle="emit('toggle', $event)"
|
||||
@open="emit('open', $event)"
|
||||
@keymove="emit('keymove', $event)"
|
||||
@select="emit('select', $event)"
|
||||
/>
|
||||
|
||||
<p
|
||||
|
||||
@@ -188,3 +188,46 @@ describe('BoardToolbar 归档', () => {
|
||||
expect(filters.includeArchived).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BoardToolbar 批量添加', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('点击「批量添加」emit batch-add', async () => {
|
||||
const { wrapper } = setup()
|
||||
|
||||
await buttonByText(wrapper, '批量添加')?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('batch-add')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BoardToolbar 多选', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
/** 文案精确匹配:「多选」是「退出多选」的子串 */
|
||||
function exactButton(wrapper: ReturnType<typeof setup>['wrapper'], text: string) {
|
||||
return wrapper.findAll('button').find((node) => node.text() === text)
|
||||
}
|
||||
|
||||
it('点击「多选」emit toggle-select', async () => {
|
||||
const { wrapper } = setup()
|
||||
|
||||
await exactButton(wrapper, '多选')?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('toggle-select')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('selecting 为 true 时按钮变成「退出多选」并标记为按下', async () => {
|
||||
const { wrapper } = setup()
|
||||
await wrapper.setProps({ selecting: true })
|
||||
|
||||
const button = exactButton(wrapper, '退出多选')
|
||||
expect(button).toBeDefined()
|
||||
expect(button?.attributes('aria-pressed')).toBe('true')
|
||||
expect(exactButton(wrapper, '多选')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,9 +10,9 @@ import { isDueToday, isOverdue } from '@/utils/date'
|
||||
|
||||
import TagFilter from './TagFilter.vue'
|
||||
|
||||
defineProps<{ hiddenNote?: string }>()
|
||||
defineProps<{ hiddenNote?: string; selecting?: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ archive: [] }>()
|
||||
const emit = defineEmits<{ archive: []; 'batch-add': []; 'toggle-select': [] }>()
|
||||
|
||||
const store = useTaskStore()
|
||||
const filters = useFilterStore()
|
||||
@@ -133,6 +133,28 @@ defineExpose({ focusSearch })
|
||||
<span v-if="hiddenNote" class="text-slate-400 dark:text-zinc-500">{{ hiddenNote }}</span>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 items-center rounded-md border border-slate-200 bg-white px-2.5 text-xs text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
|
||||
@click="emit('batch-add')"
|
||||
>
|
||||
批量添加
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 items-center rounded-md border px-2.5 text-xs transition"
|
||||
:class="
|
||||
selecting
|
||||
? 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-300'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
|
||||
"
|
||||
:aria-pressed="selecting === true"
|
||||
@click="emit('toggle-select')"
|
||||
>
|
||||
{{ selecting ? '退出多选' : '多选' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="archivableCount > 0"
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { ColumnId } from '@/types/task'
|
||||
|
||||
import QuickAddTask from './QuickAddTask.vue'
|
||||
@@ -98,3 +99,104 @@ describe('QuickAddTask', () => {
|
||||
expect(wrapper.find('input').attributes('aria-label')).toBe('添加任务')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 粘贴多行文本 = 批量添加。
|
||||
* 这里手工构造 paste 事件:jsdom 没有可用的 Clipboard API,
|
||||
* 而组件只依赖 `clipboardData.getData('text')` 这一小块接口。
|
||||
*/
|
||||
function paste(wrapper: ReturnType<typeof setup>['wrapper'], text: string): boolean {
|
||||
const event = new Event('paste', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'clipboardData', { value: { getData: () => text } })
|
||||
wrapper.find('input').element.dispatchEvent(event)
|
||||
return event.defaultPrevented
|
||||
}
|
||||
|
||||
describe('QuickAddTask 批量粘贴', () => {
|
||||
it('粘贴多行文本创建多条任务,并保持粘贴顺序', () => {
|
||||
const { store, wrapper } = setup()
|
||||
|
||||
expect(paste(wrapper, '写周报\n评审 PR\n回归测试')).toBe(true)
|
||||
|
||||
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['写周报', '评审 PR', '回归测试'])
|
||||
})
|
||||
|
||||
it('粘贴的多条任务位于已有任务之前', () => {
|
||||
const { store, wrapper } = setup()
|
||||
store.createTask({ title: '已有任务' })
|
||||
|
||||
paste(wrapper, '第一条\n第二条')
|
||||
|
||||
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['第一条', '第二条', '已有任务'])
|
||||
})
|
||||
|
||||
it('粘贴到指定列(进行中)', () => {
|
||||
const { store, wrapper } = setup('doing')
|
||||
|
||||
paste(wrapper, 'A\nB')
|
||||
|
||||
expect(store.byColumn.doing.map((task) => task.title)).toEqual(['A', 'B'])
|
||||
expect(store.byColumn.todo).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('单行粘贴不拦截,交回浏览器默认行为', () => {
|
||||
const { store, wrapper } = setup()
|
||||
|
||||
expect(paste(wrapper, '单个任务')).toBe(false)
|
||||
expect(store.tasks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('只有空行时不创建任务,也不拦截粘贴', () => {
|
||||
const { store, wrapper } = setup()
|
||||
|
||||
expect(paste(wrapper, ' \n\n')).toBe(false)
|
||||
expect(store.tasks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('粘贴时忽略空行与列表符号', () => {
|
||||
const { store, wrapper } = setup()
|
||||
|
||||
paste(wrapper, '- 写周报\n\n- 评审 PR\n')
|
||||
|
||||
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['写周报', '评审 PR'])
|
||||
})
|
||||
|
||||
it('粘贴前已输入的草稿并入第一条,不丢失也不残留', async () => {
|
||||
const { store, wrapper } = setup()
|
||||
const input = wrapper.find('input')
|
||||
await input.setValue('手写的一条')
|
||||
|
||||
paste(wrapper, '粘贴一\n粘贴二')
|
||||
|
||||
expect(store.byColumn.todo.map((task) => task.title)).toEqual([
|
||||
'手写的一条',
|
||||
'粘贴一',
|
||||
'粘贴二',
|
||||
])
|
||||
|
||||
// v-model 的写回要等一轮视图更新
|
||||
await wrapper.vm.$nextTick()
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('批量粘贴后弹出提示并保持聚焦,便于继续录入', () => {
|
||||
const { wrapper } = setup()
|
||||
|
||||
paste(wrapper, 'A\nB')
|
||||
|
||||
const toast = useToastStore()
|
||||
expect(toast.toasts.at(-1)?.message).toBe('已批量添加 2 个任务')
|
||||
expect(document.activeElement).toBe(wrapper.find('input').element)
|
||||
})
|
||||
|
||||
it('误粘贴时可以整批撤销', () => {
|
||||
const { store, wrapper } = setup()
|
||||
paste(wrapper, 'A\nB')
|
||||
|
||||
const action = useToastStore().toasts.at(-1)?.action
|
||||
expect(action?.label).toBe('撤销')
|
||||
action?.run()
|
||||
|
||||
expect(store.tasks).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { ColumnId } from '@/types/task'
|
||||
import { parseTaskLines } from '@/utils/batch'
|
||||
|
||||
const props = defineProps<{ status: ColumnId }>()
|
||||
|
||||
const store = useTaskStore()
|
||||
const toast = useToastStore()
|
||||
const title = ref('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -23,6 +26,32 @@ function submit(): void {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量录入的入口之一:粘贴多行文本时按行拆成多条任务。
|
||||
*
|
||||
* 单行粘贴不拦截,保持浏览器默认行为 —— 常规输入/替换不该被打断。
|
||||
* 输入框里已有的草稿并入第一条,既不丢内容,也不会留下会被重复提交的残留。
|
||||
*/
|
||||
function onPaste(event: ClipboardEvent): void {
|
||||
const pasted = parseTaskLines(event.clipboardData?.getData('text') ?? '')
|
||||
if (pasted.length < 2) return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const draft = title.value.trim()
|
||||
const titles = draft ? [draft, ...pasted] : pasted
|
||||
const created = store.createTasks(titles.map((item) => ({ title: item, status: props.status })))
|
||||
if (created.length === 0) return
|
||||
|
||||
title.value = ''
|
||||
const ids = created.map((task) => task.id)
|
||||
toast.show({
|
||||
message: `已批量添加 ${created.length} 个任务`,
|
||||
action: { label: '撤销', run: () => store.removeTasks(ids) },
|
||||
})
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
/** 供快捷键(N)调用 */
|
||||
function focusInput(): void {
|
||||
inputRef.value?.focus()
|
||||
@@ -42,6 +71,7 @@ defineExpose({ focusInput })
|
||||
aria-label="添加任务"
|
||||
@compositionstart="composing = true"
|
||||
@compositionend="composing = false"
|
||||
@paste="onPaste"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
@@ -127,6 +127,19 @@ describe('SideDrawer 焦点管理', () => {
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe('关闭')
|
||||
})
|
||||
|
||||
it('initialFocus 指定的元素优先获得焦点', async () => {
|
||||
const wrapper = mount(SideDrawer, {
|
||||
props: { open: true, initialFocus: 'textarea' },
|
||||
slots: { default: '<button type="button">按钮</button><textarea aria-label="清单" />' },
|
||||
global: { stubs: { teleport: true } },
|
||||
attachTo: document.body,
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
|
||||
await flush()
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe('清单')
|
||||
})
|
||||
|
||||
it('关闭后焦点回到打开前的元素', async () => {
|
||||
const trigger = document.createElement('button')
|
||||
trigger.textContent = '触发按钮'
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{ open: boolean; title?: string }>()
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
title?: string
|
||||
/**
|
||||
* 打开时优先聚焦的元素(CSS 选择器)。
|
||||
* 默认聚焦面板内第一个可聚焦元素,也就是常驻在头部的「关闭」按钮;
|
||||
* 需要「一打开就能输入」的面板(如批量添加)可用它把焦点放到正文控件上。
|
||||
*/
|
||||
initialFocus?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
@@ -67,7 +76,10 @@ watch(
|
||||
previouslyFocused = document.activeElement as HTMLElement | null
|
||||
document.addEventListener('keydown', onDocumentKeydown, true)
|
||||
void nextTick(() => {
|
||||
const target = focusableElements()[0] ?? panelRef.value
|
||||
const preferred = props.initialFocus
|
||||
? (panelRef.value?.querySelector<HTMLElement>(props.initialFocus) ?? null)
|
||||
: null
|
||||
const target = preferred ?? focusableElements()[0] ?? panelRef.value
|
||||
target?.focus()
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -202,3 +202,58 @@ describe('TaskCard 键盘操作', () => {
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskCard 多选态', () => {
|
||||
function mountSelectable(selected = false) {
|
||||
return mount(TaskCard, {
|
||||
props: { task: makeTask({ id: 'task-9' }), selectable: true, selected },
|
||||
})
|
||||
}
|
||||
|
||||
it('平时不带 checkbox 语义,多选时才加上', () => {
|
||||
expect(mountCard().find('article').attributes('role')).toBeUndefined()
|
||||
expect(mountSelectable().find('article').attributes('role')).toBe('checkbox')
|
||||
})
|
||||
|
||||
it('选中态通过 aria-checked 暴露给读屏', () => {
|
||||
expect(mountSelectable(false).find('article').attributes('aria-checked')).toBe('false')
|
||||
expect(mountSelectable(true).find('article').attributes('aria-checked')).toBe('true')
|
||||
})
|
||||
|
||||
it('选中态有可辨识的高亮样式', () => {
|
||||
const classes = mountSelectable(true).find('article').classes().join(' ')
|
||||
expect(classes).toContain('bg-sky-50')
|
||||
})
|
||||
|
||||
it('多选时点击是「选中」而不是「打开详情」', async () => {
|
||||
const wrapper = mountSelectable()
|
||||
|
||||
await wrapper.find('article').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('select')).toEqual([['task-9']])
|
||||
expect(wrapper.emitted('open')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('多选时 Enter 与空格切换选中', async () => {
|
||||
const wrapper = mountSelectable()
|
||||
|
||||
await wrapper.find('article').trigger('keydown', { key: 'Enter' })
|
||||
await wrapper.find('article').trigger('keydown', { key: ' ' })
|
||||
|
||||
expect(wrapper.emitted('select')).toEqual([['task-9'], ['task-9']])
|
||||
expect(wrapper.emitted('open')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('多选时方向键不再移动卡片', async () => {
|
||||
const wrapper = mountSelectable()
|
||||
|
||||
await wrapper.find('article').trigger('keydown', { key: 'ArrowRight' })
|
||||
|
||||
expect(wrapper.emitted('keymove')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('多选时不显示「完成」勾选按钮,避免与选中勾选混淆', () => {
|
||||
expect(mountCard().find('article button').exists()).toBe(true)
|
||||
expect(mountSelectable().find('article button').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,12 +6,18 @@ import { formatDueDate, isOverdue } from '@/utils/date'
|
||||
|
||||
const MAX_VISIBLE_TAGS = 3
|
||||
|
||||
const props = defineProps<{ task: Task }>()
|
||||
const props = defineProps<{
|
||||
task: Task
|
||||
/** 多选模式:卡片改为可勾选,点击是「选中」而不是「打开详情」 */
|
||||
selectable?: boolean
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [id: string]
|
||||
open: [id: string]
|
||||
keymove: [payload: { id: string; direction: MoveDirection }]
|
||||
select: [id: string]
|
||||
}>()
|
||||
|
||||
/** 方向键映射;左右换列,上下调顺序 */
|
||||
@@ -22,6 +28,15 @@ const MOVE_KEYS: Readonly<Record<string, MoveDirection>> = {
|
||||
ArrowDown: 'down',
|
||||
}
|
||||
|
||||
/** 多选态下点击/回车都是「切换选中」;平时是「打开详情」 */
|
||||
function activate(): void {
|
||||
if (props.selectable) {
|
||||
emit('select', props.task.id)
|
||||
return
|
||||
}
|
||||
emit('open', props.task.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片获得焦点后的键盘操作。
|
||||
* 注意:方向键必须 preventDefault,否则会连带滚动整个看板。
|
||||
@@ -29,10 +44,13 @@ const MOVE_KEYS: Readonly<Record<string, MoveDirection>> = {
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
emit('open', props.task.id)
|
||||
activate()
|
||||
return
|
||||
}
|
||||
|
||||
// 多选态不做方向键移动:此时拖拽与排序都已停用,避免误操作打乱顺序
|
||||
if (props.selectable) return
|
||||
|
||||
const direction = MOVE_KEYS[event.key]
|
||||
if (!direction) return
|
||||
|
||||
@@ -45,6 +63,19 @@ const overdue = computed(() => isOverdue(props.task.dueDate, props.task.status))
|
||||
const visibleTags = computed(() => props.task.tags.slice(0, MAX_VISIBLE_TAGS))
|
||||
const hiddenTagCount = computed(() => Math.max(0, props.task.tags.length - MAX_VISIBLE_TAGS))
|
||||
|
||||
/**
|
||||
* 卡片外观。选中态与「已完成」都要覆盖边框/背景,
|
||||
* 因此这些颜色放在条件分支里,而不是和基础类各写一份靠优先级取胜。
|
||||
*/
|
||||
const cardClass = computed(() => {
|
||||
if (props.selected) {
|
||||
return 'border-sky-400 bg-sky-50 dark:border-sky-600 dark:bg-sky-950/40'
|
||||
}
|
||||
return isDone.value
|
||||
? 'border-slate-200 bg-white opacity-60 dark:border-zinc-800 dark:bg-zinc-900'
|
||||
: 'border-slate-200 bg-white hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700'
|
||||
})
|
||||
|
||||
/**
|
||||
* 优先级用左侧色条表达,而不是一枚彩色胶囊。
|
||||
* 色条几乎不占空间,扫视一列卡片时却比文字更快分辨出轻重。
|
||||
@@ -74,22 +105,47 @@ const priorityTextClass = computed(() => {
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white shadow-card transition duration-150 dark:border-zinc-800 dark:bg-zinc-900"
|
||||
:class="
|
||||
isDone
|
||||
? 'opacity-60'
|
||||
: 'hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover dark:hover:border-zinc-700'
|
||||
"
|
||||
class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border shadow-card transition duration-150"
|
||||
:class="cardClass"
|
||||
:data-task-id="task.id"
|
||||
:role="selectable ? 'checkbox' : undefined"
|
||||
:aria-checked="selectable ? selected === true : undefined"
|
||||
:aria-label="selectable ? `选择任务「${task.title}」` : undefined"
|
||||
tabindex="0"
|
||||
@click="emit('open', task.id)"
|
||||
@click="activate"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<span class="absolute inset-y-0 left-0 w-[3px]" :class="priorityBarClass" aria-hidden="true" />
|
||||
|
||||
<div class="py-2 pr-2.5 pl-3.5">
|
||||
<div class="flex items-start gap-2">
|
||||
<!--
|
||||
多选态下用一枚纯装饰的勾选框:真正的可交互元素是整张卡片,
|
||||
这样读屏与键盘只有「一个」可聚焦控件,不会出现嵌套按钮。
|
||||
-->
|
||||
<span
|
||||
v-if="selectable"
|
||||
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border transition"
|
||||
:class="
|
||||
selected
|
||||
? 'border-sky-500 bg-sky-500 text-white'
|
||||
: 'border-slate-300 bg-white dark:border-zinc-600 dark:bg-zinc-800'
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg v-if="selected" viewBox="0 0 12 12" class="size-2.5" fill="none">
|
||||
<path
|
||||
d="m2.5 6.2 2.2 2.2 4.8-4.8"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border transition"
|
||||
:class="
|
||||
|
||||
Reference in New Issue
Block a user