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:
@@ -3,6 +3,7 @@
|
|||||||
一个轻量的个人看板工具:打开即用、数据存在本地、不依赖后端。
|
一个轻量的个人看板工具:打开即用、数据存在本地、不依赖后端。
|
||||||
|
|
||||||
- 固定三列:**待办 / 进行中 / 已完成**
|
- 固定三列:**待办 / 进行中 / 已完成**
|
||||||
|
- **已完成列按完成时间倒序**:最近完成的永远在最上面(列内不支持手动调序)
|
||||||
- 快速新增、拖拽排序、详情编辑、删除撤销;详情里可直接切换状态
|
- 快速新增、拖拽排序、详情编辑、删除撤销;详情里可直接切换状态
|
||||||
- **批量添加**:一次录入多行清单,整批共用目标列 / 优先级 / 标签 / 截止日期
|
- **批量添加**:一次录入多行清单,整批共用目标列 / 优先级 / 标签 / 截止日期
|
||||||
- **批量编辑**:多选后可统一移动列、改优先级、加/减标签、设截止日期、归档
|
- **批量编辑**:多选后可统一移动列、改优先级、加/减标签、设截止日期、归档
|
||||||
@@ -92,7 +93,8 @@ npm run dev
|
|||||||
| `↑` `↓` | 在列内上移 / 下移 |
|
| `↑` `↓` | 在列内上移 / 下移 |
|
||||||
| `Enter` | 打开任务详情 |
|
| `Enter` | 打开任务详情 |
|
||||||
|
|
||||||
在输入框内输入时不会触发全局快捷键。方向键移动在筛选态下只允许跨列(列内排序需要看到完整列表才有意义)。
|
在输入框内输入时不会触发全局快捷键。方向键移动在筛选态下只允许跨列(列内排序需要看到完整列表才有意义);
|
||||||
|
「已完成」列的列内顺序由完成时间决定,`↑` `↓` 在该列不生效(拖拽同理只能跨列)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -316,7 +318,7 @@ E2E 需要真实浏览器,默认不放进 CI(要求 runner 预装 Chrome)
|
|||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
- 单元测试:705 个用例,覆盖 `src/` 下的纯逻辑与组件行为。
|
- 单元测试:738 个用例,覆盖 `src/` 下的纯逻辑与组件行为。
|
||||||
- 端到端测试:28 个用例,分两组运行 —— 交互类跑 dev server,PWA 类跑生产构建(Service Worker 只在构建产物中生成)。覆盖「新增 → 拖拽 → 完成 → 刷新保持」「批量添加」「多选批量删除 + 撤销」「导出 → 清空 → 导入还原」「删除撤销」「搜索筛选」「键盘快捷键与方向键移动」「深色模式切换」「今日视图」「离线打开」等主链路,每个用例使用独立的浏览器上下文。
|
- 端到端测试:28 个用例,分两组运行 —— 交互类跑 dev server,PWA 类跑生产构建(Service Worker 只在构建产物中生成)。覆盖「新增 → 拖拽 → 完成 → 刷新保持」「批量添加」「多选批量删除 + 撤销」「导出 → 清空 → 导入还原」「删除撤销」「搜索筛选」「键盘快捷键与方向键移动」「深色模式切换」「今日视图」「离线打开」等主链路,每个用例使用独立的浏览器上下文。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -339,6 +341,7 @@ PLAYWRIGHT_CHANNEL=chromium npm run test:e2e # 用自带 Chromium(需先 npx
|
|||||||
|
|
||||||
- 单用户、单设备,无协作与同步能力(离线可用,但多设备之间不会同步)。
|
- 单用户、单设备,无协作与同步能力(离线可用,但多设备之间不会同步)。
|
||||||
- 看板列固定为三列,不支持自定义。
|
- 看板列固定为三列,不支持自定义。
|
||||||
|
- 「已完成」列固定按完成时间倒序,不支持手动调序(拖拽与方向键只能跨列)。
|
||||||
- 归档阈值固定为「已完成超过 7 天」,暂不可配置。
|
- 归档阈值固定为「已完成超过 7 天」,暂不可配置。
|
||||||
- 今日视图的分组规则固定(逾期 > 今天到期 > 进行中 > 待办),不可自定义。
|
- 今日视图的分组规则固定(逾期 > 今天到期 > 进行中 > 待办),不可自定义。
|
||||||
- 统计只覆盖本周完成数等基础指标,没有趋势图或历史留存。
|
- 统计只覆盖本周完成数等基础指标,没有趋势图或历史留存。
|
||||||
|
|||||||
+40
@@ -232,6 +232,7 @@ MVP 有「启动快照 + 手动导出」两道保护,但两个整份覆盖的
|
|||||||
创建时间对「先做哪件」几乎没有影响,放进 `title` 与详情抽屉足够。
|
创建时间对「先做哪件」几乎没有影响,放进 `title` 与详情抽屉足够。
|
||||||
- _增加「按创建 / 完成时间排序」开关_:完成列的顺序由手动 `sortOrder` 表达,再引入第二种排序会让
|
- _增加「按创建 / 完成时间排序」开关_:完成列的顺序由手动 `sortOrder` 表达,再引入第二种排序会让
|
||||||
「看到的顺序」与「拖拽写回的顺序」产生两个真相;当前也没有对应使用场景。
|
「看到的顺序」与「拖拽写回的顺序」产生两个真相;当前也没有对应使用场景。
|
||||||
|
(其中「完成列按完成时间排序」后来被 [ADR-009](#adr-009--已完成列改按完成时间倒序展示) 部分推翻。)
|
||||||
- _用相对时间(「3 天前」)_:文案依赖渲染时刻,页面开一整天就会和实际完成日对不上;
|
- _用相对时间(「3 天前」)_:文案依赖渲染时刻,页面开一整天就会和实际完成日对不上;
|
||||||
复盘场景下具体日期更有用。
|
复盘场景下具体日期更有用。
|
||||||
- _提升 `schemaVersion` 做迁移_:字段早已存在,且 `coerceTask` 已把它们收敛为字符串或 `null`,无需迁移。
|
- _提升 `schemaVersion` 做迁移_:字段早已存在,且 `coerceTask` 已把它们收敛为字符串或 `null`,无需迁移。
|
||||||
@@ -242,3 +243,42 @@ MVP 有「启动快照 + 手动导出」两道保护,但两个整份覆盖的
|
|||||||
拖拽换列走 `moveTask`,两者规则一致(进入「已完成」写时间、离开即清空),展示值与状态不会互相矛盾。
|
拖拽换列走 `moveTask`,两者规则一致(进入「已完成」写时间、离开即清空),展示值与状态不会互相矛盾。
|
||||||
- 新增两个纯函数与对应单测;`TrashPanel` 少了一个私有格式化函数。
|
- 新增两个纯函数与对应单测;`TrashPanel` 少了一个私有格式化函数。
|
||||||
- 卡片 `title` 是纯提示信息,不参与可访问性命名(`aria-label` 仅在多选态给出),读屏语义不变。
|
- 卡片 `title` 是纯提示信息,不参与可访问性命名(`aria-label` 仅在多选态给出),读屏语义不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-009 · 已完成列改按完成时间倒序展示
|
||||||
|
|
||||||
|
**背景**
|
||||||
|
|
||||||
|
ADR-008 否掉了「完成列按时间排序」,理由是维持单一顺序来源。但实际使用中这个取舍站不住:
|
||||||
|
完成任务时 `sortOrder` 是「追加到列尾」,于是**刚勾完的任务沉到列表最底下** ——
|
||||||
|
打开看板想问「我刚才做完什么」,得滚到列底;做完几十条之后,已完成列变成一条只有
|
||||||
|
「按完成先后升序」的考古记录。对已完成的任务来说,用户唯一关心的顺序就是完成时间。
|
||||||
|
|
||||||
|
**结论**
|
||||||
|
|
||||||
|
1. 已完成列的展示顺序由 `completedAt` 倒序决定,不再消费 `sortOrder`。
|
||||||
|
排序键收敛为一个纯函数 `sortCompletedDesc`(`src/utils/sortOrder.ts`),
|
||||||
|
转换只在 `BoardView` 的 `visibleColumns` 单点发生,store 与数据层语义不变。
|
||||||
|
2. 该列停用列内排序:拖拽与 `↑` `↓` 只允许跨列(`sortDisabled` 对 `done` 恒为 true),
|
||||||
|
避免写出「永远看不见结果」的 `sortOrder`。
|
||||||
|
3. 列头标注「按完成时间倒序」,把口径写在界面上,用户不会误以为拖不动是故障。
|
||||||
|
4. 不做数据迁移:`sortOrder`、导入导出、快照结构与 `schemaVersion` 全都不动。
|
||||||
|
`completedAt` 缺失的旧数据(手工导入、更早的备份)排在有完成时间的任务之后,
|
||||||
|
彼此之间退回 `sortOrder` 倒序,至少保持「新完成的在上面」。
|
||||||
|
|
||||||
|
**被否决的方案**
|
||||||
|
|
||||||
|
- _一次性按完成时间重排 `sortOrder`,新完成插列首_:需要迁移存量数据,且用户只要拖过一次,
|
||||||
|
顺序就再次和时间脱钩 —— 等于保留两个真相,还多了写操作。
|
||||||
|
- _只改插入位置(新完成插列首)_:改动最小,但存量列表仍是旧的升序,首屏出现
|
||||||
|
「新任务在顶、旧任务按升序续在后面」的断裂顺序。
|
||||||
|
- _加排序开关_:一个开关多一份状态、多一组需要测试的组合,而「已完成」只有一种合理顺序。
|
||||||
|
|
||||||
|
**影响**
|
||||||
|
|
||||||
|
- 落入已完成列的落点不再由拖拽位置决定(`completedAt` 刚写入即最新),这是预期行为。
|
||||||
|
- `store.byColumn.done` 仍是 `sortOrder` 语义,只有视图层按时间倒序;两者差异集中在
|
||||||
|
`BoardView.visibleColumns`,测试覆盖了「顺序与 `sortOrder` 无关」这一条。
|
||||||
|
- 已完成列成为只读顺序:拖拽、`↑` `↓`、批量移动(只能移出该列)都不会改变其内部顺序。
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,24 @@ describe('BoardColumn 渲染', () => {
|
|||||||
const { wrapper } = setup([])
|
const { wrapper } = setup([])
|
||||||
expect(wrapper.find('input[aria-label="添加任务"]').exists()).toBe(true)
|
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 拖拽契约', () => {
|
describe('BoardColumn 拖拽契约', () => {
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ useDraggable(listRef, {
|
|||||||
<header class="flex items-center gap-2 px-3 pt-3 pb-2">
|
<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" />
|
<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>
|
<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
|
<span
|
||||||
data-role="column-count"
|
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"
|
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,
|
needsNormalize,
|
||||||
normalizeColumn,
|
normalizeColumn,
|
||||||
sortByOrder,
|
sortByOrder,
|
||||||
|
sortCompletedDesc,
|
||||||
sortOrderForPosition,
|
sortOrderForPosition,
|
||||||
} from './sortOrder'
|
} from './sortOrder'
|
||||||
|
|
||||||
@@ -220,3 +221,55 @@ describe('sortByOrder', () => {
|
|||||||
expect(titles(tasks)).toEqual(['B', 'A'])
|
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)
|
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,保持相对顺序不变 */
|
/** 按 SORT_ORDER_STEP 重新均匀分配同列 sortOrder,保持相对顺序不变 */
|
||||||
export function normalizeColumn(sorted: readonly Task[]): Task[] {
|
export function normalizeColumn(sorted: readonly Task[]): Task[] {
|
||||||
return sorted.map((task, index) => ({ ...task, sortOrder: (index + 1) * SORT_ORDER_STEP }))
|
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)
|
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 { COLUMNS, PRIORITY_LABELS, type ColumnId, type MoveDirection, type Priority } from '@/types/task'
|
||||||
import { findArchivableTasks } from '@/utils/archive'
|
import { findArchivableTasks } from '@/utils/archive'
|
||||||
import { filterTasks } from '@/utils/filter'
|
import { filterTasks } from '@/utils/filter'
|
||||||
import { groupByColumn } from '@/utils/sortOrder'
|
import { groupByColumn, sortCompletedDesc } from '@/utils/sortOrder'
|
||||||
|
|
||||||
const store = useTaskStore()
|
const store = useTaskStore()
|
||||||
const filters = useFilterStore()
|
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),
|
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
|
if (!target) return
|
||||||
store.moveTask(task.id, target.id, store.byColumn[target.id].length)
|
store.moveTask(task.id, target.id, store.byColumn[target.id].length)
|
||||||
} else {
|
} else {
|
||||||
|
// 已完成列按完成时间倒序,列内调序没有意义,也不写 sortOrder
|
||||||
|
if (task.status === 'done') return
|
||||||
if (hiddenCounts.value[task.status] > 0) return
|
if (hiddenCounts.value[task.status] > 0) return
|
||||||
const column = store.byColumn[task.status]
|
const column = store.byColumn[task.status]
|
||||||
const currentIndex = column.findIndex((item) => item.id === task.id)
|
const currentIndex = column.findIndex((item) => item.id === task.id)
|
||||||
@@ -336,7 +351,7 @@ watch(filter, () => {
|
|||||||
:ref="(element) => setColumnRef(element, index)"
|
:ref="(element) => setColumnRef(element, index)"
|
||||||
:column="column"
|
:column="column"
|
||||||
:tasks="visibleColumns[column.id]"
|
:tasks="visibleColumns[column.id]"
|
||||||
:sort-disabled="hiddenCounts[column.id] > 0"
|
:sort-disabled="isSortDisabled(column.id)"
|
||||||
:selectable="selecting"
|
:selectable="selecting"
|
||||||
:selected-ids="selectedIds"
|
:selected-ids="selectedIds"
|
||||||
@toggle="toggleTask"
|
@toggle="toggleTask"
|
||||||
|
|||||||
Reference in New Issue
Block a user