feat(board): 展示任务时间,并修正已完成任务的逾期基准
CI / verify (push) Successful in 1m43s
CI / 部署到服务器 (push) Successful in 26s

时间展示(ADR-008):
- 详情抽屉新增只读「时间」区块:创建 / 更新常驻,完成时间缺失时标注「未完成」,归档任务追加归档时间;时间不进入草稿与 dirty 判定
- 已完成卡片在元信息行右侧显示「完成于 M月D日」,卡片根元素 title 汇总创建 / 完成 / 归档时间(精确到分钟)
- 回收站条目补创建时间,删掉内联 formatTime,统一走 @/utils/date
- date.ts 新增 formatDateTime(YYYY-MM-DD HH:mm)与 formatCompactDate(同年 M月D日 / 跨年带年份),空值与非法时间戳返回 null

逾期基准修复:
- 新增 formatDueDateForTask:未完成任务仍以「今天」推算;已完成任务改以「完成日」推算,19 号截止、19 号当天完成的任务不会在 20 号显示「逾期 1 天」,完成时确实超期则定格为「逾期 N 天完成」,不再随日期增长
- 新增 localDateOf:completedAt 存 UTC 时间戳,按本地时区取日期,避免跨日偏差
- TaskCard 改用 dueLabel computed 取代模板内直接调用 formatDueDate;颜色仍由 isOverdue 决定,已完成卡片保持中性色

