Files
Kanban/src/views/BoardView.vue
T
chenshoulin 51235a59a2
CI / verify (push) Successful in 1m45s
CI / 部署到服务器 (push) Successful in 27s
feat(board): 已完成列按完成时间倒序排列
- 已完成列的展示顺序改由 completedAt 倒序决定(新增 sortCompletedDesc 纯函数),
  最近完成的永远在最上面;缺 completedAt 的旧数据排在有完成时间的任务之后,
  彼此退回 sortOrder 倒序
- 该列停用列内排序:拖拽与 ↑↓ 只能跨列,列头标注「按完成时间倒序」说明口径
- 数据层不动:不迁移 sortOrder,导入导出、快照结构与 schemaVersion 均不变,
  倒序只在 BoardView.visibleColumns 单点发生
- 新增 ADR-009,并在 ADR-008 的否决项里标注被部分推翻
- 单测 705 → 738(sortOrder +5、BoardView +5、BoardColumn +1),README 同步
2026-10-22 13:15:25 +08:00

369 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>