Files
Kanban/src/components/TaskCard.vue
T
chenshoulin 9ee1261b90
CI / verify (push) Successful in 1m42s
CI / 部署到服务器 (push) Successful in 29s
feat(ui): 重做深色悬停与键盘焦点,补齐弹层出场动效
悬停:上一版靠「底色跳两档 + 四边边框一起提亮 + 外圈白光晕 + 白环」制造悬浮感,
五个变化同向叠加,结果是整张卡片均匀发亮、看着像换了张更浅的卡,光晕还把边缘糊住。
改用明暗方向表达厚度:新增受光层伪元素(顶部白 7% → 底部黑 14%,悬停淡入),
配一道锐利的接触阴影压实边界、一层远处柔化投影作高度暗示;底色与边框各只提一档。
过渡 150ms → 200ms ease-out。新增按下态:位移归零、阴影收紧、受光减弱,卡片「落回纸面」。

焦点:全局描边在深色下掺透明度柔化(sky-400 78%);卡片被 :focus-visible 时与悬停
一致地抬起并受光,焦点环退为最后一道提示,不再是一圈生硬方框。顺带删掉
:focus-visible 里的 border-radius: 4px —— 对带 rounded-* 的元素无效(layer 优先级),
对没圆角的元素反而会让聚焦瞬间跳一下。

动效:新增 pop-in(160ms) / sheet-in(260ms) / scrim-in(200ms) 三组关键帧,
下拉从锚点上方落入、抽屉自右侧滑入、遮罩淡入,组件上一律配 motion-reduce:animate-none。
只做入场不做退场:关闭是用户主动取消,瞬时消失更利落;做退场要引入 Vue Transition,
会牵扯 SideDrawer 的焦点与卸载时序,收益不抵风险。

实现要点:
- Tailwind v4 会把 @theme 的阴影值内联进 --tw-shadow,覆盖 CSS 变量对已生成的工具类
  无效,深色阴影因此直接重写 box-shadow,且规则不放进 @layer 以压过原子类;
  选择器要按 hover: 变体转义后的类名书写(.hover\:shadow-card-hover),否则静默失效
- 焦点态位移不能只改 --tw-translate-y:translate 属性声明只存在于 :hover 那条原子类
  规则里,焦点态不经过 :hover,必须直接写 translate
- 受光层要画在内容之下,因此 TaskCard 的内容容器补了 relative(同为定位元素时按 DOM
  顺序绘制,伪元素在前)

验证:vue-tsc -b 与 eslint src 干净,738 例全量单测通过;Playwright 读计算样式确认
动画时序(下拉 @30ms opacity .909、抽屉 translateX 214.77px、遮罩 opacity .419),
并用垂直亮度剖面确认卡片上亮下暗(内部亮度 33 → 20)。
2026-10-24 00:11:51 +08:00

