feat: 补齐今日视图待办组、卡片键盘操作、深色模式

今日视图
- 补上「待办」组。此前只收 逾期 / 今天到期 / 进行中,导致待办列里没设
  截止日期的任务在今日视图里完全消失 —— 用户明明有一堆事没做,却看到
  「今天没有待办事项」。现在只要任务未完成且未归档,就必定出现在某个
  分组里(归属优先级:逾期 > 今天到期 > 进行中 > 待办)。
- 统计卡片改为与分组一一对应(逾期 / 今天到期 / 进行中 / 本周完成),
  不再用「今日待办」这种把四组混在一起、与列表长度对不上的指标。

键盘操作(可访问性)
- 卡片可聚焦(Tab / 点击),← → 换列、↑ ↓ 列内调序、Enter 打开详情。
- 换列时追加到目标列末尾:用全量长度而不是可见下标,筛选态下也安全;
  列内调序要求该列渲染的是全量,否则禁用(与拖拽的规则一致)。
- 移动后手动恢复焦点 —— 卡片跨列会被重建,不恢复的话连续按键会失效。

深色模式
- 三态:跟随系统 / 浅色 / 深色,选择持久化到 localStorage。
- index.html 内联脚本在首帧之前应用主题,避免深色下闪白。
- 用 @custom-variant dark 做 class 控制(而不是纯 media query),
  才能支持「强制浅色」;html 的 color-scheme 让原生控件与滚动条跟随。

验证:单元测试 480 个、端到端测试 25 个全部通过,lint 与构建无错误。
截图脚本新增深色与移动端深色两组,便于后续对照。
This commit is contained in:
chenshoulin
2026-10-18 17:48:55 +08:00
parent 442cbbd734
commit 8721e8ab0b
32 changed files with 1022 additions and 158 deletions
+61 -14
View File
@@ -1,14 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import { PRIORITY_LABELS, type Task } from '@/types/task'
import { PRIORITY_LABELS, type MoveDirection, type Task } from '@/types/task'
import { formatDueDate, isOverdue } from '@/utils/date'
const MAX_VISIBLE_TAGS = 3
const props = defineProps<{ task: Task }>()
const emit = defineEmits<{ toggle: [id: string]; open: [id: string] }>()
const emit = defineEmits<{
toggle: [id: string]
open: [id: string]
keymove: [payload: { id: string; direction: MoveDirection }]
}>()
/** 方向键映射;左右换列,上下调顺序 */
const MOVE_KEYS: Readonly<Record<string, MoveDirection>> = {
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
ArrowDown: 'down',
}
/**
* 卡片获得焦点后的键盘操作。
* 注意:方向键必须 preventDefault,否则会连带滚动整个看板。
*/
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
emit('open', props.task.id)
return
}
const direction = MOVE_KEYS[event.key]
if (!direction) return
event.preventDefault()
emit('keymove', { id: props.task.id, direction })
}
const isDone = computed(() => props.task.status === 'done')
const overdue = computed(() => isOverdue(props.task.dueDate, props.task.status))
@@ -26,28 +56,34 @@ const priorityBarClass = computed(() => {
case 2:
return 'bg-amber-400'
default:
return 'bg-slate-300'
return 'bg-slate-300 dark:bg-slate-600'
}
})
const priorityTextClass = computed(() => {
switch (props.task.priority) {
case 3:
return 'text-rose-600'
return 'text-rose-600 dark:text-rose-400'
case 2:
return 'text-amber-700'
return 'text-amber-700 dark:text-amber-400'
default:
return 'text-slate-400'
return 'text-slate-400 dark:text-slate-500'
}
})
</script>
<template>
<article
class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white shadow-card transition duration-150"
:class="isDone ? 'opacity-60' : 'hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover'"
class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white shadow-card transition duration-150 dark:border-slate-800 dark:bg-slate-900"
:class="
isDone
? 'opacity-60'
: 'hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover dark:hover:border-slate-700'
"
:data-task-id="task.id"
tabindex="0"
@click="emit('open', task.id)"
@keydown="onKeydown"
>
<span class="absolute inset-y-0 left-0 w-[3px]" :class="priorityBarClass" aria-hidden="true" />
@@ -59,7 +95,7 @@ const priorityTextClass = computed(() => {
:class="
isDone
? 'border-emerald-500 bg-emerald-500 text-white'
: 'border-slate-300 hover:border-emerald-500 hover:bg-emerald-50'
: 'border-slate-300 hover:border-emerald-500 hover:bg-emerald-50 dark:border-slate-600 dark:hover:border-emerald-500 dark:hover:bg-emerald-950'
"
:aria-label="isDone ? '标记为未完成' : '标记为已完成'"
:aria-pressed="isDone"
@@ -78,7 +114,11 @@ const priorityTextClass = computed(() => {
<h3
class="min-w-0 flex-1 text-sm leading-snug wrap-break-word"
:class="isDone ? 'text-slate-400 line-through' : 'text-slate-800'"
:class="
isDone
? 'text-slate-400 line-through dark:text-slate-500'
: 'text-slate-800 dark:text-slate-100'
"
>
{{ task.title }}
</h3>
@@ -91,14 +131,21 @@ const priorityTextClass = computed(() => {
<span
v-if="task.dueDate"
class="font-medium"
:class="overdue ? 'text-rose-600' : 'text-slate-500'"
:class="
overdue
? 'font-medium text-rose-600 dark:text-rose-400'
: 'text-slate-500 dark:text-slate-400'
"
>
{{ formatDueDate(task.dueDate) }}
</span>
<span v-for="tag in visibleTags" :key="tag" class="text-slate-400">#{{ tag }}</span>
<span v-if="hiddenTagCount > 0" class="text-slate-400">+{{ hiddenTagCount }}</span>
<span v-for="tag in visibleTags" :key="tag" class="text-slate-400 dark:text-slate-500">
#{{ tag }}
</span>
<span v-if="hiddenTagCount > 0" class="text-slate-400 dark:text-slate-500">
+{{ hiddenTagCount }}
</span>
</div>
</div>
</article>