Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion src/pages/ExportPage.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down
167 changes: 145 additions & 22 deletions src/pages/ExportPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { createPortal } from 'react-dom'
import {
Aperture,
ArrowDown,
ArrowUp,
ArrowUpDown,
Calendar,
Check,
CheckSquare,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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: '--' })
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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>
Expand Down