mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat: enrich mutual friend identities in export dialog
This commit is contained in:
@@ -52,6 +52,7 @@ interface SnsContactIdentity {
|
|||||||
remark?: string
|
remark?: string
|
||||||
nickName?: string
|
nickName?: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedLikeUser {
|
interface ParsedLikeUser {
|
||||||
@@ -79,6 +80,7 @@ interface ArkmeLikeDetail {
|
|||||||
remark?: string
|
remark?: string
|
||||||
nickName?: string
|
nickName?: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
source: 'xml' | 'legacy'
|
source: 'xml' | 'legacy'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +94,7 @@ interface ArkmeCommentDetail {
|
|||||||
remark?: string
|
remark?: string
|
||||||
nickName?: string
|
nickName?: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
content: string
|
content: string
|
||||||
refCommentId: string
|
refCommentId: string
|
||||||
refNickname?: string
|
refNickname?: string
|
||||||
@@ -102,6 +105,7 @@ interface ArkmeCommentDetail {
|
|||||||
refRemark?: string
|
refRemark?: string
|
||||||
refNickName?: string
|
refNickName?: string
|
||||||
refDisplayName?: string
|
refDisplayName?: string
|
||||||
|
refAvatarUrl?: string
|
||||||
emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[]
|
emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[]
|
||||||
source: 'xml' | 'legacy'
|
source: 'xml' | 'legacy'
|
||||||
}
|
}
|
||||||
@@ -323,6 +327,7 @@ class SnsService {
|
|||||||
let alias: string | undefined
|
let alias: string | undefined
|
||||||
let remark: string | undefined
|
let remark: string | undefined
|
||||||
let nickName: string | undefined
|
let nickName: string | undefined
|
||||||
|
let avatarUrl = this.toOptionalString(cached?.avatarUrl)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contactResult = await wcdbService.getContact(normalized)
|
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
|
const displayName = remark || nickName || alias || cached?.displayName || normalized
|
||||||
return {
|
return {
|
||||||
username: normalized,
|
username: normalized,
|
||||||
@@ -344,7 +360,8 @@ class SnsService {
|
|||||||
wechatId: alias,
|
wechatId: alias,
|
||||||
remark,
|
remark,
|
||||||
nickName,
|
nickName,
|
||||||
displayName
|
displayName,
|
||||||
|
avatarUrl
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
identityCache.set(normalized, pending)
|
identityCache.set(normalized, pending)
|
||||||
@@ -412,6 +429,7 @@ class SnsService {
|
|||||||
remark: identity?.remark,
|
remark: identity?.remark,
|
||||||
nickName: identity?.nickName,
|
nickName: identity?.nickName,
|
||||||
displayName: identity?.displayName || nickname || username || '',
|
displayName: identity?.displayName || nickname || username || '',
|
||||||
|
avatarUrl: identity?.avatarUrl,
|
||||||
source: likeSource
|
source: likeSource
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -483,6 +501,7 @@ class SnsService {
|
|||||||
remark: actor?.remark,
|
remark: actor?.remark,
|
||||||
nickName: actor?.nickName,
|
nickName: actor?.nickName,
|
||||||
displayName: actor?.displayName || nickname || username || '',
|
displayName: actor?.displayName || nickname || username || '',
|
||||||
|
avatarUrl: actor?.avatarUrl,
|
||||||
content: comment.content || '',
|
content: comment.content || '',
|
||||||
refCommentId: comment.refCommentId || '',
|
refCommentId: comment.refCommentId || '',
|
||||||
refNickname: comment.refNickname || refActor?.displayName,
|
refNickname: comment.refNickname || refActor?.displayName,
|
||||||
@@ -493,6 +512,7 @@ class SnsService {
|
|||||||
refRemark: refActor?.remark,
|
refRemark: refActor?.remark,
|
||||||
refNickName: refActor?.nickName,
|
refNickName: refActor?.nickName,
|
||||||
refDisplayName: refActor?.displayName,
|
refDisplayName: refActor?.displayName,
|
||||||
|
refAvatarUrl: refActor?.avatarUrl,
|
||||||
emojis: comment.emojis,
|
emojis: comment.emojis,
|
||||||
source: commentSource
|
source: commentSource
|
||||||
})
|
})
|
||||||
@@ -1021,7 +1041,8 @@ class SnsService {
|
|||||||
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||||
if (!result.success || !result.timeline || result.timeline.length === 0) return result
|
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 contact = this.contactCache.get(post.username)
|
||||||
const isVideoPost = post.type === 15
|
const isVideoPost = post.type === 15
|
||||||
const videoKey = extractVideoKey(post.rawXml || '')
|
const videoKey = extractVideoKey(post.rawXml || '')
|
||||||
@@ -1061,14 +1082,22 @@ class SnsService {
|
|||||||
finalComments = this.fixCommentRefs(dllComments)
|
finalComments = this.fixCommentRefs(dllComments)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedPost: SnsPost = {
|
||||||
|
...post,
|
||||||
|
comments: finalComments
|
||||||
|
}
|
||||||
|
const { likesDetail, commentsDetail } = await this.buildArkmeInteractionDetails(normalizedPost, identityCache)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...post,
|
...post,
|
||||||
avatarUrl: contact?.avatarUrl,
|
avatarUrl: contact?.avatarUrl,
|
||||||
nickname: post.nickname || contact?.displayName || post.username,
|
nickname: post.nickname || contact?.displayName || post.username,
|
||||||
media: fixedMedia,
|
media: fixedMedia,
|
||||||
comments: finalComments
|
comments: finalComments,
|
||||||
|
likesDetail,
|
||||||
|
commentsDetail
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|
||||||
return { ...result, timeline: enrichedTimeline }
|
return { ...result, timeline: enrichedTimeline }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -623,18 +623,22 @@
|
|||||||
|
|
||||||
.session-mutual-friends-row {
|
.session-mutual-friends-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 36px minmax(120px, 0.82fr) max-content 56px 96px minmax(0, 1.28fr);
|
grid-template-columns: 36px minmax(0, 1fr) 72px 108px;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 12px;
|
padding: 12px;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 68%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 68%, transparent);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
min-height: 42px;
|
min-height: 72px;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.unconfirmed {
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 60%, transparent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-mutual-friends-rank,
|
.session-mutual-friends-rank,
|
||||||
@@ -648,6 +652,32 @@
|
|||||||
text-align: center;
|
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 {
|
.session-mutual-friends-name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
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 {
|
.session-mutual-friends-desc {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
@@ -1980,6 +2034,7 @@
|
|||||||
height: var(--contacts-default-list-height);
|
height: var(--contacts-default-list-height);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0 0 12px;
|
padding: 0 0 12px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contacts-virtuoso {
|
.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 {
|
.table-bottom-scrollbar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -2091,10 +2183,6 @@
|
|||||||
&.selected .contact-item {
|
&.selected .contact-item {
|
||||||
background: rgba(var(--primary-rgb), 0.08);
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected .row-action-cell {
|
|
||||||
background: rgba(var(--primary-rgb), 0.08);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-item {
|
.contact-item {
|
||||||
@@ -2474,26 +2562,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-action-cell {
|
.row-action-cell,
|
||||||
|
.contacts-action-rail-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
width: var(--contacts-action-col-width);
|
width: 100%;
|
||||||
min-width: var(--contacts-action-col-width);
|
min-width: 0;
|
||||||
flex-shrink: 0;
|
height: calc(var(--contacts-row-height) - 4px);
|
||||||
position: sticky;
|
padding: 0 6px 0 0;
|
||||||
right: 0;
|
box-sizing: border-box;
|
||||||
z-index: 10;
|
justify-content: center;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
|
pointer-events: auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -12px;
|
top: -12px;
|
||||||
bottom: -12px;
|
bottom: -12px;
|
||||||
left: -8px;
|
left: -12px;
|
||||||
width: 8px;
|
width: 12px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: linear-gradient(to right, transparent, var(--bg-primary));
|
background: linear-gradient(to right, transparent, var(--bg-primary));
|
||||||
}
|
}
|
||||||
@@ -4161,13 +4253,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.session-mutual-friends-row {
|
.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;
|
gap: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-mutual-friends-desc {
|
.session-mutual-friends-user {
|
||||||
display: none;
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-mutual-friends-user-avatar {
|
||||||
|
width: 36px !important;
|
||||||
|
height: 36px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-load-detail-row {
|
.session-load-detail-row {
|
||||||
|
|||||||
@@ -44,11 +44,12 @@ import {
|
|||||||
subscribeBackgroundTasks
|
subscribeBackgroundTasks
|
||||||
} from '../services/backgroundTaskMonitor'
|
} from '../services/backgroundTaskMonitor'
|
||||||
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||||||
|
import { Avatar } from '../components/Avatar'
|
||||||
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
||||||
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||||
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
|
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
|
||||||
import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../components/Export/ExportDefaultsSettingsForm'
|
import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../components/Export/ExportDefaultsSettingsForm'
|
||||||
import type { SnsPost } from '../types/sns'
|
import type { SnsCommentDetail, SnsLikeDetail, SnsPost } from '../types/sns'
|
||||||
import {
|
import {
|
||||||
cloneExportDateRange,
|
cloneExportDateRange,
|
||||||
createDefaultDateRange,
|
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 TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||||
type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson'
|
type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson'
|
||||||
|
const CONTACTS_ROW_HEIGHT = 76
|
||||||
|
|
||||||
interface ExportOptions {
|
interface ExportOptions {
|
||||||
format: TextExportFormat
|
format: TextExportFormat
|
||||||
@@ -513,6 +515,12 @@ const getAvatarLetter = (name: string): string => {
|
|||||||
return [...name][0] || '?'
|
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 toComparableNameSet = (values: Array<string | undefined | null>): Set<string> => {
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
@@ -602,7 +610,15 @@ type SessionMutualFriendDirection = 'incoming' | 'outgoing' | 'bidirectional'
|
|||||||
type SessionMutualFriendBehavior = 'likes' | 'comments' | 'both'
|
type SessionMutualFriendBehavior = 'likes' | 'comments' | 'both'
|
||||||
|
|
||||||
interface SessionMutualFriendItem {
|
interface SessionMutualFriendItem {
|
||||||
|
key: string
|
||||||
|
identityKey?: string
|
||||||
name: string
|
name: string
|
||||||
|
username?: string
|
||||||
|
wxid?: string
|
||||||
|
wechatId?: string
|
||||||
|
remark?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
isConfirmed: boolean
|
||||||
incomingLikeCount: number
|
incomingLikeCount: number
|
||||||
incomingCommentCount: number
|
incomingCommentCount: number
|
||||||
outgoingLikeCount: number
|
outgoingLikeCount: number
|
||||||
@@ -621,6 +637,63 @@ interface SessionMutualFriendsMetric {
|
|||||||
computedAt: number
|
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 {
|
interface SessionSnsRankCacheEntry {
|
||||||
likes: SessionSnsRankItem[]
|
likes: SessionSnsRankItem[]
|
||||||
comments: SessionSnsRankItem[]
|
comments: SessionSnsRankItem[]
|
||||||
@@ -678,55 +751,121 @@ const buildSessionMutualFriendsMetric = (
|
|||||||
): SessionMutualFriendsMetric => {
|
): SessionMutualFriendsMetric => {
|
||||||
const friendMap = new Map<string, SessionMutualFriendItem>()
|
const friendMap = new Map<string, SessionMutualFriendItem>()
|
||||||
|
|
||||||
for (const post of posts) {
|
const ensureItem = (seed: {
|
||||||
const createTime = Number(post?.createTime) || 0
|
name: string
|
||||||
const likes = Array.isArray(post?.likes) ? post.likes : []
|
username?: string
|
||||||
const comments = Array.isArray(post?.comments) ? post.comments : []
|
wxid?: string
|
||||||
|
wechatId?: string
|
||||||
for (const likeNameRaw of likes) {
|
remark?: string
|
||||||
const name = String(likeNameRaw || '').trim() || '未知用户'
|
avatarUrl?: string
|
||||||
const existing = friendMap.get(name)
|
identityKey?: string
|
||||||
if (existing) {
|
isConfirmed: boolean
|
||||||
existing.incomingLikeCount += 1
|
}): SessionMutualFriendItem => {
|
||||||
existing.totalCount += 1
|
const key = seed.identityKey || getSessionMutualFriendFallbackKey(seed.name)
|
||||||
existing.behavior = existing.incomingCommentCount > 0 ? 'both' : 'likes'
|
const existing = friendMap.get(key)
|
||||||
if (createTime > existing.latestTime) existing.latestTime = createTime
|
if (existing) {
|
||||||
continue
|
mergeSessionMutualFriendItemProfile(existing, seed)
|
||||||
}
|
return existing
|
||||||
friendMap.set(name, {
|
|
||||||
name,
|
|
||||||
incomingLikeCount: 1,
|
|
||||||
incomingCommentCount: 0,
|
|
||||||
outgoingLikeCount: 0,
|
|
||||||
outgoingCommentCount: 0,
|
|
||||||
totalCount: 1,
|
|
||||||
latestTime: createTime,
|
|
||||||
direction: 'incoming',
|
|
||||||
behavior: 'likes'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const comment of comments) {
|
const created: SessionMutualFriendItem = {
|
||||||
const name = String(comment?.nickname || '').trim() || '未知用户'
|
key,
|
||||||
const existing = friendMap.get(name)
|
identityKey: seed.identityKey,
|
||||||
if (existing) {
|
name: seed.name,
|
||||||
existing.incomingCommentCount += 1
|
username: seed.username,
|
||||||
existing.totalCount += 1
|
wxid: seed.wxid,
|
||||||
existing.behavior = existing.incomingLikeCount > 0 ? 'both' : 'comments'
|
wechatId: seed.wechatId,
|
||||||
if (createTime > existing.latestTime) existing.latestTime = createTime
|
remark: seed.remark,
|
||||||
continue
|
avatarUrl: seed.avatarUrl,
|
||||||
}
|
isConfirmed: seed.isConfirmed,
|
||||||
friendMap.set(name, {
|
incomingLikeCount: 0,
|
||||||
name,
|
incomingCommentCount: 0,
|
||||||
incomingLikeCount: 0,
|
outgoingLikeCount: 0,
|
||||||
incomingCommentCount: 1,
|
outgoingCommentCount: 0,
|
||||||
outgoingLikeCount: 0,
|
totalCount: 0,
|
||||||
outgoingCommentCount: 0,
|
latestTime: 0,
|
||||||
totalCount: 1,
|
direction: 'incoming',
|
||||||
latestTime: createTime,
|
behavior: 'likes'
|
||||||
direction: 'incoming',
|
}
|
||||||
behavior: 'comments'
|
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 [nowTick, setNowTick] = useState(Date.now())
|
||||||
const [isContactsListAtTop, setIsContactsListAtTop] = useState(true)
|
const [isContactsListAtTop, setIsContactsListAtTop] = useState(true)
|
||||||
const [isContactsHeaderDragging, setIsContactsHeaderDragging] = useState(false)
|
const [isContactsHeaderDragging, setIsContactsHeaderDragging] = useState(false)
|
||||||
|
const [contactsListScrollTop, setContactsListScrollTop] = useState(0)
|
||||||
|
const [contactsVisibleRange, setContactsVisibleRange] = useState({ startIndex: 0, endIndex: -1 })
|
||||||
const [contactsHorizontalScrollMetrics, setContactsHorizontalScrollMetrics] = useState({
|
const [contactsHorizontalScrollMetrics, setContactsHorizontalScrollMetrics] = useState({
|
||||||
viewportWidth: 0,
|
viewportWidth: 0,
|
||||||
contentWidth: 0
|
contentWidth: 0
|
||||||
@@ -2788,21 +2929,27 @@ function ExportPage() {
|
|||||||
|
|
||||||
const getSessionMutualFriendProfile = useCallback((sessionId: string): {
|
const getSessionMutualFriendProfile = useCallback((sessionId: string): {
|
||||||
displayName: string
|
displayName: string
|
||||||
candidateNames: Set<string>
|
remark?: string
|
||||||
|
wechatId?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
primaryIdentityKey: string
|
||||||
|
candidateIdentityKeys: Set<string>
|
||||||
} => {
|
} => {
|
||||||
const normalizedSessionId = String(sessionId || '').trim()
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
const contact = contactsList.find(item => item.username === normalizedSessionId)
|
const contact = contactsList.find(item => item.username === normalizedSessionId)
|
||||||
const session = sessionsRef.current.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 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 {
|
return {
|
||||||
displayName,
|
displayName,
|
||||||
candidateNames: toComparableNameSet([
|
remark: toOptionalString(contact?.remark),
|
||||||
displayName,
|
wechatId,
|
||||||
contact?.displayName,
|
avatarUrl: toOptionalString(contact?.avatarUrl),
|
||||||
contact?.remark,
|
primaryIdentityKey: `u:${normalizedSessionId}`,
|
||||||
contact?.nickname,
|
candidateIdentityKeys
|
||||||
contact?.alias
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
}, [contactsList])
|
}, [contactsList])
|
||||||
|
|
||||||
@@ -2814,41 +2961,54 @@ function ExportPage() {
|
|||||||
const directMetric = directMetrics[normalizedTargetSessionId]
|
const directMetric = directMetrics[normalizedTargetSessionId]
|
||||||
if (!directMetric) return null
|
if (!directMetric) return null
|
||||||
|
|
||||||
const { candidateNames } = getSessionMutualFriendProfile(normalizedTargetSessionId)
|
const targetProfile = getSessionMutualFriendProfile(normalizedTargetSessionId)
|
||||||
const mergedMap = new Map<string, SessionMutualFriendItem>()
|
const mergedMap = new Map<string, SessionMutualFriendItem>()
|
||||||
for (const item of directMetric.items) {
|
for (const item of directMetric.items) {
|
||||||
mergedMap.set(item.name, { ...item })
|
mergedMap.set(item.key, { ...item })
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [sourceSessionId, sourceMetric] of Object.entries(directMetrics)) {
|
for (const [sourceSessionId, sourceMetric] of Object.entries(directMetrics)) {
|
||||||
if (!sourceMetric || sourceSessionId === normalizedTargetSessionId) continue
|
if (!sourceMetric || sourceSessionId === normalizedTargetSessionId) continue
|
||||||
const sourceProfile = getSessionMutualFriendProfile(sourceSessionId)
|
const sourceProfile = getSessionMutualFriendProfile(sourceSessionId)
|
||||||
if (!sourceProfile.displayName) continue
|
if (!sourceProfile.displayName) continue
|
||||||
if (mergedMap.has(sourceProfile.displayName)) continue
|
const reverseMatches = sourceMetric.items.filter(item => {
|
||||||
|
if (!item.identityKey) return false
|
||||||
const reverseMatches = sourceMetric.items.filter(item => candidateNames.has(item.name))
|
return targetProfile.candidateIdentityKeys.has(item.identityKey)
|
||||||
|
})
|
||||||
if (reverseMatches.length === 0) continue
|
if (reverseMatches.length === 0) continue
|
||||||
|
|
||||||
const reverseCount = reverseMatches.reduce((sum, item) => sum + item.totalCount, 0)
|
const reverseCount = reverseMatches.reduce((sum, item) => sum + item.totalCount, 0)
|
||||||
const reverseLikeCount = reverseMatches.reduce((sum, item) => sum + item.incomingLikeCount, 0)
|
const reverseLikeCount = reverseMatches.reduce((sum, item) => sum + item.incomingLikeCount, 0)
|
||||||
const reverseCommentCount = reverseMatches.reduce((sum, item) => sum + item.incomingCommentCount, 0)
|
const reverseCommentCount = reverseMatches.reduce((sum, item) => sum + item.incomingCommentCount, 0)
|
||||||
const reverseLatestTime = reverseMatches.reduce((latest, item) => Math.max(latest, item.latestTime), 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) {
|
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.outgoingLikeCount += reverseLikeCount
|
||||||
existing.outgoingCommentCount += reverseCommentCount
|
existing.outgoingCommentCount += reverseCommentCount
|
||||||
existing.totalCount += reverseCount
|
existing.totalCount += reverseCount
|
||||||
existing.latestTime = Math.max(existing.latestTime, reverseLatestTime)
|
existing.latestTime = Math.max(existing.latestTime, reverseLatestTime)
|
||||||
existing.direction = (existing.incomingLikeCount + existing.incomingCommentCount) > 0
|
applySessionMutualFriendDerivedState(existing)
|
||||||
? 'bidirectional'
|
|
||||||
: 'outgoing'
|
|
||||||
existing.behavior = summarizeMutualFriendBehavior(
|
|
||||||
existing.incomingLikeCount + existing.outgoingLikeCount,
|
|
||||||
existing.incomingCommentCount + existing.outgoingCommentCount
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
mergedMap.set(sourceProfile.displayName, {
|
const created: SessionMutualFriendItem = {
|
||||||
|
key: sourceProfile.primaryIdentityKey,
|
||||||
|
identityKey: sourceProfile.primaryIdentityKey,
|
||||||
name: sourceProfile.displayName,
|
name: sourceProfile.displayName,
|
||||||
|
username: sourceSessionId,
|
||||||
|
wxid: sourceSessionId,
|
||||||
|
wechatId: sourceProfile.wechatId,
|
||||||
|
remark: sourceProfile.remark,
|
||||||
|
avatarUrl: sourceProfile.avatarUrl,
|
||||||
|
isConfirmed: true,
|
||||||
incomingLikeCount: 0,
|
incomingLikeCount: 0,
|
||||||
incomingCommentCount: 0,
|
incomingCommentCount: 0,
|
||||||
outgoingLikeCount: reverseLikeCount,
|
outgoingLikeCount: reverseLikeCount,
|
||||||
@@ -2857,7 +3017,8 @@ function ExportPage() {
|
|||||||
latestTime: reverseLatestTime,
|
latestTime: reverseLatestTime,
|
||||||
direction: 'outgoing',
|
direction: 'outgoing',
|
||||||
behavior: summarizeMutualFriendBehavior(reverseLikeCount, reverseCommentCount)
|
behavior: summarizeMutualFriendBehavior(reverseLikeCount, reverseCommentCount)
|
||||||
})
|
}
|
||||||
|
mergedMap.set(created.key, created)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2887,7 +3048,7 @@ function ExportPage() {
|
|||||||
for (const targetSessionId of allSessionIds) {
|
for (const targetSessionId of allSessionIds) {
|
||||||
if (targetSessionId === normalizedSessionId) continue
|
if (targetSessionId === normalizedSessionId) continue
|
||||||
const targetProfile = getSessionMutualFriendProfile(targetSessionId)
|
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)
|
impactedSessionIds.add(targetSessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4834,6 +4995,11 @@ function ExportPage() {
|
|||||||
const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex
|
const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex
|
||||||
sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex }
|
sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex }
|
||||||
sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex }
|
sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex }
|
||||||
|
setContactsVisibleRange(prev => (
|
||||||
|
prev.startIndex === startIndex && prev.endIndex === endIndex
|
||||||
|
? prev
|
||||||
|
: { startIndex, endIndex }
|
||||||
|
))
|
||||||
if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return
|
if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return
|
||||||
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
|
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
|
||||||
if (visibleTargets.length === 0) return
|
if (visibleTargets.length === 0) return
|
||||||
@@ -5037,7 +5203,18 @@ function ExportPage() {
|
|||||||
const items = sessionMutualFriendsDialogMetric?.items || []
|
const items = sessionMutualFriendsDialogMetric?.items || []
|
||||||
const keyword = sessionMutualFriendsSearch.trim().toLowerCase()
|
const keyword = sessionMutualFriendsSearch.trim().toLowerCase()
|
||||||
if (!keyword) return items
|
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])
|
}, [sessionMutualFriendsDialogMetric, sessionMutualFriendsSearch])
|
||||||
|
|
||||||
const applySessionDetailStats = useCallback((
|
const applySessionDetailStats = useCallback((
|
||||||
@@ -5618,7 +5795,7 @@ function ExportPage() {
|
|||||||
const taskCenterAlertCount = taskRunningCount + taskQueuedCount
|
const taskCenterAlertCount = taskRunningCount + taskQueuedCount
|
||||||
const hasFilteredContacts = filteredContacts.length > 0
|
const hasFilteredContacts = filteredContacts.length > 0
|
||||||
const contactsTableMinWidth = useMemo(() => {
|
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 snsWidth = shouldShowSnsColumn ? 72 + 12 : 0
|
||||||
const mutualFriendsWidth = shouldShowMutualFriendsColumn ? 72 + 12 : 0
|
const mutualFriendsWidth = shouldShowMutualFriendsColumn ? 72 + 12 : 0
|
||||||
return baseWidth + snsWidth + mutualFriendsWidth
|
return baseWidth + snsWidth + mutualFriendsWidth
|
||||||
@@ -5814,11 +5991,6 @@ function ExportPage() {
|
|||||||
const canExport = Boolean(matchedSession?.hasSession)
|
const canExport = Boolean(matchedSession?.hasSession)
|
||||||
const isSessionBindingPending = !matchedSession && (isLoading || isSessionEnriching)
|
const isSessionBindingPending = !matchedSession && (isLoading || isSessionEnriching)
|
||||||
const checked = canExport && selectedSessions.has(contact.username)
|
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 countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
|
||||||
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
|
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
|
||||||
const displayedMessageCount = countedMessages ?? hintedMessages
|
const displayedMessageCount = countedMessages ?? hintedMessages
|
||||||
@@ -5995,47 +6167,14 @@ function ExportPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}, [
|
}, [
|
||||||
lastExportBySession,
|
|
||||||
nowTick,
|
|
||||||
openContactSnsTimeline,
|
openContactSnsTimeline,
|
||||||
openSessionDetail,
|
openSessionDetail,
|
||||||
openSessionMutualFriendsDialog,
|
openSessionMutualFriendsDialog,
|
||||||
openSingleExport,
|
|
||||||
queuedSessionIds,
|
|
||||||
runningSessionIds,
|
|
||||||
selectedSessions,
|
selectedSessions,
|
||||||
sessionDetail?.wxid,
|
|
||||||
sessionContentMetrics,
|
sessionContentMetrics,
|
||||||
sessionMutualFriendsMetrics,
|
sessionMutualFriendsMetrics,
|
||||||
sessionLoadTraceMap,
|
sessionLoadTraceMap,
|
||||||
@@ -6050,6 +6189,77 @@ function ExportPage() {
|
|||||||
snsUserPostCountsStatus,
|
snsUserPostCountsStatus,
|
||||||
toggleSelectSession
|
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 handleContactsListWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => {
|
||||||
const deltaY = event.deltaY
|
const deltaY = event.deltaY
|
||||||
if (!deltaY) return
|
if (!deltaY) return
|
||||||
@@ -6067,9 +6277,19 @@ function ExportPage() {
|
|||||||
window.scrollBy({ top: deltaY, behavior: 'auto' })
|
window.scrollBy({ top: deltaY, behavior: 'auto' })
|
||||||
}
|
}
|
||||||
}, [isContactsListAtTop])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (hasFilteredContacts) return
|
if (hasFilteredContacts) return
|
||||||
setIsContactsListAtTop(true)
|
setIsContactsListAtTop(true)
|
||||||
|
setContactsListScrollTop(0)
|
||||||
|
setContactsVisibleRange({ startIndex: 0, endIndex: -1 })
|
||||||
}, [hasFilteredContacts])
|
}, [hasFilteredContacts])
|
||||||
const chooseExportFolder = useCallback(async () => {
|
const chooseExportFolder = useCallback(async () => {
|
||||||
const result = await window.electronAPI.dialog.openFile({
|
const result = await window.electronAPI.dialog.openFile({
|
||||||
@@ -6481,18 +6701,24 @@ function ExportPage() {
|
|||||||
<div
|
<div
|
||||||
className="contacts-list"
|
className="contacts-list"
|
||||||
onWheelCapture={handleContactsListWheelCapture}
|
onWheelCapture={handleContactsListWheelCapture}
|
||||||
|
onScrollCapture={handleContactsListScrollCapture}
|
||||||
>
|
>
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
ref={contactsVirtuosoRef}
|
ref={contactsVirtuosoRef}
|
||||||
className="contacts-virtuoso"
|
className="contacts-virtuoso"
|
||||||
data={filteredContacts}
|
data={filteredContacts}
|
||||||
computeItemKey={(_, contact) => contact.username}
|
computeItemKey={(_, contact) => contact.username}
|
||||||
fixedItemHeight={76}
|
fixedItemHeight={CONTACTS_ROW_HEIGHT}
|
||||||
itemContent={renderContactRow}
|
itemContent={renderContactRow}
|
||||||
rangeChanged={handleContactsRangeChanged}
|
rangeChanged={handleContactsRangeChanged}
|
||||||
atTopStateChange={setIsContactsListAtTop}
|
atTopStateChange={setIsContactsListAtTop}
|
||||||
overscan={420}
|
overscan={420}
|
||||||
/>
|
/>
|
||||||
|
<div className="contacts-action-rail">
|
||||||
|
{visibleContactsForActionRail.map(({ contact, index, top }) => (
|
||||||
|
renderContactActionRailItem(contact, index, top)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -6790,15 +7016,15 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="session-mutual-friends-tip">
|
<div className="session-mutual-friends-tip">
|
||||||
打开桌面端微信,进入到这个人的朋友圈中,刷ta 的朋友圈,刷的越多这里的数据聚合越多
|
打开桌面端微信进入对方朋友圈,刷得越多这里聚合得越全。已确认身份的共同好友会展示头像、备注、微信号;未确认的旧数据仅保留名字。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="session-mutual-friends-toolbar">
|
<div className="session-mutual-friends-toolbar">
|
||||||
<input
|
<input
|
||||||
value={sessionMutualFriendsSearch}
|
value={sessionMutualFriendsSearch}
|
||||||
onChange={(event) => setSessionMutualFriendsSearch(event.target.value)}
|
onChange={(event) => setSessionMutualFriendsSearch(event.target.value)}
|
||||||
placeholder="搜索共同好友"
|
placeholder="搜索共同好友(备注 / 微信号 / wxid)"
|
||||||
aria-label="搜索共同好友"
|
aria-label="搜索共同好友(备注 / 微信号 / wxid)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -6810,20 +7036,48 @@ function ExportPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="session-mutual-friends-list">
|
<div className="session-mutual-friends-list">
|
||||||
{filteredSessionMutualFriendsDialogItems.map((item, index) => (
|
{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-rank">{index + 1}</span>
|
||||||
<span className="session-mutual-friends-name" title={item.name}>{item.name}</span>
|
<div className="session-mutual-friends-user">
|
||||||
<span className={`session-mutual-friends-source ${item.direction}`}>
|
<Avatar
|
||||||
{getSessionMutualFriendDirectionLabel(item.direction)}
|
src={item.avatarUrl}
|
||||||
</span>
|
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-count">{item.totalCount.toLocaleString('zh-CN')}</span>
|
||||||
<span className="session-mutual-friends-latest">{formatYmdDateFromSeconds(item.latestTime)}</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
48
src/types/electron.d.ts
vendored
48
src/types/electron.d.ts
vendored
@@ -767,7 +767,53 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
likes: Array<string>
|
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
|
rawXml?: string
|
||||||
}>
|
}>
|
||||||
error?: string
|
error?: string
|
||||||
|
|||||||
@@ -28,12 +28,53 @@ export interface SnsCommentEmoji {
|
|||||||
export interface SnsComment {
|
export interface SnsComment {
|
||||||
id: string
|
id: string
|
||||||
nickname: string
|
nickname: string
|
||||||
|
username?: string
|
||||||
content: string
|
content: string
|
||||||
refCommentId: string
|
refCommentId: string
|
||||||
refNickname?: string
|
refNickname?: string
|
||||||
|
refUsername?: string
|
||||||
emojis?: SnsCommentEmoji[]
|
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 {
|
export interface SnsPost {
|
||||||
id: string
|
id: string
|
||||||
tid?: string // 数据库主键(雪花 ID),用于精确删除
|
tid?: string // 数据库主键(雪花 ID),用于精确删除
|
||||||
@@ -46,6 +87,8 @@ export interface SnsPost {
|
|||||||
media: SnsMedia[]
|
media: SnsMedia[]
|
||||||
likes: string[]
|
likes: string[]
|
||||||
comments: SnsComment[]
|
comments: SnsComment[]
|
||||||
|
likesDetail?: SnsLikeDetail[]
|
||||||
|
commentsDetail?: SnsCommentDetail[]
|
||||||
rawXml?: string
|
rawXml?: string
|
||||||
linkTitle?: string
|
linkTitle?: string
|
||||||
linkUrl?: string
|
linkUrl?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user