mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-28 07:25:53 +00:00
Merge remote-tracking branch 'upstream/dev'
This commit is contained in:
@@ -585,6 +585,263 @@ interface GroupPanelMember {
|
||||
messageCountStatus: GroupMessageCountStatus
|
||||
}
|
||||
|
||||
const QUOTED_SENDER_CACHE_TTL_MS = 10 * 60 * 1000
|
||||
const quotedSenderDisplayCache = new Map<string, { displayName: string; updatedAt: number }>()
|
||||
const quotedSenderDisplayLoading = new Map<string, Promise<string | undefined>>()
|
||||
const quotedGroupMembersCache = new Map<string, { members: GroupPanelMember[]; updatedAt: number }>()
|
||||
const quotedGroupMembersLoading = new Map<string, Promise<GroupPanelMember[]>>()
|
||||
|
||||
function buildQuotedSenderCacheKey(
|
||||
sessionId: string,
|
||||
senderUsername: string,
|
||||
isGroupChat: boolean
|
||||
): string {
|
||||
const normalizedSessionId = normalizeSearchIdentityText(sessionId) || String(sessionId || '').trim()
|
||||
const normalizedSender = normalizeSearchIdentityText(senderUsername) || String(senderUsername || '').trim()
|
||||
return `${isGroupChat ? 'group' : 'direct'}::${normalizedSessionId}::${normalizedSender}`
|
||||
}
|
||||
|
||||
function isSameQuotedSenderIdentity(left?: string | null, right?: string | null): boolean {
|
||||
const leftCandidates = buildSearchIdentityCandidates(left)
|
||||
const rightCandidates = buildSearchIdentityCandidates(right)
|
||||
if (leftCandidates.length === 0 || rightCandidates.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const leftCandidate of leftCandidates) {
|
||||
for (const rightCandidate of rightCandidates) {
|
||||
if (leftCandidate === rightCandidate) return true
|
||||
if (leftCandidate.startsWith(rightCandidate + '_')) return true
|
||||
if (rightCandidate.startsWith(leftCandidate + '_')) return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function normalizeQuotedGroupMember(member: Partial<GroupPanelMember> | null | undefined): GroupPanelMember | null {
|
||||
const username = String(member?.username || '').trim()
|
||||
if (!username) return null
|
||||
|
||||
const displayName = String(member?.displayName || '').trim()
|
||||
const nickname = String(member?.nickname || '').trim()
|
||||
const remark = String(member?.remark || '').trim()
|
||||
const alias = String(member?.alias || '').trim()
|
||||
const groupNickname = String(member?.groupNickname || '').trim()
|
||||
|
||||
return {
|
||||
username,
|
||||
displayName: displayName || groupNickname || remark || nickname || alias || username,
|
||||
avatarUrl: member?.avatarUrl,
|
||||
nickname,
|
||||
alias,
|
||||
remark,
|
||||
groupNickname,
|
||||
isOwner: Boolean(member?.isOwner),
|
||||
isFriend: Boolean(member?.isFriend),
|
||||
messageCount: Number.isFinite(member?.messageCount) ? Math.max(0, Math.floor(member?.messageCount as number)) : 0,
|
||||
messageCountStatus: 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveQuotedSenderFallbackDisplayName(
|
||||
sessionId: string,
|
||||
senderUsername?: string | null,
|
||||
fallbackDisplayName?: string | null
|
||||
): string | undefined {
|
||||
const resolved = resolveSearchSenderDisplayName(fallbackDisplayName, senderUsername, sessionId)
|
||||
if (resolved) return resolved
|
||||
return resolveSearchSenderUsernameFallback(senderUsername)
|
||||
}
|
||||
|
||||
function resolveQuotedSenderUsername(
|
||||
fromusr?: string | null,
|
||||
chatusr?: string | null
|
||||
): string {
|
||||
const normalizedChatUsr = String(chatusr || '').trim()
|
||||
const normalizedFromUsr = String(fromusr || '').trim()
|
||||
|
||||
if (normalizedChatUsr) {
|
||||
return normalizedChatUsr
|
||||
}
|
||||
|
||||
if (normalizedFromUsr.endsWith('@chatroom')) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return normalizedFromUsr
|
||||
}
|
||||
|
||||
function resolveQuotedGroupMemberDisplayName(member: GroupPanelMember): string | undefined {
|
||||
const remark = normalizeSearchIdentityText(member.remark)
|
||||
if (remark) return remark
|
||||
|
||||
const groupNickname = normalizeSearchIdentityText(member.groupNickname)
|
||||
if (groupNickname) return groupNickname
|
||||
|
||||
const nickname = normalizeSearchIdentityText(member.nickname)
|
||||
if (nickname) return nickname
|
||||
|
||||
const displayName = resolveSearchSenderDisplayName(member.displayName, member.username)
|
||||
if (displayName) return displayName
|
||||
|
||||
const alias = normalizeSearchIdentityText(member.alias)
|
||||
if (alias) return alias
|
||||
|
||||
return resolveSearchSenderUsernameFallback(member.username)
|
||||
}
|
||||
|
||||
function resolveQuotedPrivateDisplayName(contact: any): string | undefined {
|
||||
const remark = normalizeSearchIdentityText(contact?.remark)
|
||||
if (remark) return remark
|
||||
|
||||
const nickname = normalizeSearchIdentityText(
|
||||
contact?.nickName || contact?.nick_name || contact?.nickname
|
||||
)
|
||||
if (nickname) return nickname
|
||||
|
||||
const alias = normalizeSearchIdentityText(contact?.alias)
|
||||
if (alias) return alias
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function getQuotedGroupMembers(chatroomId: string): Promise<GroupPanelMember[]> {
|
||||
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||
if (!normalizedChatroomId || !normalizedChatroomId.includes('@chatroom')) {
|
||||
return []
|
||||
}
|
||||
|
||||
const cached = quotedGroupMembersCache.get(normalizedChatroomId)
|
||||
if (cached && Date.now() - cached.updatedAt < QUOTED_SENDER_CACHE_TTL_MS) {
|
||||
return cached.members
|
||||
}
|
||||
|
||||
const pending = quotedGroupMembersLoading.get(normalizedChatroomId)
|
||||
if (pending) return pending
|
||||
|
||||
const request = window.electronAPI.groupAnalytics.getGroupMembersPanelData(
|
||||
normalizedChatroomId,
|
||||
{ forceRefresh: false, includeMessageCounts: false }
|
||||
).then((result) => {
|
||||
const members = Array.isArray(result.data)
|
||||
? result.data
|
||||
.map((member) => normalizeQuotedGroupMember(member as Partial<GroupPanelMember>))
|
||||
.filter((member): member is GroupPanelMember => Boolean(member))
|
||||
: []
|
||||
|
||||
if (members.length > 0) {
|
||||
quotedGroupMembersCache.set(normalizedChatroomId, {
|
||||
members,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
return members
|
||||
}
|
||||
|
||||
return cached?.members || []
|
||||
}).catch(() => cached?.members || []).finally(() => {
|
||||
quotedGroupMembersLoading.delete(normalizedChatroomId)
|
||||
})
|
||||
|
||||
quotedGroupMembersLoading.set(normalizedChatroomId, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async function resolveQuotedSenderDisplayName(options: {
|
||||
sessionId: string
|
||||
senderUsername?: string | null
|
||||
fallbackDisplayName?: string | null
|
||||
isGroupChat?: boolean
|
||||
myWxid?: string | null
|
||||
}): Promise<string | undefined> {
|
||||
const normalizedSessionId = String(options.sessionId || '').trim()
|
||||
const normalizedSender = String(options.senderUsername || '').trim()
|
||||
const fallbackDisplayName = resolveQuotedSenderFallbackDisplayName(
|
||||
normalizedSessionId,
|
||||
normalizedSender,
|
||||
options.fallbackDisplayName
|
||||
)
|
||||
|
||||
if (!normalizedSender) {
|
||||
return fallbackDisplayName
|
||||
}
|
||||
|
||||
const cacheKey = buildQuotedSenderCacheKey(normalizedSessionId, normalizedSender, Boolean(options.isGroupChat))
|
||||
const cached = quotedSenderDisplayCache.get(cacheKey)
|
||||
if (cached && Date.now() - cached.updatedAt < QUOTED_SENDER_CACHE_TTL_MS) {
|
||||
return cached.displayName
|
||||
}
|
||||
|
||||
const pending = quotedSenderDisplayLoading.get(cacheKey)
|
||||
if (pending) return pending
|
||||
|
||||
const request = (async (): Promise<string | undefined> => {
|
||||
if (options.isGroupChat) {
|
||||
const members = await getQuotedGroupMembers(normalizedSessionId)
|
||||
const matchedMember = members.find((member) => isSameQuotedSenderIdentity(member.username, normalizedSender))
|
||||
const groupDisplayName = matchedMember ? resolveQuotedGroupMemberDisplayName(matchedMember) : undefined
|
||||
if (groupDisplayName) {
|
||||
quotedSenderDisplayCache.set(cacheKey, {
|
||||
displayName: groupDisplayName,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
return groupDisplayName
|
||||
}
|
||||
}
|
||||
|
||||
if (isCurrentUserSearchIdentity(normalizedSender, options.myWxid)) {
|
||||
const selfDisplayName = fallbackDisplayName || '我'
|
||||
quotedSenderDisplayCache.set(cacheKey, {
|
||||
displayName: selfDisplayName,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
return selfDisplayName
|
||||
}
|
||||
|
||||
try {
|
||||
const contact = await window.electronAPI.chat.getContact(normalizedSender)
|
||||
const contactDisplayName = resolveQuotedPrivateDisplayName(contact)
|
||||
if (contactDisplayName) {
|
||||
quotedSenderDisplayCache.set(cacheKey, {
|
||||
displayName: contactDisplayName,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
return contactDisplayName
|
||||
}
|
||||
} catch {
|
||||
// ignore contact lookup failures and fall back below
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await window.electronAPI.chat.getContactAvatar(normalizedSender)
|
||||
const profileDisplayName = normalizeSearchIdentityText(profile?.displayName)
|
||||
if (profileDisplayName && !isWxidLikeSearchIdentity(profileDisplayName)) {
|
||||
quotedSenderDisplayCache.set(cacheKey, {
|
||||
displayName: profileDisplayName,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
return profileDisplayName
|
||||
}
|
||||
} catch {
|
||||
// ignore avatar lookup failures and keep fallback usable
|
||||
}
|
||||
|
||||
if (fallbackDisplayName) {
|
||||
quotedSenderDisplayCache.set(cacheKey, {
|
||||
displayName: fallbackDisplayName,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
return fallbackDisplayName
|
||||
})().finally(() => {
|
||||
quotedSenderDisplayLoading.delete(cacheKey)
|
||||
})
|
||||
|
||||
quotedSenderDisplayLoading.set(cacheKey, request)
|
||||
return request
|
||||
}
|
||||
|
||||
interface SessionListCachePayload {
|
||||
updatedAt: number
|
||||
sessions: ChatSession[]
|
||||
@@ -2394,6 +2651,10 @@ function ChatPage(props: ChatPageProps) {
|
||||
const handleAccountChanged = useCallback(async () => {
|
||||
senderAvatarCache.clear()
|
||||
senderAvatarLoading.clear()
|
||||
quotedSenderDisplayCache.clear()
|
||||
quotedSenderDisplayLoading.clear()
|
||||
quotedGroupMembersCache.clear()
|
||||
quotedGroupMembersLoading.clear()
|
||||
sessionContactProfileCacheRef.current.clear()
|
||||
pendingSessionContactEnrichRef.current.clear()
|
||||
sessionContactEnrichAttemptAtRef.current.clear()
|
||||
@@ -5660,6 +5921,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
session={currentSession!}
|
||||
showTime={!showDateDivider && showTime}
|
||||
myAvatarUrl={myAvatarUrl}
|
||||
myWxid={myWxid}
|
||||
isGroupChat={isCurrentSessionGroup}
|
||||
autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled}
|
||||
onRequireModelDownload={handleRequireModelDownload}
|
||||
@@ -5678,6 +5940,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
formatDateDivider,
|
||||
currentSession,
|
||||
myAvatarUrl,
|
||||
myWxid,
|
||||
isCurrentSessionGroup,
|
||||
autoTranscribeVoiceEnabled,
|
||||
handleRequireModelDownload,
|
||||
@@ -7258,6 +7521,7 @@ function MessageBubble({
|
||||
session,
|
||||
showTime,
|
||||
myAvatarUrl,
|
||||
myWxid,
|
||||
isGroupChat,
|
||||
autoTranscribeVoiceEnabled,
|
||||
onRequireModelDownload,
|
||||
@@ -7271,6 +7535,7 @@ function MessageBubble({
|
||||
session: ChatSession;
|
||||
showTime?: boolean;
|
||||
myAvatarUrl?: string;
|
||||
myWxid?: string;
|
||||
isGroupChat?: boolean;
|
||||
autoTranscribeVoiceEnabled?: boolean;
|
||||
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
|
||||
@@ -7290,6 +7555,7 @@ function MessageBubble({
|
||||
const isSent = message.isSend === 1
|
||||
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
|
||||
const [senderName, setSenderName] = useState<string | undefined>(undefined)
|
||||
const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined)
|
||||
const senderProfileRequestSeqRef = useRef(0)
|
||||
const [emojiError, setEmojiError] = useState(false)
|
||||
const [emojiLoading, setEmojiLoading] = useState(false)
|
||||
@@ -8235,6 +8501,53 @@ function MessageBubble({
|
||||
appMsgTextCache.set(selector, value)
|
||||
return value
|
||||
}, [appMsgDoc, appMsgTextCache])
|
||||
const quotedSenderUsername = resolveQuotedSenderUsername(
|
||||
queryAppMsgText('refermsg > fromusr'),
|
||||
queryAppMsgText('refermsg > chatusr')
|
||||
)
|
||||
const quotedContent = message.quotedContent || queryAppMsgText('refermsg > content') || ''
|
||||
const quotedSenderFallbackName = useMemo(
|
||||
() => resolveQuotedSenderFallbackDisplayName(
|
||||
session.username,
|
||||
quotedSenderUsername,
|
||||
message.quotedSender || queryAppMsgText('refermsg > displayname') || ''
|
||||
),
|
||||
[message.quotedSender, queryAppMsgText, quotedSenderUsername, session.username]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const nextFallbackName = quotedSenderFallbackName || undefined
|
||||
setQuotedSenderName(nextFallbackName)
|
||||
|
||||
if (!quotedContent || !quotedSenderUsername) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
void resolveQuotedSenderDisplayName({
|
||||
sessionId: session.username,
|
||||
senderUsername: quotedSenderUsername,
|
||||
fallbackDisplayName: nextFallbackName,
|
||||
isGroupChat,
|
||||
myWxid
|
||||
}).then((resolvedName) => {
|
||||
if (cancelled) return
|
||||
setQuotedSenderName(resolvedName || nextFallbackName)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
quotedContent,
|
||||
quotedSenderFallbackName,
|
||||
quotedSenderUsername,
|
||||
session.username,
|
||||
isGroupChat,
|
||||
myWxid
|
||||
])
|
||||
|
||||
const locationMessageMeta = useMemo(() => {
|
||||
if (message.localType !== 48) return null
|
||||
@@ -8269,7 +8582,8 @@ function MessageBubble({
|
||||
: (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl)
|
||||
|
||||
// 是否有引用消息
|
||||
const hasQuote = message.quotedContent && message.quotedContent.length > 0
|
||||
const hasQuote = quotedContent.length > 0
|
||||
const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName
|
||||
|
||||
const handlePlayVideo = useCallback(async () => {
|
||||
if (!videoInfo?.videoUrl) return
|
||||
@@ -8680,7 +8994,6 @@ function MessageBubble({
|
||||
if (xmlType === '57') {
|
||||
const replyText = q('title') || cleanedParsedContent || ''
|
||||
const referContent = q('refermsg > content') || ''
|
||||
const referSender = q('refermsg > displayname') || ''
|
||||
const referType = q('refermsg > type') || ''
|
||||
|
||||
// 根据被引用消息类型渲染对应内容
|
||||
@@ -8712,7 +9025,7 @@ function MessageBubble({
|
||||
return (
|
||||
<div className="bubble-content">
|
||||
<div className="quoted-message">
|
||||
{referSender && <span className="quoted-sender">{referSender}</span>}
|
||||
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
|
||||
<span className="quoted-text">{renderReferContent()}</span>
|
||||
</div>
|
||||
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
||||
@@ -8808,11 +9121,10 @@ function MessageBubble({
|
||||
// 引用回复消息(appMsgKind='quote',xmlType=57)
|
||||
const replyText = message.linkTitle || q('title') || cleanedParsedContent || ''
|
||||
const referContent = message.quotedContent || q('refermsg > content') || ''
|
||||
const referSender = message.quotedSender || q('refermsg > displayname') || ''
|
||||
return (
|
||||
<div className="bubble-content">
|
||||
<div className="quoted-message">
|
||||
{referSender && <span className="quoted-sender">{referSender}</span>}
|
||||
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
|
||||
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
|
||||
</div>
|
||||
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
||||
@@ -9003,7 +9315,6 @@ function MessageBubble({
|
||||
if (appMsgType === '57') {
|
||||
const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanedParsedContent || ''
|
||||
const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || ''
|
||||
const referSender = parsedDoc?.querySelector('refermsg > displayname')?.textContent?.trim() || ''
|
||||
const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || ''
|
||||
|
||||
const renderReferContent2 = () => {
|
||||
@@ -9029,7 +9340,7 @@ function MessageBubble({
|
||||
return (
|
||||
<div className="bubble-content">
|
||||
<div className="quoted-message">
|
||||
{referSender && <span className="quoted-sender">{referSender}</span>}
|
||||
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
|
||||
<span className="quoted-text">{renderReferContent2()}</span>
|
||||
</div>
|
||||
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
||||
@@ -9315,8 +9626,8 @@ function MessageBubble({
|
||||
return (
|
||||
<div className="bubble-content">
|
||||
<div className="quoted-message">
|
||||
{message.quotedSender && <span className="quoted-sender">{message.quotedSender}</span>}
|
||||
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(message.quotedContent || ''))}</span>
|
||||
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
|
||||
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(quotedContent))}</span>
|
||||
</div>
|
||||
<div className="message-text">{renderTextWithEmoji(cleanedParsedContent)}</div>
|
||||
</div>
|
||||
@@ -9444,6 +9755,7 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
|
||||
if (prevProps.messageKey !== nextProps.messageKey) return false
|
||||
if (prevProps.showTime !== nextProps.showTime) return false
|
||||
if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false
|
||||
if (prevProps.myWxid !== nextProps.myWxid) return false
|
||||
if (prevProps.isGroupChat !== nextProps.isGroupChat) return false
|
||||
if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false
|
||||
if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false
|
||||
|
||||
@@ -52,10 +52,13 @@ import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '..
|
||||
import type { SnsPost } from '../types/sns'
|
||||
import {
|
||||
cloneExportDateRange,
|
||||
cloneExportDateRangeSelection,
|
||||
createDefaultDateRange,
|
||||
createDefaultExportDateRangeSelection,
|
||||
getExportDateRangeLabel,
|
||||
resolveExportDateRangeConfig,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
type ExportDateRangeSelection
|
||||
} from '../utils/exportDateRange'
|
||||
import './ExportPage.scss'
|
||||
@@ -830,6 +833,13 @@ interface SessionContentMetric {
|
||||
transferMessages?: number
|
||||
redPacketMessages?: number
|
||||
callMessages?: number
|
||||
firstTimestamp?: number
|
||||
lastTimestamp?: number
|
||||
}
|
||||
|
||||
interface TimeRangeBounds {
|
||||
minDate: Date
|
||||
maxDate: Date
|
||||
}
|
||||
|
||||
interface SessionExportCacheMeta {
|
||||
@@ -1049,27 +1059,74 @@ const normalizeMessageCount = (value: unknown): number | undefined => {
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
const normalizeTimestampSeconds = (value: unknown): number | undefined => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return undefined
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
const clampExportSelectionToBounds = (
|
||||
selection: ExportDateRangeSelection,
|
||||
bounds: TimeRangeBounds | null
|
||||
): ExportDateRangeSelection => {
|
||||
if (!bounds) return cloneExportDateRangeSelection(selection)
|
||||
|
||||
const boundedStart = startOfDay(bounds.minDate)
|
||||
const boundedEnd = endOfDay(bounds.maxDate)
|
||||
const originalStart = selection.useAllTime ? boundedStart : startOfDay(selection.dateRange.start)
|
||||
const originalEnd = selection.useAllTime ? boundedEnd : endOfDay(selection.dateRange.end)
|
||||
const nextStart = new Date(Math.min(Math.max(originalStart.getTime(), boundedStart.getTime()), boundedEnd.getTime()))
|
||||
const nextEndCandidate = new Date(Math.min(Math.max(originalEnd.getTime(), boundedStart.getTime()), boundedEnd.getTime()))
|
||||
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
|
||||
const rangeChanged = nextStart.getTime() !== originalStart.getTime() || nextEnd.getTime() !== originalEnd.getTime()
|
||||
|
||||
return {
|
||||
preset: selection.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset),
|
||||
useAllTime: selection.useAllTime,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const areExportSelectionsEqual = (left: ExportDateRangeSelection, right: ExportDateRangeSelection): boolean => (
|
||||
left.preset === right.preset &&
|
||||
left.useAllTime === right.useAllTime &&
|
||||
left.dateRange.start.getTime() === right.dateRange.start.getTime() &&
|
||||
left.dateRange.end.getTime() === right.dateRange.end.getTime()
|
||||
)
|
||||
|
||||
const pickSessionMediaMetric = (
|
||||
metricRaw: SessionExportMetric | SessionContentMetric | undefined
|
||||
): SessionContentMetric | null => {
|
||||
if (!metricRaw) return null
|
||||
const totalMessages = normalizeMessageCount(metricRaw.totalMessages)
|
||||
const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages)
|
||||
const imageMessages = normalizeMessageCount(metricRaw.imageMessages)
|
||||
const videoMessages = normalizeMessageCount(metricRaw.videoMessages)
|
||||
const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages)
|
||||
const firstTimestamp = normalizeTimestampSeconds(metricRaw.firstTimestamp)
|
||||
const lastTimestamp = normalizeTimestampSeconds(metricRaw.lastTimestamp)
|
||||
if (
|
||||
typeof totalMessages !== 'number' &&
|
||||
typeof voiceMessages !== 'number' &&
|
||||
typeof imageMessages !== 'number' &&
|
||||
typeof videoMessages !== 'number' &&
|
||||
typeof emojiMessages !== 'number'
|
||||
typeof emojiMessages !== 'number' &&
|
||||
typeof firstTimestamp !== 'number' &&
|
||||
typeof lastTimestamp !== 'number'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
totalMessages,
|
||||
voiceMessages,
|
||||
imageMessages,
|
||||
videoMessages,
|
||||
emojiMessages
|
||||
emojiMessages,
|
||||
firstTimestamp,
|
||||
lastTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1520,6 +1577,8 @@ function ExportPage() {
|
||||
const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false)
|
||||
const [snsExportVideos, setSnsExportVideos] = useState(false)
|
||||
const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false)
|
||||
const [isResolvingTimeRangeBounds, setIsResolvingTimeRangeBounds] = useState(false)
|
||||
const [timeRangeBounds, setTimeRangeBounds] = useState<TimeRangeBounds | null>(null)
|
||||
const [isExportDefaultsModalOpen, setIsExportDefaultsModalOpen] = useState(false)
|
||||
const [timeRangeSelection, setTimeRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||
const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel')
|
||||
@@ -2686,7 +2745,9 @@ function ExportPage() {
|
||||
typeof emojiMessages !== 'number' &&
|
||||
typeof transferMessages !== 'number' &&
|
||||
typeof redPacketMessages !== 'number' &&
|
||||
typeof callMessages !== 'number'
|
||||
typeof callMessages !== 'number' &&
|
||||
typeof normalizeTimestampSeconds(metricRaw.firstTimestamp) !== 'number' &&
|
||||
typeof normalizeTimestampSeconds(metricRaw.lastTimestamp) !== 'number'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
@@ -2699,7 +2760,9 @@ function ExportPage() {
|
||||
emojiMessages,
|
||||
transferMessages,
|
||||
redPacketMessages,
|
||||
callMessages
|
||||
callMessages,
|
||||
firstTimestamp: normalizeTimestampSeconds(metricRaw.firstTimestamp),
|
||||
lastTimestamp: normalizeTimestampSeconds(metricRaw.lastTimestamp)
|
||||
}
|
||||
if (typeof totalMessages === 'number') {
|
||||
nextMessageCounts[sessionId] = totalMessages
|
||||
@@ -2733,7 +2796,9 @@ function ExportPage() {
|
||||
emojiMessages: typeof metric.emojiMessages === 'number' ? metric.emojiMessages : previous.emojiMessages,
|
||||
transferMessages: typeof metric.transferMessages === 'number' ? metric.transferMessages : previous.transferMessages,
|
||||
redPacketMessages: typeof metric.redPacketMessages === 'number' ? metric.redPacketMessages : previous.redPacketMessages,
|
||||
callMessages: typeof metric.callMessages === 'number' ? metric.callMessages : previous.callMessages
|
||||
callMessages: typeof metric.callMessages === 'number' ? metric.callMessages : previous.callMessages,
|
||||
firstTimestamp: typeof metric.firstTimestamp === 'number' ? metric.firstTimestamp : previous.firstTimestamp,
|
||||
lastTimestamp: typeof metric.lastTimestamp === 'number' ? metric.lastTimestamp : previous.lastTimestamp
|
||||
}
|
||||
if (
|
||||
previous.totalMessages === nextMetric.totalMessages &&
|
||||
@@ -2743,7 +2808,9 @@ function ExportPage() {
|
||||
previous.emojiMessages === nextMetric.emojiMessages &&
|
||||
previous.transferMessages === nextMetric.transferMessages &&
|
||||
previous.redPacketMessages === nextMetric.redPacketMessages &&
|
||||
previous.callMessages === nextMetric.callMessages
|
||||
previous.callMessages === nextMetric.callMessages &&
|
||||
previous.firstTimestamp === nextMetric.firstTimestamp &&
|
||||
previous.lastTimestamp === nextMetric.lastTimestamp
|
||||
) {
|
||||
continue
|
||||
}
|
||||
@@ -3898,6 +3965,7 @@ function ExportPage() {
|
||||
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open'>) => {
|
||||
setExportDialog({ open: true, ...payload })
|
||||
setIsTimeRangeDialogOpen(false)
|
||||
setTimeRangeBounds(null)
|
||||
setTimeRangeSelection(exportDefaultDateRangeSelection)
|
||||
|
||||
setOptions(prev => {
|
||||
@@ -3960,11 +4028,143 @@ function ExportPage() {
|
||||
const closeExportDialog = useCallback(() => {
|
||||
setExportDialog(prev => ({ ...prev, open: false }))
|
||||
setIsTimeRangeDialogOpen(false)
|
||||
setTimeRangeBounds(null)
|
||||
}, [])
|
||||
|
||||
const resolveChatExportTimeRangeBounds = useCallback(async (sessionIds: string[]): Promise<TimeRangeBounds | null> => {
|
||||
const normalizedSessionIds = Array.from(new Set((sessionIds || []).map(id => String(id || '').trim()).filter(Boolean)))
|
||||
if (normalizedSessionIds.length === 0) return null
|
||||
|
||||
const sessionRowMap = new Map<string, SessionRow>()
|
||||
for (const session of sessions) {
|
||||
sessionRowMap.set(session.username, session)
|
||||
}
|
||||
|
||||
let minTimestamp: number | undefined
|
||||
let maxTimestamp: number | undefined
|
||||
const resolvedSessionBounds = new Map<string, { hasMin: boolean; hasMax: boolean }>()
|
||||
|
||||
const absorbMetric = (sessionId: string, metric?: { firstTimestamp?: number; lastTimestamp?: number } | null) => {
|
||||
if (!metric) return
|
||||
const firstTimestamp = normalizeTimestampSeconds(metric.firstTimestamp)
|
||||
const lastTimestamp = normalizeTimestampSeconds(metric.lastTimestamp)
|
||||
if (typeof firstTimestamp !== 'number' && typeof lastTimestamp !== 'number') return
|
||||
|
||||
const previous = resolvedSessionBounds.get(sessionId) || { hasMin: false, hasMax: false }
|
||||
const nextState = {
|
||||
hasMin: previous.hasMin || typeof firstTimestamp === 'number',
|
||||
hasMax: previous.hasMax || typeof lastTimestamp === 'number'
|
||||
}
|
||||
resolvedSessionBounds.set(sessionId, nextState)
|
||||
|
||||
if (typeof firstTimestamp === 'number' && (minTimestamp === undefined || firstTimestamp < minTimestamp)) {
|
||||
minTimestamp = firstTimestamp
|
||||
}
|
||||
if (typeof lastTimestamp === 'number' && (maxTimestamp === undefined || lastTimestamp > maxTimestamp)) {
|
||||
maxTimestamp = lastTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
for (const sessionId of normalizedSessionIds) {
|
||||
const sessionRow = sessionRowMap.get(sessionId)
|
||||
absorbMetric(sessionId, {
|
||||
firstTimestamp: undefined,
|
||||
lastTimestamp: sessionRow?.sortTimestamp || sessionRow?.lastTimestamp
|
||||
})
|
||||
absorbMetric(sessionId, sessionContentMetrics[sessionId])
|
||||
if (sessionDetail?.wxid === sessionId) {
|
||||
absorbMetric(sessionId, {
|
||||
firstTimestamp: sessionDetail.firstMessageTime,
|
||||
lastTimestamp: sessionDetail.latestMessageTime
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const applyStatsResult = (result?: {
|
||||
success: boolean
|
||||
data?: Record<string, SessionExportMetric>
|
||||
} | null) => {
|
||||
if (!result?.success || !result.data) return
|
||||
applySessionMediaMetricsFromStats(result.data)
|
||||
for (const sessionId of normalizedSessionIds) {
|
||||
absorbMetric(sessionId, result.data[sessionId])
|
||||
}
|
||||
}
|
||||
|
||||
const missingSessionIds = () => normalizedSessionIds.filter(sessionId => {
|
||||
const resolved = resolvedSessionBounds.get(sessionId)
|
||||
return !resolved?.hasMin || !resolved?.hasMax
|
||||
})
|
||||
|
||||
const staleSessionIds = new Set<string>()
|
||||
|
||||
if (missingSessionIds().length > 0) {
|
||||
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
missingSessionIds(),
|
||||
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||
)
|
||||
applyStatsResult(cacheResult)
|
||||
for (const sessionId of cacheResult?.needsRefresh || []) {
|
||||
staleSessionIds.add(String(sessionId || '').trim())
|
||||
}
|
||||
}
|
||||
|
||||
const sessionsNeedingFreshStats = Array.from(new Set([
|
||||
...missingSessionIds(),
|
||||
...Array.from(staleSessionIds).filter(Boolean)
|
||||
]))
|
||||
|
||||
if (sessionsNeedingFreshStats.length > 0) {
|
||||
applyStatsResult(await window.electronAPI.chat.getExportSessionStats(
|
||||
sessionsNeedingFreshStats,
|
||||
{ includeRelations: false }
|
||||
))
|
||||
}
|
||||
|
||||
if (missingSessionIds().length > 0) {
|
||||
return null
|
||||
}
|
||||
if (typeof minTimestamp !== 'number' || typeof maxTimestamp !== 'number') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
minDate: new Date(minTimestamp * 1000),
|
||||
maxDate: new Date(maxTimestamp * 1000)
|
||||
}
|
||||
}, [applySessionMediaMetricsFromStats, sessionContentMetrics, sessionDetail, sessions])
|
||||
|
||||
const openTimeRangeDialog = useCallback(() => {
|
||||
setIsTimeRangeDialogOpen(true)
|
||||
}, [])
|
||||
void (async () => {
|
||||
if (isResolvingTimeRangeBounds) return
|
||||
setIsResolvingTimeRangeBounds(true)
|
||||
try {
|
||||
let nextBounds: TimeRangeBounds | null = null
|
||||
if (exportDialog.scope !== 'sns') {
|
||||
nextBounds = await resolveChatExportTimeRangeBounds(exportDialog.sessionIds)
|
||||
}
|
||||
setTimeRangeBounds(nextBounds)
|
||||
if (nextBounds) {
|
||||
const nextSelection = clampExportSelectionToBounds(timeRangeSelection, nextBounds)
|
||||
if (!areExportSelectionsEqual(nextSelection, timeRangeSelection)) {
|
||||
setTimeRangeSelection(nextSelection)
|
||||
setOptions(prev => ({
|
||||
...prev,
|
||||
useAllTime: nextSelection.useAllTime,
|
||||
dateRange: cloneExportDateRange(nextSelection.dateRange)
|
||||
}))
|
||||
}
|
||||
}
|
||||
setIsTimeRangeDialogOpen(true)
|
||||
} catch (error) {
|
||||
console.error('导出页解析时间范围边界失败', error)
|
||||
setTimeRangeBounds(null)
|
||||
setIsTimeRangeDialogOpen(true)
|
||||
} finally {
|
||||
setIsResolvingTimeRangeBounds(false)
|
||||
}
|
||||
})()
|
||||
}, [exportDialog.scope, exportDialog.sessionIds, isResolvingTimeRangeBounds, resolveChatExportTimeRangeBounds, timeRangeSelection])
|
||||
|
||||
const closeTimeRangeDialog = useCallback(() => {
|
||||
setIsTimeRangeDialogOpen(false)
|
||||
@@ -7753,8 +7953,9 @@ function ExportPage() {
|
||||
type="button"
|
||||
className="time-range-trigger"
|
||||
onClick={openTimeRangeDialog}
|
||||
disabled={isResolvingTimeRangeBounds}
|
||||
>
|
||||
<span>{timeRangeSummaryLabel}</span>
|
||||
<span>{isResolvingTimeRangeBounds ? '正在统计可选时间...' : timeRangeSummaryLabel}</span>
|
||||
<span className="time-range-arrow">></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -7840,6 +8041,8 @@ function ExportPage() {
|
||||
<ExportDateRangeDialog
|
||||
open={isTimeRangeDialogOpen}
|
||||
value={timeRangeSelection}
|
||||
minDate={timeRangeBounds?.minDate}
|
||||
maxDate={timeRangeBounds?.maxDate}
|
||||
onClose={closeTimeRangeDialog}
|
||||
onConfirm={(nextSelection) => {
|
||||
setTimeRangeSelection(nextSelection)
|
||||
|
||||
@@ -176,6 +176,8 @@ export default function SnsPage() {
|
||||
const selectedContactUsernamesRef = useRef<string[]>(selectedContactUsernames)
|
||||
const cacheScopeKeyRef = useRef('')
|
||||
const snsUserPostCountsCacheScopeKeyRef = useRef('')
|
||||
const activeContactsLoadTaskIdRef = useRef<string | null>(null)
|
||||
const activeContactsCountTaskIdRef = useRef<string | null>(null)
|
||||
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
||||
const pendingResetFeedRef = useRef(false)
|
||||
const contactsLoadTokenRef = useRef(0)
|
||||
@@ -750,6 +752,12 @@ export default function SnsPage() {
|
||||
window.clearTimeout(contactsCountBatchTimerRef.current)
|
||||
contactsCountBatchTimerRef.current = null
|
||||
}
|
||||
if (activeContactsCountTaskIdRef.current) {
|
||||
finishBackgroundTask(activeContactsCountTaskIdRef.current, 'canceled', {
|
||||
detail: '已停止后续联系人朋友圈条数补算'
|
||||
})
|
||||
activeContactsCountTaskIdRef.current = null
|
||||
}
|
||||
if (resetProgress) {
|
||||
setContactsCountProgress({
|
||||
resolved: 0,
|
||||
@@ -814,31 +822,56 @@ export default function SnsPage() {
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
activeContactsCountTaskIdRef.current = taskId
|
||||
let normalizedCounts: Record<string, number> = {}
|
||||
try {
|
||||
const result = await window.electronAPI.sns.getUserPostCounts()
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
if (activeContactsCountTaskIdRef.current === taskId) {
|
||||
activeContactsCountTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '已停止后续加载,当前计数查询结束后不再继续分批写入'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (runToken !== contactsCountHydrationTokenRef.current) return
|
||||
if (runToken !== contactsCountHydrationTokenRef.current) {
|
||||
if (activeContactsCountTaskIdRef.current === taskId) {
|
||||
activeContactsCountTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '页面状态已刷新,本次联系人朋友圈条数补算已过期'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.success && result.counts) {
|
||||
normalizedCounts = Object.fromEntries(
|
||||
Object.entries(result.counts).map(([username, value]) => [username, normalizePostCount(value)])
|
||||
)
|
||||
normalizedCounts = pendingTargets.reduce<Record<string, number>>((acc, username) => {
|
||||
acc[username] = normalizePostCount(result.counts?.[username])
|
||||
return acc
|
||||
}, {})
|
||||
void (async () => {
|
||||
try {
|
||||
const scopeKey = await ensureSnsUserPostCountsCacheScopeKey()
|
||||
await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts)
|
||||
const currentCache = await configService.getExportSnsUserPostCountsCache(scopeKey)
|
||||
await configService.setExportSnsUserPostCountsCache(scopeKey, {
|
||||
...(currentCache?.counts || {}),
|
||||
...normalizedCounts
|
||||
})
|
||||
} catch (cacheError) {
|
||||
console.error('Failed to persist SNS user post counts cache:', cacheError)
|
||||
}
|
||||
})()
|
||||
} else {
|
||||
normalizedCounts = pendingTargets.reduce<Record<string, number>>((acc, username) => {
|
||||
acc[username] = 0
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load contact post counts:', error)
|
||||
if (activeContactsCountTaskIdRef.current === taskId) {
|
||||
activeContactsCountTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'failed', {
|
||||
detail: String(error)
|
||||
})
|
||||
@@ -848,8 +881,19 @@ export default function SnsPage() {
|
||||
let resolved = preResolved
|
||||
let cursor = 0
|
||||
const applyBatch = () => {
|
||||
if (runToken !== contactsCountHydrationTokenRef.current) return
|
||||
if (runToken !== contactsCountHydrationTokenRef.current) {
|
||||
if (activeContactsCountTaskIdRef.current === taskId) {
|
||||
activeContactsCountTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '页面状态已刷新,本次联系人朋友圈条数补算已过期'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
if (activeContactsCountTaskIdRef.current === taskId) {
|
||||
activeContactsCountTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: `已停止后续加载,已完成 ${resolved}/${totalTargets}`
|
||||
})
|
||||
@@ -870,6 +914,9 @@ export default function SnsPage() {
|
||||
running: false
|
||||
})
|
||||
contactsCountBatchTimerRef.current = null
|
||||
if (activeContactsCountTaskIdRef.current === taskId) {
|
||||
activeContactsCountTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'completed', {
|
||||
detail: '联系人朋友圈条数补算完成',
|
||||
progressText: `${totalTargets}/${totalTargets}`
|
||||
@@ -910,6 +957,18 @@ export default function SnsPage() {
|
||||
contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS)
|
||||
} else {
|
||||
contactsCountBatchTimerRef.current = null
|
||||
setContactsCountProgress({
|
||||
resolved: totalTargets,
|
||||
total: totalTargets,
|
||||
running: false
|
||||
})
|
||||
if (activeContactsCountTaskIdRef.current === taskId) {
|
||||
activeContactsCountTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'completed', {
|
||||
detail: '鑱旂郴浜烘湅鍙嬪湀鏉℃暟琛ョ畻瀹屾垚',
|
||||
progressText: `${totalTargets}/${totalTargets}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,6 +977,12 @@ export default function SnsPage() {
|
||||
|
||||
// Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序)
|
||||
const loadContacts = useCallback(async () => {
|
||||
if (activeContactsLoadTaskIdRef.current) {
|
||||
finishBackgroundTask(activeContactsLoadTaskIdRef.current, 'canceled', {
|
||||
detail: '新一轮联系人列表加载已开始,旧任务已取消'
|
||||
})
|
||||
activeContactsLoadTaskIdRef.current = null
|
||||
}
|
||||
const requestToken = ++contactsLoadTokenRef.current
|
||||
const taskId = registerBackgroundTask({
|
||||
sourcePage: 'sns',
|
||||
@@ -926,6 +991,7 @@ export default function SnsPage() {
|
||||
progressText: '初始化',
|
||||
cancelable: true
|
||||
})
|
||||
activeContactsLoadTaskIdRef.current = taskId
|
||||
stopContactsCountHydration(true)
|
||||
setContactsLoading(true)
|
||||
try {
|
||||
@@ -955,7 +1021,15 @@ export default function SnsPage() {
|
||||
}
|
||||
})
|
||||
|
||||
if (requestToken !== contactsLoadTokenRef.current) return
|
||||
if (requestToken !== contactsLoadTokenRef.current) {
|
||||
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||
activeContactsLoadTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '页面状态已刷新,本次联系人列表加载已过期'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (cachedContacts.length > 0) {
|
||||
const cachedContactsSorted = sortContactsForRanking(cachedContacts)
|
||||
setContacts(cachedContactsSorted)
|
||||
@@ -977,6 +1051,9 @@ export default function SnsPage() {
|
||||
window.electronAPI.chat.getSessions()
|
||||
])
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||
activeContactsLoadTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '已停止后续加载,当前联系人查询结束后未继续补齐'
|
||||
})
|
||||
@@ -1021,7 +1098,15 @@ export default function SnsPage() {
|
||||
}
|
||||
|
||||
let contactsList = sortContactsForRanking(Array.from(contactMap.values()))
|
||||
if (requestToken !== contactsLoadTokenRef.current) return
|
||||
if (requestToken !== contactsLoadTokenRef.current) {
|
||||
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||
activeContactsLoadTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '页面状态已刷新,本次联系人列表加载已过期'
|
||||
})
|
||||
return
|
||||
}
|
||||
setContacts(contactsList)
|
||||
const readyUsernames = new Set(
|
||||
contactsList
|
||||
@@ -1043,6 +1128,9 @@ export default function SnsPage() {
|
||||
})
|
||||
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||
activeContactsLoadTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '已停止后续加载,联系人补齐未继续写入'
|
||||
})
|
||||
@@ -1058,7 +1146,15 @@ export default function SnsPage() {
|
||||
avatarUrl: extra.avatarUrl || contact.avatarUrl
|
||||
}
|
||||
})
|
||||
if (requestToken !== contactsLoadTokenRef.current) return
|
||||
if (requestToken !== contactsLoadTokenRef.current) {
|
||||
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||
activeContactsLoadTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '页面状态已刷新,本次联系人列表加载已过期'
|
||||
})
|
||||
return
|
||||
}
|
||||
setContacts((prev) => {
|
||||
const prevMap = new Map(prev.map((contact) => [contact.username, contact]))
|
||||
const merged = contactsList.map((contact) => {
|
||||
@@ -1074,18 +1170,35 @@ export default function SnsPage() {
|
||||
})
|
||||
}
|
||||
}
|
||||
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||
activeContactsLoadTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'completed', {
|
||||
detail: `朋友圈联系人列表加载完成,共 ${contactsList.length} 人`,
|
||||
progressText: `${contactsList.length} 人`
|
||||
})
|
||||
} catch (error) {
|
||||
if (requestToken !== contactsLoadTokenRef.current) return
|
||||
if (requestToken !== contactsLoadTokenRef.current) {
|
||||
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||
activeContactsLoadTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '页面状态已刷新,本次联系人列表加载已过期'
|
||||
})
|
||||
return
|
||||
}
|
||||
console.error('Failed to load contacts:', error)
|
||||
stopContactsCountHydration(true)
|
||||
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||
activeContactsLoadTaskIdRef.current = null
|
||||
}
|
||||
finishBackgroundTask(taskId, 'failed', {
|
||||
detail: String(error)
|
||||
})
|
||||
} finally {
|
||||
if (activeContactsLoadTaskIdRef.current === taskId && requestToken !== contactsLoadTokenRef.current) {
|
||||
activeContactsLoadTaskIdRef.current = null
|
||||
}
|
||||
if (requestToken === contactsLoadTokenRef.current) {
|
||||
setContactsLoading(false)
|
||||
}
|
||||
@@ -1185,6 +1298,18 @@ export default function SnsPage() {
|
||||
window.clearTimeout(contactsCountBatchTimerRef.current)
|
||||
contactsCountBatchTimerRef.current = null
|
||||
}
|
||||
if (activeContactsCountTaskIdRef.current) {
|
||||
finishBackgroundTask(activeContactsCountTaskIdRef.current, 'canceled', {
|
||||
detail: '已离开朋友圈页,联系人朋友圈条数补算已取消'
|
||||
})
|
||||
activeContactsCountTaskIdRef.current = null
|
||||
}
|
||||
if (activeContactsLoadTaskIdRef.current) {
|
||||
finishBackgroundTask(activeContactsLoadTaskIdRef.current, 'canceled', {
|
||||
detail: '已离开朋友圈页,联系人列表加载已取消'
|
||||
})
|
||||
activeContactsLoadTaskIdRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user