mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 优化聊天记录缓存、虚拟分页与群聊媒体展示
- 使用持久化缓存加速启动并异步读取 WCDB 消息 - 修复空群缓存、头像和群成员名称丢失问题 - 修复引用图片缩略图、虚拟卸载缓存和图片预览 - 修正引用消息发送者显示为群 ID 的问题
This commit is contained in:
+276
-122
@@ -40,7 +40,9 @@ interface SelfInfo {
|
||||
|
||||
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
||||
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
||||
const INITIAL_MESSAGE_COUNT = 20
|
||||
const MESSAGE_PAGE_SIZE = 100
|
||||
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
|
||||
const EXPORT_PREVIEW_LIMIT = 20
|
||||
const getMessageIdentity = (message: Message): string => {
|
||||
if (message.localId) return `local:${message.localId}`
|
||||
@@ -48,6 +50,70 @@ const getMessageIdentity = (message: Message): string => {
|
||||
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
||||
}
|
||||
|
||||
const normalizeQuotedText = (value: string | undefined): string =>
|
||||
String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
const isInternalReferenceSender = (value: string | undefined): boolean => {
|
||||
const sender = String(value || '').trim()
|
||||
return (
|
||||
!sender ||
|
||||
sender.endsWith('@chatroom') ||
|
||||
sender.startsWith('wxid_') ||
|
||||
/^[a-z0-9_@.-]{12,}$/i.test(sender)
|
||||
)
|
||||
}
|
||||
|
||||
const enrichQuotedMessages = (messages: Message[], referenceMessages: Message[]): Message[] => {
|
||||
const imageDatNameByMd5 = new Map<string, string>()
|
||||
const messagesByContent = new Map<string, Message[]>()
|
||||
|
||||
for (const message of referenceMessages) {
|
||||
if (
|
||||
message.contentData?.type === 'image' &&
|
||||
message.contentData.md5 &&
|
||||
message.contentData.datName
|
||||
) {
|
||||
imageDatNameByMd5.set(message.contentData.md5, message.contentData.datName)
|
||||
}
|
||||
const content = normalizeQuotedText(message.content)
|
||||
if (!content) continue
|
||||
const candidates = messagesByContent.get(content) || []
|
||||
candidates.push(message)
|
||||
messagesByContent.set(content, candidates)
|
||||
}
|
||||
|
||||
return messages.map((message) => {
|
||||
if (message.contentData?.type !== 'quote') return message
|
||||
const quote = message.contentData
|
||||
let quotedImageDatName = quote.quotedImageDatName
|
||||
if (!quotedImageDatName && quote.quotedImageMd5) {
|
||||
quotedImageDatName = imageDatNameByMd5.get(quote.quotedImageMd5)
|
||||
}
|
||||
|
||||
let quotedSender = quote.quotedSender
|
||||
if (isInternalReferenceSender(quotedSender)) {
|
||||
const candidates = messagesByContent.get(normalizeQuotedText(quote.quotedContent)) || []
|
||||
const source = candidates
|
||||
.filter((candidate) => (candidate.createTime || 0) <= (message.createTime || Infinity))
|
||||
.sort((left, right) => (right.createTime || 0) - (left.createTime || 0))[0]
|
||||
if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name
|
||||
}
|
||||
|
||||
if (
|
||||
quotedSender === quote.quotedSender &&
|
||||
quotedImageDatName === quote.quotedImageDatName
|
||||
) {
|
||||
return message
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
contentData: { ...quote, quotedSender, quotedImageDatName }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
||||
if (left === right) return true
|
||||
if (left.length !== right.length) return false
|
||||
@@ -79,7 +145,7 @@ type StartupProgress = {
|
||||
}
|
||||
|
||||
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
|
||||
member.nickname || member.wxid
|
||||
member.groupNickname || member.nickname || member.remark || member.wechatNickname || member.wxid
|
||||
|
||||
const buildSyntheticGroupMessages = (
|
||||
previous: GroupSnapshot | null,
|
||||
@@ -150,7 +216,6 @@ function App(): React.ReactElement {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
|
||||
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
||||
const [dateRange, setDateRange] = useState('today') // 默认今天
|
||||
const [contentFilter, setContentFilter] = useState('')
|
||||
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||
@@ -195,6 +260,10 @@ function App(): React.ReactElement {
|
||||
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
||||
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
||||
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
|
||||
const messageHistoryRef = React.useRef<Message[]>([])
|
||||
const messagesRef = React.useRef<Message[]>([])
|
||||
const messagePrefetchRef = React.useRef<Promise<void> | null>(null)
|
||||
messagesRef.current = messages
|
||||
React.useEffect(() => {
|
||||
if (!reportNotice) return
|
||||
const timer = window.setTimeout(() => setReportNotice(''), 3200)
|
||||
@@ -486,7 +555,32 @@ function App(): React.ReactElement {
|
||||
}
|
||||
if (!autoLoginEnabled) return
|
||||
try {
|
||||
const result = await window.api.initDb(key)
|
||||
const startupCacheReady = await loadStartupCache()
|
||||
const initPromise = window.api.initDb(key)
|
||||
if (startupCacheReady) {
|
||||
setIsAuthenticated(true)
|
||||
setIsDatabaseConnected(false)
|
||||
setBootState('login')
|
||||
void initPromise.then(async (result) => {
|
||||
const success = typeof result === 'boolean' ? result : result.success
|
||||
if (!success) {
|
||||
const error = typeof result === 'boolean' ? '' : result.error
|
||||
setDbKeyStatus(`后台连接失败${error ? `: ${error}` : ''}`)
|
||||
setDbKeyStatusKind('error')
|
||||
return
|
||||
}
|
||||
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||
setIsDatabaseConnected(true)
|
||||
setDbKeyStatus('已连接数据库')
|
||||
// Cached contacts/self info are enough for startup. Native refresh is
|
||||
// intentionally user-triggered so it cannot freeze the first session.
|
||||
}).catch((error) => {
|
||||
console.warn('[Startup] background database init failed:', error)
|
||||
setDbKeyStatusKind('error')
|
||||
})
|
||||
return
|
||||
}
|
||||
const result = await initPromise
|
||||
if (!active) return
|
||||
const success = typeof result === 'boolean' ? result : result.success
|
||||
if (success) {
|
||||
@@ -497,9 +591,13 @@ function App(): React.ReactElement {
|
||||
setIsDatabaseConnected(true)
|
||||
setDbKeyStatus('已自动连接')
|
||||
setDbKeyStatusKind('success')
|
||||
await loadContacts()
|
||||
await refreshSelfInfo(3)
|
||||
setIsAuthenticated(true)
|
||||
const hasBootstrap = await loadBootstrapCache()
|
||||
if (hasBootstrap) {
|
||||
setIsAuthenticated(true)
|
||||
} else {
|
||||
await Promise.all([loadContacts(), refreshSelfInfo(3)])
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
} else {
|
||||
const error = typeof result === 'boolean' ? '' : result.error
|
||||
setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
|
||||
@@ -569,7 +667,7 @@ function App(): React.ReactElement {
|
||||
detail: '正在读取本地缓存',
|
||||
percent: 25
|
||||
})
|
||||
await loadBootstrapCache()
|
||||
const hasBootstrap = await loadBootstrapCache()
|
||||
// 持久化手动输入的密钥,供下次启动继续使用
|
||||
void window.api.saveDbKey(keyToUse).catch(() => undefined)
|
||||
void window.api.getSettings().then((current) => {
|
||||
@@ -585,15 +683,28 @@ function App(): React.ReactElement {
|
||||
})
|
||||
// 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号,
|
||||
// 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。
|
||||
await loadContacts({ waitForAvatars: false })
|
||||
await refreshSelfInfo(3)
|
||||
if (hasBootstrap) {
|
||||
// Cached contacts are sufficient for the first paint. Refresh native data in the background.
|
||||
setIsAuthenticated(true)
|
||||
void Promise.all([
|
||||
loadContacts({ waitForAvatars: false }),
|
||||
refreshSelfInfo(3)
|
||||
]).catch((error) => {
|
||||
console.warn('[Startup] background refresh failed:', error)
|
||||
})
|
||||
} else {
|
||||
await Promise.all([
|
||||
loadContacts({ waitForAvatars: false }),
|
||||
refreshSelfInfo(3)
|
||||
])
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
setStartupProgress({
|
||||
title: '加载完成',
|
||||
subtitle: '正在进入主页面',
|
||||
detail: '联系人和头像已准备好',
|
||||
percent: 100
|
||||
})
|
||||
setIsAuthenticated(true)
|
||||
setIsDatabaseConnected(true)
|
||||
setBootState('login')
|
||||
window.setTimeout(() => {
|
||||
@@ -646,10 +757,14 @@ function App(): React.ReactElement {
|
||||
const applyGroupMemberMeta = React.useCallback(
|
||||
(contact: Contact | null, baseMessages: Message[]): Message[] => {
|
||||
if (!contact || contact.type !== 'group') return baseMessages
|
||||
const enrichedMessages = enrichQuotedMessages(baseMessages, [
|
||||
...messageHistoryRef.current,
|
||||
...baseMessages
|
||||
])
|
||||
const memberMap = groupMemberMetaRef.current[contact.md5]
|
||||
if (!memberMap || memberMap.size === 0) return baseMessages
|
||||
if (!memberMap || memberMap.size === 0) return enrichedMessages
|
||||
|
||||
return baseMessages.map((message) => {
|
||||
return enrichedMessages.map((message) => {
|
||||
const senderId = String(message.senderId || message.name || '').trim()
|
||||
if (!senderId) return message
|
||||
const member = memberMap.get(senderId)
|
||||
@@ -672,23 +787,57 @@ function App(): React.ReactElement {
|
||||
[]
|
||||
)
|
||||
|
||||
const storeGroupMemberMeta = React.useCallback(
|
||||
(contact: Contact, snapshot: GroupSnapshot): void => {
|
||||
currentGroupSnapshotRef.current = snapshot
|
||||
groupMemberMetaRef.current[contact.md5] = new Map(
|
||||
snapshot.members.map((member) => [
|
||||
member.wxid,
|
||||
{ nickname: formatGroupMemberName(member), avatar: member.avatar || '' }
|
||||
])
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const loadGroupMemberMeta = React.useCallback(
|
||||
async (contact: Contact | null): Promise<GroupSnapshot | null> => {
|
||||
if (!contact || contact.type !== 'group') return null
|
||||
const snapshot = await logGroupSnapshot(contact, 'load-member-meta')
|
||||
if (!snapshot) return null
|
||||
currentGroupSnapshotRef.current = snapshot
|
||||
groupMemberMetaRef.current[contact.md5] = new Map(
|
||||
snapshot.members.map((member) => [
|
||||
member.wxid,
|
||||
{ nickname: member.nickname || member.wxid, avatar: member.avatar || '' }
|
||||
])
|
||||
)
|
||||
storeGroupMemberMeta(contact, snapshot)
|
||||
return snapshot
|
||||
},
|
||||
[logGroupSnapshot]
|
||||
[logGroupSnapshot, storeGroupMemberMeta]
|
||||
)
|
||||
|
||||
const handleReloadCurrentAvatars = React.useCallback(async (): Promise<void> => {
|
||||
const contact = selectedContact
|
||||
if (!contact) return
|
||||
|
||||
try {
|
||||
const avatars = await window.api.getContactAvatars([contact.m_nsUsrName])
|
||||
const avatar = avatars[contact.m_nsUsrName]
|
||||
if (avatar) {
|
||||
const updateContact = (item: Contact): Contact =>
|
||||
item.md5 === contact.md5 ? { ...item, avatar } : item
|
||||
setContacts((current) => current.map(updateContact))
|
||||
setFilteredContacts((current) => current.map(updateContact))
|
||||
setSelectedContact((current) =>
|
||||
current?.md5 === contact.md5 ? updateContact(current) : current
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Contacts] current avatar reload failed:', error)
|
||||
}
|
||||
|
||||
const snapshot = await loadGroupMemberMeta(contact)
|
||||
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
|
||||
setMessages((current) =>
|
||||
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId))
|
||||
)
|
||||
}, [applyGroupMemberMeta, loadGroupMemberMeta, mergeSyntheticMessages, selectedContact])
|
||||
|
||||
const handleAutoGetDbKey = async (): Promise<void> => {
|
||||
if (isFetchingDbKey) return
|
||||
setIsFetchingDbKey(true)
|
||||
@@ -761,65 +910,57 @@ function App(): React.ReactElement {
|
||||
setStartupProgress(null)
|
||||
}
|
||||
|
||||
const getDateRangeParams = (
|
||||
range: string
|
||||
): { startTime: number | undefined; endTime: number | undefined } => {
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
|
||||
|
||||
let startTime: number | undefined
|
||||
let endTime: number | undefined
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
startTime = startOfToday
|
||||
break
|
||||
case 'yesterday':
|
||||
startTime = startOfToday - 86400
|
||||
endTime = startOfToday - 1 // 昨天结束
|
||||
break
|
||||
case '7':
|
||||
startTime = Math.floor(Date.now() / 1000) - 7 * 86400
|
||||
break
|
||||
case '30':
|
||||
startTime = Math.floor(Date.now() / 1000) - 30 * 86400
|
||||
break
|
||||
case 'all':
|
||||
startTime = undefined
|
||||
break
|
||||
default:
|
||||
startTime = startOfToday
|
||||
}
|
||||
return { startTime, endTime }
|
||||
}
|
||||
|
||||
const handleSelectContact = async (contact: Contact): Promise<void> => {
|
||||
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
|
||||
setSelectedContact(contact)
|
||||
selectedContactMd5Ref.current = contact.md5
|
||||
currentGroupSnapshotRef.current = null
|
||||
setIsMessagesLoading(true)
|
||||
const { startTime, endTime } = getDateRangeParams(dateRange)
|
||||
const cachedMsgs = await window.api.getCachedMessages(contact.md5, startTime, endTime)
|
||||
const cachedPage = await window.api.getCachedMessagePage(contact.md5)
|
||||
const cachedMsgs = cachedPage.messages
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
if (cachedMsgs.length) {
|
||||
setMessages(
|
||||
applyGroupMemberMeta(
|
||||
contact,
|
||||
mergeSyntheticMessages(contact, cachedMsgs.slice(-MESSAGE_PAGE_SIZE))
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setMessages([])
|
||||
if (cachedPage.groupSnapshot) {
|
||||
storeGroupMemberMeta(contact, cachedPage.groupSnapshot)
|
||||
}
|
||||
messageHistoryRef.current = cachedMsgs
|
||||
setMessages(
|
||||
applyGroupMemberMeta(
|
||||
contact,
|
||||
mergeSyntheticMessages(contact, cachedMsgs.slice(-INITIAL_MESSAGE_COUNT))
|
||||
)
|
||||
)
|
||||
const needsLivePage =
|
||||
forceLive || (isDatabaseConnected && cachedMsgs.length < MESSAGE_PREFETCH_COUNT)
|
||||
if (!needsLivePage) {
|
||||
setIsMessagesLoading(false)
|
||||
if (contact.type === 'group' && cachedMsgs.length > 0 && !cachedPage.groupSnapshot) {
|
||||
window.setTimeout(() => {
|
||||
void loadGroupMemberMeta(contact).then((snapshot) => {
|
||||
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
|
||||
setMessages((current) =>
|
||||
applyGroupMemberMeta(
|
||||
contact,
|
||||
mergeSyntheticMessages(contact, current, snapshot.roomId)
|
||||
)
|
||||
)
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (cachedMsgs.length >= INITIAL_MESSAGE_COUNT) setIsMessagesLoading(false)
|
||||
await waitForPaint()
|
||||
try {
|
||||
const msgs = await window.api.getMessages(contact.md5, startTime, endTime, {
|
||||
limit: MESSAGE_PAGE_SIZE
|
||||
const loadLivePage = async (): Promise<void> => {
|
||||
const msgs = await window.api.getMessages(contact.md5, undefined, undefined, {
|
||||
limit: MESSAGE_PREFETCH_COUNT
|
||||
})
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs))
|
||||
setMessages(cachedMessages)
|
||||
if (contact.type === 'group') {
|
||||
messageHistoryRef.current = msgs
|
||||
const visibleMessages = applyGroupMemberMeta(
|
||||
contact,
|
||||
mergeSyntheticMessages(contact, msgs.slice(-INITIAL_MESSAGE_COUNT))
|
||||
)
|
||||
setMessages(visibleMessages)
|
||||
if (contact.type === 'group' && visibleMessages.length > 0) {
|
||||
window.setTimeout(() => {
|
||||
void loadGroupMemberMeta(contact).then((snapshot) => {
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
@@ -833,28 +974,79 @@ function App(): React.ReactElement {
|
||||
})
|
||||
}, 120)
|
||||
}
|
||||
}
|
||||
const prefetchPromise = loadLivePage()
|
||||
messagePrefetchRef.current = prefetchPromise
|
||||
try {
|
||||
await prefetchPromise
|
||||
} finally {
|
||||
if (messagePrefetchRef.current === prefetchPromise) messagePrefetchRef.current = null
|
||||
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDatabaseConnected || !selectedContact || messages.length > 0) return
|
||||
// The first live page uses async native cursors, so a cache miss can be filled
|
||||
// without blocking the Electron main thread.
|
||||
void handleSelectContact(selectedContact)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDatabaseConnected])
|
||||
|
||||
const loadStartupCache = async (): Promise<boolean> => {
|
||||
try {
|
||||
const cache = await window.api.getStartupCache()
|
||||
if (!cache?.contacts.length) return false
|
||||
setContacts(cache.contacts)
|
||||
setFilteredContacts(cache.contacts)
|
||||
if (cache.self) setSelfInfo(cache.self)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('[StartupCache] load failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadOlderMessages = async (): Promise<void> => {
|
||||
const contact = selectedContact
|
||||
if (!contact || isMessagesLoading || messages.length === 0) return
|
||||
const oldestTime = messages[0]?.createTime
|
||||
if (!contact || messagesRef.current.length === 0) return
|
||||
if (messagePrefetchRef.current) await messagePrefetchRef.current
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
const currentMessages = messagesRef.current
|
||||
const historyMessages = messageHistoryRef.current
|
||||
const firstVisibleIndex = historyMessages.findIndex(
|
||||
(message) => getMessageIdentity(message) === getMessageIdentity(currentMessages[0])
|
||||
)
|
||||
if (firstVisibleIndex > 0) {
|
||||
const inMemoryOlder = historyMessages.slice(
|
||||
Math.max(0, firstVisibleIndex - MESSAGE_PAGE_SIZE),
|
||||
firstVisibleIndex
|
||||
)
|
||||
setMessages((current) =>
|
||||
applyGroupMemberMeta(
|
||||
contact,
|
||||
mergeSyntheticMessages(contact, mergeMessagePages(inMemoryOlder, current))
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
const oldestTime = currentMessages[0]?.createTime
|
||||
if (!oldestTime) return
|
||||
const { startTime } = getDateRangeParams(dateRange)
|
||||
if (startTime && oldestTime <= startTime) return
|
||||
|
||||
setIsMessagesLoading(true)
|
||||
try {
|
||||
const olderMessages = await window.api.getMessages(
|
||||
const cachedPage = await window.api.getCachedMessagePage(
|
||||
contact.md5,
|
||||
startTime,
|
||||
oldestTime - 1,
|
||||
{ limit: MESSAGE_PAGE_SIZE }
|
||||
undefined,
|
||||
oldestTime - 1
|
||||
)
|
||||
const olderMessages = cachedPage.hit
|
||||
? cachedPage.messages
|
||||
: await window.api.getMessages(contact.md5, undefined, oldestTime - 1, {
|
||||
limit: MESSAGE_PAGE_SIZE
|
||||
})
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
|
||||
setMessages((current) =>
|
||||
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current)))
|
||||
)
|
||||
@@ -878,40 +1070,6 @@ function App(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDateRangeChange = (range: string): void => {
|
||||
setDateRange(range)
|
||||
if (selectedContact) {
|
||||
const { startTime, endTime } = getDateRangeParams(range)
|
||||
window.api
|
||||
.getCachedMessages(selectedContact.md5, startTime, endTime)
|
||||
.then((cachedMessages) => {
|
||||
if (!cachedMessages.length) return
|
||||
setMessages(
|
||||
applyGroupMemberMeta(
|
||||
selectedContact,
|
||||
mergeSyntheticMessages(selectedContact, cachedMessages.slice(-MESSAGE_PAGE_SIZE))
|
||||
)
|
||||
)
|
||||
})
|
||||
setIsMessagesLoading(true)
|
||||
window.api
|
||||
.getMessages(selectedContact.md5, startTime, endTime, { limit: MESSAGE_PAGE_SIZE })
|
||||
.then((nextMessages) => {
|
||||
setMessages(
|
||||
applyGroupMemberMeta(
|
||||
selectedContact,
|
||||
mergeSyntheticMessages(selectedContact, nextMessages)
|
||||
)
|
||||
)
|
||||
setIsMessagesLoading(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[Messages] date range load failed:', error)
|
||||
setIsMessagesLoading(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !selectedContact || !isNativeMonitorActive) return
|
||||
|
||||
@@ -928,12 +1086,11 @@ function App(): React.ReactElement {
|
||||
}
|
||||
refreshInFlight = true
|
||||
try {
|
||||
const range = getDateRangeParams(dateRange)
|
||||
const latestMessages = await window.api.getMessages(
|
||||
contactMd5,
|
||||
range.startTime,
|
||||
range.endTime,
|
||||
{ limit: MESSAGE_PAGE_SIZE }
|
||||
undefined,
|
||||
undefined,
|
||||
{ limit: INITIAL_MESSAGE_COUNT }
|
||||
)
|
||||
const nextMessages = applyGroupMemberMeta(
|
||||
selectedContact,
|
||||
@@ -975,7 +1132,6 @@ function App(): React.ReactElement {
|
||||
unsubscribe()
|
||||
}
|
||||
}, [
|
||||
dateRange,
|
||||
isAuthenticated,
|
||||
isNativeMonitorActive,
|
||||
selectedContact,
|
||||
@@ -1201,8 +1357,6 @@ function App(): React.ReactElement {
|
||||
onSearch={handleSearchContacts}
|
||||
onContentFilter={setContentFilter}
|
||||
width={sidebarWidth}
|
||||
dateRange={dateRange}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isDatabaseConnected}
|
||||
onOpenSettings={openSettings}
|
||||
@@ -1214,11 +1368,11 @@ function App(): React.ReactElement {
|
||||
messages={messages}
|
||||
isLoadingMessages={isMessagesLoading}
|
||||
contentFilter={contentFilter}
|
||||
dateRange={dateRange}
|
||||
onContentFilterChange={setContentFilter}
|
||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
|
||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
|
||||
onRefreshData={loadContacts}
|
||||
onLoadOlderMessages={() => void handleLoadOlderMessages()}
|
||||
onReloadAvatars={handleReloadCurrentAvatars}
|
||||
onLoadOlderMessages={handleLoadOlderMessages}
|
||||
onCreateGroupReport={handleOpenReportWorkspace}
|
||||
isAiLoading={reportGeneration.isGenerating}
|
||||
/>
|
||||
|
||||
@@ -1803,6 +1803,41 @@ body {
|
||||
accent-color: var(--wxex-brand);
|
||||
}
|
||||
|
||||
.chat-avatar-note {
|
||||
max-width: 320px;
|
||||
color: var(--wxex-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-avatar-reload {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-avatar-reload svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
.chat-avatar-reload svg.is-spinning {
|
||||
animation: chat-avatar-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes chat-avatar-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-status-separator {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
@@ -1827,6 +1862,12 @@ body {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.chat-avatar-note {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-conversation-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -11,62 +11,24 @@ interface ChatWindowProps {
|
||||
messages: Message[]
|
||||
isLoadingMessages?: boolean
|
||||
contentFilter?: string
|
||||
dateRange?: string
|
||||
onContentFilterChange?: (keyword: string) => void
|
||||
onRefresh?: () => void
|
||||
onRefreshData?: () => void
|
||||
onLoadOlderMessages?: () => void
|
||||
onReloadAvatars?: () => Promise<void>
|
||||
onLoadOlderMessages?: () => Promise<void>
|
||||
onCreateGroupReport?: () => void
|
||||
isAiLoading?: boolean
|
||||
}
|
||||
|
||||
const DATE_RANGE_LABELS: Record<string, string> = {
|
||||
today: '今天',
|
||||
yesterday: '昨日',
|
||||
'7': '7 天',
|
||||
'30': '30 天',
|
||||
all: '全部'
|
||||
}
|
||||
|
||||
const formatClock = (date: Date): string =>
|
||||
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
|
||||
const formatRangeDate = (date: Date, now: Date): string => {
|
||||
const clock = formatClock(date)
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return `${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
||||
}
|
||||
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
||||
}
|
||||
|
||||
const getChatHeaderRangeLabel = (range: string): string => {
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
|
||||
|
||||
if (range === 'today') return `今天 00:00—现在`
|
||||
if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
|
||||
if (range === '7') {
|
||||
const start = new Date(Date.now() - 7 * 86400000)
|
||||
return `${formatRangeDate(start, now)}—现在`
|
||||
}
|
||||
if (range === '30') {
|
||||
const start = new Date(Date.now() - 30 * 86400000)
|
||||
return `${formatRangeDate(start, now)}—现在`
|
||||
}
|
||||
if (range === 'all') return '全部记录'
|
||||
return DATE_RANGE_LABELS[range] || '当前范围'
|
||||
}
|
||||
|
||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
contact,
|
||||
messages,
|
||||
isLoadingMessages,
|
||||
contentFilter,
|
||||
dateRange = 'today',
|
||||
onContentFilterChange,
|
||||
onRefresh,
|
||||
onRefreshData,
|
||||
onReloadAvatars,
|
||||
onLoadOlderMessages,
|
||||
onCreateGroupReport,
|
||||
isAiLoading = false
|
||||
@@ -86,6 +48,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
)
|
||||
const [showAvatar, setShowAvatar] = useState(true)
|
||||
const [isAtLatest, setIsAtLatest] = useState(true)
|
||||
const [isReloadingAvatars, setIsReloadingAvatars] = useState(false)
|
||||
const previousScrollTopRef = useRef(0)
|
||||
|
||||
const scrollToBottom = useCallback((): void => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||
@@ -95,15 +59,34 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
|
||||
const target = event.currentTarget
|
||||
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
||||
setIsAtLatest(distanceToBottom <= 24)
|
||||
if (distanceToBottom <= 24) {
|
||||
setIsAtLatest(true)
|
||||
} else if (target.scrollTop < previousScrollTopRef.current - 1) {
|
||||
setIsAtLatest(false)
|
||||
}
|
||||
previousScrollTopRef.current = target.scrollTop
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
previousScrollTopRef.current = 0
|
||||
setIsAtLatest(true)
|
||||
}, [contact?.md5])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAtLatest) return
|
||||
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [isAtLatest, messages, scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAtLatest) return
|
||||
const content = messageListRef.current?.querySelector('.virtual-message-list')
|
||||
if (!content) return
|
||||
const observer = new ResizeObserver(() => scrollToBottom())
|
||||
observer.observe(content)
|
||||
return () => observer.disconnect()
|
||||
}, [contact?.md5, isAtLatest, scrollToBottom])
|
||||
|
||||
const openImagePreview = (imageUrl: string): void => {
|
||||
setPreviewImage(imageUrl)
|
||||
setImageScale(1)
|
||||
@@ -155,6 +138,16 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
imageDragRef.current = null
|
||||
}
|
||||
|
||||
const handleReloadAvatars = async (): Promise<void> => {
|
||||
if (!onReloadAvatars || isReloadingAvatars) return
|
||||
setIsReloadingAvatars(true)
|
||||
try {
|
||||
await onReloadAvatars()
|
||||
} finally {
|
||||
setIsReloadingAvatars(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewImage) return
|
||||
|
||||
@@ -186,14 +179,11 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
}, [messages, contentFilter])
|
||||
if (!contact) return <EmptyConversationState />
|
||||
|
||||
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
|
||||
|
||||
return (
|
||||
<div className="chat-window">
|
||||
<ChatHeader
|
||||
contact={contact}
|
||||
isGroupChat={isGroupChat}
|
||||
dateRangeLabel={dateRangeLabel}
|
||||
loadedCount={messages.length}
|
||||
filteredCount={filteredMessages.length}
|
||||
contentFilter={contentFilter || ''}
|
||||
@@ -221,7 +211,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
count={filteredMessages.length}
|
||||
showAvatar={showAvatar}
|
||||
isAtLatest={isAtLatest}
|
||||
isReloadingAvatars={isReloadingAvatars}
|
||||
onShowAvatarChange={setShowAvatar}
|
||||
onReloadAvatars={() => void handleReloadAvatars()}
|
||||
onJumpToLatest={scrollToBottom}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import type { JSX, MouseEvent } from 'react'
|
||||
|
||||
type CachedImage = { data: string; isThumbnail: boolean }
|
||||
|
||||
const MAX_IMAGE_CACHE_ENTRIES = 80
|
||||
const imageDataUrlCache = new Map<string, CachedImage>()
|
||||
|
||||
function imageCacheKeys(imageMd5?: string, imageDatName?: string): string[] {
|
||||
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
|
||||
Boolean
|
||||
)
|
||||
}
|
||||
|
||||
function getCachedImage(imageMd5?: string, imageDatName?: string): CachedImage | undefined {
|
||||
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
|
||||
const cached = imageDataUrlCache.get(key)
|
||||
if (cached) {
|
||||
imageDataUrlCache.delete(key)
|
||||
imageDataUrlCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function cacheImage(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
cached: CachedImage
|
||||
): void {
|
||||
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
|
||||
imageDataUrlCache.delete(key)
|
||||
imageDataUrlCache.set(key, cached)
|
||||
}
|
||||
while (imageDataUrlCache.size > MAX_IMAGE_CACHE_ENTRIES) {
|
||||
const oldestKey = imageDataUrlCache.keys().next().value
|
||||
if (!oldestKey) break
|
||||
imageDataUrlCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
interface ImageBubbleProps {
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
@@ -16,11 +55,12 @@ export function ImageBubble({
|
||||
isThumb = false,
|
||||
onImageClick
|
||||
}: ImageBubbleProps): JSX.Element {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const initialCachedImage = getCachedImage(imageMd5, imageDatName)
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isThumbnail, setIsThumbnail] = useState(false)
|
||||
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const loadImage = useCallback(async () => {
|
||||
@@ -34,6 +74,10 @@ export function ImageBubble({
|
||||
try {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
||||
if (result.success && result.data?.startsWith('data:image/')) {
|
||||
cacheImage(imageMd5, imageDatName, {
|
||||
data: result.data,
|
||||
isThumbnail: Boolean(result.isThumb)
|
||||
})
|
||||
setImageUrl(result.data)
|
||||
setIsThumbnail(Boolean(result.isThumb))
|
||||
setError(null)
|
||||
@@ -92,6 +136,10 @@ export function ImageBubble({
|
||||
force: true
|
||||
})
|
||||
if (result.success && result.data?.startsWith('data:image/')) {
|
||||
cacheImage(imageMd5, imageDatName, {
|
||||
data: result.data,
|
||||
isThumbnail: Boolean(result.isThumb)
|
||||
})
|
||||
setImageUrl(result.data)
|
||||
setIsThumbnail(Boolean(result.isThumb))
|
||||
setError(null)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
|
||||
interface ChatHeaderProps {
|
||||
contact: Contact
|
||||
isGroupChat: boolean
|
||||
dateRangeLabel: string
|
||||
loadedCount: number
|
||||
filteredCount: number
|
||||
contentFilter: string
|
||||
@@ -20,7 +19,6 @@ interface ChatHeaderProps {
|
||||
export function ChatHeader({
|
||||
contact,
|
||||
isGroupChat,
|
||||
dateRangeLabel,
|
||||
loadedCount,
|
||||
filteredCount,
|
||||
contentFilter,
|
||||
@@ -65,7 +63,6 @@ export function ChatHeader({
|
||||
<h2>{displayName}</h2>
|
||||
<div className="chat-title-meta">
|
||||
<span>{typeLabel}</span>
|
||||
<span>{dateRangeLabel}</span>
|
||||
<span>{visibleCount} 条消息</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react'
|
||||
import { RefreshIcon } from './icons'
|
||||
|
||||
interface ChatStatusBarProps {
|
||||
count: number
|
||||
showAvatar: boolean
|
||||
isAtLatest: boolean
|
||||
isReloadingAvatars: boolean
|
||||
onShowAvatarChange: (show: boolean) => void
|
||||
onReloadAvatars: () => void
|
||||
onJumpToLatest: () => void
|
||||
}
|
||||
|
||||
@@ -12,7 +15,9 @@ export function ChatStatusBar({
|
||||
count,
|
||||
showAvatar,
|
||||
isAtLatest,
|
||||
isReloadingAvatars,
|
||||
onShowAvatarChange,
|
||||
onReloadAvatars,
|
||||
onJumpToLatest
|
||||
}: ChatStatusBarProps): React.ReactElement {
|
||||
const jumpDisabled = isAtLatest || count === 0
|
||||
@@ -29,6 +34,22 @@ export function ChatStatusBar({
|
||||
/>
|
||||
<span>显示头像</span>
|
||||
</label>
|
||||
<span
|
||||
className="chat-avatar-note"
|
||||
title="头像或名称缺失时,请先在微信中进入该聊天,再尝试重新加载"
|
||||
>
|
||||
头像或名称缺失时,请先在微信中进入该聊天
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-avatar-reload"
|
||||
onClick={onReloadAvatars}
|
||||
disabled={isReloadingAvatars}
|
||||
title="尝试重新加载当前聊天的头像和名称"
|
||||
>
|
||||
<RefreshIcon className={isReloadingAvatars ? 'is-spinning' : ''} />
|
||||
<span>{isReloadingAvatars ? '加载中' : '重新加载头像'}</span>
|
||||
</button>
|
||||
<span className="chat-status-separator" aria-hidden />
|
||||
<button type="button" onClick={onJumpToLatest} disabled={jumpDisabled}>
|
||||
{jumpDisabled ? '已是最新消息' : '跳转到最新消息'}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function MessageGroup({
|
||||
)}
|
||||
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
|
||||
<div className="message-stack">
|
||||
{!isMine && isGroupChat && shouldShowAvatar && (
|
||||
{!isMine && isGroupChat && (
|
||||
<div className="message-sender-name">{displayName}</div>
|
||||
)}
|
||||
{group.messages.map((message, index) => (
|
||||
|
||||
@@ -14,7 +14,7 @@ interface MessageListProps {
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
bottomRef: React.RefObject<HTMLDivElement | null>
|
||||
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
||||
onReachTop?: () => void
|
||||
onReachTop?: () => Promise<void>
|
||||
onImageClick: (imageUrl: string) => void
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ export function MessageList({
|
||||
onImageClick
|
||||
}: MessageListProps): React.ReactElement {
|
||||
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
||||
const groupsRef = React.useRef(groups)
|
||||
const loadingOlderRef = React.useRef(false)
|
||||
groupsRef.current = groups
|
||||
// TanStack Virtual intentionally exposes mutable measurement methods.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer({
|
||||
@@ -45,7 +48,54 @@ export function MessageList({
|
||||
|
||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>): void => {
|
||||
onScroll(event)
|
||||
if (event.currentTarget.scrollTop < 120) onReachTop?.()
|
||||
const scrollElement = event.currentTarget
|
||||
if (
|
||||
scrollElement.scrollTop >= 48 ||
|
||||
loadingOlderRef.current ||
|
||||
isLoadingMessages ||
|
||||
!onReachTop
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingOlderRef.current = true
|
||||
const previousGroupCount = groups.length
|
||||
const previousScrollTop = scrollElement.scrollTop
|
||||
const previousScrollHeight = scrollElement.scrollHeight
|
||||
const anchorMessageId = groups[0]?.messages[0]?.id
|
||||
void (async () => {
|
||||
try {
|
||||
await onReachTop()
|
||||
for (let frame = 0; frame < 8; frame += 1) {
|
||||
if (groupsRef.current.length > previousGroupCount) break
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
if (!anchorMessageId) return
|
||||
const anchorIndex = groupsRef.current.findIndex((group) =>
|
||||
group.messages.some((message) => message.id === anchorMessageId)
|
||||
)
|
||||
if (anchorIndex <= 0) return
|
||||
virtualizer.measure()
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||
const addedHeight = scrollElement.scrollHeight - previousScrollHeight
|
||||
if (addedHeight > 0) {
|
||||
scrollElement.scrollTop = previousScrollTop + addedHeight
|
||||
} else {
|
||||
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
|
||||
}
|
||||
|
||||
// Variable-height groups can finish measuring one frame later. Preserve
|
||||
// the same message anchor after that final measurement as well.
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||
if (scrollElement.scrollTop < 48) {
|
||||
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
|
||||
}
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
loadingOlderRef.current = false
|
||||
}, 250)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,8 +19,6 @@ export interface ConversationSidebarProps {
|
||||
onSearch: (keyword: string) => void
|
||||
onContentFilter: (keyword: string) => void
|
||||
width: number
|
||||
dateRange: string
|
||||
onDateRangeChange: (range: string) => void
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
onOpenSettings: () => void
|
||||
@@ -37,8 +35,6 @@ export function ConversationSidebar({
|
||||
onSelectContact,
|
||||
onSearch,
|
||||
width,
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
onOpenSettings
|
||||
@@ -83,9 +79,7 @@ export function ConversationSidebar({
|
||||
<ConversationSidebarHeader
|
||||
totalCount={contacts.length}
|
||||
searchValue={searchTerm}
|
||||
dateRange={dateRange}
|
||||
onSearchChange={handleSearchChange}
|
||||
onDateRangeChange={onDateRangeChange}
|
||||
/>
|
||||
<div ref={listRef} className="conversation-list" aria-label="会话列表">
|
||||
<div className="conversation-virtual-content" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import React from 'react'
|
||||
import { ConversationSearch } from './ConversationSearch'
|
||||
import { DateRangeSelector } from './DateRangeSelector'
|
||||
|
||||
interface ConversationSidebarHeaderProps {
|
||||
totalCount: number
|
||||
searchValue: string
|
||||
dateRange: string
|
||||
onSearchChange: (value: string) => void
|
||||
onDateRangeChange: (range: string) => void
|
||||
}
|
||||
|
||||
export function ConversationSidebarHeader({
|
||||
totalCount,
|
||||
searchValue,
|
||||
dateRange,
|
||||
onSearchChange,
|
||||
onDateRangeChange
|
||||
onSearchChange
|
||||
}: ConversationSidebarHeaderProps): React.ReactElement {
|
||||
return (
|
||||
<div className="conversation-sidebar-header">
|
||||
@@ -24,7 +19,6 @@ export function ConversationSidebarHeader({
|
||||
<span>{totalCount} 个会话</span>
|
||||
</div>
|
||||
<ConversationSearch value={searchValue} onChange={onSearchChange} />
|
||||
<DateRangeSelector value={dateRange} onChange={onDateRangeChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
interface DateRangeSelectorProps {
|
||||
value: string
|
||||
onChange: (range: string) => void
|
||||
}
|
||||
|
||||
const DATE_RANGE_OPTIONS = [
|
||||
{ key: 'today', label: '今天' },
|
||||
{ key: 'yesterday', label: '昨日' },
|
||||
{ key: '7', label: '7 天' },
|
||||
{ key: '30', label: '30 天' },
|
||||
{ key: 'all', label: '全部' }
|
||||
]
|
||||
|
||||
export function DateRangeSelector({ value, onChange }: DateRangeSelectorProps): React.ReactElement {
|
||||
return (
|
||||
<div className="conversation-date-range" aria-label="时间范围">
|
||||
{DATE_RANGE_OPTIONS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`conversation-date-range-button ${value === item.key ? 'active' : ''}`}
|
||||
onClick={() => onChange(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user