From c301f36912a9acd9dbc1abd638f087eff38b5c57 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 18:08:09 +0800 Subject: [PATCH] feat(export): add sns count and timeline popup in session detail --- src/pages/ExportPage.scss | 253 +++++++++++++++++++++++- src/pages/ExportPage.tsx | 393 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 643 insertions(+), 3 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 4ac3e47..034cd9b 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -120,7 +120,7 @@ } .session-load-detail-modal { - width: min(760px, 100%); + width: min(820px, 100%); max-height: min(78vh, 860px); overflow: hidden; border-radius: 14px; @@ -200,14 +200,14 @@ .session-load-detail-row { display: grid; - grid-template-columns: 1.1fr 1fr 0.8fr 0.8fr; + grid-template-columns: minmax(76px, 0.78fr) minmax(260px, 1.55fr) minmax(84px, 0.74fr) minmax(84px, 0.74fr); gap: 10px; align-items: center; padding: 9px 12px; font-size: 12px; color: var(--text-secondary); border-bottom: 1px solid color-mix(in srgb, var(--border-color) 66%, transparent); - min-width: 540px; + min-width: 620px; &:last-child { border-bottom: none; @@ -230,10 +230,14 @@ .session-load-detail-status-cell { display: inline-flex; + flex-wrap: wrap; align-items: center; justify-content: flex-start; gap: 6px; min-width: 0; + overflow: visible !important; + text-overflow: clip !important; + white-space: normal !important; } .session-load-detail-status-icon { @@ -245,6 +249,7 @@ color: var(--text-tertiary); font-size: 11px; font-variant-numeric: tabular-nums; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; letter-spacing: 0.1px; flex-shrink: 0; } @@ -2038,6 +2043,10 @@ } } + .detail-sns-entry-btn { + white-space: nowrap; + } + .copy-btn { display: flex; align-items: center; @@ -2149,6 +2158,218 @@ } } +.export-session-sns-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; + background: rgba(15, 23, 42, 0.38); +} + +.export-session-sns-dialog { + width: min(760px, 100%); + max-height: min(86vh, 860px); + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-secondary-solid, #ffffff); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24); + display: flex; + flex-direction: column; + overflow: hidden; + + .sns-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-color); + } + + .sns-dialog-header-main { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + } + + .sns-dialog-avatar { + width: 42px; + height: 42px; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + overflow: hidden; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 14px; + font-weight: 600; + } + } + + .sns-dialog-meta { + min-width: 0; + + h4 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .sns-dialog-username { + margin-top: 2px; + font-size: 12px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .sns-dialog-stats { + margin-top: 4px; + font-size: 12px; + color: var(--text-secondary); + } + + .close-btn { + border: none; + background: transparent; + color: var(--text-secondary); + width: 28px; + height: 28px; + border-radius: 7px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .sns-dialog-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px 14px 14px; + } + + .sns-post-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .sns-post-card { + border: 1px solid var(--border-color); + background: var(--bg-primary); + border-radius: 10px; + padding: 10px 11px; + } + + .sns-post-time { + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 6px; + } + + .sns-post-content { + white-space: pre-wrap; + word-break: break-word; + font-size: 13px; + color: var(--text-primary); + line-height: 1.55; + } + + .sns-post-media-grid { + margin-top: 8px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + } + + .sns-post-media-item { + border: none; + padding: 0; + border-radius: 8px; + overflow: hidden; + background: var(--bg-secondary); + position: relative; + cursor: pointer; + aspect-ratio: 1 / 1; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } + + .sns-post-media-video-tag { + position: absolute; + right: 6px; + bottom: 6px; + background: rgba(0, 0, 0, 0.64); + color: #fff; + border-radius: 5px; + font-size: 11px; + line-height: 1; + padding: 3px 5px; + } + + .sns-dialog-status { + padding: 16px 0; + text-align: center; + color: var(--text-secondary); + font-size: 13px; + + &.empty { + color: var(--text-tertiary); + } + } + + .sns-dialog-load-more { + display: block; + margin: 12px auto 0; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + border-radius: 8px; + padding: 8px 16px; + font-size: 13px; + cursor: pointer; + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + background: var(--bg-hover); + } + } +} + .table-state { display: flex; align-items: center; @@ -2862,6 +3083,15 @@ margin-left: 0; } + .session-load-detail-modal { + width: min(94vw, 820px); + } + + .session-load-detail-row { + grid-template-columns: minmax(68px, 0.72fr) minmax(232px, 1.6fr) minmax(80px, 0.72fr) minmax(80px, 0.72fr); + min-width: 560px; + } + .table-wrap { --contacts-message-col-width: 104px; --contacts-media-col-width: 62px; @@ -2961,4 +3191,21 @@ .export-session-detail-panel { width: calc(100vw - 12px); } + + .export-session-sns-overlay { + padding: 12px 8px; + } + + .export-session-sns-dialog { + width: min(100vw - 16px, 760px); + max-height: calc(100vh - 24px); + + .sns-dialog-header { + padding: 12px; + } + + .sns-dialog-body { + padding: 10px 10px 12px; + } + } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c079795..21d9f82 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -38,6 +38,7 @@ import { onOpenSingleExport } from '../services/exportBridge' import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' +import type { SnsPost } from '../types/sns' import './ExportPage.scss' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' @@ -422,6 +423,20 @@ const formatYmdHmDateTime = (timestamp?: number): string => { return `${y}-${m}-${day} ${h}:${min}` } +const isSingleContactSession = (sessionId: string): boolean => { + const normalized = String(sessionId || '').trim() + if (!normalized) return false + if (normalized.includes('@chatroom')) return false + if (normalized.startsWith('gh_')) return false + return true +} + +const isSnsVideoMediaUrl = (url?: string): boolean => { + if (!url) return false + const lower = url.toLowerCase() + return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb') +} + const formatPathBrief = (value: string, maxLength = 52): string => { const normalized = String(value || '') if (normalized.length <= maxLength) return normalized @@ -661,6 +676,12 @@ interface SessionDetail { messageTables: { dbName: string; tableName: string; count: number }[] } +interface SessionSnsTimelineTarget { + username: string + displayName: string + avatarUrl?: string +} + interface SessionExportMetric { totalMessages: number voiceMessages: number @@ -1302,6 +1323,15 @@ function ExportPage() { const [isRefreshingSessionDetailStats, setIsRefreshingSessionDetailStats] = useState(false) const [isLoadingSessionRelationStats, setIsLoadingSessionRelationStats] = useState(false) const [copiedDetailField, setCopiedDetailField] = useState(null) + const [snsUserPostCounts, setSnsUserPostCounts] = useState>({}) + const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle') + const [sessionSnsTimelineTarget, setSessionSnsTimelineTarget] = useState(null) + const [sessionSnsTimelinePosts, setSessionSnsTimelinePosts] = useState([]) + const [sessionSnsTimelineLoading, setSessionSnsTimelineLoading] = useState(false) + const [sessionSnsTimelineLoadingMore, setSessionSnsTimelineLoadingMore] = useState(false) + const [sessionSnsTimelineHasMore, setSessionSnsTimelineHasMore] = useState(false) + const [sessionSnsTimelineTotalPosts, setSessionSnsTimelineTotalPosts] = useState(null) + const [sessionSnsTimelineStatsLoading, setSessionSnsTimelineStatsLoading] = useState(false) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('B') @@ -1391,6 +1421,9 @@ function ExportPage() { const isLoadingSessionCountsRef = useRef(false) const activeTabRef = useRef('private') const detailStatsPriorityRef = useRef(false) + const sessionSnsTimelinePostsRef = useRef([]) + const sessionSnsTimelineLoadingRef = useRef(false) + const sessionSnsTimelineRequestTokenRef = useRef(0) const sessionPreciseRefreshAtRef = useRef>({}) const sessionLoadProgressSnapshotRef = useRef>({}) const sessionMediaMetricQueueRef = useRef([]) @@ -1774,6 +1807,10 @@ function ExportPage() { hasSeededSnsStatsRef.current = hasSeededSnsStats }, [hasSeededSnsStats]) + useEffect(() => { + sessionSnsTimelinePostsRef.current = sessionSnsTimelinePosts + }, [sessionSnsTimelinePosts]) + const preselectSessionIds = useMemo(() => { const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null const rawList = Array.isArray(state?.preselectSessionIds) @@ -1919,6 +1956,177 @@ function ExportPage() { } }, []) + const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => { + if (snsUserPostCountsStatus === 'loading') return + if (!options?.force && snsUserPostCountsStatus === 'ready') return + + setSnsUserPostCountsStatus('loading') + try { + const result = await window.electronAPI.sns.getUserPostCounts() + if (result.success && result.counts) { + const normalized: Record = {} + for (const [rawUsername, rawCount] of Object.entries(result.counts)) { + const username = String(rawUsername || '').trim() + if (!username) continue + const value = Number(rawCount) + normalized[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 + } + setSnsUserPostCounts(normalized) + setSnsUserPostCountsStatus('ready') + return + } + + setSnsUserPostCountsStatus('error') + } catch (error) { + console.error('加载朋友圈用户条数失败:', error) + setSnsUserPostCountsStatus('error') + } + }, [snsUserPostCountsStatus]) + + const loadSessionSnsTimelinePosts = useCallback(async (target: SessionSnsTimelineTarget, options?: { reset?: boolean }) => { + const reset = Boolean(options?.reset) + if (sessionSnsTimelineLoadingRef.current) return + + sessionSnsTimelineLoadingRef.current = true + if (reset) { + setSessionSnsTimelineLoading(true) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineHasMore(false) + } else { + setSessionSnsTimelineLoadingMore(true) + } + + const requestToken = ++sessionSnsTimelineRequestTokenRef.current + + try { + const limit = 20 + let endTime: number | undefined + if (!reset && sessionSnsTimelinePostsRef.current.length > 0) { + endTime = sessionSnsTimelinePostsRef.current[sessionSnsTimelinePostsRef.current.length - 1].createTime - 1 + } + + const result = await window.electronAPI.sns.getTimeline(limit, 0, [target.username], '', undefined, endTime) + if (requestToken !== sessionSnsTimelineRequestTokenRef.current) return + + if (!result.success || !Array.isArray(result.timeline)) { + if (reset) { + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineHasMore(false) + } + return + } + + const timeline = [...(result.timeline as SnsPost[])].sort((a, b) => b.createTime - a.createTime) + if (reset) { + setSessionSnsTimelinePosts(timeline) + setSessionSnsTimelineHasMore(timeline.length >= limit) + return + } + + const existingIds = new Set(sessionSnsTimelinePostsRef.current.map((post) => post.id)) + const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id)) + if (uniqueOlder.length > 0) { + const merged = [...sessionSnsTimelinePostsRef.current, ...uniqueOlder].sort((a, b) => b.createTime - a.createTime) + setSessionSnsTimelinePosts(merged) + } + if (timeline.length < limit) { + setSessionSnsTimelineHasMore(false) + } + } catch (error) { + console.error('加载联系人朋友圈失败:', error) + if (requestToken === sessionSnsTimelineRequestTokenRef.current && reset) { + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineHasMore(false) + } + } finally { + if (requestToken === sessionSnsTimelineRequestTokenRef.current) { + sessionSnsTimelineLoadingRef.current = false + setSessionSnsTimelineLoading(false) + setSessionSnsTimelineLoadingMore(false) + } + } + }, []) + + const closeSessionSnsTimeline = useCallback(() => { + sessionSnsTimelineRequestTokenRef.current += 1 + sessionSnsTimelineLoadingRef.current = false + setSessionSnsTimelineTarget(null) + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineLoading(false) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineHasMore(false) + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(false) + }, []) + + const openSessionSnsTimeline = useCallback(() => { + const normalizedSessionId = String(sessionDetail?.wxid || '').trim() + if (!isSingleContactSession(normalizedSessionId) || !sessionDetail) return + + const target: SessionSnsTimelineTarget = { + username: normalizedSessionId, + displayName: sessionDetail.displayName || sessionDetail.remark || sessionDetail.nickName || normalizedSessionId, + avatarUrl: sessionDetail.avatarUrl + } + + setSessionSnsTimelineTarget(target) + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineHasMore(false) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineLoading(false) + + if (snsUserPostCountsStatus === 'ready') { + const count = Number(snsUserPostCounts[normalizedSessionId] || 0) + setSessionSnsTimelineTotalPosts(Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0) + setSessionSnsTimelineStatsLoading(false) + } else { + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(true) + } + + void loadSessionSnsTimelinePosts(target, { reset: true }) + void loadSnsUserPostCounts() + }, [ + loadSessionSnsTimelinePosts, + loadSnsUserPostCounts, + sessionDetail, + snsUserPostCounts, + snsUserPostCountsStatus + ]) + + const loadMoreSessionSnsTimeline = useCallback(() => { + if (!sessionSnsTimelineTarget || sessionSnsTimelineLoading || sessionSnsTimelineLoadingMore || !sessionSnsTimelineHasMore) return + void loadSessionSnsTimelinePosts(sessionSnsTimelineTarget, { reset: false }) + }, [ + loadSessionSnsTimelinePosts, + sessionSnsTimelineHasMore, + sessionSnsTimelineLoading, + sessionSnsTimelineLoadingMore, + sessionSnsTimelineTarget + ]) + + const renderSessionSnsTimelineStats = useCallback((): string => { + const loadedCount = sessionSnsTimelinePosts.length + const loadPart = sessionSnsTimelineStatsLoading + ? `已加载 ${loadedCount} / 总数统计中...` + : sessionSnsTimelineTotalPosts === null + ? `已加载 ${loadedCount} 条` + : `已加载 ${loadedCount} / 共 ${sessionSnsTimelineTotalPosts} 条` + + if (sessionSnsTimelineLoading && loadedCount === 0) return `${loadPart} | 加载中...` + if (loadedCount === 0) return loadPart + + const latest = sessionSnsTimelinePosts[0]?.createTime + const earliest = sessionSnsTimelinePosts[sessionSnsTimelinePosts.length - 1]?.createTime + const rangeText = `${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}` + return `${loadPart} | ${rangeText}` + }, [ + sessionSnsTimelineLoading, + sessionSnsTimelinePosts, + sessionSnsTimelineStatsLoading, + sessionSnsTimelineTotalPosts + ]) + const mergeSessionContentMetrics = useCallback((input: Record) => { const entries = Object.entries(input) if (entries.length === 0) return @@ -4081,6 +4289,27 @@ function ExportPage() { .slice(0, 20) }, [sessionDetail?.wxid, exportRecordsBySession]) + const sessionDetailSupportsSnsTimeline = useMemo(() => { + const sessionId = String(sessionDetail?.wxid || '').trim() + return isSingleContactSession(sessionId) + }, [sessionDetail?.wxid]) + + const sessionDetailSnsCountLabel = useMemo(() => { + const sessionId = String(sessionDetail?.wxid || '').trim() + if (!sessionId || !sessionDetailSupportsSnsTimeline) return '朋友圈:0条' + + if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') { + return '朋友圈:统计中...' + } + if (snsUserPostCountsStatus === 'error') { + return '朋友圈:统计失败' + } + + const count = Number(snsUserPostCounts[sessionId] || 0) + const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0 + return `朋友圈:${normalized}条` + }, [sessionDetail?.wxid, sessionDetailSupportsSnsTimeline, snsUserPostCounts, snsUserPostCountsStatus]) + const applySessionDetailStats = useCallback(( sessionId: string, metric: SessionExportMetric, @@ -4371,14 +4600,58 @@ function ExportPage() { } }, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid]) + useEffect(() => { + if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return + if (snsUserPostCountsStatus === 'idle') { + void loadSnsUserPostCounts() + } + }, [ + loadSnsUserPostCounts, + sessionDetailSupportsSnsTimeline, + showSessionDetailPanel, + snsUserPostCountsStatus + ]) + + useEffect(() => { + if (!sessionSnsTimelineTarget) return + if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') { + setSessionSnsTimelineStatsLoading(true) + return + } + if (snsUserPostCountsStatus === 'ready') { + const total = Number(snsUserPostCounts[sessionSnsTimelineTarget.username] || 0) + setSessionSnsTimelineTotalPosts(Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0) + setSessionSnsTimelineStatsLoading(false) + return + } + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(false) + }, [sessionSnsTimelineTarget, snsUserPostCounts, snsUserPostCountsStatus]) + + useEffect(() => { + if (sessionSnsTimelineTotalPosts === null) return + if (sessionSnsTimelinePosts.length >= sessionSnsTimelineTotalPosts) { + setSessionSnsTimelineHasMore(false) + } + }, [sessionSnsTimelinePosts.length, sessionSnsTimelineTotalPosts]) + const closeSessionDetailPanel = useCallback(() => { detailRequestSeqRef.current += 1 detailStatsPriorityRef.current = false + sessionSnsTimelineRequestTokenRef.current += 1 + sessionSnsTimelineLoadingRef.current = false setShowSessionDetailPanel(false) setIsLoadingSessionDetail(false) setIsLoadingSessionDetailExtra(false) setIsRefreshingSessionDetailStats(false) setIsLoadingSessionRelationStats(false) + setSessionSnsTimelineTarget(null) + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineLoading(false) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineHasMore(false) + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(false) }, []) const openSessionDetail = useCallback((sessionId: string) => { @@ -4410,6 +4683,17 @@ function ExportPage() { return () => window.removeEventListener('keydown', handleKeyDown) }, [showSessionLoadDetailModal]) + useEffect(() => { + if (!sessionSnsTimelineTarget) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeSessionSnsTimeline() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [closeSessionSnsTimeline, sessionSnsTimelineTarget]) + const handleCopyDetailField = useCallback(async (text: string, field: string) => { try { await navigator.clipboard.writeText(text) @@ -5228,6 +5512,21 @@ function ExportPage() { )} + {sessionDetailSupportsSnsTimeline && ( +
+ + 朋友圈 + + + +
+ )}
@@ -5454,6 +5753,100 @@ function ExportPage() {
)} + + {sessionSnsTimelineTarget && ( +
+
event.stopPropagation()} + > +
+
+
+ {sessionSnsTimelineTarget.avatarUrl ? ( + + ) : ( + {getAvatarLetter(sessionSnsTimelineTarget.displayName || sessionSnsTimelineTarget.username)} + )} +
+
+

{sessionSnsTimelineTarget.displayName}

+
@{sessionSnsTimelineTarget.username}
+
{renderSessionSnsTimelineStats()}
+
+
+ +
+ +
+ {sessionSnsTimelinePosts.length > 0 && ( +
+ {sessionSnsTimelinePosts.map((post) => ( +
+
{formatYmdHmDateTime(post.createTime * 1000)}
+ {post.contentDesc &&
{post.contentDesc}
} + {Array.isArray(post.media) && post.media.length > 0 && ( +
+ {post.media.slice(0, 9).map((media, mediaIndex) => { + const mediaUrl = String(media?.url || media?.thumb || '') + const previewUrl = String(media?.thumb || media?.url || '') + if (!mediaUrl || !previewUrl) return null + const isVideo = isSnsVideoMediaUrl(mediaUrl) + return ( + + ) + })} +
+ )} +
+ ))} +
+ )} + + {sessionSnsTimelineLoading && ( +
正在加载该联系人的朋友圈...
+ )} + + {!sessionSnsTimelineLoading && sessionSnsTimelinePosts.length === 0 && ( +
该联系人暂无朋友圈
+ )} + + {!sessionSnsTimelineLoading && sessionSnsTimelineHasMore && ( + + )} +
+
+
+ )}