mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat(export): add sns count and timeline popup in session detail
This commit is contained in:
@@ -120,7 +120,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.session-load-detail-modal {
|
.session-load-detail-modal {
|
||||||
width: min(760px, 100%);
|
width: min(820px, 100%);
|
||||||
max-height: min(78vh, 860px);
|
max-height: min(78vh, 860px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -200,14 +200,14 @@
|
|||||||
|
|
||||||
.session-load-detail-row {
|
.session-load-detail-row {
|
||||||
display: grid;
|
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;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 66%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 66%, transparent);
|
||||||
min-width: 540px;
|
min-width: 620px;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
@@ -230,10 +230,14 @@
|
|||||||
|
|
||||||
.session-load-detail-status-cell {
|
.session-load-detail-status-cell {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: visible !important;
|
||||||
|
text-overflow: clip !important;
|
||||||
|
white-space: normal !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-load-detail-status-icon {
|
.session-load-detail-status-icon {
|
||||||
@@ -245,6 +249,7 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
letter-spacing: 0.1px;
|
letter-spacing: 0.1px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -2038,6 +2043,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-sns-entry-btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.copy-btn {
|
.copy-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
.table-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2862,6 +3083,15 @@
|
|||||||
margin-left: 0;
|
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 {
|
.table-wrap {
|
||||||
--contacts-message-col-width: 104px;
|
--contacts-message-col-width: 104px;
|
||||||
--contacts-media-col-width: 62px;
|
--contacts-media-col-width: 62px;
|
||||||
@@ -2961,4 +3191,21 @@
|
|||||||
.export-session-detail-panel {
|
.export-session-detail-panel {
|
||||||
width: calc(100vw - 12px);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
onOpenSingleExport
|
onOpenSingleExport
|
||||||
} from '../services/exportBridge'
|
} from '../services/exportBridge'
|
||||||
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||||||
|
import type { SnsPost } from '../types/sns'
|
||||||
import './ExportPage.scss'
|
import './ExportPage.scss'
|
||||||
|
|
||||||
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
||||||
@@ -422,6 +423,20 @@ const formatYmdHmDateTime = (timestamp?: number): string => {
|
|||||||
return `${y}-${m}-${day} ${h}:${min}`
|
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 formatPathBrief = (value: string, maxLength = 52): string => {
|
||||||
const normalized = String(value || '')
|
const normalized = String(value || '')
|
||||||
if (normalized.length <= maxLength) return normalized
|
if (normalized.length <= maxLength) return normalized
|
||||||
@@ -661,6 +676,12 @@ interface SessionDetail {
|
|||||||
messageTables: { dbName: string; tableName: string; count: number }[]
|
messageTables: { dbName: string; tableName: string; count: number }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SessionSnsTimelineTarget {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface SessionExportMetric {
|
interface SessionExportMetric {
|
||||||
totalMessages: number
|
totalMessages: number
|
||||||
voiceMessages: number
|
voiceMessages: number
|
||||||
@@ -1302,6 +1323,15 @@ function ExportPage() {
|
|||||||
const [isRefreshingSessionDetailStats, setIsRefreshingSessionDetailStats] = useState(false)
|
const [isRefreshingSessionDetailStats, setIsRefreshingSessionDetailStats] = useState(false)
|
||||||
const [isLoadingSessionRelationStats, setIsLoadingSessionRelationStats] = useState(false)
|
const [isLoadingSessionRelationStats, setIsLoadingSessionRelationStats] = useState(false)
|
||||||
const [copiedDetailField, setCopiedDetailField] = useState<string | null>(null)
|
const [copiedDetailField, setCopiedDetailField] = useState<string | null>(null)
|
||||||
|
const [snsUserPostCounts, setSnsUserPostCounts] = useState<Record<string, number>>({})
|
||||||
|
const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||||||
|
const [sessionSnsTimelineTarget, setSessionSnsTimelineTarget] = useState<SessionSnsTimelineTarget | null>(null)
|
||||||
|
const [sessionSnsTimelinePosts, setSessionSnsTimelinePosts] = useState<SnsPost[]>([])
|
||||||
|
const [sessionSnsTimelineLoading, setSessionSnsTimelineLoading] = useState(false)
|
||||||
|
const [sessionSnsTimelineLoadingMore, setSessionSnsTimelineLoadingMore] = useState(false)
|
||||||
|
const [sessionSnsTimelineHasMore, setSessionSnsTimelineHasMore] = useState(false)
|
||||||
|
const [sessionSnsTimelineTotalPosts, setSessionSnsTimelineTotalPosts] = useState<number | null>(null)
|
||||||
|
const [sessionSnsTimelineStatsLoading, setSessionSnsTimelineStatsLoading] = useState(false)
|
||||||
|
|
||||||
const [exportFolder, setExportFolder] = useState('')
|
const [exportFolder, setExportFolder] = useState('')
|
||||||
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
|
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
|
||||||
@@ -1391,6 +1421,9 @@ function ExportPage() {
|
|||||||
const isLoadingSessionCountsRef = useRef(false)
|
const isLoadingSessionCountsRef = useRef(false)
|
||||||
const activeTabRef = useRef<ConversationTab>('private')
|
const activeTabRef = useRef<ConversationTab>('private')
|
||||||
const detailStatsPriorityRef = useRef(false)
|
const detailStatsPriorityRef = useRef(false)
|
||||||
|
const sessionSnsTimelinePostsRef = useRef<SnsPost[]>([])
|
||||||
|
const sessionSnsTimelineLoadingRef = useRef(false)
|
||||||
|
const sessionSnsTimelineRequestTokenRef = useRef(0)
|
||||||
const sessionPreciseRefreshAtRef = useRef<Record<string, number>>({})
|
const sessionPreciseRefreshAtRef = useRef<Record<string, number>>({})
|
||||||
const sessionLoadProgressSnapshotRef = useRef<Record<string, { loaded: number; total: number }>>({})
|
const sessionLoadProgressSnapshotRef = useRef<Record<string, { loaded: number; total: number }>>({})
|
||||||
const sessionMediaMetricQueueRef = useRef<string[]>([])
|
const sessionMediaMetricQueueRef = useRef<string[]>([])
|
||||||
@@ -1774,6 +1807,10 @@ function ExportPage() {
|
|||||||
hasSeededSnsStatsRef.current = hasSeededSnsStats
|
hasSeededSnsStatsRef.current = hasSeededSnsStats
|
||||||
}, [hasSeededSnsStats])
|
}, [hasSeededSnsStats])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionSnsTimelinePostsRef.current = sessionSnsTimelinePosts
|
||||||
|
}, [sessionSnsTimelinePosts])
|
||||||
|
|
||||||
const preselectSessionIds = useMemo(() => {
|
const preselectSessionIds = useMemo(() => {
|
||||||
const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null
|
const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null
|
||||||
const rawList = Array.isArray(state?.preselectSessionIds)
|
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<string, number> = {}
|
||||||
|
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<string, SessionExportMetric | SessionContentMetric | undefined>) => {
|
const mergeSessionContentMetrics = useCallback((input: Record<string, SessionExportMetric | SessionContentMetric | undefined>) => {
|
||||||
const entries = Object.entries(input)
|
const entries = Object.entries(input)
|
||||||
if (entries.length === 0) return
|
if (entries.length === 0) return
|
||||||
@@ -4081,6 +4289,27 @@ function ExportPage() {
|
|||||||
.slice(0, 20)
|
.slice(0, 20)
|
||||||
}, [sessionDetail?.wxid, exportRecordsBySession])
|
}, [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((
|
const applySessionDetailStats = useCallback((
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
metric: SessionExportMetric,
|
metric: SessionExportMetric,
|
||||||
@@ -4371,14 +4600,58 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
|
}, [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(() => {
|
const closeSessionDetailPanel = useCallback(() => {
|
||||||
detailRequestSeqRef.current += 1
|
detailRequestSeqRef.current += 1
|
||||||
detailStatsPriorityRef.current = false
|
detailStatsPriorityRef.current = false
|
||||||
|
sessionSnsTimelineRequestTokenRef.current += 1
|
||||||
|
sessionSnsTimelineLoadingRef.current = false
|
||||||
setShowSessionDetailPanel(false)
|
setShowSessionDetailPanel(false)
|
||||||
setIsLoadingSessionDetail(false)
|
setIsLoadingSessionDetail(false)
|
||||||
setIsLoadingSessionDetailExtra(false)
|
setIsLoadingSessionDetailExtra(false)
|
||||||
setIsRefreshingSessionDetailStats(false)
|
setIsRefreshingSessionDetailStats(false)
|
||||||
setIsLoadingSessionRelationStats(false)
|
setIsLoadingSessionRelationStats(false)
|
||||||
|
setSessionSnsTimelineTarget(null)
|
||||||
|
setSessionSnsTimelinePosts([])
|
||||||
|
setSessionSnsTimelineLoading(false)
|
||||||
|
setSessionSnsTimelineLoadingMore(false)
|
||||||
|
setSessionSnsTimelineHasMore(false)
|
||||||
|
setSessionSnsTimelineTotalPosts(null)
|
||||||
|
setSessionSnsTimelineStatsLoading(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const openSessionDetail = useCallback((sessionId: string) => {
|
const openSessionDetail = useCallback((sessionId: string) => {
|
||||||
@@ -4410,6 +4683,17 @@ function ExportPage() {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [showSessionLoadDetailModal])
|
}, [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) => {
|
const handleCopyDetailField = useCallback(async (text: string, field: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
@@ -5228,6 +5512,21 @@ function ExportPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{sessionDetailSupportsSnsTimeline && (
|
||||||
|
<div className="detail-item">
|
||||||
|
<Aperture size={14} />
|
||||||
|
<span className="label">朋友圈</span>
|
||||||
|
<span className="value">
|
||||||
|
<button
|
||||||
|
className="detail-inline-btn detail-sns-entry-btn"
|
||||||
|
type="button"
|
||||||
|
onClick={openSessionSnsTimeline}
|
||||||
|
>
|
||||||
|
{sessionDetailSnsCountLabel}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
@@ -5454,6 +5753,100 @@ function ExportPage() {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sessionSnsTimelineTarget && (
|
||||||
|
<div className="export-session-sns-overlay" onClick={closeSessionSnsTimeline}>
|
||||||
|
<div
|
||||||
|
className="export-session-sns-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="联系人朋友圈"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="sns-dialog-header">
|
||||||
|
<div className="sns-dialog-header-main">
|
||||||
|
<div className="sns-dialog-avatar">
|
||||||
|
{sessionSnsTimelineTarget.avatarUrl ? (
|
||||||
|
<img src={sessionSnsTimelineTarget.avatarUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<span>{getAvatarLetter(sessionSnsTimelineTarget.displayName || sessionSnsTimelineTarget.username)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sns-dialog-meta">
|
||||||
|
<h4>{sessionSnsTimelineTarget.displayName}</h4>
|
||||||
|
<div className="sns-dialog-username">@{sessionSnsTimelineTarget.username}</div>
|
||||||
|
<div className="sns-dialog-stats">{renderSessionSnsTimelineStats()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="close-btn" type="button" onClick={closeSessionSnsTimeline}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sns-dialog-body">
|
||||||
|
{sessionSnsTimelinePosts.length > 0 && (
|
||||||
|
<div className="sns-post-list">
|
||||||
|
{sessionSnsTimelinePosts.map((post) => (
|
||||||
|
<article className="sns-post-card" key={post.id}>
|
||||||
|
<div className="sns-post-time">{formatYmdHmDateTime(post.createTime * 1000)}</div>
|
||||||
|
{post.contentDesc && <div className="sns-post-content">{post.contentDesc}</div>}
|
||||||
|
{Array.isArray(post.media) && post.media.length > 0 && (
|
||||||
|
<div className="sns-post-media-grid">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
className="sns-post-media-item"
|
||||||
|
key={`${post.id}-media-${mediaIndex}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (isVideo) {
|
||||||
|
void window.electronAPI.window.openVideoPlayerWindow(mediaUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void window.electronAPI.window.openImageViewerWindow(
|
||||||
|
mediaUrl,
|
||||||
|
media?.livePhoto?.url || undefined
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={previewUrl} alt="" loading="lazy" referrerPolicy="no-referrer" />
|
||||||
|
{isVideo && <span className="sns-post-media-video-tag">视频</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sessionSnsTimelineLoading && (
|
||||||
|
<div className="sns-dialog-status">正在加载该联系人的朋友圈...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!sessionSnsTimelineLoading && sessionSnsTimelinePosts.length === 0 && (
|
||||||
|
<div className="sns-dialog-status empty">该联系人暂无朋友圈</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!sessionSnsTimelineLoading && sessionSnsTimelineHasMore && (
|
||||||
|
<button
|
||||||
|
className="sns-dialog-load-more"
|
||||||
|
type="button"
|
||||||
|
onClick={loadMoreSessionSnsTimeline}
|
||||||
|
disabled={sessionSnsTimelineLoadingMore}
|
||||||
|
>
|
||||||
|
{sessionSnsTimelineLoadingMore ? '正在加载...' : '加载更多'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user