悬停:上一版靠「底色跳两档 + 四边边框一起提亮 + 外圈白光晕 + 白环」制造悬浮感, 五个变化同向叠加,结果是整张卡片均匀发亮、看着像换了张更浅的卡,光晕还把边缘糊住。 改用明暗方向表达厚度:新增受光层伪元素(顶部白 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)。
244 lines
8.1 KiB
Vue
244 lines
8.1 KiB
Vue
<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>
|