feat: enrich mutual friend identities in export dialog

This commit is contained in:
aits2026
2026-03-06 20:12:32 +08:00
parent a6a202f6ff
commit 94a010c9b2
5 changed files with 621 additions and 151 deletions

View File

@@ -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<string, Promise<SnsContactIdentity | null>>()
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 }
}

View File

@@ -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 {

View File

@@ -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<string | undefined | null>): Set<string> => {
const set = new Set<string>()
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<Pick<SessionMutualFriendItem, 'identityKey' | 'username' | 'wxid' | 'wechatId' | 'remark' | 'avatarUrl' | 'name' | 'isConfirmed'>>
): 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<string, SessionMutualFriendItem>()
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<string>
remark?: string
wechatId?: string
avatarUrl?: string
primaryIdentityKey: string
candidateIdentityKeys: Set<string>
} => {
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<string>()
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<string, SessionMutualFriendItem>()
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() {
)}
</div>
)}
<div className="row-action-cell">
<div className={`row-action-main ${hasRecentExport ? '' : 'single-line'}`.trim()}>
<div className={`row-export-action-stack ${hasRecentExport ? '' : 'single-line'}`.trim()}>
<button
type="button"
className={`row-export-link ${isRunning ? 'state-running' : ''} ${!canExport ? 'state-disabled' : ''}`}
disabled={!canExport || isRunning}
onClick={() => {
if (!matchedSession || !matchedSession.hasSession) return
openSingleExport({
...matchedSession,
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
})
}}
>
{!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '单会话导出'}
</button>
{hasRecentExport && <span className="row-export-time">{recentExportTime}</span>}
</div>
<button
className={`row-detail-btn ${showSessionDetailPanel && sessionDetail?.wxid === contact.username ? 'active' : ''}`}
onClick={() => openSessionDetail(contact.username)}
>
</button>
</div>
</div>
</div>
</div>
)
}, [
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 (
<div
key={contact.username}
className={`contacts-action-rail-row ${checked ? 'selected' : ''}`}
style={{ top: `${top}px` }}
data-index={index}
>
<div className="contacts-action-rail-card">
<div className={`row-action-main ${hasRecentExport ? '' : 'single-line'}`.trim()}>
<div className={`row-export-action-stack ${hasRecentExport ? '' : 'single-line'}`.trim()}>
<button
type="button"
className={`row-export-link ${isRunning ? 'state-running' : ''} ${!canExport ? 'state-disabled' : ''}`}
disabled={!canExport || isRunning}
onClick={() => {
if (!matchedSession || !matchedSession.hasSession) return
openSingleExport({
...matchedSession,
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
})
}}
>
{!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '单会话导出'}
</button>
{hasRecentExport && <span className="row-export-time">{recentExportTime}</span>}
</div>
<button
className={`row-detail-btn ${showSessionDetailPanel && sessionDetail?.wxid === contact.username ? 'active' : ''}`}
onClick={() => openSessionDetail(contact.username)}
>
</button>
</div>
</div>
</div>
)
}, [
lastExportBySession,
nowTick,
openSessionDetail,
openSingleExport,
queuedSessionIds,
runningSessionIds,
selectedSessions,
sessionDetail?.wxid,
sessionRowByUsername
])
const handleContactsListWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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() {
<div
className="contacts-list"
onWheelCapture={handleContactsListWheelCapture}
onScrollCapture={handleContactsListScrollCapture}
>
<Virtuoso
ref={contactsVirtuosoRef}
className="contacts-virtuoso"
data={filteredContacts}
computeItemKey={(_, contact) => contact.username}
fixedItemHeight={76}
fixedItemHeight={CONTACTS_ROW_HEIGHT}
itemContent={renderContactRow}
rangeChanged={handleContactsRangeChanged}
atTopStateChange={setIsContactsListAtTop}
overscan={420}
/>
<div className="contacts-action-rail">
{visibleContactsForActionRail.map(({ contact, index, top }) => (
renderContactActionRailItem(contact, index, top)
))}
</div>
</div>
)}
</div>
@@ -6790,15 +7016,15 @@ function ExportPage() {
</div>
<div className="session-mutual-friends-tip">
ta
</div>
<div className="session-mutual-friends-toolbar">
<input
value={sessionMutualFriendsSearch}
onChange={(event) => setSessionMutualFriendsSearch(event.target.value)}
placeholder="搜索共同好友"
aria-label="搜索共同好友"
placeholder="搜索共同好友(备注 / 微信号 / wxid"
aria-label="搜索共同好友(备注 / 微信号 / wxid"
/>
</div>
@@ -6810,20 +7036,48 @@ function ExportPage() {
) : (
<div className="session-mutual-friends-list">
{filteredSessionMutualFriendsDialogItems.map((item, index) => (
<div className="session-mutual-friends-row" key={`${sessionMutualFriendsDialogTarget.username}-${item.name}`}>
<div className={`session-mutual-friends-row ${item.isConfirmed ? 'confirmed' : 'unconfirmed'}`} key={`${sessionMutualFriendsDialogTarget.username}-${item.key}`}>
<span className="session-mutual-friends-rank">{index + 1}</span>
<span className="session-mutual-friends-name" title={item.name}>{item.name}</span>
<span className={`session-mutual-friends-source ${item.direction}`}>
{getSessionMutualFriendDirectionLabel(item.direction)}
</span>
<div className="session-mutual-friends-user">
<Avatar
src={item.avatarUrl}
name={item.name}
size={40}
shape="rounded"
className="session-mutual-friends-user-avatar"
/>
<div className="session-mutual-friends-user-main">
<div className="session-mutual-friends-user-head">
<span className="session-mutual-friends-name" title={item.name}>{item.name}</span>
<span className={`session-mutual-friends-source ${item.direction}`}>
{getSessionMutualFriendDirectionLabel(item.direction)}
</span>
{!item.isConfirmed && (
<span className="session-mutual-friends-identity-badge"></span>
)}
</div>
<div className="session-mutual-friends-identity" title={item.isConfirmed ? (item.wechatId ? `微信号: ${item.wechatId}` : '微信号: 未设置') : '仅从旧数据中解析到名字'}>
{item.isConfirmed ? (
item.wechatId ? `微信号: ${item.wechatId}` : '微信号: 未设置'
) : (
'仅从旧数据中解析到名字'
)}
</div>
<div className="session-mutual-friends-identity secondary" title={item.isConfirmed ? `wxid: ${item.wxid || item.username || ''}` : '没有可用的 wxid未做自动匹配'}>
{item.isConfirmed
? `wxid: ${item.wxid || item.username || ''}`
: '没有可用的 wxid未做自动匹配'}
</div>
<span
className="session-mutual-friends-desc"
title={describeSessionMutualFriendRelation(item, sessionMutualFriendsDialogTarget.displayName)}
>
{describeSessionMutualFriendRelation(item, sessionMutualFriendsDialogTarget.displayName)}
</span>
</div>
</div>
<span className="session-mutual-friends-count">{item.totalCount.toLocaleString('zh-CN')}</span>
<span className="session-mutual-friends-latest">{formatYmdDateFromSeconds(item.latestTime)}</span>
<span
className="session-mutual-friends-desc"
title={describeSessionMutualFriendRelation(item, sessionMutualFriendsDialogTarget.displayName)}
>
{describeSessionMutualFriendRelation(item, sessionMutualFriendsDialogTarget.displayName)}
</span>
</div>
))}
</div>

View File

@@ -767,7 +767,53 @@ export interface ElectronAPI {
}
}>
likes: Array<string>
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

View File

@@ -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