feat(board): 已完成列按完成时间倒序排列
- 已完成列的展示顺序改由 completedAt 倒序决定(新增 sortCompletedDesc 纯函数), 最近完成的永远在最上面;缺 completedAt 的旧数据排在有完成时间的任务之后, 彼此退回 sortOrder 倒序 - 该列停用列内排序:拖拽与 ↑↓ 只能跨列,列头标注「按完成时间倒序」说明口径 - 数据层不动:不迁移 sortOrder,导入导出、快照结构与 schemaVersion 均不变, 倒序只在 BoardView.visibleColumns 单点发生 - 新增 ADR-009,并在 ADR-008 的否决项里标注被部分推翻 - 单测 705 → 738(sortOrder +5、BoardView +5、BoardColumn +1),README 同步
This commit is contained in:
@@ -77,6 +77,24 @@ describe('BoardColumn 渲染', () => {
|
||||
const { wrapper } = setup([])
|
||||
expect(wrapper.find('input[aria-label="添加任务"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('已完成列标注排序口径,其它列不标注', () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const store = useTaskStore()
|
||||
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
|
||||
|
||||
const mountWith = (column: ColumnDef) =>
|
||||
mount(BoardColumn, {
|
||||
props: { column, tasks: [], sortDisabled: column.id === 'done' },
|
||||
global: { plugins: [pinia] },
|
||||
})
|
||||
|
||||
expect(mountWith({ id: 'done', title: '已完成' }).find('[data-role="column-sort-hint"]').text()).toBe(
|
||||
'按完成时间倒序',
|
||||
)
|
||||
expect(mountWith(TODO_COLUMN).find('[data-role="column-sort-hint"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BoardColumn 拖拽契约', () => {
|
||||
|
||||
@@ -72,6 +72,14 @@ useDraggable(listRef, {
|
||||
<header class="flex items-center gap-2 px-3 pt-3 pb-2">
|
||||
<span class="size-1.5 shrink-0 rounded-full" :class="dotClass" aria-hidden="true" />
|
||||
<h2 class="text-sm font-medium text-slate-700 dark:text-zinc-300">{{ column.title }}</h2>
|
||||
<span
|
||||
v-if="column.id === 'done'"
|
||||
data-role="column-sort-hint"
|
||||
class="text-[11px] text-slate-400 dark:text-zinc-500"
|
||||
title="已完成列固定按完成时间倒序,最近完成的在最上面,不支持手动调序"
|
||||
>
|
||||
按完成时间倒序
|
||||
</span>
|
||||
<span
|
||||
data-role="column-count"
|
||||
class="ml-auto rounded-full border border-slate-200 bg-white px-2 py-0.5 text-xs font-medium text-slate-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
needsNormalize,
|
||||
normalizeColumn,
|
||||
sortByOrder,
|
||||
sortCompletedDesc,
|
||||
sortOrderForPosition,
|
||||
} from './sortOrder'
|
||||
|
||||
@@ -220,3 +221,55 @@ describe('sortByOrder', () => {
|
||||
expect(titles(tasks)).toEqual(['B', 'A'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortCompletedDesc', () => {
|
||||
it('按完成时间倒序,最近完成的最上面', () => {
|
||||
const tasks = [
|
||||
makeTask({ title: '早', status: 'done', completedAt: '2026-10-01T09:00:00.000Z' }),
|
||||
makeTask({ title: '晚', status: 'done', completedAt: '2026-10-20T09:00:00.000Z' }),
|
||||
makeTask({ title: '中', status: 'done', completedAt: '2026-10-10T09:00:00.000Z' }),
|
||||
]
|
||||
expect(titles(sortCompletedDesc(tasks))).toEqual(['晚', '中', '早'])
|
||||
})
|
||||
|
||||
it('与 sortOrder 无关:sortOrder 更大的不会因此排前面', () => {
|
||||
const tasks = [
|
||||
makeTask({
|
||||
title: '刚完成',
|
||||
status: 'done',
|
||||
sortOrder: 1024,
|
||||
completedAt: '2026-10-20T09:00:00.000Z',
|
||||
}),
|
||||
makeTask({
|
||||
title: '早完成',
|
||||
status: 'done',
|
||||
sortOrder: 9999,
|
||||
completedAt: '2026-10-01T09:00:00.000Z',
|
||||
}),
|
||||
]
|
||||
expect(titles(sortCompletedDesc(tasks))).toEqual(['刚完成', '早完成'])
|
||||
})
|
||||
|
||||
it('缺 completedAt 的旧数据排在后面,彼此按 sortOrder 倒序', () => {
|
||||
const tasks = [
|
||||
makeTask({ title: '无完成时间小', status: 'done', sortOrder: 1024 }),
|
||||
makeTask({
|
||||
title: '有时间',
|
||||
status: 'done',
|
||||
sortOrder: 5000,
|
||||
completedAt: '2026-10-20T09:00:00.000Z',
|
||||
}),
|
||||
makeTask({ title: '无完成时间大', status: 'done', sortOrder: 2048 }),
|
||||
]
|
||||
expect(titles(sortCompletedDesc(tasks))).toEqual(['有时间', '无完成时间大', '无完成时间小'])
|
||||
})
|
||||
|
||||
it('不修改入参', () => {
|
||||
const tasks = [
|
||||
makeTask({ title: '早', status: 'done', completedAt: '2026-10-01T09:00:00.000Z' }),
|
||||
makeTask({ title: '晚', status: 'done', completedAt: '2026-10-20T09:00:00.000Z' }),
|
||||
]
|
||||
sortCompletedDesc(tasks)
|
||||
expect(titles(tasks)).toEqual(['早', '晚'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,29 @@ export function sortByOrder(tasks: readonly Task[]): Task[] {
|
||||
return [...tasks].sort(compareBySortOrder)
|
||||
}
|
||||
|
||||
/**
|
||||
* 已完成的展示顺序:按完成时间**倒序**,最近完成的永远在最上面。
|
||||
*
|
||||
* 为什么不沿用 sortOrder:完成时间本身就是用户唯一关心的「顺序语义」,
|
||||
* 手调只会让「刚勾完的任务跑到列表末尾」这种反直觉的情况重新出现。
|
||||
* 因此已完成列放弃手动排序(拖拽与方向键在该列不生效),
|
||||
* 排序键固定为 completedAt。
|
||||
*
|
||||
* completedAt 缺失(旧数据、手工导入)时退回 sortOrder 倒序:
|
||||
* 至少保证「新完成的在上面」这个观感不被一条脏数据打断。
|
||||
*/
|
||||
export function compareByCompletedDesc(a: Task, b: Task): number {
|
||||
const left = a.completedAt ?? ''
|
||||
const right = b.completedAt ?? ''
|
||||
if (left !== right) return right.localeCompare(left)
|
||||
return b.sortOrder - a.sortOrder
|
||||
}
|
||||
|
||||
/** 按完成时间倒序排列(不修改入参) */
|
||||
export function sortCompletedDesc(tasks: readonly Task[]): Task[] {
|
||||
return [...tasks].sort(compareByCompletedDesc)
|
||||
}
|
||||
|
||||
/** 按 SORT_ORDER_STEP 重新均匀分配同列 sortOrder,保持相对顺序不变 */
|
||||
export function normalizeColumn(sorted: readonly Task[]): Task[] {
|
||||
return sorted.map((task, index) => ({ ...task, sortOrder: (index + 1) * SORT_ORDER_STEP }))
|
||||
|
||||
@@ -853,3 +853,66 @@ describe('BoardView 批量编辑', () => {
|
||||
expect(store.activeTasks.every((task) => task.archivedAt === null)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BoardView 已完成列倒序', () => {
|
||||
/** 建一条已完成任务并指定完成时刻 */
|
||||
function createDone(
|
||||
store: ReturnType<typeof useTaskStore>,
|
||||
title: string,
|
||||
completedAt: string,
|
||||
): void {
|
||||
const task = store.createTask({ title, status: 'done' })
|
||||
store.updateTask(task?.id ?? '', { completedAt })
|
||||
}
|
||||
|
||||
function doneTitles(wrapper: ReturnType<typeof mountBoard>): string[] {
|
||||
return wrapper.findAll('section')[2]?.findAll('article h3').map((node) => node.text()) ?? []
|
||||
}
|
||||
|
||||
it('按完成时间倒序渲染,最近完成的最上面(与 sortOrder 无关)', () => {
|
||||
const { pinia, store } = setup()
|
||||
// 先完成「早」,后完成「刚」:sortOrder 与完成时间的先后一致,只能验证倒序
|
||||
createDone(store, '早完成', dayjs().subtract(5, 'day').toISOString())
|
||||
createDone(store, '刚完成', dayjs().toISOString())
|
||||
|
||||
expect(doneTitles(mountBoard(pinia))).toEqual(['刚完成', '早完成'])
|
||||
})
|
||||
|
||||
it('完成顺序与 sortOrder 相反时,仍以完成时间为准', () => {
|
||||
const { pinia, store } = setup()
|
||||
// 「早」先建(sortOrder 更小),但完成时间更晚
|
||||
createDone(store, '早完成', dayjs().toISOString())
|
||||
createDone(store, '刚完成', dayjs().subtract(5, 'day').toISOString())
|
||||
|
||||
expect(doneTitles(mountBoard(pinia))).toEqual(['早完成', '刚完成'])
|
||||
})
|
||||
|
||||
it('已完成列始终停用列内排序(拖拽只允许跨列)', () => {
|
||||
const { pinia } = setup()
|
||||
const columns = mountBoard(pinia).findAllComponents(BoardColumn)
|
||||
expect(columns[2]?.props('sortDisabled')).toBe(true)
|
||||
expect(columns[0]?.props('sortDisabled')).toBe(false)
|
||||
})
|
||||
|
||||
it('已完成列不响应上下方向键(列内顺序由完成时间决定)', async () => {
|
||||
const { pinia, store } = setup()
|
||||
createDone(store, '刚完成', dayjs().toISOString())
|
||||
createDone(store, '早完成', dayjs().subtract(5, 'day').toISOString())
|
||||
const wrapper = mountBoard(pinia)
|
||||
|
||||
const top = store.byColumn.done[0]
|
||||
const before = store.byColumn.done.map((task) => task.title)
|
||||
wrapper.findAllComponents(BoardColumn)[2]?.vm.$emit('keymove', { id: top?.id ?? '', direction: 'down' })
|
||||
await nextTick()
|
||||
|
||||
expect(store.byColumn.done.map((task) => task.title)).toEqual(before)
|
||||
expect(doneTitles(wrapper)).toEqual(['刚完成', '早完成'])
|
||||
})
|
||||
|
||||
it('列头标注排序口径,避免用户以为拖不动是 bug', () => {
|
||||
const { pinia } = setup()
|
||||
const hint = mountBoard(pinia).find('[data-role="column-sort-hint"]')
|
||||
|
||||
expect(hint.text()).toBe('按完成时间倒序')
|
||||
})
|
||||
})
|
||||
|
||||
+18
-3
@@ -15,7 +15,7 @@ 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 } from '@/utils/sortOrder'
|
||||
import { groupByColumn, sortCompletedDesc } from '@/utils/sortOrder'
|
||||
|
||||
const store = useTaskStore()
|
||||
const filters = useFilterStore()
|
||||
@@ -45,7 +45,11 @@ function setColumnRef(element: unknown, index: number): void {
|
||||
}
|
||||
|
||||
/** 实际渲染的看板:先按条件筛选,再按列分组,列计数随之反映结果 */
|
||||
const visibleColumns = computed(() => groupByColumn(filterTasks(store.activeTasks, filter.value)))
|
||||
const visibleColumns = computed(() => {
|
||||
const grouped = groupByColumn(filterTasks(store.activeTasks, filter.value))
|
||||
// 已完成列按完成时间倒序展示,与 sortOrder 无关(见 sortCompletedDesc)
|
||||
return { ...grouped, done: sortCompletedDesc(grouped.done) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 每列被隐藏的数量(被筛选掉 + 已归档)。
|
||||
@@ -64,6 +68,15 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 被「隐藏归档」挡掉、且本来能通过当前筛选的任务数。
|
||||
*
|
||||
@@ -163,6 +176,8 @@ function handleKeyMove(payload: { id: string; direction: MoveDirection }): void
|
||||
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)
|
||||
@@ -336,7 +351,7 @@ watch(filter, () => {
|
||||
:ref="(element) => setColumnRef(element, index)"
|
||||
:column="column"
|
||||
:tasks="visibleColumns[column.id]"
|
||||
:sort-disabled="hiddenCounts[column.id] > 0"
|
||||
:sort-disabled="isSortDisabled(column.id)"
|
||||
:selectable="selecting"
|
||||
:selected-ids="selectedIds"
|
||||
@toggle="toggleTask"
|
||||
|
||||
Reference in New Issue
Block a user