244 lines
8.1 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 { computed } from 'vue'
import { PRIORITY_LABELS, type MoveDirection, type Task } from '@/types/task'
import { formatCompactDate, formatDateTime, formatDueDateForTask, isOverdue } from '@/utils/date'
const MAX_VISIBLE_TAGS = 3
const props = defineProps<{
task: Task
/** 多选模式:卡片改为可勾选,点击是「选中」而不是「打开详情」 */
selectable?: boolean
selected?: boolean
}>()
const emit = defineEmits<{
toggle: [id: string]
open: [id: string]
keymove: [payload: { id: string; direction: MoveDirection }]
select: [id: string]
}>()
/** 方向键映射;左右换列,上下调顺序 */
const MOVE_KEYS: Readonly<Record<string, MoveDirection>> = {
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
ArrowDown: 'down',
}
/** 多选态下点击/回车都是「切换选中」;平时是「打开详情」 */
function activate(): void {
if (props.selectable) {
emit('select', props.task.id)
return
}
emit('open', props.task.id)
}
/**
* 卡片获得焦点后的键盘操作。
* 注意:方向键必须 preventDefault,否则会连带滚动整个看板。
*/
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
activate()
return
}
// 多选态不做方向键移动:此时拖拽与排序都已停用,避免误操作打乱顺序
if (props.selectable) 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))
/**
* 截止日期文案:已完成的任务以「完成日」为基准,未完成的以「今天」为基准。
* 这样 19 号截止、19 号完成的任务第二天不会再被说成「逾期 1 天」。
*/
const dueLabel = computed(() =>
props.task.dueDate
? formatDueDateForTask(props.task.dueDate, props.task.status, props.task.completedAt)
: null,
)
const visibleTags = computed(() => props.task.tags.slice(0, MAX_VISIBLE_TAGS))
const hiddenTagCount = computed(() => Math.max(0, props.task.tags.length - MAX_VISIBLE_TAGS))
/**
* 完成时间:已完成卡片上显示一行短日期,精确到分钟的时间留给 `title` 与详情抽屉。
* 未完成时 completedAt 为 null,这一行整体不渲染,避免给待办卡片增加噪音。
*/
const completedAtFull = computed(() => formatDateTime(props.task.completedAt))
const completedAtShort = computed(() => formatCompactDate(props.task.completedAt))
/** 悬停卡片即可看到完整时间,不必先打开详情抽屉 */
const timeTitle = computed(() => {
const lines = [`创建于 ${formatDateTime(props.task.createdAt) ?? '—'}`]
if (completedAtFull.value) lines.push(`完成于 ${completedAtFull.value}`)
if (props.task.archivedAt) lines.push(`归档于 ${formatDateTime(props.task.archivedAt) ?? '—'}`)
return lines.join('\n')
})
/**
* 卡片外观。选中态与「已完成」都要覆盖边框/背景,
* 因此这些颜色放在条件分支里,而不是和基础类各写一份靠优先级取胜。
*/
const cardClass = computed(() => {
if (props.selected) {
return 'border-sky-400 bg-sky-50 dark:border-sky-600 dark:bg-sky-950/40'
}
return isDone.value
? 'border-slate-200 bg-white opacity-60 dark:border-zinc-800 dark:bg-zinc-900'
: 'border-slate-200 bg-white hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700 dark:hover:bg-zinc-800/25'
})
/**
* 优先级用左侧色条表达,而不是一枚彩色胶囊。
* 色条几乎不占空间,扫视一列卡片时却比文字更快分辨出轻重。
*/
const priorityBarClass = computed(() => {
switch (props.task.priority) {
case 3:
return 'bg-rose-400'
case 2:
return 'bg-amber-400'
default:
return 'bg-slate-300 dark:bg-zinc-600'
}
})
const priorityTextClass = computed(() => {
switch (props.task.priority) {
case 3:
return 'text-rose-600 dark:text-rose-400'
case 2:
return 'text-amber-700 dark:text-amber-400'
default:
return 'text-slate-400 dark:text-zinc-500'
}
})
</script>
<template>
<article
class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border shadow-card transition duration-200 ease-out"
:class="cardClass"
:data-task-id="task.id"
:role="selectable ? 'checkbox' : undefined"
:aria-checked="selectable ? selected === true : undefined"
:aria-label="selectable ? `选择任务「${task.title}」` : undefined"
:title="timeTitle"
tabindex="0"
@click="activate"
@keydown="onKeydown"
>
<span class="absolute inset-y-0 left-0 w-[3px]" :class="priorityBarClass" aria-hidden="true" />
<div class="relative py-2 pr-2.5 pl-3.5">
<div class="flex items-start gap-2">
<!--
多选态下用一枚纯装饰的勾选框:真正的可交互元素是整张卡片,
这样读屏与键盘只有「一个」可聚焦控件,不会出现嵌套按钮。
-->
<span
v-if="selectable"
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border transition"
:class="
selected
? 'border-sky-500 bg-sky-500 text-white'
: 'border-slate-300 bg-white dark:border-zinc-600 dark:bg-zinc-800'
"
aria-hidden="true"
>
<svg v-if="selected" viewBox="0 0 12 12" class="size-2.5" fill="none">
<path
d="m2.5 6.2 2.2 2.2 4.8-4.8"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<button
v-else
type="button"
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border transition"
:class="
isDone
? 'border-emerald-500 bg-emerald-500 text-white'
: 'border-slate-300 hover:border-emerald-500 hover:bg-emerald-50 dark:border-zinc-600 dark:hover:border-emerald-500 dark:hover:bg-emerald-950'
"
:aria-label="isDone ? '标记为未完成' : '标记为已完成'"
:aria-pressed="isDone"
@click.stop="emit('toggle', task.id)"
>
<svg v-if="isDone" viewBox="0 0 12 12" class="size-2.5" fill="none" aria-hidden="true">
<path
d="m2.5 6.2 2.2 2.2 4.8-4.8"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<h3
class="min-w-0 flex-1 text-sm leading-snug wrap-break-word"
:class="
isDone
? 'text-slate-400 line-through dark:text-zinc-500'
: 'text-slate-800 dark:text-zinc-100'
"
>
{{ task.title }}
</h3>
</div>
<div class="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 pl-6 text-xs">
<span class="font-medium" :class="priorityTextClass">
{{ PRIORITY_LABELS[task.priority] }}
</span>
<span
v-if="dueLabel"
:class="
overdue
? 'font-medium text-rose-600 dark:text-rose-400'
: 'text-slate-500 dark:text-zinc-400'
"
>
{{ dueLabel }}
</span>
<span v-for="tag in visibleTags" :key="tag" class="text-slate-400 dark:text-zinc-500">
#{{ tag }}
</span>
<span v-if="hiddenTagCount > 0" class="text-slate-400 dark:text-zinc-500">
+{{ hiddenTagCount }}
</span>
<span
v-if="completedAtShort"
data-role="completed-at"
:title="`完成于 ${completedAtFull}`"
class="ml-auto text-emerald-700/80 dark:text-emerald-400/80"
>
完成于 {{ completedAtShort }}
</span>
</div>
</div>
</article>
</template>