Merge pull request #805 from chadblur/listbytime

导出页面的聊天列表新增最近活跃时间,并支持按记录数&聊天排序。新增AI生成的项目架构分析文档
This commit is contained in:
xuncha
2026-05-26 18:43:24 +08:00
committed by GitHub
2 changed files with 235 additions and 23 deletions

View File

@@ -1940,10 +1940,11 @@
--contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width));
--contacts-left-sticky-width: calc(var(--contacts-select-col-width) + var(--contacts-main-col-width) + var(--contacts-column-gap));
--contacts-message-col-width: 94px;
--contacts-latest-time-col-width: 128px;
--contacts-media-col-width: 58px;
--contacts-action-col-width: 126px;
--contacts-actions-sticky-width: 160px;
--contacts-table-min-width: 1120px;
--contacts-table-min-width: 1248px;
overflow: hidden;
border: none;
border-radius: 8px;
@@ -2192,6 +2193,58 @@
box-sizing: border-box;
}
.contacts-list-header-latest-time {
width: var(--contacts-latest-time-col-width);
min-width: var(--contacts-latest-time-col-width);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
text-align: center;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-sizing: border-box;
}
.contacts-list-header-sortable {
background: transparent;
border: none;
padding: 4px 6px;
margin: 0;
color: inherit;
font: inherit;
cursor: pointer;
border-radius: 6px;
gap: 4px;
transition: background-color 0.12s ease, color 0.12s ease;
&:hover {
background: color-mix(in srgb, var(--primary) 10%, transparent);
color: var(--primary);
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
&.is-active {
color: var(--primary);
}
}
.contacts-list-header-sort-icon {
color: inherit;
flex-shrink: 0;
&.muted {
color: var(--text-tertiary);
opacity: 0.6;
}
}
.contacts-list-header-media {
width: var(--contacts-media-col-width);
min-width: var(--contacts-media-col-width);
@@ -2509,6 +2562,37 @@
box-sizing: border-box;
}
.row-latest-time {
width: var(--contacts-latest-time-col-width);
min-width: var(--contacts-latest-time-col-width);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
text-align: center;
box-sizing: border-box;
}
.row-latest-time-value {
margin: 0;
font-size: 12px;
line-height: 1.2;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
&.muted {
color: var(--text-tertiary);
}
}
.row-media-metric {
width: var(--contacts-media-col-width);
min-width: var(--contacts-media-col-width);
@@ -5035,6 +5119,7 @@
--contacts-name-text-width: 10em;
--contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width));
--contacts-message-col-width: 94px;
--contacts-latest-time-col-width: 120px;
--contacts-media-col-width: 56px;
--contacts-action-col-width: 126px;
}
@@ -5062,6 +5147,10 @@
min-width: var(--contacts-message-col-width);
}
.table-wrap .row-latest-time {
min-width: var(--contacts-latest-time-col-width);
}
.table-wrap .row-media-metric {
min-width: var(--contacts-media-col-width);
}

View File

