mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-05-27 15:11:23 +00:00
Merge pull request #805 from chadblur/listbytime
导出页面的聊天列表新增最近活跃时间,并支持按记录数&聊天排序。新增AI生成的项目架构分析文档
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user