diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 2bb2908..5bf4d43 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -52,6 +52,7 @@ interface SnsContactIdentity { remark?: string nickName?: string displayName: string + avatarUrl?: string } interface ParsedLikeUser { @@ -79,6 +80,7 @@ interface ArkmeLikeDetail { remark?: string nickName?: string displayName: string + avatarUrl?: string source: 'xml' | 'legacy' } @@ -92,6 +94,7 @@ interface ArkmeCommentDetail { remark?: string nickName?: string displayName: string + avatarUrl?: string content: string refCommentId: string refNickname?: string @@ -102,6 +105,7 @@ interface ArkmeCommentDetail { refRemark?: string refNickName?: string refDisplayName?: string + refAvatarUrl?: string emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] source: 'xml' | 'legacy' } @@ -323,6 +327,7 @@ class SnsService { let alias: string | undefined let remark: string | undefined let nickName: string | undefined + let avatarUrl = this.toOptionalString(cached?.avatarUrl) try { const contactResult = await wcdbService.getContact(normalized) @@ -336,6 +341,17 @@ class SnsService { // 联系人补全失败不影响导出 } + if (!avatarUrl) { + try { + const avatarResult = await wcdbService.getAvatarUrls([normalized]) + if (avatarResult.success && avatarResult.map) { + avatarUrl = this.toOptionalString(avatarResult.map[normalized]) + } + } catch { + // 头像补全失败不影响导出 + } + } + const displayName = remark || nickName || alias || cached?.displayName || normalized return { username: normalized, @@ -344,7 +360,8 @@ class SnsService { wechatId: alias, remark, nickName, - displayName + displayName, + avatarUrl } })() identityCache.set(normalized, pending) @@ -412,6 +429,7 @@ class SnsService { remark: identity?.remark, nickName: identity?.nickName, displayName: identity?.displayName || nickname || username || '', + avatarUrl: identity?.avatarUrl, source: likeSource }) } @@ -483,6 +501,7 @@ class SnsService { remark: actor?.remark, nickName: actor?.nickName, displayName: actor?.displayName || nickname || username || '', + avatarUrl: actor?.avatarUrl, content: comment.content || '', refCommentId: comment.refCommentId || '', refNickname: comment.refNickname || refActor?.displayName, @@ -493,6 +512,7 @@ class SnsService { refRemark: refActor?.remark, refNickName: refActor?.nickName, refDisplayName: refActor?.displayName, + refAvatarUrl: refActor?.avatarUrl, emojis: comment.emojis, source: commentSource }) @@ -1021,7 +1041,8 @@ class SnsService { const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) if (!result.success || !result.timeline || result.timeline.length === 0) return result - const enrichedTimeline = result.timeline.map((post: any) => { + const identityCache = new Map>() + const enrichedTimeline = await Promise.all(result.timeline.map(async (post: any) => { const contact = this.contactCache.get(post.username) const isVideoPost = post.type === 15 const videoKey = extractVideoKey(post.rawXml || '') @@ -1061,14 +1082,22 @@ class SnsService { finalComments = this.fixCommentRefs(dllComments) } + const normalizedPost: SnsPost = { + ...post, + comments: finalComments + } + const { likesDetail, commentsDetail } = await this.buildArkmeInteractionDetails(normalizedPost, identityCache) + return { ...post, avatarUrl: contact?.avatarUrl, nickname: post.nickname || contact?.displayName || post.username, media: fixedMedia, - comments: finalComments + comments: finalComments, + likesDetail, + commentsDetail } - }) + })) return { ...result, timeline: enrichedTimeline } } diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index d1c8c0d..e6f895c 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -623,18 +623,22 @@ .session-mutual-friends-row { display: grid; - grid-template-columns: 36px minmax(120px, 0.82fr) max-content 56px 96px minmax(0, 1.28fr); - gap: 10px; + grid-template-columns: 36px minmax(0, 1fr) 72px 108px; + gap: 12px; align-items: center; - padding: 8px 12px; + padding: 12px; border-bottom: 1px solid color-mix(in srgb, var(--border-color) 68%, transparent); font-size: 12px; color: var(--text-secondary); - min-height: 42px; + min-height: 72px; &:last-child { border-bottom: none; } + + &.unconfirmed { + background: color-mix(in srgb, var(--bg-secondary) 60%, transparent); + } } .session-mutual-friends-rank, @@ -648,6 +652,32 @@ text-align: center; } +.session-mutual-friends-user { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.session-mutual-friends-user-avatar { + flex-shrink: 0; +} + +.session-mutual-friends-user-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.session-mutual-friends-user-head { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + .session-mutual-friends-name { min-width: 0; overflow: hidden; @@ -687,6 +717,30 @@ } } +.session-mutual-friends-identity-badge { + border-radius: 999px; + padding: 3px 8px; + font-size: 10px; + line-height: 1; + color: #92400e; + border: 1px solid color-mix(in srgb, #d97706 34%, var(--border-color)); + background: color-mix(in srgb, #f59e0b 11%, var(--bg-secondary)); + white-space: nowrap; +} + +.session-mutual-friends-identity { + min-width: 0; + color: var(--text-secondary); + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &.secondary { + color: var(--text-tertiary); + } +} + .session-mutual-friends-desc { min-width: 0; color: var(--text-tertiary); @@ -1980,6 +2034,7 @@ height: var(--contacts-default-list-height); overflow: hidden; padding: 0 0 12px; + position: relative; } .contacts-virtuoso { @@ -1997,6 +2052,43 @@ } } + .contacts-action-rail { + position: absolute; + top: 0; + right: 0; + bottom: 12px; + width: max(var(--contacts-action-col-width), 184px); + z-index: 18; + pointer-events: none; + + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -12px; + width: 12px; + pointer-events: none; + background: linear-gradient(to right, transparent, var(--bg-primary)); + } + } + + .contacts-action-rail-row { + position: absolute; + right: 0; + width: 100%; + height: var(--contacts-row-height); + padding: 0 0 4px; + box-sizing: border-box; + display: flex; + justify-content: flex-end; + pointer-events: none; + + &.selected .contacts-action-rail-card { + background: rgba(var(--primary-rgb), 0.08); + } + } + .table-bottom-scrollbar { flex: 0 0 auto; overflow-x: auto; @@ -2091,10 +2183,6 @@ &.selected .contact-item { background: rgba(var(--primary-rgb), 0.08); } - - &.selected .row-action-cell { - background: rgba(var(--primary-rgb), 0.08); - } } .contact-item { @@ -2474,26 +2562,30 @@ } } -.row-action-cell { +.row-action-cell, +.contacts-action-rail-card { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; - width: var(--contacts-action-col-width); - min-width: var(--contacts-action-col-width); - flex-shrink: 0; - position: sticky; - right: 0; - z-index: 10; + width: 100%; + min-width: 0; + height: calc(var(--contacts-row-height) - 4px); + padding: 0 6px 0 0; + box-sizing: border-box; + justify-content: center; background: var(--bg-primary); + pointer-events: auto; + position: relative; + z-index: 1; &::before { content: ''; position: absolute; top: -12px; bottom: -12px; - left: -8px; - width: 8px; + left: -12px; + width: 12px; pointer-events: none; background: linear-gradient(to right, transparent, var(--bg-primary)); } @@ -4161,13 +4253,19 @@ } .session-mutual-friends-row { - grid-template-columns: 30px minmax(88px, 0.9fr) max-content 44px 74px; + grid-template-columns: 28px minmax(0, 1fr) 56px 72px; gap: 8px; font-size: 12px; } - .session-mutual-friends-desc { - display: none; + .session-mutual-friends-user { + align-items: flex-start; + gap: 10px; + } + + .session-mutual-friends-user-avatar { + width: 36px !important; + height: 36px !important; } .session-load-detail-row { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 38d01a5..b5741ce 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -44,11 +44,12 @@ import { subscribeBackgroundTasks } from '../services/backgroundTaskMonitor' import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' +import { Avatar } from '../components/Avatar' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../components/Export/ExportDefaultsSettingsForm' -import type { SnsPost } from '../types/sns' +import type { SnsCommentDetail, SnsLikeDetail, SnsPost } from '../types/sns' import { cloneExportDateRange, createDefaultDateRange, @@ -72,6 +73,7 @@ type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname' type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson' +const CONTACTS_ROW_HEIGHT = 76 interface ExportOptions { format: TextExportFormat @@ -513,6 +515,12 @@ const getAvatarLetter = (name: string): string => { return [...name][0] || '?' } +const toOptionalString = (value: unknown): string | undefined => { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed ? trimmed : undefined +} + const toComparableNameSet = (values: Array): Set => { const set = new Set() for (const value of values) { @@ -602,7 +610,15 @@ type SessionMutualFriendDirection = 'incoming' | 'outgoing' | 'bidirectional' type SessionMutualFriendBehavior = 'likes' | 'comments' | 'both' interface SessionMutualFriendItem { + key: string + identityKey?: string name: string + username?: string + wxid?: string + wechatId?: string + remark?: string + avatarUrl?: string + isConfirmed: boolean incomingLikeCount: number incomingCommentCount: number outgoingLikeCount: number @@ -621,6 +637,63 @@ interface SessionMutualFriendsMetric { computedAt: number } +const getSessionMutualFriendIdentityKey = (username?: string, wechatId?: string): string | undefined => { + const normalizedUsername = toOptionalString(username) + if (normalizedUsername) return `u:${normalizedUsername}` + const normalizedWechatId = toOptionalString(wechatId) + if (normalizedWechatId) return `w:${normalizedWechatId}` + return undefined +} + +const getSessionMutualFriendFallbackKey = (name: string): string => `n:${name || '未知用户'}` + +const resolveSessionMutualFriendName = (params: { + displayName?: string + remark?: string + nickName?: string + wechatId?: string + nickname?: string + username?: string +}): string => { + return ( + toOptionalString(params.displayName) || + toOptionalString(params.remark) || + toOptionalString(params.nickName) || + toOptionalString(params.wechatId) || + toOptionalString(params.nickname) || + toOptionalString(params.username) || + '未知用户' + ) +} + +const applySessionMutualFriendDerivedState = (item: SessionMutualFriendItem): void => { + const incomingTotal = item.incomingLikeCount + item.incomingCommentCount + const outgoingTotal = item.outgoingLikeCount + item.outgoingCommentCount + item.direction = incomingTotal > 0 && outgoingTotal > 0 + ? 'bidirectional' + : incomingTotal > 0 + ? 'incoming' + : 'outgoing' + item.behavior = summarizeMutualFriendBehavior( + item.incomingLikeCount + item.outgoingLikeCount, + item.incomingCommentCount + item.outgoingCommentCount + ) +} + +const mergeSessionMutualFriendItemProfile = ( + target: SessionMutualFriendItem, + profile: Partial> +): void => { + if (profile.identityKey && !target.identityKey) target.identityKey = profile.identityKey + if (profile.username && !target.username) target.username = profile.username + if (profile.wxid && !target.wxid) target.wxid = profile.wxid + if (profile.wechatId && !target.wechatId) target.wechatId = profile.wechatId + if (profile.remark && !target.remark) target.remark = profile.remark + if (profile.avatarUrl && !target.avatarUrl) target.avatarUrl = profile.avatarUrl + if (profile.name && (!target.name || !target.isConfirmed)) target.name = profile.name + if (profile.isConfirmed) target.isConfirmed = true +} + interface SessionSnsRankCacheEntry { likes: SessionSnsRankItem[] comments: SessionSnsRankItem[] @@ -678,55 +751,121 @@ const buildSessionMutualFriendsMetric = ( ): SessionMutualFriendsMetric => { const friendMap = new Map() - for (const post of posts) { - const createTime = Number(post?.createTime) || 0 - const likes = Array.isArray(post?.likes) ? post.likes : [] - const comments = Array.isArray(post?.comments) ? post.comments : [] - - for (const likeNameRaw of likes) { - const name = String(likeNameRaw || '').trim() || '未知用户' - const existing = friendMap.get(name) - if (existing) { - existing.incomingLikeCount += 1 - existing.totalCount += 1 - existing.behavior = existing.incomingCommentCount > 0 ? 'both' : 'likes' - if (createTime > existing.latestTime) existing.latestTime = createTime - continue - } - friendMap.set(name, { - name, - incomingLikeCount: 1, - incomingCommentCount: 0, - outgoingLikeCount: 0, - outgoingCommentCount: 0, - totalCount: 1, - latestTime: createTime, - direction: 'incoming', - behavior: 'likes' - }) + const ensureItem = (seed: { + name: string + username?: string + wxid?: string + wechatId?: string + remark?: string + avatarUrl?: string + identityKey?: string + isConfirmed: boolean + }): SessionMutualFriendItem => { + const key = seed.identityKey || getSessionMutualFriendFallbackKey(seed.name) + const existing = friendMap.get(key) + if (existing) { + mergeSessionMutualFriendItemProfile(existing, seed) + return existing } - for (const comment of comments) { - const name = String(comment?.nickname || '').trim() || '未知用户' - const existing = friendMap.get(name) - if (existing) { - existing.incomingCommentCount += 1 - existing.totalCount += 1 - existing.behavior = existing.incomingLikeCount > 0 ? 'both' : 'comments' - if (createTime > existing.latestTime) existing.latestTime = createTime - continue - } - friendMap.set(name, { - name, - incomingLikeCount: 0, - incomingCommentCount: 1, - outgoingLikeCount: 0, - outgoingCommentCount: 0, - totalCount: 1, - latestTime: createTime, - direction: 'incoming', - behavior: 'comments' + const created: SessionMutualFriendItem = { + key, + identityKey: seed.identityKey, + name: seed.name, + username: seed.username, + wxid: seed.wxid, + wechatId: seed.wechatId, + remark: seed.remark, + avatarUrl: seed.avatarUrl, + isConfirmed: seed.isConfirmed, + incomingLikeCount: 0, + incomingCommentCount: 0, + outgoingLikeCount: 0, + outgoingCommentCount: 0, + totalCount: 0, + latestTime: 0, + direction: 'incoming', + behavior: 'likes' + } + friendMap.set(key, created) + return created + } + + for (const post of posts) { + const createTime = Number(post?.createTime) || 0 + const likesDetail = Array.isArray(post?.likesDetail) && post.likesDetail.length > 0 + ? post.likesDetail + : (Array.isArray(post?.likes) ? post.likes : []).map((likeNameRaw): SnsLikeDetail => ({ + nickname: String(likeNameRaw || '').trim() || '未知用户', + displayName: String(likeNameRaw || '').trim() || '未知用户', + source: 'legacy' + })) + const commentsDetail = Array.isArray(post?.commentsDetail) && post.commentsDetail.length > 0 + ? post.commentsDetail + : (Array.isArray(post?.comments) ? post.comments : []).map((comment): SnsCommentDetail => ({ + id: String(comment?.id || ''), + nickname: String(comment?.nickname || '').trim() || '未知用户', + displayName: String(comment?.nickname || '').trim() || '未知用户', + content: String(comment?.content || ''), + refCommentId: String(comment?.refCommentId || ''), + refNickname: comment?.refNickname, + refUsername: comment?.refUsername, + emojis: comment?.emojis, + source: 'legacy' + })) + + for (const like of likesDetail) { + const username = toOptionalString(like.username || like.wxid) + const wechatId = toOptionalString(like.wechatId || like.alias) + const identityKey = getSessionMutualFriendIdentityKey(username, wechatId) + const existing = ensureItem({ + name: resolveSessionMutualFriendName({ + displayName: like.displayName, + remark: like.remark, + nickName: like.nickName, + wechatId, + nickname: like.nickname, + username + }), + username, + wxid: username, + wechatId, + remark: toOptionalString(like.remark), + avatarUrl: toOptionalString(like.avatarUrl), + identityKey, + isConfirmed: Boolean(identityKey && username) }) + existing.incomingLikeCount += 1 + existing.totalCount += 1 + if (createTime > existing.latestTime) existing.latestTime = createTime + applySessionMutualFriendDerivedState(existing) + } + + for (const comment of commentsDetail) { + const username = toOptionalString(comment.username || comment.wxid) + const wechatId = toOptionalString(comment.wechatId || comment.alias) + const identityKey = getSessionMutualFriendIdentityKey(username, wechatId) + const existing = ensureItem({ + name: resolveSessionMutualFriendName({ + displayName: comment.displayName, + remark: comment.remark, + nickName: comment.nickName, + wechatId, + nickname: comment.nickname, + username + }), + username, + wxid: username, + wechatId, + remark: toOptionalString(comment.remark), + avatarUrl: toOptionalString(comment.avatarUrl), + identityKey, + isConfirmed: Boolean(identityKey && username) + }) + existing.incomingCommentCount += 1 + existing.totalCount += 1 + if (createTime > existing.latestTime) existing.latestTime = createTime + applySessionMutualFriendDerivedState(existing) } } @@ -1513,6 +1652,8 @@ function ExportPage() { const [nowTick, setNowTick] = useState(Date.now()) const [isContactsListAtTop, setIsContactsListAtTop] = useState(true) const [isContactsHeaderDragging, setIsContactsHeaderDragging] = useState(false) + const [contactsListScrollTop, setContactsListScrollTop] = useState(0) + const [contactsVisibleRange, setContactsVisibleRange] = useState({ startIndex: 0, endIndex: -1 }) const [contactsHorizontalScrollMetrics, setContactsHorizontalScrollMetrics] = useState({ viewportWidth: 0, contentWidth: 0 @@ -2788,21 +2929,27 @@ function ExportPage() { const getSessionMutualFriendProfile = useCallback((sessionId: string): { displayName: string - candidateNames: Set + remark?: string + wechatId?: string + avatarUrl?: string + primaryIdentityKey: string + candidateIdentityKeys: Set } => { const normalizedSessionId = String(sessionId || '').trim() const contact = contactsList.find(item => item.username === normalizedSessionId) const session = sessionsRef.current.find(item => item.username === normalizedSessionId) const displayName = contact?.displayName || contact?.remark || contact?.nickname || session?.displayName || normalizedSessionId + const wechatId = toOptionalString(contact?.alias) + const candidateIdentityKeys = new Set() + candidateIdentityKeys.add(`u:${normalizedSessionId}`) + if (wechatId) candidateIdentityKeys.add(`w:${wechatId}`) return { displayName, - candidateNames: toComparableNameSet([ - displayName, - contact?.displayName, - contact?.remark, - contact?.nickname, - contact?.alias - ]) + remark: toOptionalString(contact?.remark), + wechatId, + avatarUrl: toOptionalString(contact?.avatarUrl), + primaryIdentityKey: `u:${normalizedSessionId}`, + candidateIdentityKeys } }, [contactsList]) @@ -2814,41 +2961,54 @@ function ExportPage() { const directMetric = directMetrics[normalizedTargetSessionId] if (!directMetric) return null - const { candidateNames } = getSessionMutualFriendProfile(normalizedTargetSessionId) + const targetProfile = getSessionMutualFriendProfile(normalizedTargetSessionId) const mergedMap = new Map() for (const item of directMetric.items) { - mergedMap.set(item.name, { ...item }) + mergedMap.set(item.key, { ...item }) } for (const [sourceSessionId, sourceMetric] of Object.entries(directMetrics)) { if (!sourceMetric || sourceSessionId === normalizedTargetSessionId) continue const sourceProfile = getSessionMutualFriendProfile(sourceSessionId) if (!sourceProfile.displayName) continue - if (mergedMap.has(sourceProfile.displayName)) continue - - const reverseMatches = sourceMetric.items.filter(item => candidateNames.has(item.name)) + const reverseMatches = sourceMetric.items.filter(item => { + if (!item.identityKey) return false + return targetProfile.candidateIdentityKeys.has(item.identityKey) + }) if (reverseMatches.length === 0) continue const reverseCount = reverseMatches.reduce((sum, item) => sum + item.totalCount, 0) const reverseLikeCount = reverseMatches.reduce((sum, item) => sum + item.incomingLikeCount, 0) const reverseCommentCount = reverseMatches.reduce((sum, item) => sum + item.incomingCommentCount, 0) const reverseLatestTime = reverseMatches.reduce((latest, item) => Math.max(latest, item.latestTime), 0) - const existing = mergedMap.get(sourceProfile.displayName) + const existing = mergedMap.get(sourceProfile.primaryIdentityKey) if (existing) { + mergeSessionMutualFriendItemProfile(existing, { + identityKey: sourceProfile.primaryIdentityKey, + username: sourceSessionId, + wxid: sourceSessionId, + wechatId: sourceProfile.wechatId, + remark: sourceProfile.remark, + avatarUrl: sourceProfile.avatarUrl, + name: sourceProfile.displayName, + isConfirmed: true + }) existing.outgoingLikeCount += reverseLikeCount existing.outgoingCommentCount += reverseCommentCount existing.totalCount += reverseCount existing.latestTime = Math.max(existing.latestTime, reverseLatestTime) - existing.direction = (existing.incomingLikeCount + existing.incomingCommentCount) > 0 - ? 'bidirectional' - : 'outgoing' - existing.behavior = summarizeMutualFriendBehavior( - existing.incomingLikeCount + existing.outgoingLikeCount, - existing.incomingCommentCount + existing.outgoingCommentCount - ) + applySessionMutualFriendDerivedState(existing) } else { - mergedMap.set(sourceProfile.displayName, { + const created: SessionMutualFriendItem = { + key: sourceProfile.primaryIdentityKey, + identityKey: sourceProfile.primaryIdentityKey, name: sourceProfile.displayName, + username: sourceSessionId, + wxid: sourceSessionId, + wechatId: sourceProfile.wechatId, + remark: sourceProfile.remark, + avatarUrl: sourceProfile.avatarUrl, + isConfirmed: true, incomingLikeCount: 0, incomingCommentCount: 0, outgoingLikeCount: reverseLikeCount, @@ -2857,7 +3017,8 @@ function ExportPage() { latestTime: reverseLatestTime, direction: 'outgoing', behavior: summarizeMutualFriendBehavior(reverseLikeCount, reverseCommentCount) - }) + } + mergedMap.set(created.key, created) } } @@ -2887,7 +3048,7 @@ function ExportPage() { for (const targetSessionId of allSessionIds) { if (targetSessionId === normalizedSessionId) continue const targetProfile = getSessionMutualFriendProfile(targetSessionId) - if (directMetric.items.some(item => targetProfile.candidateNames.has(item.name))) { + if (directMetric.items.some(item => item.identityKey && targetProfile.candidateIdentityKeys.has(item.identityKey))) { impactedSessionIds.add(targetSessionId) } } @@ -4834,6 +4995,11 @@ function ExportPage() { const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex } sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex } + setContactsVisibleRange(prev => ( + prev.startIndex === startIndex && prev.endIndex === endIndex + ? prev + : { startIndex, endIndex } + )) if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) if (visibleTargets.length === 0) return @@ -5037,7 +5203,18 @@ function ExportPage() { const items = sessionMutualFriendsDialogMetric?.items || [] const keyword = sessionMutualFriendsSearch.trim().toLowerCase() if (!keyword) return items - return items.filter(item => item.name.toLowerCase().includes(keyword)) + return items.filter((item) => { + const haystack = [ + item.name, + item.remark, + item.wechatId, + item.wxid, + item.username + ] + .map(value => String(value || '').toLowerCase()) + .join(' ') + return haystack.includes(keyword) + }) }, [sessionMutualFriendsDialogMetric, sessionMutualFriendsSearch]) const applySessionDetailStats = useCallback(( @@ -5618,7 +5795,7 @@ function ExportPage() { const taskCenterAlertCount = taskRunningCount + taskQueuedCount const hasFilteredContacts = filteredContacts.length > 0 const contactsTableMinWidth = useMemo(() => { - const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + 140 + (8 * 12) + const baseWidth = 24 + 34 + 44 + 160 + 120 + (4 * 72) + (7 * 12) const snsWidth = shouldShowSnsColumn ? 72 + 12 : 0 const mutualFriendsWidth = shouldShowMutualFriendsColumn ? 72 + 12 : 0 return baseWidth + snsWidth + mutualFriendsWidth @@ -5814,11 +5991,6 @@ function ExportPage() { const canExport = Boolean(matchedSession?.hasSession) const isSessionBindingPending = !matchedSession && (isLoading || isSessionEnriching) const checked = canExport && selectedSessions.has(contact.username) - const isRunning = canExport && runningSessionIds.has(contact.username) - const isQueued = canExport && queuedSessionIds.has(contact.username) - const recentExportTimestamp = lastExportBySession[contact.username] - const hasRecentExport = canExport && Boolean(recentExportTimestamp) - const recentExportTime = hasRecentExport ? formatRecentExportTime(recentExportTimestamp, nowTick) : '' const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username]) const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) const displayedMessageCount = countedMessages ?? hintedMessages @@ -5995,47 +6167,14 @@ function ExportPage() { )} )} -
-
-
- - {hasRecentExport && {recentExportTime}} -
- -
-
) }, [ - lastExportBySession, - nowTick, openContactSnsTimeline, openSessionDetail, openSessionMutualFriendsDialog, - openSingleExport, - queuedSessionIds, - runningSessionIds, selectedSessions, - sessionDetail?.wxid, sessionContentMetrics, sessionMutualFriendsMetrics, sessionLoadTraceMap, @@ -6050,6 +6189,77 @@ function ExportPage() { snsUserPostCountsStatus, toggleSelectSession ]) + const visibleContactsForActionRail = useMemo(() => { + if (!hasFilteredContacts || contactsVisibleRange.endIndex < contactsVisibleRange.startIndex || contactsVisibleRange.endIndex < 0) return [] + const startIndex = Math.max(0, Math.min(filteredContacts.length - 1, contactsVisibleRange.startIndex)) + const endIndex = Math.max(startIndex, Math.min(filteredContacts.length - 1, contactsVisibleRange.endIndex)) + if (!Number.isFinite(startIndex) || !Number.isFinite(endIndex) || endIndex < startIndex) return [] + return filteredContacts.slice(startIndex, endIndex + 1).map((contact, offset) => { + const index = startIndex + offset + return { + contact, + index, + top: (index * CONTACTS_ROW_HEIGHT) - contactsListScrollTop + } + }) + }, [contactsListScrollTop, contactsVisibleRange.endIndex, contactsVisibleRange.startIndex, filteredContacts, hasFilteredContacts]) + const renderContactActionRailItem = useCallback((contact: ContactInfo, index: number, top: number) => { + const matchedSession = sessionRowByUsername.get(contact.username) + const canExport = Boolean(matchedSession?.hasSession) + const checked = canExport && selectedSessions.has(contact.username) + const isRunning = canExport && runningSessionIds.has(contact.username) + const isQueued = canExport && queuedSessionIds.has(contact.username) + const recentExportTimestamp = lastExportBySession[contact.username] + const hasRecentExport = canExport && Boolean(recentExportTimestamp) + const recentExportTime = hasRecentExport ? formatRecentExportTime(recentExportTimestamp, nowTick) : '' + + return ( +
+
+
+
+ + {hasRecentExport && {recentExportTime}} +
+ +
+
+
+ ) + }, [ + lastExportBySession, + nowTick, + openSessionDetail, + openSingleExport, + queuedSessionIds, + runningSessionIds, + selectedSessions, + sessionDetail?.wxid, + sessionRowByUsername + ]) const handleContactsListWheelCapture = useCallback((event: WheelEvent) => { const deltaY = event.deltaY if (!deltaY) return @@ -6067,9 +6277,19 @@ function ExportPage() { window.scrollBy({ top: deltaY, behavior: 'auto' }) } }, [isContactsListAtTop]) + const handleContactsListScrollCapture = useCallback((event: UIEvent) => { + const target = event.target + if (!(target instanceof HTMLDivElement)) return + const nextScrollTop = Math.max(0, target.scrollTop) + setContactsListScrollTop(prev => ( + Math.abs(prev - nextScrollTop) > 1 ? nextScrollTop : prev + )) + }, []) useEffect(() => { if (hasFilteredContacts) return setIsContactsListAtTop(true) + setContactsListScrollTop(0) + setContactsVisibleRange({ startIndex: 0, endIndex: -1 }) }, [hasFilteredContacts]) const chooseExportFolder = useCallback(async () => { const result = await window.electronAPI.dialog.openFile({ @@ -6481,18 +6701,24 @@ function ExportPage() {
contact.username} - fixedItemHeight={76} + fixedItemHeight={CONTACTS_ROW_HEIGHT} itemContent={renderContactRow} rangeChanged={handleContactsRangeChanged} atTopStateChange={setIsContactsListAtTop} overscan={420} /> +
+ {visibleContactsForActionRail.map(({ contact, index, top }) => ( + renderContactActionRailItem(contact, index, top) + ))} +
)} @@ -6790,15 +7016,15 @@ function ExportPage() {
- 打开桌面端微信,进入到这个人的朋友圈中,刷ta 的朋友圈,刷的越多这里的数据聚合越多 + 打开桌面端微信进入对方朋友圈,刷得越多这里聚合得越全。已确认身份的共同好友会展示头像、备注、微信号;未确认的旧数据仅保留名字。
setSessionMutualFriendsSearch(event.target.value)} - placeholder="搜索共同好友" - aria-label="搜索共同好友" + placeholder="搜索共同好友(备注 / 微信号 / wxid)" + aria-label="搜索共同好友(备注 / 微信号 / wxid)" />
@@ -6810,20 +7036,48 @@ function ExportPage() { ) : (
{filteredSessionMutualFriendsDialogItems.map((item, index) => ( -
+
{index + 1} - {item.name} - - {getSessionMutualFriendDirectionLabel(item.direction)} - +
+ +
+
+ {item.name} + + {getSessionMutualFriendDirectionLabel(item.direction)} + + {!item.isConfirmed && ( + 身份未确认 + )} +
+
+ {item.isConfirmed ? ( + item.wechatId ? `微信号: ${item.wechatId}` : '微信号: 未设置' + ) : ( + '仅从旧数据中解析到名字' + )} +
+
+ {item.isConfirmed + ? `wxid: ${item.wxid || item.username || ''}` + : '没有可用的 wxid,未做自动匹配'} +
+ + {describeSessionMutualFriendRelation(item, sessionMutualFriendsDialogTarget.displayName)} + +
+
{item.totalCount.toLocaleString('zh-CN')} {formatYmdDateFromSeconds(item.latestTime)} - - {describeSessionMutualFriendRelation(item, sessionMutualFriendsDialogTarget.displayName)} -
))}
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 72aaa57..ef06b47 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -767,7 +767,53 @@ export interface ElectronAPI { } }> likes: Array - comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> }> + comments: Array<{ + id: string + nickname: string + username?: string + content: string + refCommentId: string + refNickname?: string + refUsername?: string + emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> + }> + likesDetail?: Array<{ + nickname: string + username?: string + wxid?: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string + avatarUrl?: string + source: 'xml' | 'legacy' + }> + commentsDetail?: Array<{ + id: string + nickname: string + username?: string + wxid?: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string + avatarUrl?: string + content: string + refCommentId: string + refNickname?: string + refUsername?: string + refWxid?: string + refAlias?: string + refWechatId?: string + refRemark?: string + refNickName?: string + refDisplayName?: string + refAvatarUrl?: string + emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> + source: 'xml' | 'legacy' + }> rawXml?: string }> error?: string diff --git a/src/types/sns.ts b/src/types/sns.ts index 9193385..ccfeba3 100644 --- a/src/types/sns.ts +++ b/src/types/sns.ts @@ -28,12 +28,53 @@ export interface SnsCommentEmoji { export interface SnsComment { id: string nickname: string + username?: string content: string refCommentId: string refNickname?: string + refUsername?: string emojis?: SnsCommentEmoji[] } +export interface SnsLikeDetail { + nickname: string + username?: string + wxid?: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string + avatarUrl?: string + source: 'xml' | 'legacy' +} + +export interface SnsCommentDetail { + id: string + nickname: string + username?: string + wxid?: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string + avatarUrl?: string + content: string + refCommentId: string + refNickname?: string + refUsername?: string + refWxid?: string + refAlias?: string + refWechatId?: string + refRemark?: string + refNickName?: string + refDisplayName?: string + refAvatarUrl?: string + emojis?: SnsCommentEmoji[] + source: 'xml' | 'legacy' +} + export interface SnsPost { id: string tid?: string // 数据库主键(雪花 ID),用于精确删除 @@ -46,6 +87,8 @@ export interface SnsPost { media: SnsMedia[] likes: string[] comments: SnsComment[] + likesDetail?: SnsLikeDetail[] + commentsDetail?: SnsCommentDetail[] rawXml?: string linkTitle?: string linkUrl?: string