@@ -4,6 +4,9 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { createPortal } from 'react-dom'
import {
Aperture,
ArrowDown,
ArrowUp,
ArrowUpDown,
Calendar,
Check,
CheckSquare,
@@ -656,6 +659,41 @@ const formatYmdHmDateTime = (timestamp?: number): string => {
return `${y}-${m}-${day} ${h}:${min}`
}
const formatLatestMessageTimeFromSeconds = (
timestamp?: number,
now: number = Date.now()
): { text: string; title: string } => {
if (!timestamp || !Number.isFinite(timestamp) || timestamp <= 0) {
return { text: '--', title: '' }
}
const ms = timestamp * 1000
const absolute = formatYmdHmDateTime(ms)
const diff = Math.max(0, now - ms)
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) {
return { text: '刚刚', title: absolute }
}
if (diff < hour) {
const minutes = Math.max(1, Math.floor(diff / minute))
return { text: `${minutes} 分钟前`, title: absolute }
}
if (diff < day) {
const hours = Math.max(1, Math.floor(diff / hour))
return { text: `${hours} 小时前`, title: absolute }
}
return { text: absolute, title: absolute }
}
type ContactsSortKey = 'messageCount' | 'latestMessageTime'
type ContactsSortOrder = 'desc' | 'asc'
interface ContactsSortConfig {
key: ContactsSortKey | null
order: ContactsSortOrder | null
}
const DEFAULT_CONTACTS_SORT_CONFIG: ContactsSortConfig = { key: null, order: null }
const isSingleContactSession = (sessionId: string): boolean => {
const normalized = String(sessionId || '').trim()
if (!normalized) return false
@@ -2269,6 +2307,18 @@ function ExportPage() {
const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState<SessionSnsTimelineTarget | null>(null)
const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('')
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskRecord[]>([])
const [contactsSortConfig, setContactsSortConfig] = useState<ContactsSortConfig>(DEFAULT_CONTACTS_SORT_CONFIG)
const toggleContactsSort = useCallback((key: ContactsSortKey) => {
setContactsSortConfig(prev => {
if (prev.key !== key) {
return { key, order: 'desc' }
}
if (prev.order === 'desc') return { key, order: 'asc' }
if (prev.order === 'asc') return DEFAULT_CONTACTS_SORT_CONFIG
return { key, order: 'desc' }
})
}, [])
const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
@@ -6661,34 +6711,47 @@ function ExportPage() {
)
})
const indexedContacts = contacts.map((contact, index) => ({
contact,
index,
count: (() => {
const counted = normalizeMessageCount(sessionMessageCounts[contact.username])
if (typeof counted === 'number') return counted
const hinted = normalizeMessageCount(sessionRowByUsername.get(contact.username)?.messageCountHint)
return hinted
})()
}))
const indexedContacts = contacts.map((contact, index) => {
const sessionRow = sessionRowByUsername.get(contact.username)
const counted = normalizeMessageCount(sessionMessageCounts[contact.username])
const hinted = normalizeMessageCount(sessionRow?.messageCountHint)
const count = typeof counted === 'number' ? counted : hinted
const rowTs = sessionRow?.lastTimestamp || sessionRow?.sortTimestamp
const latestTime = typeof rowTs === 'number' && rowTs > 0 ? rowTs : undefined
return { contact, index, count, latestTime }
})
const compareNullable = (a: number | undefined, b: number | undefined, order: ContactsSortOrder): number => {
const aHas = typeof a === 'number' && Number.isFinite(a)
const bHas = typeof b === 'number' && Number.isFinite(b)
if (aHas && bHas) {
const diff = (a as number) - (b as number)
return order === 'desc' ? -diff : diff
}
if (aHas) return -1
if (bHas) return 1
return 0
}
const sortKey = contactsSortConfig.key
const sortOrder = contactsSortConfig.order ?? 'desc'
indexedContacts.sort((a, b) => {
const aHasCount = typeof a.count === 'number'
const bHasCount = typeof b.count === 'number'
if (aHasCount && bHasCount) {
const diff = (b.count as number) - (a.count as number)
if (sortKey === 'latestMessageTime') {
const diff = compareNullable(a.latestTime, b.latestTime, sortOrder)
if (diff !== 0) return diff
} else if (sortKey === 'messageCount') {
const diff = compareNullable(a.count, b.count, sortOrder)
if (diff !== 0) return diff
} else {
const diff = compareNullable(a.count, b.count, 'desc')
if (diff !== 0) return diff
} else if (aHasCount) {
return -1
} else if (bHasCount) {
return 1
}
// 无统计值或同分时保持原顺序,避免列表频繁跳动。
return a.index - b.index
})
return indexedContacts.map(item => item.contact)
}, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername])
}, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername, contactsSortConfig])
const keywordMatchedContactUsernameSet = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase()
@@ -6897,7 +6960,7 @@ function ExportPage() {
useEffect(() => {
contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' })
setIsContactsListAtTop(true)
}, [activeTab, searchKeyword])
}, [activeTab, searchKeyword, contactsSortConfig])
const collectVisibleSessionMetricTargets = useCallback((sourceContacts: ContactInfo[]): string[] => {
if (sourceContacts.length === 0) return []
@@ -8408,6 +8471,15 @@ function ExportPage() {
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
const displayedMessageCount = countedMessages ?? hintedMessages
const mediaMetric = sessionContentMetrics[contact.username]
const rowLatestTs = matchedSession?.lastTimestamp || matchedSession?.sortTimestamp
const resolvedLatestTs = typeof rowLatestTs === 'number' && rowLatestTs > 0 ? rowLatestTs : undefined
const latestTimeInfo = formatLatestMessageTimeFromSeconds(resolvedLatestTs, nowTick)
const latestTimeState: { state: 'value'; text: string; title: string } | { state: 'loading' } | { state: 'na'; text: '--' } =
!canExport
? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' })
: (typeof resolvedLatestTs === 'number' && resolvedLatestTs > 0
? { state: 'value', text: latestTimeInfo.text, title: latestTimeInfo.title }
: { state: 'na', text: '--' })
const messageCountState: { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } =
!canExport
? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' })
@@ -8523,6 +8595,18 @@ function ExportPage() {
</button>
)}
</div>
<div className="row-latest-time">
{latestTimeState.state === 'loading'
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="最新消息时间加载中" />
: (
<span
className={`row-latest-time-value ${latestTimeState.state === 'value' ? '' : 'muted'}`}
title={latestTimeState.state === 'value' ? latestTimeState.title : undefined}
>
{latestTimeState.text}
</span>
)}
</div>
<div className="row-media-metric">
<strong className="row-media-metric-value">
{emojiMetric.state === 'loading'
@@ -9471,7 +9555,46 @@ function ExportPage() {
<span className="contacts-list-header-main-label">{contactsHeaderMainLabel}</span>
</span>
</span>
<span className="contacts-list-header-count"></span>
<button
type="button"
className={`contacts-list-header-count contacts-list-header-sortable ${contactsSortConfig.key === 'messageCount' ? 'is-active' : ''}`}
onClick={() => toggleContactsSort('messageCount')}
title={
contactsSortConfig.key !== 'messageCount'
? '按总消息数降序排列'
: contactsSortConfig.order === 'desc'
? '切换为按总消息数升序'
: '取消排序(恢复默认)'
}
>
<span></span>
{contactsSortConfig.key === 'messageCount'
? (contactsSortConfig.order === 'asc'
? <ArrowUp size={12} className="contacts-list-header-sort-icon" />
: <ArrowDown size={12} className="contacts-list-header-sort-icon" />)
: <ArrowUpDown size={12} className="contacts-list-header-sort-icon muted" />
}
</button>
<button
type="button"
className={`contacts-list-header-latest-time contacts-list-header-sortable ${contactsSortConfig.key === 'latestMessageTime' ? 'is-active' : ''}`}
onClick={() => toggleContactsSort('latestMessageTime')}
title={
contactsSortConfig.key !== 'latestMessageTime'
? '按最新消息时间降序排列'
: contactsSortConfig.order === 'desc'
? '切换为按最新消息时间升序'
: '取消排序(恢复默认)'
}
>
<span></span>
{contactsSortConfig.key === 'latestMessageTime'
? (contactsSortConfig.order === 'asc'
? <ArrowUp size={12} className="contacts-list-header-sort-icon" />
: <ArrowDown size={12} className="contacts-list-header-sort-icon" />)
: <ArrowUpDown size={12} className="contacts-list-header-sort-icon muted" />
}
</button>
<span className="contacts-list-header-media"></span>
<span className="contacts-list-header-media"></span>
<span className="contacts-list-header-media"></span>