fix: clarify conversation list timestamps
The recent-conversation list previously showed only a time, making older entries ambiguous. It now shows local time for conversations updated today, a local date for older entries, and includes the year when needed; the full timestamp remains available on hover. Release note: 会话列表现在当天显示时间、较早会话显示日期,跨年记录同时显示年份,更容易快速判断最近更新时间。
This commit is contained in:
@@ -490,6 +490,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
|||||||
- 使用 `standard` 壳层和统一 `PageHeader`。
|
- 使用 `standard` 壳层和统一 `PageHeader`。
|
||||||
- 搜索、范围和时间筛选位于筛选工具栏。
|
- 搜索、范围和时间筛选位于筛选工具栏。
|
||||||
- 行项目统一显示标题、范围、最近更新时间和必要状态。
|
- 行项目统一显示标题、范围、最近更新时间和必要状态。
|
||||||
|
- 侧栏最近会话的更新时间在当天显示本地时间,非当天显示本地日期;非当年记录必须同时显示年份。悬停时间信息时提供完整日期和时间。
|
||||||
- 删除入口使用 `danger-ghost`,并按数据可恢复性执行确认或撤销策略。
|
- 删除入口使用 `danger-ghost`,并按数据可恢复性执行确认或撤销策略。
|
||||||
|
|
||||||
### 13.3 知识库
|
### 13.3 知识库
|
||||||
|
|||||||
@@ -181,7 +181,11 @@ import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts'
|
|||||||
import { ReleaseNotesDialog } from './ReleaseNotesDialog'
|
import { ReleaseNotesDialog } from './ReleaseNotesDialog'
|
||||||
import { scheduleIdleRoutePreload } from './idle-route-preload'
|
import { scheduleIdleRoutePreload } from './idle-route-preload'
|
||||||
import { createPreloadableComponent } from './preloadable-component'
|
import { createPreloadableComponent } from './preloadable-component'
|
||||||
import { formatTime, type TimeFormatLocale } from './time-format'
|
import {
|
||||||
|
formatConversationListTime,
|
||||||
|
type TimeFormatLocale
|
||||||
|
} from './time-format'
|
||||||
|
import { formatMediumDateTime } from './locale-formatters'
|
||||||
import { formatCompactTokens } from './token-format'
|
import { formatCompactTokens } from './token-format'
|
||||||
import {
|
import {
|
||||||
pruneKeepAliveEntries,
|
pruneKeepAliveEntries,
|
||||||
@@ -6464,7 +6468,22 @@ function App(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<small>{formatTime(conversation.updatedAt, locale)}</small>
|
<small>
|
||||||
|
<time
|
||||||
|
dateTime={new Date(
|
||||||
|
conversation.updatedAt
|
||||||
|
).toISOString()}
|
||||||
|
title={formatMediumDateTime(
|
||||||
|
conversation.updatedAt,
|
||||||
|
locale
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatConversationListTime(
|
||||||
|
conversation.updatedAt,
|
||||||
|
locale
|
||||||
|
)}
|
||||||
|
</time>
|
||||||
|
</small>
|
||||||
</button>
|
</button>
|
||||||
{activeConversationIds.has(conversation.id) && (
|
{activeConversationIds.has(conversation.id) && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { formatTime } from './time-format'
|
import { formatConversationListTime, formatTime } from './time-format'
|
||||||
|
|
||||||
describe('formatTime', () => {
|
describe('formatTime', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -27,4 +27,47 @@ describe('formatTime', () => {
|
|||||||
expect(secondChinese).not.toBe('')
|
expect(secondChinese).not.toBe('')
|
||||||
expect(formatter).toHaveBeenCalledTimes(2)
|
expect(formatter).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows the time for a conversation updated today', () => {
|
||||||
|
const timestamp = new Date(2026, 7, 17, 9, 5).getTime()
|
||||||
|
const referenceTimestamp = new Date(2026, 7, 17, 23, 30).getTime()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatConversationListTime(timestamp, 'zh-CN', referenceTimestamp)
|
||||||
|
).toBe(
|
||||||
|
new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(timestamp)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the date after the local calendar day changes', () => {
|
||||||
|
const timestamp = new Date(2026, 7, 16, 23, 30).getTime()
|
||||||
|
const referenceTimestamp = new Date(2026, 7, 17, 0, 15).getTime()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatConversationListTime(timestamp, 'zh-CN', referenceTimestamp)
|
||||||
|
).toBe(
|
||||||
|
new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
}).format(timestamp)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes the year for a conversation from another year', () => {
|
||||||
|
const timestamp = new Date(2025, 11, 31, 23, 30).getTime()
|
||||||
|
const referenceTimestamp = new Date(2026, 0, 1, 0, 15).getTime()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatConversationListTime(timestamp, 'en-US', referenceTimestamp)
|
||||||
|
).toBe(
|
||||||
|
new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
}).format(timestamp)
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ const timeFormatters: Partial<
|
|||||||
Record<TimeFormatLocale, Intl.DateTimeFormat>
|
Record<TimeFormatLocale, Intl.DateTimeFormat>
|
||||||
> = {}
|
> = {}
|
||||||
|
|
||||||
|
const conversationDateFormatters: Partial<
|
||||||
|
Record<TimeFormatLocale, Intl.DateTimeFormat>
|
||||||
|
> = {}
|
||||||
|
|
||||||
|
const conversationDateWithYearFormatters: Partial<
|
||||||
|
Record<TimeFormatLocale, Intl.DateTimeFormat>
|
||||||
|
> = {}
|
||||||
|
|
||||||
export function formatTime(
|
export function formatTime(
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
locale: TimeFormatLocale
|
locale: TimeFormatLocale
|
||||||
@@ -16,3 +24,43 @@ export function formatTime(
|
|||||||
}))
|
}))
|
||||||
return formatter.format(timestamp)
|
return formatter.format(timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatConversationListTime(
|
||||||
|
timestamp: number,
|
||||||
|
locale: TimeFormatLocale,
|
||||||
|
referenceTimestamp = Date.now()
|
||||||
|
): string {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
const referenceDate = new Date(referenceTimestamp)
|
||||||
|
const isSameYear = date.getFullYear() === referenceDate.getFullYear()
|
||||||
|
const isToday =
|
||||||
|
isSameYear &&
|
||||||
|
date.getMonth() === referenceDate.getMonth() &&
|
||||||
|
date.getDate() === referenceDate.getDate()
|
||||||
|
|
||||||
|
if (isToday) {
|
||||||
|
return formatTime(timestamp, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSameYear) {
|
||||||
|
const formatter =
|
||||||
|
conversationDateFormatters[locale] ??
|
||||||
|
(conversationDateFormatters[locale] = new Intl.DateTimeFormat(locale, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
}))
|
||||||
|
return formatter.format(timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatter =
|
||||||
|
conversationDateWithYearFormatters[locale] ??
|
||||||
|
(conversationDateWithYearFormatters[locale] = new Intl.DateTimeFormat(
|
||||||
|
locale,
|
||||||
|
{
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
}
|
||||||
|
))
|
||||||
|
return formatter.format(timestamp)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user