- 已完成列的展示顺序改由 completedAt 倒序决定(新增 sortCompletedDesc 纯函数), 最近完成的永远在最上面;缺 completedAt 的旧数据排在有完成时间的任务之后, 彼此退回 sortOrder 倒序 - 该列停用列内排序:拖拽与 ↑↓ 只能跨列,列头标注「按完成时间倒序」说明口径 - 数据层不动:不迁移 sortOrder,导入导出、快照结构与 schemaVersion 均不变, 倒序只在 BoardView.visibleColumns 单点发生 - 新增 ADR-009,并在 ADR-008 的否决项里标注被部分推翻 - 单测 705 → 738(sortOrder +5、BoardView +5、BoardColumn +1),README 同步
369 lines
13 KiB
Vue
369 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import { storeToRefs } from 'pinia'
|
||
import { computed, nextTick, ref, watch } from 'vue'
|
||
|
||
import BoardColumn from '@/components/BoardColumn.vue'
|
||
import BoardToolbar from '@/components/BoardToolbar.vue'
|
||
import BatchAddDrawer from '@/components/BatchAddDrawer.vue'
|
||
import BatchSelectionBar from '@/components/BatchSelectionBar.vue'
|
||
import TaskDetailDrawer from '@/components/TaskDetailDrawer.vue'
|
||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||
import { useFilterStore } from '@/stores/filters'
|
||
import { useTaskStore } from '@/stores/tasks'
|
||
import { useToastStore } from '@/stores/toast'
|
||
import { useUiStore } from '@/stores/ui'
|
||
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type MoveDirection, type Priority } from '@/types/task'
|
||
import { findArchivableTasks } from '@/utils/archive'
|
||
import { filterTasks } from '@/utils/filter'
|
||
import { groupByColumn, sortCompletedDesc } from '@/utils/sortOrder'
|
||
|
||
const store = useTaskStore()
|
||
const filters = useFilterStore()
|
||
const toast = useToastStore()
|
||
const ui = useUiStore()
|
||
const { filter, includeArchived } = storeToRefs(filters)
|
||
|
||
/** 详情抽屉当前编辑的任务 id;任务被删除后自动变为 null,抽屉随之关闭 */
|
||
const editingId = ref<string | null>(null)
|
||
const editingTask = computed(
|
||
() => store.activeTasks.find((task) => task.id === editingId.value) ?? null,
|
||
)
|
||
|
||
/** 批量添加面板 */
|
||
const batchOpen = ref(false)
|
||
|
||
/** 多选模式与已选任务;多选时卡片改为勾选、拖拽停用 */
|
||
const selecting = ref(false)
|
||
const selectedIds = ref<Set<string>>(new Set())
|
||
|
||
const toolbarRef = ref<InstanceType<typeof BoardToolbar> | null>(null)
|
||
const columnRefs = new Map<number, InstanceType<typeof BoardColumn>>()
|
||
|
||
function setColumnRef(element: unknown, index: number): void {
|
||
if (element) columnRefs.set(index, element as InstanceType<typeof BoardColumn>)
|
||
else columnRefs.delete(index)
|
||
}
|
||
|
||
/** 实际渲染的看板:先按条件筛选,再按列分组,列计数随之反映结果 */
|
||
const visibleColumns = computed(() => {
|
||
const grouped = groupByColumn(filterTasks(store.activeTasks, filter.value))
|
||
// 已完成列按完成时间倒序展示,与 sortOrder 无关(见 sortCompletedDesc)
|
||
return { ...grouped, done: sortCompletedDesc(grouped.done) }
|
||
})
|
||
|
||
/**
|
||
* 每列被隐藏的数量(被筛选掉 + 已归档)。
|
||
* 大于 0 说明该列渲染的是子集,此时必须禁用列内排序,
|
||
* 否则 Sortable 给出的子集下标会把全量顺序写坏。
|
||
*/
|
||
const hiddenCounts = computed(() => {
|
||
const result = {} as Record<ColumnId, number>
|
||
for (const column of COLUMNS) {
|
||
result[column.id] = store.byColumn[column.id].length - visibleColumns.value[column.id].length
|
||
}
|
||
return result
|
||
})
|
||
|
||
const hiddenTotal = computed(() =>
|
||
COLUMNS.reduce((total, column) => total + hiddenCounts.value[column.id], 0),
|
||
)
|
||
|
||
/**
|
||
* 列内排序是否停用:
|
||
* - 该列渲染的是子集(被筛选 / 归档隐藏)时,Sortable 给的是子集下标,写回会坏全量顺序
|
||
* - 已完成列的顺序由完成时间决定(倒序),手动排序没有意义,一律停用
|
||
*/
|
||
function isSortDisabled(columnId: ColumnId): boolean {
|
||
return columnId === 'done' || hiddenCounts.value[columnId] > 0
|
||
}
|
||
|
||
/**
|
||
* 被「隐藏归档」挡掉、且本来能通过当前筛选的任务数。
|
||
*
|
||
* 单独算一遍是为了给提示分层:用户看到「已隐藏 3 个」时,
|
||
* 应该知道其中多少是搜索/筛选造成的、多少只是归档没显示。
|
||
*/
|
||
const archivedHiddenTotal = computed(() => {
|
||
if (includeArchived.value) return 0
|
||
const archived = store.activeTasks.filter((task) => task.archivedAt !== null)
|
||
// filter.includeArchived 为 false 时会把归档任务直接排除,这里显式放开
|
||
return filterTasks(archived, { ...filter.value, includeArchived: true }).length
|
||
})
|
||
|
||
const filteredHiddenTotal = computed(() =>
|
||
Math.max(0, hiddenTotal.value - archivedHiddenTotal.value),
|
||
)
|
||
|
||
/**
|
||
* 列内排序在「渲染的是子集」时会被禁用(见 drag.ts),
|
||
* 这里把原因写出来,避免用户拖不动却不知道为什么。
|
||
*/
|
||
const hiddenNote = computed(() => {
|
||
if (hiddenTotal.value === 0) return undefined
|
||
|
||
const reasons: string[] = []
|
||
if (filteredHiddenTotal.value > 0) reasons.push(`筛选 ${filteredHiddenTotal.value}`)
|
||
if (archivedHiddenTotal.value > 0) reasons.push(`归档 ${archivedHiddenTotal.value}`)
|
||
const detail = reasons.length > 0 ? `(${reasons.join(' + ')},列内排序已暂停)` : ''
|
||
|
||
return `已隐藏 ${hiddenTotal.value} 个${detail}`
|
||
})
|
||
|
||
const archivableTasks = computed(() => findArchivableTasks(store.activeTasks))
|
||
|
||
function archiveCompleted(): void {
|
||
const targets = archivableTasks.value
|
||
if (targets.length === 0) return
|
||
|
||
const ids = targets.map((task) => task.id)
|
||
const count = store.archiveTasks(ids)
|
||
if (count === 0) return
|
||
|
||
// 归档后这些卡片默认不可见,留在选择里会让「已选」与眼前看到的对不上
|
||
if (selecting.value) clearSelection()
|
||
|
||
toast.show({
|
||
message: `已归档 ${count} 个已完成任务`,
|
||
action: { label: '撤销', run: () => store.unarchiveTasks(ids) },
|
||
})
|
||
}
|
||
|
||
/** 有面板打开时不响应全局快捷键,避免误触 */
|
||
function whenNoPanel(action: () => void): () => void {
|
||
return () => {
|
||
if (editingId.value !== null || batchOpen.value || selecting.value || ui.anyPanelOpen) return
|
||
action()
|
||
}
|
||
}
|
||
|
||
useKeyboardShortcuts({
|
||
n: whenNoPanel(() => columnRefs.get(0)?.focusQuickAdd()),
|
||
'/': whenNoPanel(() => toolbarRef.value?.focusSearch()),
|
||
})
|
||
|
||
function toggleTask(id: string): void {
|
||
const task = store.activeTasks.find((item) => item.id === id)
|
||
if (!task) return
|
||
store.updateTask(id, { status: task.status === 'done' ? 'todo' : 'done' })
|
||
}
|
||
|
||
function openTask(id: string): void {
|
||
editingId.value = id
|
||
}
|
||
|
||
function closeDetail(): void {
|
||
editingId.value = null
|
||
}
|
||
|
||
function handleMove(payload: { id: string; toColumn: ColumnId; toIndex: number }): void {
|
||
store.moveTask(payload.id, payload.toColumn, payload.toIndex)
|
||
}
|
||
|
||
/**
|
||
* 键盘移动(方向键)。
|
||
*
|
||
* 左右换列时追加到目标列末尾 —— 用全量长度而不是可见下标,
|
||
* 因此即使当前处于筛选态也不会把顺序写坏。
|
||
* 上下调顺序则要求该列渲染的是全量,否则下标没有意义。
|
||
*/
|
||
function handleKeyMove(payload: { id: string; direction: MoveDirection }): void {
|
||
const task = store.activeTasks.find((item) => item.id === payload.id)
|
||
if (!task) return
|
||
|
||
if (payload.direction === 'left' || payload.direction === 'right') {
|
||
const currentIndex = COLUMNS.findIndex((column) => column.id === task.status)
|
||
const target = COLUMNS[currentIndex + (payload.direction === 'left' ? -1 : 1)]
|
||
if (!target) return
|
||
store.moveTask(task.id, target.id, store.byColumn[target.id].length)
|
||
} else {
|
||
// 已完成列按完成时间倒序,列内调序没有意义,也不写 sortOrder
|
||
if (task.status === 'done') return
|
||
if (hiddenCounts.value[task.status] > 0) return
|
||
const column = store.byColumn[task.status]
|
||
const currentIndex = column.findIndex((item) => item.id === task.id)
|
||
const nextIndex = currentIndex + (payload.direction === 'up' ? -1 : 1)
|
||
if (nextIndex < 0 || nextIndex >= column.length) return
|
||
store.moveTask(task.id, task.status, nextIndex)
|
||
}
|
||
|
||
// 卡片跨列后会被重建,焦点必须手动找回,否则连续按键会失效
|
||
void nextTick(() => {
|
||
document.querySelector<HTMLElement>(`[data-task-id="${payload.id}"]`)?.focus()
|
||
})
|
||
}
|
||
|
||
/* ---------- 多选与批量删除 ---------- */
|
||
|
||
/** 当前可见(筛选后)的任务 id,全选以它为准:所见即所选 */
|
||
const visibleTaskIds = computed(() =>
|
||
COLUMNS.flatMap((column) => visibleColumns.value[column.id].map((task) => task.id)),
|
||
)
|
||
|
||
/** 真正存在的已选任务:跨标签页同步等情况下可能出现已不存在的 id */
|
||
const selectedTasks = computed(() =>
|
||
store.activeTasks.filter((task) => selectedIds.value.has(task.id)),
|
||
)
|
||
const selectedCount = computed(() => selectedTasks.value.length)
|
||
|
||
function toggleSelectMode(): void {
|
||
if (selecting.value) exitSelection()
|
||
else selecting.value = true
|
||
}
|
||
|
||
function exitSelection(): void {
|
||
selecting.value = false
|
||
selectedIds.value = new Set()
|
||
}
|
||
|
||
function toggleSelect(id: string): void {
|
||
const next = new Set(selectedIds.value)
|
||
if (next.has(id)) next.delete(id)
|
||
else next.add(id)
|
||
selectedIds.value = next
|
||
}
|
||
|
||
function selectAllVisible(): void {
|
||
selectedIds.value = new Set(visibleTaskIds.value)
|
||
}
|
||
|
||
function clearSelection(): void {
|
||
selectedIds.value = new Set()
|
||
}
|
||
|
||
/**
|
||
* 删除选中的任务。不做二次确认 —— 与单条删除同一套约定:
|
||
* 立即删除 + Toast 撤销,误删可以整批还原(连原顺序一起)。
|
||
*/
|
||
function removeSelected(): void {
|
||
const removed = store.removeTasks(selectedTasks.value.map((task) => task.id))
|
||
if (removed.length === 0) return
|
||
|
||
exitSelection()
|
||
toast.show({
|
||
message: `已删除 ${removed.length} 个任务`,
|
||
action: { label: '撤销', run: () => store.restoreTasks(removed) },
|
||
})
|
||
}
|
||
|
||
/* ---------- 批量编辑 ---------- */
|
||
|
||
/** 一次批量操作只写一次仓库;返回实际更新数量便于提示 */
|
||
function selectedTaskIds(): string[] {
|
||
return selectedTasks.value.map((task) => task.id)
|
||
}
|
||
|
||
function batchMove(status: ColumnId): void {
|
||
const count = store.updateTasks(selectedTaskIds(), { status })
|
||
if (count === 0) return
|
||
const title = COLUMNS.find((column) => column.id === status)?.title ?? status
|
||
toast.show({ message: `已把 ${count} 个任务移动到「${title}」` })
|
||
}
|
||
|
||
function batchPriority(priority: Priority): void {
|
||
const count = store.updateTasks(selectedTaskIds(), { priority })
|
||
if (count === 0) return
|
||
toast.show({ message: `已将 ${count} 个任务设为「${PRIORITY_LABELS[priority]}」优先级` })
|
||
}
|
||
|
||
function batchAddTag(tag: string): void {
|
||
const count = store.updateTasks(selectedTaskIds(), { addTags: [tag] })
|
||
if (count === 0) return
|
||
toast.show({ message: `已为 ${count} 个任务添加标签「${tag}」` })
|
||
}
|
||
|
||
function batchRemoveTag(tag: string): void {
|
||
const count = store.updateTasks(selectedTaskIds(), { removeTags: [tag] })
|
||
if (count === 0) return
|
||
toast.show({ message: `已从 ${count} 个任务移除标签「${tag}」` })
|
||
}
|
||
|
||
function batchDue(date: string | null): void {
|
||
const count = store.updateTasks(selectedTaskIds(), { dueDate: date })
|
||
if (count === 0) return
|
||
toast.show({
|
||
message: date
|
||
? `已将 ${count} 个任务的截止日期设为 ${date}`
|
||
: `已清除 ${count} 个任务的截止日期`,
|
||
})
|
||
}
|
||
|
||
/** 批量归档后卡片会从看板消失,因此退出多选并给一次撤销入口 */
|
||
function batchArchive(): void {
|
||
const ids = selectedTaskIds()
|
||
if (ids.length === 0) return
|
||
|
||
const count = store.archiveTasks(ids)
|
||
if (count === 0) return
|
||
|
||
exitSelection()
|
||
toast.show({
|
||
message: `已归档 ${count} 个任务`,
|
||
action: { label: '撤销', run: () => store.unarchiveTasks(ids) },
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 筛选条件一变就清空选择:选择必须始终对应「眼前看得到的卡片」,
|
||
* 否则会出现「删掉了看不见的任务」这种难以预料的结果。
|
||
*/
|
||
watch(filter, () => {
|
||
if (selectedIds.value.size > 0) clearSelection()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex min-h-0 flex-1 flex-col gap-3 p-4 md:p-6">
|
||
<BoardToolbar
|
||
ref="toolbarRef"
|
||
:hidden-note="hiddenNote"
|
||
:selecting="selecting"
|
||
@archive="archiveCompleted"
|
||
@batch-add="batchOpen = true"
|
||
@toggle-select="toggleSelectMode"
|
||
@manage-tags="ui.openTags()"
|
||
/>
|
||
|
||
<BatchSelectionBar
|
||
v-if="selecting"
|
||
:count="selectedCount"
|
||
:total="visibleTaskIds.length"
|
||
:tags="store.allTags"
|
||
@select-all="selectAllVisible"
|
||
@clear="clearSelection"
|
||
@remove="removeSelected"
|
||
@done="exitSelection"
|
||
@move="batchMove"
|
||
@priority="batchPriority"
|
||
@add-tag="batchAddTag"
|
||
@remove-tag="batchRemoveTag"
|
||
@due="batchDue"
|
||
@archive="batchArchive"
|
||
/>
|
||
|
||
<!--
|
||
移动端:整块区域滚动,各列按内容自然高度排布;
|
||
桌面端:容器固定高度,每列内部各自滚动。
|
||
-->
|
||
<div class="grid flex-1 gap-3 overflow-y-auto md:min-h-0 md:grid-cols-3 md:overflow-hidden">
|
||
<BoardColumn
|
||
v-for="(column, index) in COLUMNS"
|
||
:key="column.id"
|
||
:ref="(element) => setColumnRef(element, index)"
|
||
:column="column"
|
||
:tasks="visibleColumns[column.id]"
|
||
:sort-disabled="isSortDisabled(column.id)"
|
||
:selectable="selecting"
|
||
:selected-ids="selectedIds"
|
||
@toggle="toggleTask"
|
||
@open="openTask"
|
||
@move="handleMove"
|
||
@keymove="handleKeyMove"
|
||
@select="toggleSelect"
|
||
/>
|
||
</div>
|
||
|
||
<TaskDetailDrawer :task="editingTask" @close="closeDetail" />
|
||
<BatchAddDrawer :open="batchOpen" @close="batchOpen = false" />
|
||
</div>
|
||
</template>
|