测试:date / TaskCard 补时间格式化与逾期基准用例(当天完成、提前完成、完成时超期定格、缺 completedAt、跨年),全量 728 单测通过;vue-tsc 与 eslint 干净。
This commit is contained in:
chenshoulin
2026-10-20 09:23:29 +08:00
parent a269694834
commit b39267fba7
10 changed files with 392 additions and 11 deletions
+52 -1
View File
@@ -81,11 +81,32 @@ describe('截止日期', () => {
it('已完成任务不再标记逾期', () => {
const yesterday = dayjs().subtract(2, 'day').format('YYYY-MM-DD')
const wrapper = mountCard({ dueDate: yesterday, status: 'done' })
const completedAt = dayjs().subtract(1, 'day').hour(10).minute(0).toISOString()
const wrapper = mountCard({ dueDate: yesterday, status: 'done', completedAt })
const badge = wrapper.findAll('span').find((node) => node.text().includes('逾期'))
expect(badge?.text()).toBe('逾期 1 天完成')
expect(badge?.classes().join(' ')).not.toContain('rose')
})
it('当天完成的任务第二天不再显示逾期天数', () => {
// 复现:19 号截止、19 号完成,20 号查看时曾显示「逾期 1 天」
const dueDate = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
const completedAt = dayjs().subtract(1, 'day').hour(10).minute(0).toISOString()
const wrapper = mountCard({ dueDate, status: 'done', completedAt })
expect(wrapper.text()).not.toContain('逾期')
expect(wrapper.text()).toContain('原定')
expect(wrapper.text()).toMatch(/原定 \d+月\d+日/)
})
it('完成时已超期的任务显示「逾期 N 天完成」,不再随日期增长', () => {
const dueDate = dayjs().subtract(3, 'day').format('YYYY-MM-DD')
const completedAt = dayjs().subtract(2, 'day').hour(9).minute(0).toISOString()
const wrapper = mountCard({ dueDate, status: 'done', completedAt })
expect(wrapper.text()).toContain('逾期 1 天完成')
})
it('今天到期显示「今天」', () => {
expect(mountCard({ dueDate: dayjs().format('YYYY-MM-DD') }).text()).toContain('今天')
})
@@ -105,6 +126,36 @@ describe('已完成样式', () => {
})
})
describe('时间信息', () => {
it('已完成卡片显示完成日期', () => {
const completedAt = new Date(2026, 9, 18, 10, 20).toISOString()
const wrapper = mountCard({ status: 'done', completedAt })
expect(wrapper.text()).toContain('完成于')
expect(wrapper.text()).toContain('10月18日')
})
it('未完成卡片不显示完成时间', () => {
expect(mountCard({ completedAt: null }).text()).not.toContain('完成于')
})
it('卡片 title 暴露完整创建与完成时间', () => {
const createdAt = new Date(2026, 9, 18, 10, 8).toISOString()
const completedAt = new Date(2026, 9, 18, 10, 20).toISOString()
const title = mountCard({ status: 'done', createdAt, completedAt })
.find('article')
.attributes('title')
expect(title).toContain('创建于 2026-10-18 10:08')
expect(title).toContain('完成于 2026-10-18 10:20')
})
it('归档任务在 title 中补充归档时间', () => {
const archivedAt = new Date(2026, 9, 19, 8, 0).toISOString()
const title = mountCard({ archivedAt }).find('article').attributes('title')
expect(title).toContain('归档于 2026-10-19 08:00')
})
})
describe('标签展示', () => {
it('最多显示 3 个标签', () => {
const wrapper = mountCard({ tags: ['a', 'b', 'c', 'd', 'e'] })
+38 -3
View File
@@ -2,7 +2,7 @@
import { computed } from 'vue'
import { PRIORITY_LABELS, type MoveDirection, type Task } from '@/types/task'
import { formatDueDate, isOverdue } from '@/utils/date'
import { formatCompactDate, formatDateTime, formatDueDateForTask, isOverdue } from '@/utils/date'
const MAX_VISIBLE_TAGS = 3
@@ -60,9 +60,34 @@ function onKeydown(event: KeyboardEvent): void {
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')
})
/**
* 卡片外观。选中态与「已完成」都要覆盖边框/背景,
* 因此这些颜色放在条件分支里,而不是和基础类各写一份靠优先级取胜。
@@ -111,6 +136,7 @@ const priorityTextClass = computed(() => {
:role="selectable ? 'checkbox' : undefined"
:aria-checked="selectable ? selected === true : undefined"
:aria-label="selectable ? `选择任务「${task.title}」` : undefined"
:title="timeTitle"
tabindex="0"
@click="activate"
@keydown="onKeydown"
@@ -186,14 +212,14 @@ const priorityTextClass = computed(() => {
</span>
<span
v-if="task.dueDate"
v-if="dueLabel"
:class="
overdue
? 'font-medium text-rose-600 dark:text-rose-400'
: 'text-slate-500 dark:text-zinc-400'
"
>
{{ formatDueDate(task.dueDate) }}
{{ dueLabel }}
</span>
<span v-for="tag in visibleTags" :key="tag" class="text-slate-400 dark:text-zinc-500">
@@ -202,6 +228,15 @@ const priorityTextClass = computed(() => {
<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>
+40
View File
@@ -209,6 +209,46 @@ describe('TaskDetailDrawer 状态', () => {
})
})
describe('TaskDetailDrawer 时间信息', () => {
/** `YYYY-MM-DD HH:mm` */
const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/
it('显示创建与更新时间,未完成时完成时间标注为「未完成」', () => {
const { wrapper } = setup()
expect(wrapper.find('[data-role="created-at"]').text()).toMatch(DATE_TIME_PATTERN)
expect(wrapper.find('[data-role="updated-at"]').text()).toMatch(DATE_TIME_PATTERN)
expect(wrapper.find('[data-role="completed-at"]').text()).toBe('未完成')
})
it('任务完成后显示完成时间', async () => {
const { store, wrapper, taskId } = setup()
store.updateTask(taskId, { status: 'done' })
await wrapper.setProps({ task: store.tasks.find((item) => item.id === taskId) ?? null })
expect(wrapper.find('[data-role="completed-at"]').text()).toMatch(DATE_TIME_PATTERN)
})
it('已完成但缺少完成时间时显示「未记录」', async () => {
const { store, wrapper, taskId } = setup()
store.updateTask(taskId, { status: 'done', completedAt: null })
await wrapper.setProps({ task: store.tasks.find((item) => item.id === taskId) ?? null })
expect(wrapper.find('[data-role="completed-at"]').text()).toBe('未记录')
})
it('归档任务额外显示归档时间', async () => {
const { store, wrapper, taskId } = setup()
store.archiveTasks([taskId])
await wrapper.setProps({ task: store.tasks.find((item) => item.id === taskId) ?? null })
expect(wrapper.find('[data-role="archived-at"]').text()).toMatch(DATE_TIME_PATTERN)
})
})
describe('TaskDetailDrawer 归档', () => {
it('归档任务显示恢复入口,点击后取消归档', async () => {
const { store, toast, wrapper, taskId } = setup()
+58
View File
@@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority, type Task } from '@/types/task'
import { formatDateTime } from '@/utils/date'
import DatePicker from './DatePicker.vue'
import SideDrawer from './SideDrawer.vue'
@@ -53,6 +54,29 @@ watch(
const archived = computed(() => props.task !== null && props.task.archivedAt !== null)
/**
* 只读时间信息。
*
* createdAt 与 updatedAt 由 store 维护,completedAt 在状态进出「已完成」时
* 由 applyTaskPatch 写入 / 清除;这里只负责展示,不参与草稿与 dirty 判定
* —— 时间不是用户能编辑的字段,改了标题也不该让「创建于」看起来被改过。
*/
const createdAtText = computed(() => formatDateTime(props.task?.createdAt ?? null))
const updatedAtText = computed(() => formatDateTime(props.task?.updatedAt ?? null))
const completedAtText = computed(() => formatDateTime(props.task?.completedAt ?? null))
const archivedAtText = computed(() => formatDateTime(props.task?.archivedAt ?? null))
/**
* 完成时间的兜底文案。
*
* 旧数据或手工导入的数据可能出现「status 是已完成、completedAt 却是空」的情况,
* 此时显示「未记录」而不是「未完成」—— 后者会和卡片上的已完成状态自相矛盾。
*/
const completedLabel = computed(() => {
if (completedAtText.value) return completedAtText.value
return props.task?.status === 'done' ? '未记录' : '未完成'
})
function sameTags(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((tag, index) => tag === right[index])
}
@@ -224,6 +248,40 @@ function remove(): void {
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">标签</span>
<TagInput v-model="tags" :suggestions="store.allTags" />
</div>
<section
class="flex flex-col gap-2 rounded-md border border-slate-200 bg-slate-50/70 px-3 py-2.5 dark:border-zinc-800 dark:bg-zinc-800/40"
aria-labelledby="task-time-heading"
>
<h3 id="task-time-heading" class="text-sm font-medium text-slate-600 dark:text-zinc-400">
时间
</h3>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt class="text-slate-400 dark:text-zinc-500">创建</dt>
<dd data-role="created-at" class="text-slate-600 dark:text-zinc-300">
{{ createdAtText ?? '—' }}
</dd>
<dt class="text-slate-400 dark:text-zinc-500">完成</dt>
<dd data-role="completed-at" class="text-slate-600 dark:text-zinc-300">
<span :class="completedAtText ? '' : 'text-slate-400 dark:text-zinc-500'">
{{ completedLabel }}
</span>
</dd>
<dt class="text-slate-400 dark:text-zinc-500">更新</dt>
<dd data-role="updated-at" class="text-slate-600 dark:text-zinc-300">
{{ updatedAtText ?? '—' }}
</dd>
<template v-if="archivedAtText">
<dt class="text-slate-400 dark:text-zinc-500">归档</dt>
<dd data-role="archived-at" class="text-slate-600 dark:text-zinc-300">
{{ archivedAtText }}
</dd>
</template>
</dl>
</section>
</form>
<template #footer>
+1
View File
@@ -44,6 +44,7 @@ describe('TrashPanel 展示', () => {
expect(wrapper.text()).toContain('被删除的任务')
expect(wrapper.text()).toContain('原列 进行中')
expect(wrapper.text()).toContain('删除于')
expect(wrapper.text()).toContain('创建于')
expect(wrapper.text()).toContain('共 1 个任务')
})
+3 -6
View File
@@ -7,12 +7,12 @@
* 恢复则保留原 sortOrder 与归档状态,回到原来的位置。
*/
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import { COLUMNS, type Task } from '@/types/task'
import { formatDateTime } from '@/utils/date'
import SideDrawer from './SideDrawer.vue'
@@ -30,10 +30,6 @@ const columnTitles = Object.fromEntries(COLUMNS.map((column) => [column.id, colu
const count = computed(() => store.trashTasks.length)
function formatTime(iso: string): string {
return dayjs(iso).format('YYYY-MM-DD HH:mm')
}
function restore(task: Task): void {
store.restoreTask(task)
toast.show({ message: `已恢复「${task.title}」` })
@@ -133,7 +129,8 @@ watch(
</div>
<p class="text-xs text-slate-400 dark:text-zinc-500">
删除于 {{ formatTime(task.deletedAt ?? task.updatedAt) }} · 原列
删除于 {{ formatDateTime(task.deletedAt ?? task.updatedAt) ?? '—' }} · 创建于
{{ formatDateTime(task.createdAt) ?? '—' }} · 原列
{{ columnTitles[task.status] }}
</p>