Merge branch 'codex/ts0305-01-export-module-upgrade' into HEAD

# Conflicts:
#	electron/main.ts
This commit is contained in:
aits2026 committed 2026-03-05 20:45:54 +08:00
commit 54684ea3c9
16 files changed
+3871 -292

No files matched your search

+84 -11
View File
@@ -24,7 +24,7 @@ import { windowsHelloService } from './services/windowsHelloService'
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
import { cloudControlService } from './services/cloudControlService'
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
import { httpService } from './services/httpService'
@@ -87,10 +87,12 @@ let onboardingWindow: BrowserWindow | null = null
// Splash 启动窗口
let splashWindow: BrowserWindow | null = null
const sessionChatWindows = new Map<string, BrowserWindow>()
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
const keyService = new KeyService()
let mainWindowReady = false
let shouldShowMain = true
let isAppQuitting = false
// 更新下载状态管理(Issue #294 修复)
let isDownloadInProgress = false
@@ -123,6 +125,47 @@ interface AnnualReportYearsTaskState {
updatedAt: number
}
interface OpenSessionChatWindowOptions {
source?: 'chat' | 'export'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => {
return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat'
}
const normalizeSessionChatWindowOptionString = (value: unknown): string => {
return String(value || '').trim()
}
const loadSessionChatWindowContent = (
win: BrowserWindow,
sessionId: string,
source: 'chat' | 'export',
options?: OpenSessionChatWindowOptions
) => {
const queryParams = new URLSearchParams({
sessionId,
source
})
const initialDisplayName = normalizeSessionChatWindowOptionString(options?.initialDisplayName)
const initialAvatarUrl = normalizeSessionChatWindowOptionString(options?.initialAvatarUrl)
const initialContactType = normalizeSessionChatWindowOptionString(options?.initialContactType)
if (initialDisplayName) queryParams.set('initialDisplayName', initialDisplayName)
if (initialAvatarUrl) queryParams.set('initialAvatarUrl', initialAvatarUrl)
if (initialContactType) queryParams.set('initialContactType', initialContactType)
const query = queryParams.toString()
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${query}`)
return
}
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/chat-window?${query}`
})
}
const annualReportYearsLoadTasks = new Map<string, AnnualReportYearsTaskState>()
const annualReportYearsTaskByCacheKey = new Map<string, string>()
const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>()
@@ -290,6 +333,21 @@ function createWindow(options: { autoShow?: boolean } = {}) {
callback(false)
})
win.on('closed', () => {
if (mainWindow !== win) return
mainWindow = null
mainWindowReady = false
if (process.platform !== 'darwin' && !isAppQuitting) {
// 隐藏通知窗也是 BrowserWindow,必须销毁,否则会阻止应用退出。
destroyNotificationWindow()
if (BrowserWindow.getAllWindows().length === 0) {
app.quit()
}
}
})
return win
}
@@ -688,12 +746,18 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
/**
* 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域)
*/
function createSessionChatWindow(sessionId: string) {
function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWindowOptions) {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return null
const normalizedSource = normalizeSessionChatWindowSource(options?.source)
const existing = sessionChatWindows.get(normalizedSessionId)
if (existing && !existing.isDestroyed()) {
const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat'
if (trackedSource !== normalizedSource) {
loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource, options)
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
}
if (existing.isMinimized()) {
existing.restore()
}
@@ -730,10 +794,9 @@ function createSessionChatWindow(sessionId: string) {
autoHideMenuBar: true
})
const sessionParam = `sessionId=${encodeURIComponent(normalizedSessionId)}`
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${sessionParam}`)
loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource, options)
if (process.env.VITE_DEV_SERVER_URL) {
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
if (win.webContents.isDevToolsOpened()) {
@@ -744,10 +807,6 @@ function createSessionChatWindow(sessionId: string) {
event.preventDefault()
}
})
} else {
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/chat-window?${sessionParam}`
})
}
win.once('ready-to-show', () => {
@@ -759,10 +818,12 @@ function createSessionChatWindow(sessionId: string) {
const tracked = sessionChatWindows.get(normalizedSessionId)
if (tracked === win) {
sessionChatWindows.delete(normalizedSessionId)
sessionChatWindowSources.delete(normalizedSessionId)
}
})
sessionChatWindows.set(normalizedSessionId, win)
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
return win
}
@@ -1071,8 +1132,8 @@ function registerIpcHandlers() {
})
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string) => {
const win = createSessionChatWindow(sessionId)
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => {
const win = createSessionChatWindow(sessionId, options)
return Boolean(win)
})
@@ -1410,6 +1471,7 @@ function registerIpcHandlers() {
forceRefresh?: boolean
allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
}) => {
return chatService.getExportSessionStats(sessionIds, options)
})
@@ -1463,6 +1525,10 @@ function registerIpcHandlers() {
return snsService.getSnsUsernames()
})
ipcMain.handle('sns:getUserPostCounts', async () => {
return snsService.getUserPostCounts()
})
ipcMain.handle('sns:getExportStats', async () => {
return snsService.getExportStats()
})
@@ -1471,6 +1537,10 @@ function registerIpcHandlers() {
return snsService.getExportStatsFast()
})
ipcMain.handle('sns:getUserPostStats', async (_, username: string) => {
return snsService.getUserPostStats(username)
})
ipcMain.handle('sns:debugResource', async (_, url: string) => {
return snsService.debugResource(url)
})
@@ -2373,6 +2443,9 @@ app.whenReady().then(async () => {
})
app.on('before-quit', async () => {
isAppQuitting = true
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
destroyNotificationWindow()
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
try { await httpService.stop() } catch {}
// 终止 wcdb Worker 线程,避免线程阻止进程退出
+19 -3
View File
@@ -99,8 +99,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
openSessionChatWindow: (sessionId: string) =>
ipcRenderer.invoke('window:openSessionChatWindow', sessionId)
openSessionChatWindow: (
sessionId: string,
options?: {
source?: 'chat' | 'export'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
) =>
ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
},
// 数据库路径
@@ -174,7 +182,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
getExportSessionStats: (
sessionIds: string[],
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean }
options?: {
includeRelations?: boolean
forceRefresh?: boolean
allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
}
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
getGroupMyMessageCountHint: (chatroomId: string) =>
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
@@ -339,8 +353,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'),
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username),
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
+49 -48
View File
@@ -164,6 +164,7 @@ interface ExportSessionStatsOptions {
forceRefresh?: boolean
allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
}
interface ExportSessionStatsCacheMeta {
@@ -5209,39 +5210,36 @@ class ChatService {
return { success: true, detail: cachedDetail.detail }
}
const [tableStatsResult, statsResult] = await Promise.allSettled([
wcdbService.getMessageTableStats(normalizedSessionId),
(async (): Promise<ExportSessionStats | null> => {
const cachedStats = this.getSessionStatsCacheEntry(normalizedSessionId)
if (cachedStats && this.supportsRequestedRelation(cachedStats.entry, false)) {
return this.fromSessionStatsCacheStats(cachedStats.entry.stats)
}
const myWxid = this.configService.get('myWxid') || ''
const selfIdentitySet = new Set<string>(this.buildIdentityKeys(myWxid))
const stats = await this.getOrComputeSessionExportStats(normalizedSessionId, false, selfIdentitySet)
this.setSessionStatsCacheEntry(normalizedSessionId, stats, false)
return stats
})()
])
const statsSnapshot = statsResult.status === 'fulfilled'
? statsResult.value
: null
const firstMessageTime = statsSnapshot && Number.isFinite(statsSnapshot.firstTimestamp)
? Math.max(0, Math.floor(statsSnapshot.firstTimestamp as number))
: undefined
const latestMessageTime = statsSnapshot && Number.isFinite(statsSnapshot.lastTimestamp)
? Math.max(0, Math.floor(statsSnapshot.lastTimestamp as number))
: undefined
const tableStatsResult = await wcdbService.getMessageTableStats(normalizedSessionId)
const messageTables: { dbName: string; tableName: string; count: number }[] = []
if (tableStatsResult.status === 'fulfilled' && tableStatsResult.value.success && tableStatsResult.value.tables) {
for (const row of tableStatsResult.value.tables) {
let firstMessageTime: number | undefined
let latestMessageTime: number | undefined
if (tableStatsResult.success && tableStatsResult.tables) {
for (const row of tableStatsResult.tables) {
messageTables.push({
dbName: basename(row.db_path || ''),
tableName: row.table_name || '',
count: parseInt(row.count || '0', 10)
})
const firstTs = this.getRowInt(
row,
['first_timestamp', 'firstTimestamp', 'first_time', 'firstTime', 'min_create_time', 'minCreateTime'],
0
)
if (firstTs > 0 && (firstMessageTime === undefined || firstTs < firstMessageTime)) {
firstMessageTime = firstTs
}
const lastTs = this.getRowInt(
row,
['last_timestamp', 'lastTimestamp', 'last_time', 'lastTime', 'max_create_time', 'maxCreateTime'],
0
)
if (lastTs > 0 && (latestMessageTime === undefined || lastTs > latestMessageTime)) {
latestMessageTime = lastTs
}
}
}
@@ -5357,6 +5355,7 @@ class ChatService {
const forceRefresh = options.forceRefresh === true
const allowStaleCache = options.allowStaleCache === true
const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true
const cacheOnly = options.cacheOnly === true
const normalizedSessionIds = Array.from(
new Set(
@@ -5380,32 +5379,34 @@ class ChatService {
? this.getGroupMyMessageCountHintEntry(sessionId)
: null
const cachedResult = this.getSessionStatsCacheEntry(sessionId)
if (!forceRefresh && !preferAccurateSpecialTypes) {
if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) {
const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs
if (!stale || allowStaleCache) {
resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats)
if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) {
resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount
}
cacheMeta[sessionId] = {
updatedAt: cachedResult.entry.updatedAt,
stale,
includeRelations: cachedResult.entry.includeRelations,
source: cachedResult.source
}
if (stale) {
needsRefreshSet.add(sessionId)
}
continue
const canUseCache = cacheOnly || (!forceRefresh && !preferAccurateSpecialTypes)
if (canUseCache && cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) {
const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs
if (!stale || allowStaleCache || cacheOnly) {
resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats)
if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) {
resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount
}
cacheMeta[sessionId] = {
updatedAt: cachedResult.entry.updatedAt,
stale,
includeRelations: cachedResult.entry.includeRelations,
source: cachedResult.source
}
if (stale) {
needsRefreshSet.add(sessionId)
}
}
// allowStaleCache 仅对“已有缓存”生效;无缓存会话仍需进入计算流程。
if (allowStaleCache && cachedResult) {
needsRefreshSet.add(sessionId)
continue
}
}
// allowStaleCache/cacheOnly 仅对“已有缓存”生效;无缓存会话不会直接算重查询。
if (canUseCache && allowStaleCache && cachedResult) {
needsRefreshSet.add(sessionId)
continue
}
if (cacheOnly) {
continue
}
pendingSessionIds.push(sessionId)
}
+86 -1
View File
@@ -292,7 +292,9 @@ class SnsService {
private contactCache: ContactCacheService
private imageCache = new Map<string, string>()
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
private userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
private readonly userPostCountsCacheTtlMs = 5 * 60 * 1000
private lastTimelineFallbackAt = 0
private readonly timelineFallbackCooldownMs = 3 * 60 * 1000
@@ -864,6 +866,84 @@ class SnsService {
})
}
private async getUserPostCountsFromTimeline(): Promise<Record<string, number>> {
const pageSize = 500
const counts: Record<string, number> = {}
let offset = 0
for (let round = 0; round < 2000; round++) {
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
if (!result.success || !Array.isArray(result.timeline)) {
throw new Error(result.error || '获取朋友圈用户总条数失败')
}
const rows = result.timeline
if (rows.length === 0) break
for (const row of rows) {
const username = this.pickTimelineUsername(row)
if (!username) continue
counts[username] = (counts[username] || 0) + 1
}
if (rows.length < pageSize) break
offset += rows.length
}
return counts
}
async getUserPostCounts(options?: {
preferCache?: boolean
}): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
const preferCache = options?.preferCache ?? true
const now = Date.now()
try {
if (
preferCache &&
this.userPostCountsCache &&
now - this.userPostCountsCache.updatedAt <= this.userPostCountsCacheTtlMs
) {
return { success: true, counts: this.userPostCountsCache.counts }
}
const counts = await this.getUserPostCountsFromTimeline()
this.userPostCountsCache = {
counts,
updatedAt: Date.now()
}
return { success: true, counts }
} catch (error) {
console.error('[SnsService] getUserPostCounts failed:', error)
if (this.userPostCountsCache) {
return { success: true, counts: this.userPostCountsCache.counts }
}
return { success: false, error: String(error) }
}
}
async getUserPostStats(username: string): Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> {
const normalizedUsername = this.toOptionalString(username)
if (!normalizedUsername) {
return { success: false, error: '用户名不能为空' }
}
const countsResult = await this.getUserPostCounts({ preferCache: true })
if (countsResult.success) {
const totalPosts = countsResult.counts?.[normalizedUsername] ?? 0
return {
success: true,
data: {
username: normalizedUsername,
totalPosts: Math.max(0, Number(totalPosts || 0))
}
}
}
return { success: false, error: countsResult.error || '统计单个好友朋友圈失败' }
}
// 安装朋友圈删除拦截
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
return wcdbService.installSnsBlockDeleteTrigger()
@@ -881,7 +961,12 @@ class SnsService {
// 从数据库直接删除朋友圈记录
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
return wcdbService.deleteSnsPost(postId)
const result = await wcdbService.deleteSnsPost(postId)
if (result.success) {
this.userPostCountsCache = null
this.exportStatsCache = null
}
return result
}
/**
+22
View File
@@ -5,6 +5,28 @@ import { ConfigService } from '../services/config'
let notificationWindow: BrowserWindow | null = null
let closeTimer: NodeJS.Timeout | null = null
export function destroyNotificationWindow() {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
lastNotificationData = null
if (!notificationWindow || notificationWindow.isDestroyed()) {
notificationWindow = null
return
}
const win = notificationWindow
notificationWindow = null
try {
win.destroy()
} catch (error) {
console.warn('[NotificationWindow] Failed to destroy window:', error)
}
}
export function createNotificationWindow() {
if (notificationWindow && !notificationWindow.isDestroyed()) {
return notificationWindow
+16 -2
View File
@@ -402,8 +402,22 @@ function App() {
// 独立会话聊天窗口(仅显示聊天内容区域)
if (isStandaloneChatWindow) {
const sessionId = new URLSearchParams(location.search).get('sessionId') || ''
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} />
const params = new URLSearchParams(location.search)
const sessionId = params.get('sessionId') || ''
const standaloneSource = params.get('source')
const standaloneInitialDisplayName = params.get('initialDisplayName')
const standaloneInitialAvatarUrl = params.get('initialAvatarUrl')
const standaloneInitialContactType = params.get('initialContactType')
return (
<ChatPage
standaloneSessionWindow
initialSessionId={sessionId}
standaloneSource={standaloneSource}
standaloneInitialDisplayName={standaloneInitialDisplayName}
standaloneInitialAvatarUrl={standaloneInitialAvatarUrl}
standaloneInitialContactType={standaloneInitialContactType}
/>
)
}
// 独立通知窗口
+32 -4
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { Search, Calendar, User, X, Filter, Check } from 'lucide-react'
import React from 'react'
import { Search, Calendar, User, X, Loader2 } from 'lucide-react'
import { Avatar } from '../Avatar'
// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved
@@ -7,6 +7,14 @@ interface Contact {
username: string
displayName: string
avatarUrl?: string
postCount?: number
postCountStatus?: 'idle' | 'loading' | 'ready'
}
interface ContactsCountProgress {
resolved: number
total: number
running: boolean
}
interface SnsFilterPanelProps {
@@ -21,6 +29,7 @@ interface SnsFilterPanelProps {
contactSearch: string
setContactSearch: (val: string) => void
loading?: boolean
contactsCountProgress?: ContactsCountProgress
}
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
@@ -34,11 +43,12 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
contacts,
contactSearch,
setContactSearch,
loading
loading,
contactsCountProgress
}) => {
const filteredContacts = contacts.filter(c =>
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
)
@@ -152,8 +162,17 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
)}
</div>
{contactsCountProgress && contactsCountProgress.total > 0 && (
<div className="contact-count-progress">
{contactsCountProgress.running
? `朋友圈条数统计中 ${contactsCountProgress.resolved}/${contactsCountProgress.total}`
: `朋友圈条数已统计 ${contactsCountProgress.total}/${contactsCountProgress.total}`}
</div>
)}
<div className="contact-list-scroll">
{filteredContacts.map(contact => {
const isPostCountReady = contact.postCountStatus === 'ready'
return (
<div
key={contact.username}
@@ -164,6 +183,15 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
<div className="contact-meta">
<span className="contact-name">{contact.displayName}</span>
</div>
<div className="contact-post-count-wrap">
{isPostCountReady ? (
<span className="contact-post-count">{Math.max(0, Math.floor(Number(contact.postCount || 0)))}条</span>
) : (
<span className="contact-post-count-loading" title="统计中">
<Loader2 size={13} className="spinning" />
</span>
)}
</div>
</div>
)
})}
+42 -15
View File
@@ -243,10 +243,12 @@ interface SnsPostItemProps {
post: SnsPost
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
onDebug: (post: SnsPost) => void
onDelete?: (postId: string) => void
onDelete?: (postId: string, username: string) => void
onOpenAuthorPosts?: (post: SnsPost) => void
hideAuthorMeta?: boolean
}
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete }) => {
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts, hideAuthorMeta = false }) => {
const [mediaDeleted, setMediaDeleted] = useState(false)
const [dbDeleted, setDbDeleted] = useState(false)
const [deleting, setDeleting] = useState(false)
@@ -299,31 +301,56 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id)
if (r.success) {
setDbDeleted(true)
onDelete?.(post.id)
onDelete?.(post.id, post.username)
}
} finally {
setDeleting(false)
}
}
const handleOpenAuthorPosts = (e: React.MouseEvent) => {
e.stopPropagation()
onOpenAuthorPosts?.(post)
}
return (
<>
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
<div className="post-avatar-col">
<Avatar
src={post.avatarUrl}
name={post.nickname}
size={48}
shape="rounded"
/>
</div>
{!hideAuthorMeta && (
<div className="post-avatar-col">
<button
type="button"
className="author-trigger-btn avatar-trigger"
onClick={handleOpenAuthorPosts}
title="查看该发布者的全部朋友圈"
>
<Avatar
src={post.avatarUrl}
name={post.nickname}
size={48}
shape="rounded"
/>
</button>
</div>
)}
<div className="post-content-col">
<div className="post-header-row">
<div className="post-author-info">
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
<span className="post-time">{formatTime(post.createTime)}</span>
</div>
{hideAuthorMeta ? (
<span className="post-time post-time-standalone">{formatTime(post.createTime)}</span>
) : (
<div className="post-author-info">
<button
type="button"
className="author-trigger-btn author-name-trigger"
onClick={handleOpenAuthorPosts}
title="查看该发布者的全部朋友圈"
>
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
</button>
<span className="post-time">{formatTime(post.createTime)}</span>
</div>
)}
<div className="post-header-actions">
{(mediaDeleted || dbDeleted) && (
<span className="post-deleted-badge">
+24
View File
@@ -1783,6 +1783,30 @@
z-index: 2;
}
.standalone-phase-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
background: color-mix(in srgb, var(--bg-tertiary) 82%, transparent);
color: var(--text-secondary);
font-size: 14px;
pointer-events: none;
.spin {
animation: spin 1s linear infinite;
}
small {
color: var(--text-tertiary);
font-size: 12px;
}
}
.empty-chat-inline {
display: flex;
flex-direction: column;
+157 -23
View File
@@ -204,8 +204,13 @@ function formatYmdHmDateTime(timestamp?: number): string {
interface ChatPageProps {
standaloneSessionWindow?: boolean
initialSessionId?: string | null
standaloneSource?: string | null
standaloneInitialDisplayName?: string | null
standaloneInitialAvatarUrl?: string | null
standaloneInitialContactType?: string | null
}
type StandaloneLoadStage = 'idle' | 'connecting' | 'loading' | 'ready'
interface SessionDetail {
wxid: string
@@ -408,8 +413,20 @@ const SessionItem = React.memo(function SessionItem({
function ChatPage(props: ChatPageProps) {
const { standaloneSessionWindow = false, initialSessionId = null } = props
const {
standaloneSessionWindow = false,
initialSessionId = null,
standaloneSource = null,
standaloneInitialDisplayName = null,
standaloneInitialAvatarUrl = null,
standaloneInitialContactType = null
} = props
const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId])
const normalizedStandaloneSource = useMemo(() => String(standaloneSource || '').trim().toLowerCase(), [standaloneSource])
const normalizedStandaloneInitialDisplayName = useMemo(() => String(standaloneInitialDisplayName || '').trim(), [standaloneInitialDisplayName])
const normalizedStandaloneInitialAvatarUrl = useMemo(() => String(standaloneInitialAvatarUrl || '').trim(), [standaloneInitialAvatarUrl])
const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType])
const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export'
const navigate = useNavigate()
const {
@@ -493,7 +510,12 @@ function ChatPage(props: ChatPageProps) {
const [hasInitialMessages, setHasInitialMessages] = useState(false)
const [isSessionSwitching, setIsSessionSwitching] = useState(false)
const [noMessageTable, setNoMessageTable] = useState(false)
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null)
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(normalizedStandaloneInitialDisplayName || null)
const [fallbackAvatarUrl, setFallbackAvatarUrl] = useState<string | null>(normalizedStandaloneInitialAvatarUrl || null)
const [standaloneLoadStage, setStandaloneLoadStage] = useState<StandaloneLoadStage>(
standaloneSessionWindow && normalizedInitialSessionId ? 'connecting' : 'idle'
)
const [standaloneInitialLoadRequested, setStandaloneInitialLoadRequested] = useState(false)
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(new Set())
@@ -2408,9 +2430,9 @@ function ChatPage(props: ChatPageProps) {
}, [appendMessages, getMessageKey])
// 选择会话
const selectSessionById = useCallback((sessionId: string) => {
const selectSessionById = useCallback((sessionId: string, options: { force?: boolean } = {}) => {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId || normalizedSessionId === currentSessionId) return
if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return
const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1
sessionSwitchRequestSeqRef.current = switchRequestSeq
@@ -2734,7 +2756,7 @@ function ChatPage(props: ChatPageProps) {
}, [currentSessionId, messages.length, isLoadingMessages])
useEffect(() => {
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
if (currentSessionId && isConnected && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
if (pendingSessionLoadRef.current === currentSessionId) return
if (initialLoadRequestedSessionRef.current === currentSessionId) return
initialLoadRequestedSessionRef.current = currentSessionId
@@ -2745,7 +2767,7 @@ function ChatPage(props: ChatPageProps) {
forceInitialLimit: 30
})
}
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
}, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
useEffect(() => {
return () => {
@@ -2906,7 +2928,21 @@ function ChatPage(props: ChatPageProps) {
// 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback)
const currentSession = (() => {
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
if (found || !currentSessionId) return found
if (found) {
if (
standaloneSessionWindow &&
normalizedInitialSessionId &&
found.username === normalizedInitialSessionId
) {
return {
...found,
displayName: found.displayName || fallbackDisplayName || found.username,
avatarUrl: found.avatarUrl || fallbackAvatarUrl || undefined
}
}
return found
}
if (!currentSessionId) return found
return {
username: currentSessionId,
type: 0,
@@ -2916,6 +2952,7 @@ function ChatPage(props: ChatPageProps) {
lastTimestamp: 0,
lastMsgType: 0,
displayName: fallbackDisplayName || currentSessionId,
avatarUrl: fallbackAvatarUrl || undefined,
} as ChatSession
})()
const filteredGroupPanelMembers = useMemo(() => {
@@ -2935,33 +2972,121 @@ function ChatPage(props: ChatPageProps) {
}, [groupMemberSearchKeyword, groupPanelMembers])
const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId))
const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog
const isCurrentSessionGroup = Boolean(
currentSession && (
isGroupChatSession(currentSession.username) ||
(
standaloneSessionWindow &&
currentSession.username === normalizedInitialSessionId &&
normalizedStandaloneInitialContactType === 'group'
)
)
)
useEffect(() => {
if (!standaloneSessionWindow) return
setStandaloneInitialLoadRequested(false)
setStandaloneLoadStage(normalizedInitialSessionId ? 'connecting' : 'idle')
setFallbackDisplayName(normalizedStandaloneInitialDisplayName || null)
setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl || null)
}, [
standaloneSessionWindow,
normalizedInitialSessionId,
normalizedStandaloneInitialDisplayName,
normalizedStandaloneInitialAvatarUrl
])
useEffect(() => {
if (!standaloneSessionWindow) return
if (!normalizedInitialSessionId) return
if (normalizedStandaloneInitialDisplayName) {
setFallbackDisplayName(normalizedStandaloneInitialDisplayName)
}
if (normalizedStandaloneInitialAvatarUrl) {
setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl)
}
if (!currentSessionId) {
setCurrentSession(normalizedInitialSessionId, { preserveMessages: false })
}
if (!isConnected || isConnecting) {
setStandaloneLoadStage('connecting')
}
}, [
standaloneSessionWindow,
normalizedInitialSessionId,
normalizedStandaloneInitialDisplayName,
normalizedStandaloneInitialAvatarUrl,
currentSessionId,
isConnected,
isConnecting,
setCurrentSession
])
useEffect(() => {
if (!standaloneSessionWindow) return
if (!normalizedInitialSessionId) return
if (!isConnected || isConnecting) return
if (currentSessionId === normalizedInitialSessionId) return
selectSessionById(normalizedInitialSessionId)
if (currentSessionId === normalizedInitialSessionId && standaloneInitialLoadRequested) return
setStandaloneInitialLoadRequested(true)
setStandaloneLoadStage('loading')
selectSessionById(normalizedInitialSessionId, {
force: currentSessionId === normalizedInitialSessionId
})
}, [
standaloneSessionWindow,
normalizedInitialSessionId,
isConnected,
isConnecting,
currentSessionId,
standaloneInitialLoadRequested,
selectSessionById
])
useEffect(() => {
if (!standaloneSessionWindow || !normalizedInitialSessionId) return
if (!isConnected || isConnecting) {
setStandaloneLoadStage('connecting')
return
}
if (!standaloneInitialLoadRequested) {
setStandaloneLoadStage('loading')
return
}
if (currentSessionId !== normalizedInitialSessionId) {
setStandaloneLoadStage('loading')
return
}
if (isLoadingMessages || isSessionSwitching) {
setStandaloneLoadStage('loading')
return
}
setStandaloneLoadStage('ready')
}, [
standaloneSessionWindow,
normalizedInitialSessionId,
isConnected,
isConnecting,
standaloneInitialLoadRequested,
currentSessionId,
isLoadingMessages,
isSessionSwitching
])
// 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称
useEffect(() => {
if (!currentSessionId) return
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
if (found) {
setFallbackDisplayName(null)
if (found.displayName) setFallbackDisplayName(found.displayName)
if (found.avatarUrl) setFallbackAvatarUrl(found.avatarUrl)
return
}
loadContactInfoBatch([currentSessionId]).then(() => {
const cached = senderAvatarCache.get(currentSessionId)
if (cached?.displayName) setFallbackDisplayName(cached.displayName)
if (cached?.avatarUrl) setFallbackAvatarUrl(cached.avatarUrl)
})
}, [currentSessionId, sessions])
@@ -3738,16 +3863,16 @@ function ChatPage(props: ChatPageProps) {
src={currentSession.avatarUrl}
name={currentSession.displayName || currentSession.username}
size={40}
className={isGroupChatSession(currentSession.username) ? 'group session-avatar' : 'session-avatar'}
className={isCurrentSessionGroup ? 'group session-avatar' : 'session-avatar'}
/>
<div className="header-info">
<h3>{currentSession.displayName || currentSession.username}</h3>
{isGroupChatSession(currentSession.username) && (
{isCurrentSessionGroup && (
<div className="header-subtitle">群聊</div>
)}
</div>
<div className="header-actions">
{!standaloneSessionWindow && isGroupChatSession(currentSession.username) && (
{!standaloneSessionWindow && isCurrentSessionGroup && (
<button
className="icon-btn group-analytics-btn"
onClick={handleGroupAnalytics}
@@ -3756,7 +3881,7 @@ function ChatPage(props: ChatPageProps) {
<BarChart3 size={18} />
</button>
)}
{isGroupChatSession(currentSession.username) && (
{isCurrentSessionGroup && (
<button
className={`icon-btn group-members-btn ${showGroupMembersPanel ? 'active' : ''}`}
onClick={toggleGroupMembersPanel}
@@ -3863,13 +3988,15 @@ function ChatPage(props: ChatPageProps) {
>
<RefreshCw size={18} className={isRefreshingMessages ? 'spin' : ''} />
</button>
<button
className={`icon-btn detail-btn ${showDetailPanel ? 'active' : ''}`}
onClick={toggleDetailPanel}
title="会话详情"
>
<Info size={18} />
</button>
{!shouldHideStandaloneDetailButton && (
<button
className={`icon-btn detail-btn ${showDetailPanel ? 'active' : ''}`}
onClick={toggleDetailPanel}
title="会话详情"
>
<Info size={18} />
</button>
)}
</div>
</div>
@@ -3881,6 +4008,13 @@ function ChatPage(props: ChatPageProps) {
)}
<div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'} ${isSessionSwitching ? 'switching' : ''}`}>
{standaloneSessionWindow && standaloneLoadStage !== 'ready' && (
<div className="standalone-phase-overlay" role="status" aria-live="polite">
<Loader2 size={22} className="spin" />
<span>{standaloneLoadStage === 'connecting' ? '正在建立连接...' : '正在加载最近消息...'}</span>
{connectionError && <small>{connectionError}</small>}
</div>
)}
{isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
<div className="loading-messages loading-overlay">
<Loader2 size={24} />
@@ -3937,7 +4071,7 @@ function ChatPage(props: ChatPageProps) {
session={currentSession}
showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl}
isGroupChat={isGroupChatSession(currentSession.username)}
isGroupChat={isCurrentSessionGroup}
onRequireModelDownload={handleRequireModelDownload}
onContextMenu={handleContextMenu}
isSelectionMode={isSelectionMode}
@@ -3969,7 +4103,7 @@ function ChatPage(props: ChatPageProps) {
</div>
{/* 群成员面板 */}
{showGroupMembersPanel && isGroupChatSession(currentSession.username) && (
{showGroupMembersPanel && isCurrentSessionGroup && (
<div className="detail-panel group-members-panel">
<div className="detail-header">
<h4>群成员</h4>
+707 -48
View File
@@ -27,6 +27,27 @@
gap: 6px;
}
.session-load-detail-entry {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 5px 10px;
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-secondary);
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
&:hover {
border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color));
color: var(--text-primary);
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
}
}
.export-section-title {
margin: 0;
font-size: 15px;
@@ -87,6 +108,152 @@
}
}
.session-load-detail-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.42);
display: flex;
align-items: center;
justify-content: center;
z-index: 2200;
padding: 20px;
}
.session-load-detail-modal {
width: min(820px, 100%);
max-height: min(78vh, 860px);
overflow: hidden;
border-radius: 14px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28);
display: flex;
flex-direction: column;
}
.session-load-detail-header {
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
h4 {
margin: 0;
font-size: 15px;
color: var(--text-primary);
}
p {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-tertiary);
}
}
.session-load-detail-close {
border: 1px solid var(--border-color);
border-radius: 8px;
width: 28px;
height: 28px;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
&:hover {
color: var(--text-primary);
border-color: var(--text-tertiary);
}
}
.session-load-detail-body {
padding: 12px 16px 16px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 14px;
}
.session-load-detail-block {
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--card-bg);
h5 {
margin: 0;
padding: 10px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
font-size: 13px;
color: var(--text-primary);
}
}
.session-load-detail-table {
display: flex;
flex-direction: column;
overflow-x: auto;
}
.session-load-detail-row {
display: grid;
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: 620px;
&:last-child {
border-bottom: none;
}
> span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.header {
font-size: 11px;
color: var(--text-tertiary);
font-weight: 600;
background: color-mix(in srgb, var(--bg-secondary) 75%, transparent);
}
}
.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 {
color: var(--text-tertiary);
flex-shrink: 0;
}
.session-load-detail-progress-pulse {
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;
}
.global-export-controls {
background: var(--card-bg);
border: 1px solid var(--border-color);
@@ -995,6 +1162,7 @@
--contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows));
--contacts-select-col-width: 34px;
--contacts-message-col-width: 120px;
--contacts-media-col-width: 72px;
--contacts-action-col-width: 280px;
overflow: hidden;
border: 1px solid var(--border-color);
@@ -1167,6 +1335,16 @@
text-overflow: ellipsis;
}
.contacts-list-header-media {
width: var(--contacts-media-col-width);
min-width: var(--contacts-media-col-width);
text-align: center;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contacts-list-header-actions {
width: var(--contacts-action-col-width);
display: flex;
@@ -1347,12 +1525,72 @@
width: var(--contacts-message-col-width);
min-width: var(--contacts-message-col-width);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
flex-shrink: 0;
text-align: center;
}
.row-media-metric {
width: var(--contacts-media-col-width);
min-width: var(--contacts-media-col-width);
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
text-align: center;
}
.row-media-metric-value {
margin: 0;
font-size: 12px;
line-height: 1.2;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 14px;
}
.row-media-metric-icon {
color: var(--text-tertiary);
}
.row-sns-metric-btn {
border: none;
background: transparent;
margin: 0;
padding: 0;
min-height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1.2;
color: var(--primary);
font-variant-numeric: tabular-nums;
cursor: pointer;
&:hover {
color: var(--primary-hover);
text-decoration: underline;
text-underline-offset: 2px;
}
&:focus-visible {
outline: 2px solid color-mix(in srgb, var(--primary) 48%, transparent);
outline-offset: 2px;
border-radius: 4px;
}
&.loading {
color: var(--text-tertiary);
}
}
.row-message-stats {
width: 100%;
display: flex;
@@ -1397,6 +1635,30 @@
.row-message-stat.total .row-message-count-value {
font-size: 13px;
}
.row-open-chat-link {
border: none;
padding: 0;
margin: 0;
background: transparent;
color: var(--primary);
font-size: 12px;
line-height: 1.2;
font-weight: 600;
cursor: pointer;
&:hover {
color: var(--primary-hover);
text-decoration: underline;
text-underline-offset: 2px;
}
&:focus-visible {
outline: 2px solid color-mix(in srgb, var(--primary) 30%, transparent);
outline-offset: 2px;
border-radius: 4px;
}
}
}
.table-virtuoso {
@@ -1532,7 +1794,7 @@
}
}
.row-action-cell {
.row-action-cell {
display: flex;
flex-direction: column;
align-items: flex-end;
@@ -1542,37 +1804,10 @@
.row-action-main {
display: inline-flex;
align-items: center;
align-items: flex-start;
gap: 6px;
}
.row-open-chat-btn {
border: 1px solid color-mix(in srgb, var(--primary) 38%, var(--border-color));
border-radius: 8px;
padding: 7px 10px;
background: color-mix(in srgb, var(--primary) 12%, var(--bg-secondary));
color: var(--primary);
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--primary) 18%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary) 55%, var(--border-color));
}
&:disabled {
opacity: 0.65;
cursor: not-allowed;
color: var(--text-tertiary);
border-color: var(--border-color);
background: var(--bg-secondary);
}
}
.row-detail-btn {
border: 1px solid var(--border-color);
border-radius: 8px;
@@ -1596,47 +1831,76 @@
}
}
.row-export-btn {
.row-export-action-stack {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 84px;
}
.row-export-link {
border: none;
border-radius: 8px;
padding: 7px 10px;
background: var(--primary);
color: #fff;
padding: 0;
margin: 0;
background: transparent;
color: var(--primary);
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
line-height: 1.2;
font-weight: 600;
white-space: nowrap;
&:hover:not(:disabled) {
background: var(--primary-hover);
color: var(--primary-hover);
text-decoration: underline;
text-underline-offset: 2px;
}
&:disabled {
opacity: 0.75;
cursor: not-allowed;
}
&.running {
background: color-mix(in srgb, var(--primary) 80%, #000);
&:focus-visible {
outline: 2px solid color-mix(in srgb, var(--primary) 30%, transparent);
outline-offset: 2px;
border-radius: 4px;
}
&.paused {
background: rgba(250, 173, 20, 0.16);
color: #d48806;
border: 1px solid rgba(250, 173, 20, 0.38);
&.state-running {
cursor: progress;
}
&.no-session {
background: var(--bg-secondary);
&.state-disabled {
color: var(--text-tertiary);
border: 1px dashed var(--border-color);
text-decoration: none;
}
}
.row-export-time {
font-size: 11px;
line-height: 1.2;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
text-align: center;
}
.row-export-link.state-running + .row-export-time {
color: var(--primary);
font-weight: 600;
}
.row-export-link.state-running:hover:not(:disabled),
.row-export-link.state-running:focus-visible {
color: var(--primary);
text-decoration: none;
}
.row-export-link.state-disabled:hover:not(:disabled),
.row-export-link.state-disabled:focus-visible {
color: var(--text-tertiary);
text-decoration: none;
}
}
@@ -1840,6 +2104,10 @@
}
}
.detail-sns-entry-btn {
white-space: nowrap;
}
.copy-btn {
display: flex;
align-items: center;
@@ -1951,6 +2219,333 @@
}
}
.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);
}
.sns-dialog-header-actions {
display: flex;
align-items: flex-start;
gap: 8px;
flex-shrink: 0;
}
.sns-dialog-rank-switch {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
}
.sns-dialog-rank-btn {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
height: 28px;
padding: 0 10px;
font-size: 12px;
line-height: 1;
cursor: pointer;
white-space: nowrap;
&:hover {
color: var(--text-primary);
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
}
&.active {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 52%, var(--border-color));
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
}
}
.sns-dialog-rank-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 248px;
max-height: calc((28px * 15) + 16px);
overflow-y: auto;
border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color));
border-radius: 10px;
background: var(--bg-primary);
box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18);
padding: 8px;
z-index: 12;
}
.sns-dialog-rank-empty {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
text-align: center;
padding: 6px 0;
}
.sns-dialog-rank-row {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 0 4px;
border-radius: 7px;
&:hover {
background: var(--bg-hover);
}
}
.sns-dialog-rank-index {
font-size: 12px;
color: var(--text-tertiary);
text-align: right;
font-variant-numeric: tabular-nums;
}
.sns-dialog-rank-name {
font-size: 12px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sns-dialog-rank-count {
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.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-tip {
padding: 10px 16px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary));
font-size: 12px;
line-height: 1.6;
color: var(--text-secondary);
word-break: break-word;
}
.sns-dialog-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 14px 14px;
}
.export-session-sns-posts-list {
gap: 14px;
}
.post-header-actions {
display: none;
}
.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;
@@ -2660,8 +3255,22 @@
font-size: 14px;
}
.session-load-detail-entry {
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;
--contacts-action-col-width: 236px;
}
@@ -2688,6 +3297,10 @@
min-width: var(--contacts-message-col-width);
}
.table-wrap .row-media-metric {
min-width: var(--contacts-media-col-width);
}
.table-wrap .row-message-stats {
gap: 6px;
}
@@ -2700,10 +3313,19 @@
font-size: 11px;
}
.table-wrap .row-media-metric-value {
font-size: 11px;
}
.table-wrap .row-message-stat.total .row-message-count-value {
font-size: 12px;
}
.table-wrap .row-open-chat-link,
.table-wrap .row-export-link {
font-size: 11px;
}
.export-dialog-overlay {
padding: 10px;
}
@@ -2746,4 +3368,41 @@
.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-header-actions {
gap: 6px;
}
.sns-dialog-rank-btn {
height: 26px;
padding: 0 8px;
font-size: 11px;
}
.sns-dialog-rank-panel {
width: min(78vw, 232px);
max-height: calc((28px * 15) + 16px);
}
.sns-dialog-tip {
padding: 10px 12px;
line-height: 1.55;
}
.sns-dialog-body {
padding: 10px 10px 12px;
}
}
}
+1753 -117
View File
@@ -38,6 +38,8 @@ import {
onOpenSingleExport
} from '../services/exportBridge'
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import { SnsPostItem } from '../components/Sns/SnsPostItem'
import type { SnsPost } from '../types/sns'
import './ExportPage.scss'
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
@@ -45,6 +47,7 @@ type TaskStatus = 'queued' | 'running' | 'success' | 'error'
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji'
type ContentCardType = ContentType | 'sns'
type SnsRankMode = 'likes' | 'comments'
type SessionLayout = 'shared' | 'per-session'
@@ -163,6 +166,14 @@ interface TimeRangeDialogDraft {
}
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
const SESSION_MEDIA_METRIC_BATCH_SIZE = 12
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120
const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200
const SNS_USER_POST_COUNT_BATCH_SIZE = 12
const SNS_USER_POST_COUNT_BATCH_INTERVAL_MS = 120
const contentTypeLabels: Record<ContentType, string> = {
text: '聊天文本',
voice: '语音',
@@ -171,6 +182,13 @@ const contentTypeLabels: Record<ContentType, string> = {
emoji: '表情包'
}
const conversationTabLabels: Record<ConversationTab, string> = {
private: '私聊',
group: '群聊',
official: '公众号',
former_friend: '曾经的好友'
}
const getContentTypeLabel = (type: ContentType): string => {
return contentTypeLabels[type] || type
}
@@ -409,6 +427,14 @@ 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 formatPathBrief = (value: string, maxLength = 52): string => {
const normalized = String(value || '')
if (normalized.length <= maxLength) return normalized
@@ -573,6 +599,10 @@ const isContentScopeSession = (session: SessionRow): boolean => (
session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend'
)
const isExportConversationSession = (session: SessionRow): boolean => (
session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend'
)
const exportKindPriority: Record<ConversationTab, number> = {
private: 0,
group: 1,
@@ -648,6 +678,12 @@ interface SessionDetail {
messageTables: { dbName: string; tableName: string; count: number }[]
}
interface SessionSnsTimelineTarget {
username: string
displayName: string
avatarUrl?: string
}
interface SessionExportMetric {
totalMessages: number
voiceMessages: number
@@ -684,6 +720,30 @@ interface SessionExportCacheMeta {
source: 'memory' | 'disk' | 'fresh'
}
type SessionLoadStageStatus = 'pending' | 'loading' | 'done' | 'failed'
interface SessionLoadStageState {
status: SessionLoadStageStatus
startedAt?: number
finishedAt?: number
error?: string
}
interface SessionLoadTraceState {
messageCount: SessionLoadStageState
mediaMetrics: SessionLoadStageState
snsPostCounts: SessionLoadStageState
}
interface SessionLoadStageSummary {
total: number
loaded: number
statusLabel: string
startedAt?: number
finishedAt?: number
latestProgressAt?: number
}
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
let timer: ReturnType<typeof setTimeout> | null = null
try {
@@ -869,6 +929,48 @@ const normalizeMessageCount = (value: unknown): number | undefined => {
return Math.floor(parsed)
}
const pickSessionMediaMetric = (
metricRaw: SessionExportMetric | SessionContentMetric | undefined
): SessionContentMetric | null => {
if (!metricRaw) return null
const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages)
const imageMessages = normalizeMessageCount(metricRaw.imageMessages)
const videoMessages = normalizeMessageCount(metricRaw.videoMessages)
const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages)
if (
typeof voiceMessages !== 'number' &&
typeof imageMessages !== 'number' &&
typeof videoMessages !== 'number' &&
typeof emojiMessages !== 'number'
) {
return null
}
return {
voiceMessages,
imageMessages,
videoMessages,
emojiMessages
}
}
const hasCompleteSessionMediaMetric = (metricRaw: SessionContentMetric | undefined): boolean => {
if (!metricRaw) return false
return (
typeof normalizeMessageCount(metricRaw.voiceMessages) === 'number' &&
typeof normalizeMessageCount(metricRaw.imageMessages) === 'number' &&
typeof normalizeMessageCount(metricRaw.videoMessages) === 'number' &&
typeof normalizeMessageCount(metricRaw.emojiMessages) === 'number'
)
}
const createDefaultSessionLoadStage = (): SessionLoadStageState => ({ status: 'pending' })
const createDefaultSessionLoadTrace = (): SessionLoadTraceState => ({
messageCount: createDefaultSessionLoadStage(),
mediaMetrics: createDefaultSessionLoadStage(),
snsPostCounts: createDefaultSessionLoadStage()
})
const WriteLayoutSelector = memo(function WriteLayoutSelector({
writeLayout,
onChange,
@@ -1208,19 +1310,33 @@ function ExportPage() {
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null)
const [sessionMessageCounts, setSessionMessageCounts] = useState<Record<string, number>>({})
const [isLoadingSessionCounts, setIsLoadingSessionCounts] = useState(false)
const [isSessionCountStageReady, setIsSessionCountStageReady] = useState(false)
const [sessionContentMetrics, setSessionContentMetrics] = useState<Record<string, SessionContentMetric>>({})
const [sessionLoadTraceMap, setSessionLoadTraceMap] = useState<Record<string, SessionLoadTraceState>>({})
const [sessionLoadProgressPulseMap, setSessionLoadProgressPulseMap] = useState<Record<string, { at: number; delta: number }>>({})
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const [contactsLoadSession, setContactsLoadSession] = useState<ContactsLoadSession | null>(null)
const [contactsLoadIssue, setContactsLoadIssue] = useState<ContactsLoadIssue | null>(null)
const [showContactsDiagnostics, setShowContactsDiagnostics] = useState(false)
const [contactsDiagnosticTick, setContactsDiagnosticTick] = useState(Date.now())
const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false)
const [showSessionLoadDetailModal, setShowSessionLoadDetailModal] = useState(false)
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
const [isLoadingSessionDetail, setIsLoadingSessionDetail] = useState(false)
const [isLoadingSessionDetailExtra, setIsLoadingSessionDetailExtra] = useState(false)
const [isRefreshingSessionDetailStats, setIsRefreshingSessionDetailStats] = useState(false)
const [isLoadingSessionRelationStats, setIsLoadingSessionRelationStats] = useState(false)
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 [sessionSnsRankMode, setSessionSnsRankMode] = useState<SnsRankMode | null>(null)
const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
@@ -1298,6 +1414,7 @@ function ExportPage() {
const sessionTableSectionRef = useRef<HTMLDivElement | null>(null)
const detailRequestSeqRef = useRef(0)
const sessionsRef = useRef<SessionRow[]>([])
const sessionContentMetricsRef = useRef<Record<string, SessionContentMetric>>({})
const contactsListSizeRef = useRef(0)
const contactsUpdatedAtRef = useRef<number | null>(null)
const sessionsHydratedAtRef = useRef(0)
@@ -1306,7 +1423,29 @@ function ExportPage() {
const activeTaskCountRef = useRef(0)
const hasBaseConfigReadyRef = useRef(false)
const sessionCountRequestIdRef = useRef(0)
const isLoadingSessionCountsRef = useRef(false)
const activeTabRef = useRef<ConversationTab>('private')
const detailStatsPriorityRef = useRef(false)
const sessionSnsTimelinePostsRef = useRef<SnsPost[]>([])
const sessionSnsTimelineLoadingRef = useRef(false)
const sessionSnsTimelineRequestTokenRef = useRef(0)
const snsUserPostCountsHydrationTokenRef = useRef(0)
const snsUserPostCountsBatchTimerRef = useRef<number | null>(null)
const sessionPreciseRefreshAtRef = useRef<Record<string, number>>({})
const sessionLoadProgressSnapshotRef = useRef<Record<string, { loaded: number; total: number }>>({})
const sessionMediaMetricQueueRef = useRef<string[]>([])
const sessionMediaMetricQueuedSetRef = useRef<Set<string>>(new Set())
const sessionMediaMetricLoadingSetRef = useRef<Set<string>>(new Set())
const sessionMediaMetricReadySetRef = useRef<Set<string>>(new Set())
const sessionMediaMetricRunIdRef = useRef(0)
const sessionMediaMetricWorkerRunningRef = useRef(false)
const sessionMediaMetricBackgroundFeedTimerRef = useRef<number | null>(null)
const sessionMediaMetricPersistTimerRef = useRef<number | null>(null)
const sessionMediaMetricPendingPersistRef = useRef<Record<string, configService.ExportSessionContentMetricCacheEntry>>({})
const sessionMediaMetricVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({
startIndex: 0,
endIndex: -1
})
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
if (exportCacheScopeReadyRef.current) {
@@ -1356,6 +1495,104 @@ function ExportPage() {
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
}, [contactsLoadTimeoutMs])
useEffect(() => {
isLoadingSessionCountsRef.current = isLoadingSessionCounts
}, [isLoadingSessionCounts])
useEffect(() => {
sessionContentMetricsRef.current = sessionContentMetrics
}, [sessionContentMetrics])
const patchSessionLoadTraceStage = useCallback((
sessionIds: string[],
stageKey: keyof SessionLoadTraceState,
status: SessionLoadStageStatus,
options?: { force?: boolean; error?: string }
) => {
if (sessionIds.length === 0) return
const now = Date.now()
setSessionLoadTraceMap(prev => {
let changed = false
const next = { ...prev }
for (const sessionIdRaw of sessionIds) {
const sessionId = String(sessionIdRaw || '').trim()
if (!sessionId) continue
const prevTrace = next[sessionId] || createDefaultSessionLoadTrace()
const prevStage = prevTrace[stageKey] || createDefaultSessionLoadStage()
if (!options?.force && prevStage.status === 'done' && status !== 'done') {
continue
}
let stageChanged = false
const nextStage: SessionLoadStageState = { ...prevStage }
if (nextStage.status !== status) {
nextStage.status = status
stageChanged = true
}
if (status === 'loading') {
if (!nextStage.startedAt) {
nextStage.startedAt = now
stageChanged = true
}
if (nextStage.finishedAt) {
nextStage.finishedAt = undefined
stageChanged = true
}
if (nextStage.error) {
nextStage.error = undefined
stageChanged = true
}
} else if (status === 'done') {
if (!nextStage.startedAt) {
nextStage.startedAt = now
stageChanged = true
}
if (!nextStage.finishedAt) {
nextStage.finishedAt = now
stageChanged = true
}
if (nextStage.error) {
nextStage.error = undefined
stageChanged = true
}
} else if (status === 'failed') {
if (!nextStage.startedAt) {
nextStage.startedAt = now
stageChanged = true
}
if (!nextStage.finishedAt) {
nextStage.finishedAt = now
stageChanged = true
}
const nextError = options?.error || '加载失败'
if (nextStage.error !== nextError) {
nextStage.error = nextError
stageChanged = true
}
} else if (status === 'pending') {
if (nextStage.startedAt !== undefined) {
nextStage.startedAt = undefined
stageChanged = true
}
if (nextStage.finishedAt !== undefined) {
nextStage.finishedAt = undefined
stageChanged = true
}
if (nextStage.error !== undefined) {
nextStage.error = undefined
stageChanged = true
}
}
if (!stageChanged) continue
next[sessionId] = {
...prevTrace,
[stageKey]: nextStage
}
changed = true
}
return changed ? next : prev
})
}, [])
const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => {
const scopeKey = options?.scopeKey || await ensureExportCacheScope()
const loadVersion = contactsLoadVersionRef.current + 1
@@ -1577,6 +1814,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)
@@ -1722,6 +1963,346 @@ function ExportPage() {
}
}, [])
const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => {
if (snsUserPostCountsStatus === 'loading') return
if (!options?.force && snsUserPostCountsStatus === 'ready') return
const targetSessionIds = sessionsRef.current
.filter((session) => session.hasSession && isSingleContactSession(session.username))
.map((session) => session.username)
snsUserPostCountsHydrationTokenRef.current += 1
const runToken = snsUserPostCountsHydrationTokenRef.current
if (snsUserPostCountsBatchTimerRef.current) {
window.clearTimeout(snsUserPostCountsBatchTimerRef.current)
snsUserPostCountsBatchTimerRef.current = null
}
if (targetSessionIds.length === 0) {
setSnsUserPostCountsStatus('ready')
return
}
const scopeKey = exportCacheScopeReadyRef.current
? exportCacheScopeRef.current
: await ensureExportCacheScope()
const targetSet = new Set(targetSessionIds)
let cachedCounts: Record<string, number> = {}
try {
const cached = await configService.getExportSnsUserPostCountsCache(scopeKey)
cachedCounts = cached?.counts || {}
} catch (cacheError) {
console.error('读取导出页朋友圈条数缓存失败:', cacheError)
}
const cachedTargetCounts = Object.entries(cachedCounts).reduce<Record<string, number>>((acc, [sessionId, countRaw]) => {
if (!targetSet.has(sessionId)) return acc
const nextCount = Number(countRaw)
acc[sessionId] = Number.isFinite(nextCount) ? Math.max(0, Math.floor(nextCount)) : 0
return acc
}, {})
const cachedReadySessionIds = Object.keys(cachedTargetCounts)
if (cachedReadySessionIds.length > 0) {
setSnsUserPostCounts(prev => ({ ...prev, ...cachedTargetCounts }))
patchSessionLoadTraceStage(cachedReadySessionIds, 'snsPostCounts', 'done')
}
const pendingSessionIds = options?.force
? targetSessionIds
: targetSessionIds.filter((sessionId) => !(sessionId in cachedTargetCounts))
if (pendingSessionIds.length === 0) {
setSnsUserPostCountsStatus('ready')
return
}
patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'pending', { force: true })
patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'loading')
setSnsUserPostCountsStatus('loading')
let normalizedCounts: Record<string, number> = {}
try {
const result = await window.electronAPI.sns.getUserPostCounts()
if (runToken !== snsUserPostCountsHydrationTokenRef.current) return
if (!result.success || !result.counts) {
patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'failed', {
error: result.error || '朋友圈条数统计失败'
})
setSnsUserPostCountsStatus('error')
return
}
for (const [rawUsername, rawCount] of Object.entries(result.counts)) {
const username = String(rawUsername || '').trim()
if (!username) continue
const value = Number(rawCount)
normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
}
void (async () => {
try {
await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts)
} catch (cacheError) {
console.error('写入导出页朋友圈条数缓存失败:', cacheError)
}
})()
} catch (error) {
console.error('加载朋友圈用户条数失败:', error)
if (runToken !== snsUserPostCountsHydrationTokenRef.current) return
patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'failed', {
error: String(error)
})
setSnsUserPostCountsStatus('error')
return
}
let cursor = 0
const applyBatch = () => {
if (runToken !== snsUserPostCountsHydrationTokenRef.current) return
const batchSessionIds = pendingSessionIds.slice(cursor, cursor + SNS_USER_POST_COUNT_BATCH_SIZE)
if (batchSessionIds.length === 0) {
setSnsUserPostCountsStatus('ready')
snsUserPostCountsBatchTimerRef.current = null
return
}
const batchCounts: Record<string, number> = {}
for (const sessionId of batchSessionIds) {
const nextCount = normalizedCounts[sessionId]
batchCounts[sessionId] = Number.isFinite(nextCount) ? Math.max(0, Math.floor(nextCount)) : 0
}
setSnsUserPostCounts(prev => ({ ...prev, ...batchCounts }))
patchSessionLoadTraceStage(batchSessionIds, 'snsPostCounts', 'done')
cursor += batchSessionIds.length
if (cursor < targetSessionIds.length) {
snsUserPostCountsBatchTimerRef.current = window.setTimeout(applyBatch, SNS_USER_POST_COUNT_BATCH_INTERVAL_MS)
} else {
setSnsUserPostCountsStatus('ready')
snsUserPostCountsBatchTimerRef.current = null
}
}
applyBatch()
}, [ensureExportCacheScope, patchSessionLoadTraceStage, 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
setSessionSnsRankMode(null)
setSessionSnsTimelineTarget(null)
setSessionSnsTimelinePosts([])
setSessionSnsTimelineLoading(false)
setSessionSnsTimelineLoadingMore(false)
setSessionSnsTimelineHasMore(false)
setSessionSnsTimelineTotalPosts(null)
setSessionSnsTimelineStatsLoading(false)
}, [])
const openSessionSnsTimelineByTarget = useCallback((target: SessionSnsTimelineTarget) => {
setSessionSnsRankMode(null)
setSessionSnsTimelineTarget(target)
setSessionSnsTimelinePosts([])
setSessionSnsTimelineHasMore(false)
setSessionSnsTimelineLoadingMore(false)
setSessionSnsTimelineLoading(false)
if (snsUserPostCountsStatus === 'ready') {
const count = Number(snsUserPostCounts[target.username] || 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,
snsUserPostCounts,
snsUserPostCountsStatus
])
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
}
openSessionSnsTimelineByTarget(target)
}, [openSessionSnsTimelineByTarget, sessionDetail])
const openContactSnsTimeline = useCallback((contact: ContactInfo) => {
const normalizedSessionId = String(contact?.username || '').trim()
if (!isSingleContactSession(normalizedSessionId)) return
openSessionSnsTimelineByTarget({
username: normalizedSessionId,
displayName: contact.displayName || contact.remark || contact.nickname || normalizedSessionId,
avatarUrl: contact.avatarUrl
})
}, [openSessionSnsTimelineByTarget])
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 sessionSnsLikeRankings = useMemo(() => {
const rankMap = new Map<string, { name: string; count: number; latestTime: number }>()
for (const post of sessionSnsTimelinePosts) {
const createTime = Number(post?.createTime) || 0
const likes = Array.isArray(post?.likes) ? post.likes : []
for (const likeNameRaw of likes) {
const name = String(likeNameRaw || '').trim() || '未知用户'
const current = rankMap.get(name)
if (current) {
current.count += 1
if (createTime > current.latestTime) current.latestTime = createTime
continue
}
rankMap.set(name, { name, count: 1, latestTime: createTime })
}
}
return [...rankMap.values()].sort((a, b) => {
if (b.count !== a.count) return b.count - a.count
if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime
return a.name.localeCompare(b.name, 'zh-CN')
})
}, [sessionSnsTimelinePosts])
const sessionSnsCommentRankings = useMemo(() => {
const rankMap = new Map<string, { name: string; count: number; latestTime: number }>()
for (const post of sessionSnsTimelinePosts) {
const createTime = Number(post?.createTime) || 0
const comments = Array.isArray(post?.comments) ? post.comments : []
for (const comment of comments) {
const name = String(comment?.nickname || '').trim() || '未知用户'
const current = rankMap.get(name)
if (current) {
current.count += 1
if (createTime > current.latestTime) current.latestTime = createTime
continue
}
rankMap.set(name, { name, count: 1, latestTime: createTime })
}
}
return [...rankMap.values()].sort((a, b) => {
if (b.count !== a.count) return b.count - a.count
if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime
return a.name.localeCompare(b.name, 'zh-CN')
})
}, [sessionSnsTimelinePosts])
const toggleSessionSnsRankMode = useCallback((mode: SnsRankMode) => {
setSessionSnsRankMode((prev) => (prev === mode ? null : mode))
}, [])
const sessionSnsActiveRankings = useMemo(() => {
if (sessionSnsRankMode === 'likes') return sessionSnsLikeRankings
if (sessionSnsRankMode === 'comments') return sessionSnsCommentRankings
return []
}, [sessionSnsCommentRankings, sessionSnsLikeRankings, sessionSnsRankMode])
const mergeSessionContentMetrics = useCallback((input: Record<string, SessionExportMetric | SessionContentMetric | undefined>) => {
const entries = Object.entries(input)
if (entries.length === 0) return
@@ -1818,6 +2399,194 @@ function ExportPage() {
}
}, [])
const resetSessionMediaMetricLoader = useCallback(() => {
sessionMediaMetricRunIdRef.current += 1
sessionMediaMetricQueueRef.current = []
sessionMediaMetricQueuedSetRef.current.clear()
sessionMediaMetricLoadingSetRef.current.clear()
sessionMediaMetricReadySetRef.current.clear()
sessionMediaMetricWorkerRunningRef.current = false
sessionMediaMetricPendingPersistRef.current = {}
sessionMediaMetricVisibleRangeRef.current = { startIndex: 0, endIndex: -1 }
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
sessionMediaMetricBackgroundFeedTimerRef.current = null
}
if (sessionMediaMetricPersistTimerRef.current) {
window.clearTimeout(sessionMediaMetricPersistTimerRef.current)
sessionMediaMetricPersistTimerRef.current = null
}
}, [])
const flushSessionMediaMetricCache = useCallback(async () => {
const pendingMetrics = sessionMediaMetricPendingPersistRef.current
sessionMediaMetricPendingPersistRef.current = {}
if (Object.keys(pendingMetrics).length === 0) return
try {
const scopeKey = await ensureExportCacheScope()
const existing = await configService.getExportSessionContentMetricCache(scopeKey)
const nextMetrics = {
...(existing?.metrics || {}),
...pendingMetrics
}
await configService.setExportSessionContentMetricCache(scopeKey, nextMetrics)
} catch (error) {
console.error('写入导出页会话内容统计缓存失败:', error)
}
}, [ensureExportCacheScope])
const scheduleFlushSessionMediaMetricCache = useCallback(() => {
if (sessionMediaMetricPersistTimerRef.current) return
sessionMediaMetricPersistTimerRef.current = window.setTimeout(() => {
sessionMediaMetricPersistTimerRef.current = null
void flushSessionMediaMetricCache()
}, SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS)
}, [flushSessionMediaMetricCache])
const isSessionMediaMetricReady = useCallback((sessionId: string): boolean => {
if (!sessionId) return true
if (sessionMediaMetricReadySetRef.current.has(sessionId)) return true
const existing = sessionContentMetricsRef.current[sessionId]
if (hasCompleteSessionMediaMetric(existing)) {
sessionMediaMetricReadySetRef.current.add(sessionId)
return true
}
return false
}, [])
const enqueueSessionMediaMetricRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => {
const front = options?.front === true
const incoming: string[] = []
for (const sessionIdRaw of sessionIds) {
const sessionId = String(sessionIdRaw || '').trim()
if (!sessionId) continue
if (sessionMediaMetricQueuedSetRef.current.has(sessionId)) continue
if (sessionMediaMetricLoadingSetRef.current.has(sessionId)) continue
if (isSessionMediaMetricReady(sessionId)) continue
sessionMediaMetricQueuedSetRef.current.add(sessionId)
incoming.push(sessionId)
}
if (incoming.length === 0) return
patchSessionLoadTraceStage(incoming, 'mediaMetrics', 'pending')
if (front) {
sessionMediaMetricQueueRef.current = [...incoming, ...sessionMediaMetricQueueRef.current]
} else {
sessionMediaMetricQueueRef.current.push(...incoming)
}
}, [isSessionMediaMetricReady, patchSessionLoadTraceStage])
const applySessionMediaMetricsFromStats = useCallback((data?: Record<string, SessionExportMetric>) => {
if (!data) return
const nextMetrics: Record<string, SessionContentMetric> = {}
let hasPatch = false
for (const [sessionIdRaw, metricRaw] of Object.entries(data)) {
const sessionId = String(sessionIdRaw || '').trim()
if (!sessionId) continue
const metric = pickSessionMediaMetric(metricRaw)
if (!metric) continue
nextMetrics[sessionId] = metric
hasPatch = true
sessionMediaMetricPendingPersistRef.current[sessionId] = {
...sessionMediaMetricPendingPersistRef.current[sessionId],
...metric
}
if (hasCompleteSessionMediaMetric(metric)) {
sessionMediaMetricReadySetRef.current.add(sessionId)
}
}
if (hasPatch) {
mergeSessionContentMetrics(nextMetrics)
scheduleFlushSessionMediaMetricCache()
}
}, [mergeSessionContentMetrics, scheduleFlushSessionMediaMetricCache])
const runSessionMediaMetricWorker = useCallback(async (runId: number) => {
if (sessionMediaMetricWorkerRunningRef.current) return
sessionMediaMetricWorkerRunningRef.current = true
try {
while (runId === sessionMediaMetricRunIdRef.current) {
if (isLoadingSessionCountsRef.current || detailStatsPriorityRef.current) {
await new Promise(resolve => window.setTimeout(resolve, 80))
continue
}
if (sessionMediaMetricQueueRef.current.length === 0) break
const batchSessionIds: string[] = []
while (batchSessionIds.length < SESSION_MEDIA_METRIC_BATCH_SIZE && sessionMediaMetricQueueRef.current.length > 0) {
const nextId = sessionMediaMetricQueueRef.current.shift()
if (!nextId) continue
sessionMediaMetricQueuedSetRef.current.delete(nextId)
if (sessionMediaMetricLoadingSetRef.current.has(nextId)) continue
if (isSessionMediaMetricReady(nextId)) continue
sessionMediaMetricLoadingSetRef.current.add(nextId)
batchSessionIds.push(nextId)
}
if (batchSessionIds.length === 0) {
continue
}
patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'loading')
try {
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
batchSessionIds,
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
)
if (runId !== sessionMediaMetricRunIdRef.current) return
if (cacheResult.success && cacheResult.data) {
applySessionMediaMetricsFromStats(cacheResult.data as Record<string, SessionExportMetric>)
}
const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId))
if (missingSessionIds.length > 0) {
const freshResult = await window.electronAPI.chat.getExportSessionStats(
missingSessionIds,
{ includeRelations: false, allowStaleCache: true }
)
if (runId !== sessionMediaMetricRunIdRef.current) return
if (freshResult.success && freshResult.data) {
applySessionMediaMetricsFromStats(freshResult.data as Record<string, SessionExportMetric>)
}
}
} catch (error) {
console.error('导出页加载会话媒体统计失败:', error)
patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'failed', {
error: String(error)
})
} finally {
const completedSessionIds: string[] = []
for (const sessionId of batchSessionIds) {
sessionMediaMetricLoadingSetRef.current.delete(sessionId)
if (isSessionMediaMetricReady(sessionId)) {
sessionMediaMetricReadySetRef.current.add(sessionId)
completedSessionIds.push(sessionId)
}
}
if (completedSessionIds.length > 0) {
patchSessionLoadTraceStage(completedSessionIds, 'mediaMetrics', 'done')
}
}
await new Promise(resolve => window.setTimeout(resolve, 0))
}
} finally {
sessionMediaMetricWorkerRunningRef.current = false
if (runId === sessionMediaMetricRunIdRef.current && sessionMediaMetricQueueRef.current.length > 0) {
void runSessionMediaMetricWorker(runId)
}
}
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage])
const scheduleSessionMediaMetricWorker = useCallback(() => {
if (!isSessionCountStageReady) return
if (isLoadingSessionCountsRef.current) return
if (sessionMediaMetricWorkerRunningRef.current) return
const runId = sessionMediaMetricRunIdRef.current
void runSessionMediaMetricWorker(runId)
}, [isSessionCountStageReady, runSessionMediaMetricWorker])
const loadSessionMessageCounts = useCallback(async (
sourceSessions: SessionRow[],
priorityTab: ConversationTab,
@@ -1829,8 +2598,12 @@ function ExportPage() {
const requestId = sessionCountRequestIdRef.current + 1
sessionCountRequestIdRef.current = requestId
const isStale = () => sessionCountRequestIdRef.current !== requestId
setIsSessionCountStageReady(false)
const exportableSessions = sourceSessions.filter(session => session.hasSession)
const exportableSessionIds = exportableSessions.map(session => session.username)
const exportableSessionIdSet = new Set(exportableSessionIds)
patchSessionLoadTraceStage(exportableSessionIds, 'messageCount', 'pending', { force: true })
const seededHintCounts = exportableSessions.reduce<Record<string, number>>((acc, session) => {
const nextCount = normalizeMessageCount(session.messageCountHint)
if (typeof nextCount === 'number') {
@@ -1839,12 +2612,17 @@ function ExportPage() {
return acc
}, {})
const seededPersistentCounts = Object.entries(options?.seededCounts || {}).reduce<Record<string, number>>((acc, [sessionId, countRaw]) => {
if (!exportableSessionIdSet.has(sessionId)) return acc
const nextCount = normalizeMessageCount(countRaw)
if (typeof nextCount === 'number') {
acc[sessionId] = nextCount
}
return acc
}, {})
const seededPersistentSessionIds = Object.keys(seededPersistentCounts)
if (seededPersistentSessionIds.length > 0) {
patchSessionLoadTraceStage(seededPersistentSessionIds, 'messageCount', 'done')
}
const seededCounts = { ...seededHintCounts, ...seededPersistentCounts }
const accumulatedCounts: Record<string, number> = { ...seededCounts }
setSessionMessageCounts(seededCounts)
@@ -1859,6 +2637,9 @@ function ExportPage() {
if (exportableSessions.length === 0) {
setIsLoadingSessionCounts(false)
if (!isStale()) {
setIsSessionCountStageReady(true)
}
return { ...accumulatedCounts }
}
@@ -1894,26 +2675,54 @@ function ExportPage() {
setIsLoadingSessionCounts(true)
try {
if (detailStatsPriorityRef.current) {
return { ...accumulatedCounts }
}
if (prioritizedSessionIds.length > 0) {
patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading')
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds)
if (isStale()) return { ...accumulatedCounts }
if (priorityResult.success) {
applyCounts(priorityResult.counts)
patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'done')
} else {
patchSessionLoadTraceStage(
prioritizedSessionIds,
'messageCount',
'failed',
{ error: priorityResult.error || '总消息数加载失败' }
)
}
}
if (detailStatsPriorityRef.current) {
return { ...accumulatedCounts }
}
if (remainingSessionIds.length > 0) {
patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading')
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds)
if (isStale()) return { ...accumulatedCounts }
if (remainingResult.success) {
applyCounts(remainingResult.counts)
patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'done')
} else {
patchSessionLoadTraceStage(
remainingSessionIds,
'messageCount',
'failed',
{ error: remainingResult.error || '总消息数加载失败' }
)
}
}
} catch (error) {
console.error('导出页加载会话消息总数失败:', error)
patchSessionLoadTraceStage(exportableSessionIds, 'messageCount', 'failed', {
error: String(error)
})
} finally {
if (!isStale()) {
setIsLoadingSessionCounts(false)
setIsSessionCountStageReady(true)
if (options?.scopeKey && Object.keys(accumulatedCounts).length > 0) {
try {
await configService.setExportSessionMessageCountCache(options.scopeKey, accumulatedCounts)
@@ -1924,18 +2733,31 @@ function ExportPage() {
}
}
return { ...accumulatedCounts }
}, [mergeSessionContentMetrics])
}, [mergeSessionContentMetrics, patchSessionLoadTraceStage])
const loadSessions = useCallback(async () => {
const loadToken = Date.now()
sessionLoadTokenRef.current = loadToken
sessionsHydratedAtRef.current = 0
sessionPreciseRefreshAtRef.current = {}
resetSessionMediaMetricLoader()
setIsLoading(true)
setIsSessionEnriching(false)
sessionCountRequestIdRef.current += 1
setSessionMessageCounts({})
setSessionContentMetrics({})
setSessionLoadTraceMap({})
setSessionLoadProgressPulseMap({})
sessionLoadProgressSnapshotRef.current = {}
snsUserPostCountsHydrationTokenRef.current += 1
if (snsUserPostCountsBatchTimerRef.current) {
window.clearTimeout(snsUserPostCountsBatchTimerRef.current)
snsUserPostCountsBatchTimerRef.current = null
}
setSnsUserPostCounts({})
setSnsUserPostCountsStatus('idle')
setIsLoadingSessionCounts(false)
setIsSessionCountStageReady(false)
const isStale = () => sessionLoadTokenRef.current !== loadToken
@@ -1945,10 +2767,12 @@ function ExportPage() {
const [
cachedContactsPayload,
cachedMessageCountsPayload
cachedMessageCountsPayload,
cachedContentMetricsPayload
] = await Promise.all([
loadContactsCaches(scopeKey),
configService.getExportSessionMessageCountCache(scopeKey)
configService.getExportSessionMessageCountCache(scopeKey),
configService.getExportSessionContentMetricCache(scopeKey)
])
if (isStale()) return
@@ -1962,7 +2786,7 @@ function ExportPage() {
const cachedContactMap = toContactMapFromCaches(cachedContacts, cachedAvatarEntries)
if (cachedContacts.length > 0) {
syncContactTypeCounts(Object.values(cachedContactMap))
setSessions(toSessionRowsWithContacts([], cachedContactMap))
setSessions(toSessionRowsWithContacts([], cachedContactMap).filter(isExportConversationSession))
setSessionDataSource('cache')
setIsLoading(false)
}
@@ -1981,7 +2805,7 @@ function ExportPage() {
if (sessionsResult.success && sessionsResult.sessions) {
const rawSessions = sessionsResult.sessions
const baseSessions = toSessionRowsWithContacts(rawSessions, cachedContactMap)
const baseSessions = toSessionRowsWithContacts(rawSessions, cachedContactMap).filter(isExportConversationSession)
const exportableSessionIds = baseSessions
.filter((session) => session.hasSession)
.map((session) => session.username)
@@ -2000,6 +2824,22 @@ function ExportPage() {
acc[sessionId] = { totalMessages: count }
return acc
}, {})
const cachedContentMetrics = Object.entries(cachedContentMetricsPayload?.metrics || {}).reduce<Record<string, SessionContentMetric>>((acc, [sessionId, rawMetric]) => {
if (!exportableSessionIdSet.has(sessionId)) return acc
const metric = pickSessionMediaMetric(rawMetric)
if (!metric) return acc
acc[sessionId] = metric
if (hasCompleteSessionMediaMetric(metric)) {
sessionMediaMetricReadySetRef.current.add(sessionId)
}
return acc
}, {})
const cachedContentMetricReadySessionIds = Object.entries(cachedContentMetrics)
.filter(([, metric]) => hasCompleteSessionMediaMetric(metric))
.map(([sessionId]) => sessionId)
if (cachedContentMetricReadySessionIds.length > 0) {
patchSessionLoadTraceStage(cachedContentMetricReadySessionIds, 'mediaMetrics', 'done')
}
if (isStale()) return
if (Object.keys(cachedMessageCounts).length > 0) {
@@ -2008,6 +2848,9 @@ function ExportPage() {
if (Object.keys(cachedCountAsMetrics).length > 0) {
mergeSessionContentMetrics(cachedCountAsMetrics)
}
if (Object.keys(cachedContentMetrics).length > 0) {
mergeSessionContentMetrics(cachedContentMetrics)
}
setSessions(baseSessions)
sessionsHydratedAtRef.current = Date.now()
void (async () => {
@@ -2027,12 +2870,14 @@ function ExportPage() {
setIsSessionEnriching(true)
void (async () => {
try {
if (detailStatsPriorityRef.current) return
let contactMap = { ...cachedContactMap }
let avatarEntries = { ...cachedAvatarEntries }
let hasFreshNetworkData = false
let hasNetworkContactsSnapshot = false
if (isStale()) return
if (detailStatsPriorityRef.current) return
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS)
if (isStale()) return
@@ -2091,6 +2936,7 @@ function ExportPage() {
if (needsEnrichment.length > 0) {
for (let i = 0; i < needsEnrichment.length; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) {
if (isStale()) return
if (detailStatsPriorityRef.current) return
const batch = needsEnrichment.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE)
if (batch.length === 0) continue
try {
@@ -2152,7 +2998,7 @@ function ExportPage() {
}, contactMap)
if (isStale()) return
const nextSessions = toSessionRowsWithContacts(rawSessions, contactMap)
const nextSessions = toSessionRowsWithContacts(rawSessions, contactMap).filter(isExportConversationSession)
.map((session) => {
const extra = extraContactMap[session.username]
const displayName = extra?.displayName || session.displayName || session.username
@@ -2205,7 +3051,7 @@ function ExportPage() {
} finally {
if (!isStale()) setIsLoading(false)
}
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, syncContactTypeCounts])
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, resetSessionMediaMetricLoader, syncContactTypeCounts])
useEffect(() => {
if (!isExportRoute) return
@@ -2237,10 +3083,22 @@ function ExportPage() {
// 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。
sessionLoadTokenRef.current = Date.now()
sessionCountRequestIdRef.current += 1
snsUserPostCountsHydrationTokenRef.current += 1
if (snsUserPostCountsBatchTimerRef.current) {
window.clearTimeout(snsUserPostCountsBatchTimerRef.current)
snsUserPostCountsBatchTimerRef.current = null
}
setIsSessionEnriching(false)
setIsLoadingSessionCounts(false)
setSnsUserPostCountsStatus(prev => (prev === 'loading' ? 'idle' : prev))
}, [isExportRoute])
useEffect(() => {
if (activeTab === 'official') {
setActiveTab('private')
}
}, [activeTab])
useEffect(() => {
activeTabRef.current = activeTab
}, [activeTab])
@@ -3280,9 +4138,11 @@ function ExportPage() {
const activeTabLabel = useMemo(() => {
if (activeTab === 'private') return '私聊'
if (activeTab === 'group') return '群聊'
if (activeTab === 'former_friend') return '曾经的好友'
return '公众号'
return '曾经的好友'
}, [activeTab])
const shouldShowSnsColumn = useMemo(() => (
activeTab === 'private' || activeTab === 'former_friend'
), [activeTab])
const sessionRowByUsername = useMemo(() => {
const map = new Map<string, SessionRow>()
@@ -3336,11 +4196,297 @@ function ExportPage() {
return indexedContacts.map(item => item.contact)
}, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername])
const keywordMatchedContactUsernameSet = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase()
const matched = new Set<string>()
for (const contact of contactsList) {
if (!contact?.username) continue
if (!keyword) {
matched.add(contact.username)
continue
}
if (
(contact.displayName || '').toLowerCase().includes(keyword) ||
(contact.remark || '').toLowerCase().includes(keyword) ||
(contact.nickname || '').toLowerCase().includes(keyword) ||
(contact.alias || '').toLowerCase().includes(keyword) ||
contact.username.toLowerCase().includes(keyword)
) {
matched.add(contact.username)
}
}
return matched
}, [contactsList, searchKeyword])
const loadDetailTargetsByTab = useMemo(() => {
const targets: Record<ConversationTab, string[]> = {
private: [],
group: [],
official: [],
former_friend: []
}
for (const session of sessions) {
if (!session.hasSession) continue
if (!keywordMatchedContactUsernameSet.has(session.username)) continue
targets[session.kind].push(session.username)
}
return targets
}, [keywordMatchedContactUsernameSet, sessions])
const formatLoadDetailTime = useCallback((value?: number): string => {
if (!value || !Number.isFinite(value)) return '--'
return new Date(value).toLocaleTimeString('zh-CN', { hour12: false })
}, [])
const getLoadDetailStatusLabel = useCallback((loaded: number, total: number, hasStarted: boolean): string => {
if (total <= 0) return '待加载'
if (loaded >= total) return `已完成 ${total}`
if (hasStarted) return `加载中 ${loaded}/${total}`
return '待加载'
}, [])
const summarizeLoadTraceForTab = useCallback((
sessionIds: string[],
stageKey: keyof SessionLoadTraceState
): SessionLoadStageSummary => {
const total = sessionIds.length
let loaded = 0
let hasStarted = false
let earliestStart: number | undefined
let latestFinish: number | undefined
let latestProgressAt: number | undefined
for (const sessionId of sessionIds) {
const stage = sessionLoadTraceMap[sessionId]?.[stageKey]
if (stage?.status === 'done') {
loaded += 1
if (typeof stage.finishedAt === 'number') {
latestProgressAt = latestProgressAt === undefined
? stage.finishedAt
: Math.max(latestProgressAt, stage.finishedAt)
}
}
if (stage?.status === 'loading' || stage?.status === 'failed' || typeof stage?.startedAt === 'number') {
hasStarted = true
}
if (typeof stage?.startedAt === 'number') {
earliestStart = earliestStart === undefined
? stage.startedAt
: Math.min(earliestStart, stage.startedAt)
}
if (typeof stage?.finishedAt === 'number') {
latestFinish = latestFinish === undefined
? stage.finishedAt
: Math.max(latestFinish, stage.finishedAt)
}
}
return {
total,
loaded,
statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted),
startedAt: earliestStart,
finishedAt: loaded >= total ? latestFinish : undefined,
latestProgressAt
}
}, [getLoadDetailStatusLabel, sessionLoadTraceMap])
const createNotApplicableLoadSummary = useCallback((): SessionLoadStageSummary => {
return {
total: 0,
loaded: 0,
statusLabel: '不适用'
}
}, [])
const sessionLoadDetailRows = useMemo(() => {
const tabOrder: ConversationTab[] = ['private', 'group', 'former_friend']
return tabOrder.map((tab) => {
const sessionIds = loadDetailTargetsByTab[tab] || []
const snsSessionIds = sessionIds.filter((sessionId) => isSingleContactSession(sessionId))
const snsPostCounts = tab === 'private'
? summarizeLoadTraceForTab(snsSessionIds, 'snsPostCounts')
: createNotApplicableLoadSummary()
return {
tab,
label: conversationTabLabels[tab],
messageCount: summarizeLoadTraceForTab(sessionIds, 'messageCount'),
mediaMetrics: summarizeLoadTraceForTab(sessionIds, 'mediaMetrics'),
snsPostCounts
}
})
}, [createNotApplicableLoadSummary, loadDetailTargetsByTab, summarizeLoadTraceForTab])
const formatLoadDetailPulseTime = useCallback((value?: number): string => {
if (!value || !Number.isFinite(value)) return '--'
return new Date(value).toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}, [])
useEffect(() => {
const previousSnapshot = sessionLoadProgressSnapshotRef.current
const nextSnapshot: Record<string, { loaded: number; total: number }> = {}
const resetKeys: string[] = []
const updates: Array<{ key: string; at: number; delta: number }> = []
const stageKeys: Array<keyof SessionLoadTraceState> = ['messageCount', 'mediaMetrics', 'snsPostCounts']
for (const row of sessionLoadDetailRows) {
for (const stageKey of stageKeys) {
const summary = row[stageKey]
const key = `${stageKey}:${row.tab}`
const loaded = Number.isFinite(summary.loaded) ? Math.max(0, Math.floor(summary.loaded)) : 0
const total = Number.isFinite(summary.total) ? Math.max(0, Math.floor(summary.total)) : 0
nextSnapshot[key] = { loaded, total }
const previous = previousSnapshot[key]
if (!previous || previous.total !== total || loaded < previous.loaded) {
resetKeys.push(key)
continue
}
if (loaded > previous.loaded) {
updates.push({
key,
at: summary.latestProgressAt || Date.now(),
delta: loaded - previous.loaded
})
}
}
}
sessionLoadProgressSnapshotRef.current = nextSnapshot
if (resetKeys.length === 0 && updates.length === 0) return
setSessionLoadProgressPulseMap(prev => {
let changed = false
const next = { ...prev }
for (const key of resetKeys) {
if (!(key in next)) continue
delete next[key]
changed = true
}
for (const update of updates) {
const previous = next[update.key]
if (previous && previous.at === update.at && previous.delta === update.delta) continue
next[update.key] = { at: update.at, delta: update.delta }
changed = true
}
return changed ? next : prev
})
}, [sessionLoadDetailRows])
useEffect(() => {
contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' })
setIsContactsListAtTop(true)
}, [activeTab, searchKeyword])
const collectVisibleSessionMetricTargets = useCallback((sourceContacts: ContactInfo[]): string[] => {
if (sourceContacts.length === 0) return []
const startCandidate = sessionMediaMetricVisibleRangeRef.current.startIndex
const endCandidate = sessionMediaMetricVisibleRangeRef.current.endIndex
const startIndex = Math.max(0, Math.min(sourceContacts.length - 1, startCandidate >= 0 ? startCandidate : 0))
const visibleEnd = endCandidate >= startIndex
? endCandidate
: Math.min(sourceContacts.length - 1, startIndex + 9)
const endIndex = Math.max(startIndex, Math.min(sourceContacts.length - 1, visibleEnd + SESSION_MEDIA_METRIC_PREFETCH_ROWS))
const sessionIds: string[] = []
for (let index = startIndex; index <= endIndex; index += 1) {
const contact = sourceContacts[index]
if (!contact?.username) continue
const mappedSession = sessionRowByUsername.get(contact.username)
if (!mappedSession?.hasSession) continue
sessionIds.push(contact.username)
}
return sessionIds
}, [sessionRowByUsername])
const handleContactsRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => {
const startIndex = Number.isFinite(range?.startIndex) ? Math.max(0, Math.floor(range.startIndex)) : 0
const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex
sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex }
if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
if (visibleTargets.length === 0) return
enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
scheduleSessionMediaMetricWorker()
}, [collectVisibleSessionMetricTargets, enqueueSessionMediaMetricRequests, filteredContacts, isSessionCountStageReady, scheduleSessionMediaMetricWorker])
useEffect(() => {
if (!isSessionCountStageReady || filteredContacts.length === 0) return
const runId = sessionMediaMetricRunIdRef.current
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
if (visibleTargets.length > 0) {
enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
scheduleSessionMediaMetricWorker()
}
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
sessionMediaMetricBackgroundFeedTimerRef.current = null
}
const visibleTargetSet = new Set(visibleTargets)
let cursor = 0
const feedNext = () => {
if (runId !== sessionMediaMetricRunIdRef.current) return
if (isLoadingSessionCountsRef.current) return
const batchIds: string[] = []
while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) {
const contact = filteredContacts[cursor]
cursor += 1
if (!contact?.username) continue
if (visibleTargetSet.has(contact.username)) continue
const mappedSession = sessionRowByUsername.get(contact.username)
if (!mappedSession?.hasSession) continue
batchIds.push(contact.username)
}
if (batchIds.length > 0) {
enqueueSessionMediaMetricRequests(batchIds)
scheduleSessionMediaMetricWorker()
}
if (cursor < filteredContacts.length) {
sessionMediaMetricBackgroundFeedTimerRef.current = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS)
}
}
feedNext()
return () => {
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
sessionMediaMetricBackgroundFeedTimerRef.current = null
}
}
}, [
collectVisibleSessionMetricTargets,
enqueueSessionMediaMetricRequests,
filteredContacts,
isSessionCountStageReady,
scheduleSessionMediaMetricWorker,
sessionRowByUsername
])
useEffect(() => {
return () => {
snsUserPostCountsHydrationTokenRef.current += 1
if (snsUserPostCountsBatchTimerRef.current) {
window.clearTimeout(snsUserPostCountsBatchTimerRef.current)
snsUserPostCountsBatchTimerRef.current = null
}
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
sessionMediaMetricBackgroundFeedTimerRef.current = null
}
if (sessionMediaMetricPersistTimerRef.current) {
window.clearTimeout(sessionMediaMetricPersistTimerRef.current)
sessionMediaMetricPersistTimerRef.current = null
}
void flushSessionMediaMetricCache()
}
}, [flushSessionMediaMetricCache])
const contactByUsername = useMemo(() => {
const map = new Map<string, ContactInfo>()
for (const contact of contactsList) {
@@ -3358,6 +4504,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,
@@ -3399,6 +4566,11 @@ function ExportPage() {
const loadSessionDetail = useCallback(async (sessionId: string) => {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return
const preciseCacheKey = `${exportCacheScopeRef.current}::${normalizedSessionId}`
detailStatsPriorityRef.current = true
sessionCountRequestIdRef.current += 1
setIsLoadingSessionCounts(false)
const requestSeq = ++detailRequestSeqRef.current
const mappedSession = sessionRowByUsername.get(normalizedSessionId)
@@ -3510,19 +4682,13 @@ function ExportPage() {
}
try {
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true }
)
])
if (requestSeq !== detailRequestSeqRef.current) return
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) {
const detail = extraResultSettled.value.detail
if (detail) {
const extraPromise = window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId)
void (async () => {
try {
const extraResult = await extraPromise
if (requestSeq !== detailRequestSeqRef.current) return
if (!extraResult.success || !extraResult.detail) return
const detail = extraResult.detail
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
return {
@@ -3532,51 +4698,73 @@ function ExportPage() {
messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : []
}
})
} catch (error) {
console.error('导出页加载会话详情补充信息失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingSessionDetailExtra(false)
}
}
})()
let quickMetric: SessionExportMetric | undefined
let quickCacheMeta: SessionExportCacheMeta | undefined
try {
const quickStatsResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
if (quickStatsResult.success) {
quickMetric = quickStatsResult.data?.[normalizedSessionId] as SessionExportMetric | undefined
quickCacheMeta = quickStatsResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
if (quickMetric) {
applySessionDetailStats(normalizedSessionId, quickMetric, quickCacheMeta, false)
} else if (quickCacheMeta) {
const cacheMeta = quickCacheMeta
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
return {
...prev,
statsUpdatedAt: cacheMeta.updatedAt,
statsStale: cacheMeta.stale
}
})
}
}
} catch (error) {
console.error('导出页读取会话统计缓存失败:', error)
}
let refreshIncludeRelations = false
let shouldRefreshStats = false
if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) {
const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined
const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
refreshIncludeRelations = Boolean(cacheMeta?.includeRelations)
if (metric) {
applySessionDetailStats(normalizedSessionId, metric, cacheMeta, refreshIncludeRelations)
} else if (cacheMeta) {
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
return {
...prev,
relationStatsLoaded: refreshIncludeRelations || prev.relationStatsLoaded,
statsUpdatedAt: cacheMeta.updatedAt,
statsStale: cacheMeta.stale
}
})
}
shouldRefreshStats = Array.isArray(statsResultSettled.value.needsRefresh) &&
statsResultSettled.value.needsRefresh.includes(normalizedSessionId)
}
const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0
const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS
const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
if (shouldRefreshStats) {
if (shouldRunPreciseRefresh) {
setIsRefreshingSessionDetailStats(true)
void (async () => {
try {
// 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。
const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: refreshIncludeRelations, forceRefresh: true, preferAccurateSpecialTypes: true }
{ includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
if (freshResult.success && freshResult.data) {
const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
if (metric) {
applySessionDetailStats(
normalizedSessionId,
metric,
cacheMeta,
refreshIncludeRelations ? true : undefined
)
applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false)
sessionPreciseRefreshAtRef.current[preciseCacheKey] = Date.now()
} else if (cacheMeta) {
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
return {
...prev,
statsUpdatedAt: cacheMeta.updatedAt,
statsStale: cacheMeta.stale
}
})
}
}
} catch (error) {
@@ -3590,7 +4778,6 @@ function ExportPage() {
}
} catch (error) {
console.error('导出页加载会话详情补充统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingSessionDetailExtra(false)
}
@@ -3619,36 +4806,6 @@ function ExportPage() {
if (metric) {
applySessionDetailStats(normalizedSessionId, metric, cacheMeta, true)
}
const needRefresh = relationResult.success &&
Array.isArray(relationResult.needsRefresh) &&
relationResult.needsRefresh.includes(normalizedSessionId)
if (needRefresh) {
setIsRefreshingSessionDetailStats(true)
void (async () => {
try {
const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
if (freshResult.success && freshResult.data) {
const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
if (freshMetric) {
applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true)
}
}
} catch (error) {
console.error('导出页刷新会话关系统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsRefreshingSessionDetailStats(false)
}
}
})()
}
} catch (error) {
console.error('导出页加载会话关系统计失败:', error)
} finally {
@@ -3658,20 +4815,78 @@ function ExportPage() {
}
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
useEffect(() => {
if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return
if (snsUserPostCountsStatus === 'idle') {
void loadSnsUserPostCounts()
}
}, [
loadSnsUserPostCounts,
sessionDetailSupportsSnsTimeline,
showSessionDetailPanel,
snsUserPostCountsStatus
])
useEffect(() => {
if (!isExportRoute || !isSessionCountStageReady) return
if (snsUserPostCountsStatus !== 'idle') return
const timer = window.setTimeout(() => {
void loadSnsUserPostCounts()
}, 260)
return () => window.clearTimeout(timer)
}, [isExportRoute, isSessionCountStageReady, loadSnsUserPostCounts, 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) => {
if (!sessionId) return
detailStatsPriorityRef.current = true
setShowSessionDetailPanel(true)
if (isSingleContactSession(sessionId)) {
void loadSnsUserPostCounts()
}
void loadSessionDetail(sessionId)
}, [loadSessionDetail])
}, [loadSessionDetail, loadSnsUserPostCounts])
useEffect(() => {
if (!showSessionDetailPanel) return
@@ -3684,6 +4899,31 @@ function ExportPage() {
return () => window.removeEventListener('keydown', handleKeyDown)
}, [closeSessionDetailPanel, showSessionDetailPanel])
useEffect(() => {
if (!showSessionLoadDetailModal) return
if (snsUserPostCountsStatus === 'idle') {
void loadSnsUserPostCounts()
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setShowSessionLoadDetailModal(false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [loadSnsUserPostCounts, showSessionLoadDetailModal, snsUserPostCountsStatus])
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)
@@ -3798,6 +5038,22 @@ function ExportPage() {
const taskRunningCount = tasks.filter(task => task.status === 'running').length
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
const hasFilteredContacts = filteredContacts.length > 0
const sessionLoadDetailUpdatedAt = useMemo(() => {
let latest = 0
for (const row of sessionLoadDetailRows) {
const candidateTimes = [
row.messageCount.finishedAt || row.messageCount.startedAt || 0,
row.mediaMetrics.finishedAt || row.mediaMetrics.startedAt || 0,
row.snsPostCounts.finishedAt || row.snsPostCounts.startedAt || 0
]
for (const candidate of candidateTimes) {
if (candidate > latest) {
latest = candidate
}
}
}
return latest
}, [sessionLoadDetailRows])
const closeTaskCenter = useCallback(() => {
setIsTaskCenterOpen(false)
setExpandedPerfTaskId(null)
@@ -3811,15 +5067,50 @@ function ExportPage() {
const checked = canExport && selectedSessions.has(contact.username)
const isRunning = canExport && runningSessionIds.has(contact.username)
const isQueued = canExport && queuedSessionIds.has(contact.username)
const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : ''
const recentExportTimestamp = lastExportBySession[contact.username]
const hasRecentExport = canExport && Boolean(recentExportTimestamp)
const recentExportTime = hasRecentExport ? formatRecentExportTime(recentExportTimestamp, nowTick) : ''
const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
const displayedMessageCount = countedMessages ?? hintedMessages
const mediaMetric = sessionContentMetrics[contact.username]
const messageCountLabel = !canExport
? '--'
: typeof displayedMessageCount === 'number'
? displayedMessageCount.toLocaleString('zh-CN')
: '获取中'
const metricToDisplay = (value: unknown): { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } => {
const normalized = normalizeMessageCount(value)
if (!canExport) return { state: 'na', text: '--' }
if (typeof normalized === 'number') {
return { state: 'value', text: normalized.toLocaleString('zh-CN') }
}
return { state: 'loading' }
}
const emojiMetric = metricToDisplay(mediaMetric?.emojiMessages)
const voiceMetric = metricToDisplay(mediaMetric?.voiceMessages)
const imageMetric = metricToDisplay(mediaMetric?.imageMessages)
const videoMetric = metricToDisplay(mediaMetric?.videoMessages)
const supportsSnsTimeline = isSingleContactSession(contact.username)
const hasSnsCount = Object.prototype.hasOwnProperty.call(snsUserPostCounts, contact.username)
const snsStageStatus = sessionLoadTraceMap[contact.username]?.snsPostCounts?.status
const isSnsCountLoading = (
supportsSnsTimeline &&
!hasSnsCount &&
(
snsStageStatus === 'pending' ||
snsStageStatus === 'loading' ||
snsUserPostCountsStatus === 'loading' ||
snsUserPostCountsStatus === 'idle'
)
)
const snsRawCount = Number(snsUserPostCounts[contact.username] || 0)
const snsCount = Number.isFinite(snsRawCount) ? Math.max(0, Math.floor(snsRawCount)) : 0
const openChatLabel = contact.type === 'friend'
? '打开私聊'
: contact.type === 'group'
? '打开群聊'
: '打开对话'
return (
<div className={`contact-row ${checked ? 'selected' : ''}`}>
<div className="contact-item">
@@ -3851,47 +5142,98 @@ function ExportPage() {
{messageCountLabel}
</strong>
</div>
</div>
<div className="row-action-cell">
<div className="row-action-main">
{canExport && (
<button
className="row-open-chat-btn"
disabled={!canExport}
title={canExport ? '在新窗口打开该会话' : '该联系人暂无会话记录'}
type="button"
className="row-open-chat-link"
title="在新窗口打开该会话"
onClick={() => {
if (!canExport) return
void window.electronAPI.window.openSessionChatWindow(contact.username)
void window.electronAPI.window.openSessionChatWindow(contact.username, {
source: 'export',
initialDisplayName: contact.displayName || contact.username,
initialAvatarUrl: contact.avatarUrl,
initialContactType: contact.type
})
}}
>
<ExternalLink size={13} />
打开对话
{openChatLabel}
</button>
)}
</div>
<div className="row-media-metric">
<strong className="row-media-metric-value">
{emojiMetric.state === 'loading'
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="统计加载中" />
: emojiMetric.text}
</strong>
</div>
<div className="row-media-metric">
<strong className="row-media-metric-value">
{voiceMetric.state === 'loading'
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="统计加载中" />
: voiceMetric.text}
</strong>
</div>
<div className="row-media-metric">
<strong className="row-media-metric-value">
{imageMetric.state === 'loading'
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="统计加载中" />
: imageMetric.text}
</strong>
</div>
<div className="row-media-metric">
<strong className="row-media-metric-value">
{videoMetric.state === 'loading'
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="统计加载中" />
: videoMetric.text}
</strong>
</div>
{shouldShowSnsColumn && (
<div className="row-media-metric">
{supportsSnsTimeline ? (
<button
type="button"
className={`row-sns-metric-btn ${isSnsCountLoading ? 'loading' : ''}`}
title={`查看 ${contact.displayName || contact.username} 的朋友圈`}
onClick={() => openContactSnsTimeline(contact)}
>
{isSnsCountLoading
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="朋友圈统计加载中" />
: hasSnsCount
? `${snsCount.toLocaleString('zh-CN')} 条`
: '--'}
</button>
) : (
<strong className="row-media-metric-value">--</strong>
)}
</div>
)}
<div className="row-action-cell">
<div className="row-action-main">
<button
className={`row-detail-btn ${showSessionDetailPanel && sessionDetail?.wxid === contact.username ? 'active' : ''}`}
onClick={() => openSessionDetail(contact.username)}
>
详情
</button>
<button
className={`row-export-btn ${isRunning ? 'running' : ''} ${!canExport ? 'no-session' : ''}`}
disabled={!canExport || isRunning}
onClick={() => {
if (!matchedSession || !matchedSession.hasSession) return
openSingleExport({
...matchedSession,
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
})
}}
>
{isRunning ? (
<>
<Loader2 size={14} className="spin" />
导出中
</>
) : !canExport ? '暂无会话' : isQueued ? '排队中' : '单会话导出'}
</button>
<div className="row-export-action-stack">
<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>
</div>
{recent && <span className="row-export-time">{recent}</span>}
</div>
</div>
</div>
@@ -3899,15 +5241,21 @@ function ExportPage() {
}, [
lastExportBySession,
nowTick,
openContactSnsTimeline,
openSessionDetail,
openSingleExport,
queuedSessionIds,
runningSessionIds,
selectedSessions,
sessionDetail?.wxid,
sessionContentMetrics,
sessionLoadTraceMap,
sessionMessageCounts,
sessionRowByUsername,
showSessionDetailPanel,
shouldShowSnsColumn,
snsUserPostCounts,
snsUserPostCountsStatus,
toggleSelectSession
])
const handleContactsListWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => {
@@ -4094,6 +5442,14 @@ function ExportPage() {
'你可以先在列表中筛选目标会话,再批量导出,结果会保留每个会话的结构与时间线。'
]}
/>
<button
className="session-load-detail-entry"
type="button"
onClick={() => setShowSessionLoadDetailModal(true)}
>
<ClipboardList size={14} />
<span>数据加载详情</span>
</button>
</div>
<div className="session-table-section" ref={sessionTableSectionRef}>
<div className="session-table-layout">
@@ -4107,9 +5463,6 @@ function ExportPage() {
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}>
群聊 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.group}
</button>
<button className={`tab-btn ${activeTab === 'official' ? 'active' : ''}`} onClick={() => setActiveTab('official')}>
公众号 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.official}
</button>
<button className={`tab-btn ${activeTab === 'former_friend' ? 'active' : ''}`} onClick={() => setActiveTab('former_friend')}>
曾经的好友 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.former_friend}
</button>
@@ -4160,6 +5513,13 @@ function ExportPage() {
<span className="contacts-list-header-main-label">联系人(头像/名称/微信号)</span>
</span>
<span className="contacts-list-header-count">总消息数</span>
<span className="contacts-list-header-media">表情包</span>
<span className="contacts-list-header-media">语音</span>
<span className="contacts-list-header-media">图片</span>
<span className="contacts-list-header-media">视频</span>
{shouldShowSnsColumn && (
<span className="contacts-list-header-media">朋友圈</span>
)}
<span className="contacts-list-header-actions">
{selectedCount > 0 && (
<>
@@ -4237,6 +5597,7 @@ function ExportPage() {
data={filteredContacts}
computeItemKey={(_, contact) => contact.username}
itemContent={renderContactRow}
rangeChanged={handleContactsRangeChanged}
atTopStateChange={setIsContactsListAtTop}
overscan={420}
/>
@@ -4244,6 +5605,147 @@ function ExportPage() {
)}
</div>
{showSessionLoadDetailModal && (
<div
className="session-load-detail-overlay"
onClick={() => setShowSessionLoadDetailModal(false)}
>
<div
className="session-load-detail-modal"
role="dialog"
aria-modal="true"
aria-label="数据加载详情"
onClick={(event) => event.stopPropagation()}
>
<div className="session-load-detail-header">
<div>
<h4>数据加载详情</h4>
<p>
更新时间:
{sessionLoadDetailUpdatedAt > 0
? new Date(sessionLoadDetailUpdatedAt).toLocaleString('zh-CN')
: '暂无'}
</p>
</div>
<button
className="session-load-detail-close"
type="button"
onClick={() => setShowSessionLoadDetailModal(false)}
aria-label="关闭"
>
<X size={16} />
</button>
</div>
<div className="session-load-detail-body">
<section className="session-load-detail-block">
<h5>总消息数</h5>
<div className="session-load-detail-table">
<div className="session-load-detail-row header">
<span>会话类型</span>
<span>加载状态</span>
<span>开始时间</span>
<span>完成时间</span>
</div>
{sessionLoadDetailRows.map((row) => {
const pulse = sessionLoadProgressPulseMap[`messageCount:${row.tab}`]
const isLoading = row.messageCount.statusLabel.startsWith('加载中')
return (
<div className="session-load-detail-row" key={`message-${row.tab}`}>
<span>{row.label}</span>
<span className="session-load-detail-status-cell">
<span>{row.messageCount.statusLabel}</span>
{isLoading && (
<Loader2 size={12} className="spin session-load-detail-status-icon" aria-label="加载中" />
)}
{isLoading && pulse && pulse.delta > 0 && (
<span className="session-load-detail-progress-pulse">
{formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条
</span>
)}
</span>
<span>{formatLoadDetailTime(row.messageCount.startedAt)}</span>
<span>{formatLoadDetailTime(row.messageCount.finishedAt)}</span>
</div>
)
})}
</div>
</section>
<section className="session-load-detail-block">
<h5>多媒体统计(表情包/图片/视频/语音)</h5>
<div className="session-load-detail-table">
<div className="session-load-detail-row header">
<span>会话类型</span>
<span>加载状态</span>
<span>开始时间</span>
<span>完成时间</span>
</div>
{sessionLoadDetailRows.map((row) => {
const pulse = sessionLoadProgressPulseMap[`mediaMetrics:${row.tab}`]
const isLoading = row.mediaMetrics.statusLabel.startsWith('加载中')
return (
<div className="session-load-detail-row" key={`media-${row.tab}`}>
<span>{row.label}</span>
<span className="session-load-detail-status-cell">
<span>{row.mediaMetrics.statusLabel}</span>
{isLoading && (
<Loader2 size={12} className="spin session-load-detail-status-icon" aria-label="加载中" />
)}
{isLoading && pulse && pulse.delta > 0 && (
<span className="session-load-detail-progress-pulse">
{formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条
</span>
)}
</span>
<span>{formatLoadDetailTime(row.mediaMetrics.startedAt)}</span>
<span>{formatLoadDetailTime(row.mediaMetrics.finishedAt)}</span>
</div>
)
})}
</div>
</section>
<section className="session-load-detail-block">
<h5>朋友圈条数统计</h5>
<div className="session-load-detail-table">
<div className="session-load-detail-row header">
<span>会话类型</span>
<span>加载状态</span>
<span>开始时间</span>
<span>完成时间</span>
</div>
{sessionLoadDetailRows
.filter((row) => row.tab === 'private')
.map((row) => {
const pulse = sessionLoadProgressPulseMap[`snsPostCounts:${row.tab}`]
const isLoading = row.snsPostCounts.statusLabel.startsWith('加载中')
return (
<div className="session-load-detail-row" key={`sns-count-${row.tab}`}>
<span>{row.label}</span>
<span className="session-load-detail-status-cell">
<span>{row.snsPostCounts.statusLabel}</span>
{isLoading && (
<Loader2 size={12} className="spin session-load-detail-status-icon" aria-label="加载中" />
)}
{isLoading && pulse && pulse.delta > 0 && (
<span className="session-load-detail-progress-pulse">
{formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条
</span>
)}
</span>
<span>{formatLoadDetailTime(row.snsPostCounts.startedAt)}</span>
<span>{formatLoadDetailTime(row.snsPostCounts.finishedAt)}</span>
</div>
)
})}
</div>
</section>
</div>
</div>
</div>
)}
{showSessionDetailPanel && (
<div
className="export-session-detail-overlay"
@@ -4317,6 +5819,21 @@ function ExportPage() {
</button>
</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 className="detail-section">
@@ -4543,6 +6060,125 @@ function ExportPage() {
</aside>
</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>
<div className="sns-dialog-header-actions">
<div className="sns-dialog-rank-switch">
<button
type="button"
className={`sns-dialog-rank-btn ${sessionSnsRankMode === 'likes' ? 'active' : ''}`}
onClick={() => toggleSessionSnsRankMode('likes')}
>
点赞排行
</button>
<button
type="button"
className={`sns-dialog-rank-btn ${sessionSnsRankMode === 'comments' ? 'active' : ''}`}
onClick={() => toggleSessionSnsRankMode('comments')}
>
评论排行
</button>
{sessionSnsRankMode && (
<div
className="sns-dialog-rank-panel"
role="region"
aria-label={sessionSnsRankMode === 'likes' ? '点赞排行' : '评论排行'}
>
{sessionSnsActiveRankings.length === 0 ? (
<div className="sns-dialog-rank-empty">
{sessionSnsRankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'}
</div>
) : (
sessionSnsActiveRankings.slice(0, 15).map((item, index) => (
<div className="sns-dialog-rank-row" key={`${sessionSnsRankMode}-${item.name}`}>
<span className="sns-dialog-rank-index">{index + 1}</span>
<span className="sns-dialog-rank-name" title={item.name}>{item.name}</span>
<span className="sns-dialog-rank-count">
{item.count.toLocaleString('zh-CN')}
{sessionSnsRankMode === 'likes' ? '次' : '条'}
</span>
</div>
))
)}
</div>
)}
</div>
<button className="close-btn" type="button" onClick={closeSessionSnsTimeline}>
<X size={16} />
</button>
</div>
</div>
<div className="sns-dialog-tip">
在微信桌面客户端中打开这个人的朋友圈浏览,可快速把其朋友圈同步到这里。若你在乎这个人,一定要试试~
</div>
<div className="sns-dialog-body">
{sessionSnsTimelinePosts.length > 0 && (
<div className="posts-list author-timeline-posts-list export-session-sns-posts-list">
{sessionSnsTimelinePosts.map((post) => (
<SnsPostItem
key={post.id}
post={post}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={() => {}}
hideAuthorMeta
/>
))}
</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>
+207
View File
@@ -179,6 +179,30 @@
flex-shrink: 0;
}
.author-trigger-btn {
background: transparent;
border: none;
padding: 0;
margin: 0;
color: inherit;
cursor: pointer;
}
.avatar-trigger {
border-radius: 12px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.1);
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
}
.post-content-col {
flex: 1;
min-width: 0;
@@ -206,6 +230,30 @@
margin-bottom: 2px;
}
.author-name-trigger {
align-self: flex-start;
border-radius: 6px;
margin-bottom: 2px;
.author-name {
transition: color 0.15s ease, text-decoration-color 0.15s ease;
text-decoration: underline;
text-decoration-color: transparent;
text-underline-offset: 2px;
margin-bottom: 0;
}
&:hover .author-name {
color: var(--primary);
text-decoration-color: currentColor;
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
}
.post-time {
font-size: 12px;
color: var(--text-tertiary);
@@ -219,6 +267,13 @@
flex-shrink: 0;
}
.post-time-standalone {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.2;
padding-top: 2px;
}
.debug-btn {
opacity: 0;
transition: opacity 0.2s;
@@ -1043,6 +1098,14 @@
}
}
.contact-count-progress {
padding: 8px 16px 10px;
font-size: 11px;
color: var(--text-tertiary);
border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 70%, transparent);
font-variant-numeric: tabular-nums;
}
.contact-list-scroll {
flex: 1;
overflow-y: auto;
@@ -1120,6 +1183,40 @@
text-overflow: ellipsis;
}
}
.contact-post-count-wrap {
margin-left: 8px;
min-width: 46px;
display: flex;
justify-content: flex-end;
align-items: center;
flex-shrink: 0;
}
.contact-post-count {
font-size: 12px;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.contact-post-count-loading {
color: var(--text-tertiary);
display: inline-flex;
align-items: center;
justify-content: center;
.spinning {
animation: spin 0.8s linear infinite;
}
}
&.selected {
.contact-post-count {
color: var(--primary);
font-weight: 600;
}
}
}
}
@@ -1317,6 +1414,116 @@
}
}
.author-timeline-dialog {
background: var(--sns-card-bg);
border-radius: var(--sns-border-radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
width: min(860px, 94vw);
max-height: 86vh;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
overflow: hidden;
animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.author-timeline-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
display: flex;
align-items: flex-start;
justify-content: space-between;
.close-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 6px;
border-radius: 6px;
display: flex;
&:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
}
}
.author-timeline-meta {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.author-timeline-meta-text {
min-width: 0;
h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary);
}
}
.author-timeline-username {
margin-top: 2px;
font-size: 12px;
color: var(--text-secondary);
}
.author-timeline-stats {
margin-top: 4px;
font-size: 12px;
color: var(--text-secondary);
}
.author-timeline-body {
padding: 16px;
overflow-y: auto;
min-height: 180px;
max-height: calc(86vh - 96px);
}
.author-timeline-posts-list {
gap: 16px;
}
.author-timeline-loading {
margin-top: 12px;
}
.author-timeline-empty {
padding: 42px 10px 30px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
}
.author-timeline-load-more {
display: block;
margin: 12px auto 2px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: 999px;
padding: 7px 16px;
font-size: 13px;
cursor: pointer;
&:hover:not(:disabled) {
color: var(--primary);
border-color: var(--primary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
@keyframes slide-up-fade {
from {
opacity: 0;
+598 -18
View File
@@ -5,17 +5,31 @@ import './SnsPage.scss'
import { SnsPost } from '../types/sns'
import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel'
import { Avatar } from '../components/Avatar'
import * as configService from '../services/config'
const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const SNS_PAGE_CACHE_POST_LIMIT = 200
const SNS_PAGE_CACHE_SCOPE_FALLBACK = '__default__'
const CONTACT_COUNT_SORT_DEBOUNCE_MS = 200
const CONTACT_COUNT_BATCH_SIZE = 10
type ContactPostCountStatus = 'idle' | 'loading' | 'ready'
interface Contact {
username: string
displayName: string
avatarUrl?: string
type?: 'friend' | 'former_friend' | 'sns_only'
lastSessionTimestamp?: number
postCount?: number
postCountStatus?: ContactPostCountStatus
}
interface ContactsCountProgress {
resolved: number
total: number
running: boolean
}
interface SnsOverviewStats {
@@ -28,6 +42,12 @@ interface SnsOverviewStats {
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
interface AuthorTimelineTarget {
username: string
nickname: string
avatarUrl?: string
}
export default function SnsPage() {
const [posts, setPosts] = useState<SnsPost[]>([])
const [loading, setLoading] = useState(false)
@@ -51,10 +71,22 @@ export default function SnsPage() {
const [contacts, setContacts] = useState<Contact[]>([])
const [contactSearch, setContactSearch] = useState('')
const [contactsLoading, setContactsLoading] = useState(false)
const [contactsCountProgress, setContactsCountProgress] = useState<ContactsCountProgress>({
resolved: 0,
total: 0,
running: false
})
// UI states
const [showJumpDialog, setShowJumpDialog] = useState(false)
const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
const [authorTimelineTarget, setAuthorTimelineTarget] = useState<AuthorTimelineTarget | null>(null)
const [authorTimelinePosts, setAuthorTimelinePosts] = useState<SnsPost[]>([])
const [authorTimelineLoading, setAuthorTimelineLoading] = useState(false)
const [authorTimelineLoadingMore, setAuthorTimelineLoadingMore] = useState(false)
const [authorTimelineHasMore, setAuthorTimelineHasMore] = useState(false)
const [authorTimelineTotalPosts, setAuthorTimelineTotalPosts] = useState<number | null>(null)
const [authorTimelineStatsLoading, setAuthorTimelineStatsLoading] = useState(false)
// 导出相关状态
const [showExportDialog, setShowExportDialog] = useState(false)
@@ -81,19 +113,30 @@ export default function SnsPage() {
const [hasNewer, setHasNewer] = useState(false)
const [loadingNewer, setLoadingNewer] = useState(false)
const postsRef = useRef<SnsPost[]>([])
const contactsRef = useRef<Contact[]>([])
const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats)
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
const selectedUsernamesRef = useRef<string[]>(selectedUsernames)
const searchKeywordRef = useRef(searchKeyword)
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
const cacheScopeKeyRef = useRef('')
const snsUserPostCountsCacheScopeKeyRef = useRef('')
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const contactsLoadTokenRef = useRef(0)
const contactsCountHydrationTokenRef = useRef(0)
const contactsCountBatchTimerRef = useRef<number | null>(null)
const authorTimelinePostsRef = useRef<SnsPost[]>([])
const authorTimelineLoadingRef = useRef(false)
const authorTimelineRequestTokenRef = useRef(0)
const authorTimelineStatsTokenRef = useRef(0)
// Sync posts ref
useEffect(() => {
postsRef.current = posts
}, [posts])
useEffect(() => {
contactsRef.current = contacts
}, [contacts])
useEffect(() => {
overviewStatsRef.current = overviewStats
}, [overviewStats])
@@ -109,6 +152,9 @@ export default function SnsPage() {
useEffect(() => {
jumpTargetDateRef.current = jumpTargetDate
}, [jumpTargetDate])
useEffect(() => {
authorTimelinePostsRef.current = authorTimelinePosts
}, [authorTimelinePosts])
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
useLayoutEffect(() => {
const snapshot = scrollAdjustmentRef.current;
@@ -132,6 +178,43 @@ export default function SnsPage() {
return `${year}-${month}-${day}`
}
const decodeHtmlEntities = (text: string): string => {
if (!text) return ''
return text
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.trim()
}
const normalizePostCount = useCallback((value: unknown): number => {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return 0
return Math.max(0, Math.floor(numeric))
}, [])
const compareContactsForRanking = useCallback((a: Contact, b: Contact): number => {
const aReady = a.postCountStatus === 'ready'
const bReady = b.postCountStatus === 'ready'
if (aReady && bReady) {
const countDiff = normalizePostCount(b.postCount) - normalizePostCount(a.postCount)
if (countDiff !== 0) return countDiff
} else if (aReady !== bReady) {
return aReady ? -1 : 1
}
const tsDiff = Number(b.lastSessionTimestamp || 0) - Number(a.lastSessionTimestamp || 0)
if (tsDiff !== 0) return tsDiff
return (a.displayName || a.username).localeCompare((b.displayName || b.username), 'zh-Hans-CN')
}, [normalizePostCount])
const sortContactsForRanking = useCallback((input: Contact[]): Contact[] => {
return [...input].sort(compareContactsForRanking)
}, [compareContactsForRanking])
const isDefaultViewNow = useCallback(() => {
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
}, [])
@@ -144,6 +227,21 @@ export default function SnsPage() {
return scopeKey
}, [])
const ensureSnsUserPostCountsCacheScopeKey = useCallback(async () => {
if (snsUserPostCountsCacheScopeKeyRef.current) return snsUserPostCountsCacheScopeKeyRef.current
const [wxidRaw, dbPathRaw] = await Promise.all([
configService.getMyWxid(),
configService.getDbPath()
])
const wxid = String(wxidRaw || '').trim()
const dbPath = String(dbPathRaw || '').trim()
const scopeKey = (dbPath || wxid)
? `${dbPath}::${wxid}`
: 'default'
snsUserPostCountsCacheScopeKeyRef.current = scopeKey
return scopeKey
}, [])
const persistSnsPageCache = useCallback(async (patch?: { posts?: SnsPost[]; overviewStats?: SnsOverviewStats }) => {
if (!isDefaultViewNow()) return
try {
@@ -275,8 +373,7 @@ export default function SnsPage() {
if (overviewStatsStatus === 'loading') {
return '统计中...'
}
const myPostsLabel = overviewStats.myPosts === null ? '--' : String(overviewStats.myPosts)
return `共 ${overviewStats.totalPosts} 条 | 我的朋友圈 ${myPostsLabel} 条 | ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} | ${overviewStats.totalFriends} 位好友`
return `共 ${overviewStats.totalPosts} 条 | ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} | ${overviewStats.totalFriends} 位好友`
}
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
@@ -390,31 +487,235 @@ export default function SnsPage() {
}
}, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedUsernames])
// Load Contacts(仅加载好友/曾经好友,不再统计朋友圈条数)
const stopContactsCountHydration = useCallback((resetProgress = false) => {
contactsCountHydrationTokenRef.current += 1
if (contactsCountBatchTimerRef.current) {
window.clearTimeout(contactsCountBatchTimerRef.current)
contactsCountBatchTimerRef.current = null
}
if (resetProgress) {
setContactsCountProgress({
resolved: 0,
total: 0,
running: false
})
} else {
setContactsCountProgress((prev) => ({ ...prev, running: false }))
}
}, [])
const hydrateContactPostCounts = useCallback(async (
usernames: string[],
options?: { force?: boolean; readyUsernames?: Set<string> }
) => {
const force = options?.force === true
const targets = usernames
.map((username) => String(username || '').trim())
.filter(Boolean)
stopContactsCountHydration(true)
if (targets.length === 0) return
const readySet = options?.readyUsernames || new Set(
contactsRef.current
.filter((contact) => contact.postCountStatus === 'ready' && typeof contact.postCount === 'number')
.map((contact) => contact.username)
)
const pendingTargets = force ? targets : targets.filter((username) => !readySet.has(username))
const runToken = ++contactsCountHydrationTokenRef.current
const totalTargets = targets.length
const targetSet = new Set(pendingTargets)
if (pendingTargets.length > 0) {
setContacts((prev) => {
let changed = false
const next = prev.map((contact) => {
if (!targetSet.has(contact.username)) return contact
if (contact.postCountStatus === 'loading' && typeof contact.postCount !== 'number') return contact
changed = true
return {
...contact,
postCount: force ? undefined : contact.postCount,
postCountStatus: 'loading' as ContactPostCountStatus
}
})
return changed ? sortContactsForRanking(next) : prev
})
}
const preResolved = Math.max(0, totalTargets - pendingTargets.length)
setContactsCountProgress({
resolved: preResolved,
total: totalTargets,
running: pendingTargets.length > 0
})
if (pendingTargets.length === 0) return
let normalizedCounts: Record<string, number> = {}
try {
const result = await window.electronAPI.sns.getUserPostCounts()
if (runToken !== contactsCountHydrationTokenRef.current) return
if (result.success && result.counts) {
normalizedCounts = Object.fromEntries(
Object.entries(result.counts).map(([username, value]) => [username, normalizePostCount(value)])
)
void (async () => {
try {
const scopeKey = await ensureSnsUserPostCountsCacheScopeKey()
await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts)
} catch (cacheError) {
console.error('Failed to persist SNS user post counts cache:', cacheError)
}
})()
}
} catch (error) {
console.error('Failed to load contact post counts:', error)
}
let resolved = preResolved
let cursor = 0
const applyBatch = () => {
if (runToken !== contactsCountHydrationTokenRef.current) return
const batch = pendingTargets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE)
if (batch.length === 0) {
setContactsCountProgress({
resolved: totalTargets,
total: totalTargets,
running: false
})
contactsCountBatchTimerRef.current = null
return
}
const batchSet = new Set(batch)
setContacts((prev) => {
let changed = false
const next = prev.map((contact) => {
if (!batchSet.has(contact.username)) return contact
const nextCount = normalizePostCount(normalizedCounts[contact.username])
if (contact.postCountStatus === 'ready' && contact.postCount === nextCount) return contact
changed = true
return {
...contact,
postCount: nextCount,
postCountStatus: 'ready' as ContactPostCountStatus
}
})
return changed ? sortContactsForRanking(next) : prev
})
resolved += batch.length
cursor += batch.length
setContactsCountProgress({
resolved,
total: totalTargets,
running: resolved < totalTargets
})
if (cursor < totalTargets) {
contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS)
} else {
contactsCountBatchTimerRef.current = null
}
}
applyBatch()
}, [normalizePostCount, sortContactsForRanking, stopContactsCountHydration])
// Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序)
const loadContacts = useCallback(async () => {
const requestToken = ++contactsLoadTokenRef.current
stopContactsCountHydration(true)
setContactsLoading(true)
try {
const contactsResult = await window.electronAPI.chat.getContacts()
const snsPostCountsScopeKey = await ensureSnsUserPostCountsCacheScopeKey()
const [cachedPostCountsItem, cachedContactsItem, cachedAvatarItem] = await Promise.all([
configService.getExportSnsUserPostCountsCache(snsPostCountsScopeKey),
configService.getContactsListCache(snsPostCountsScopeKey),
configService.getContactsAvatarCache(snsPostCountsScopeKey)
])
const cachedPostCounts = cachedPostCountsItem?.counts || {}
const cachedAvatarMap = cachedAvatarItem?.avatars || {}
const cachedContacts = (cachedContactsItem?.contacts || [])
.filter((contact) => contact.type === 'friend' || contact.type === 'former_friend')
.map((contact) => {
const cachedCount = cachedPostCounts[contact.username]
const hasCachedCount = typeof cachedCount === 'number' && Number.isFinite(cachedCount)
return {
username: contact.username,
displayName: contact.displayName || contact.username,
avatarUrl: cachedAvatarMap[contact.username]?.avatarUrl,
type: (contact.type === 'former_friend' ? 'former_friend' : 'friend') as 'friend' | 'former_friend',
lastSessionTimestamp: 0,
postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined,
postCountStatus: hasCachedCount ? 'ready' as ContactPostCountStatus : 'idle' as ContactPostCountStatus
}
})
if (requestToken !== contactsLoadTokenRef.current) return
if (cachedContacts.length > 0) {
const cachedContactsSorted = sortContactsForRanking(cachedContacts)
setContacts(cachedContactsSorted)
setContactsLoading(false)
const cachedReadyCount = cachedContactsSorted.filter(contact => contact.postCountStatus === 'ready').length
setContactsCountProgress({
resolved: cachedReadyCount,
total: cachedContactsSorted.length,
running: cachedReadyCount < cachedContactsSorted.length
})
}
const [contactsResult, sessionsResult] = await Promise.all([
window.electronAPI.chat.getContacts(),
window.electronAPI.chat.getSessions()
])
const contactMap = new Map<string, Contact>()
const sessionTimestampMap = new Map<string, number>()
if (sessionsResult.success && Array.isArray(sessionsResult.sessions)) {
for (const session of sessionsResult.sessions) {
const username = String(session?.username || '').trim()
if (!username) continue
const ts = Math.max(
Number(session?.sortTimestamp || 0),
Number(session?.lastTimestamp || 0)
)
const prevTs = Number(sessionTimestampMap.get(username) || 0)
if (ts > prevTs) {
sessionTimestampMap.set(username, ts)
}
}
}
if (contactsResult.success && contactsResult.contacts) {
for (const c of contactsResult.contacts) {
if (c.type === 'friend' || c.type === 'former_friend') {
const cachedCount = cachedPostCounts[c.username]
const hasCachedCount = typeof cachedCount === 'number' && Number.isFinite(cachedCount)
contactMap.set(c.username, {
username: c.username,
displayName: c.displayName,
avatarUrl: c.avatarUrl,
type: c.type === 'former_friend' ? 'former_friend' : 'friend'
type: c.type === 'former_friend' ? 'former_friend' : 'friend',
lastSessionTimestamp: Number(sessionTimestampMap.get(c.username) || 0),
postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined,
postCountStatus: hasCachedCount ? 'ready' : 'idle'
})
}
}
}
let contactsList = Array.from(contactMap.values())
let contactsList = sortContactsForRanking(Array.from(contactMap.values()))
if (requestToken !== contactsLoadTokenRef.current) return
setContacts(contactsList)
const readyUsernames = new Set(
contactsList
.filter((contact) => contact.postCountStatus === 'ready' && typeof contact.postCount === 'number')
.map((contact) => contact.username)
)
void hydrateContactPostCounts(
contactsList.map(contact => contact.username),
{ readyUsernames }
)
const allUsernames = contactsList.map(c => c.username)
@@ -422,7 +723,7 @@ export default function SnsPage() {
if (allUsernames.length > 0) {
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
if (enriched.success && enriched.contacts) {
contactsList = contactsList.map(contact => {
contactsList = contactsList.map((contact) => {
const extra = enriched.contacts?.[contact.username]
if (!extra) return contact
return {
@@ -432,19 +733,178 @@ export default function SnsPage() {
}
})
if (requestToken !== contactsLoadTokenRef.current) return
setContacts(contactsList)
setContacts((prev) => {
const prevMap = new Map(prev.map((contact) => [contact.username, contact]))
const merged = contactsList.map((contact) => {
const previous = prevMap.get(contact.username)
return {
...contact,
lastSessionTimestamp: previous?.lastSessionTimestamp ?? contact.lastSessionTimestamp,
postCount: previous?.postCount,
postCountStatus: previous?.postCountStatus ?? contact.postCountStatus
}
})
return sortContactsForRanking(merged)
})
}
}
} catch (error) {
if (requestToken !== contactsLoadTokenRef.current) return
console.error('Failed to load contacts:', error)
stopContactsCountHydration(true)
} finally {
if (requestToken === contactsLoadTokenRef.current) {
setContactsLoading(false)
}
}
}, [ensureSnsUserPostCountsCacheScopeKey, hydrateContactPostCounts, sortContactsForRanking, stopContactsCountHydration])
const closeAuthorTimeline = useCallback(() => {
authorTimelineRequestTokenRef.current += 1
authorTimelineStatsTokenRef.current += 1
authorTimelineLoadingRef.current = false
setAuthorTimelineTarget(null)
setAuthorTimelinePosts([])
setAuthorTimelineLoading(false)
setAuthorTimelineLoadingMore(false)
setAuthorTimelineHasMore(false)
setAuthorTimelineTotalPosts(null)
setAuthorTimelineStatsLoading(false)
}, [])
const loadAuthorTimelineTotalPosts = useCallback(async (target: AuthorTimelineTarget) => {
const requestToken = ++authorTimelineStatsTokenRef.current
setAuthorTimelineStatsLoading(true)
setAuthorTimelineTotalPosts(null)
try {
const result = await window.electronAPI.sns.getUserPostCounts()
if (requestToken !== authorTimelineStatsTokenRef.current) return
if (result.success && result.counts) {
const totalPosts = result.counts[target.username] ?? 0
setAuthorTimelineTotalPosts(Math.max(0, Number(totalPosts || 0)))
} else {
setAuthorTimelineTotalPosts(null)
}
} catch (error) {
console.error('Failed to load author timeline total posts:', error)
if (requestToken === authorTimelineStatsTokenRef.current) {
setAuthorTimelineTotalPosts(null)
}
} finally {
if (requestToken === authorTimelineStatsTokenRef.current) {
setAuthorTimelineStatsLoading(false)
}
}
}, [])
const loadAuthorTimelinePosts = useCallback(async (target: AuthorTimelineTarget, options: { reset?: boolean } = {}) => {
const { reset = false } = options
if (authorTimelineLoadingRef.current) return
authorTimelineLoadingRef.current = true
if (reset) {
setAuthorTimelineLoading(true)
setAuthorTimelineLoadingMore(false)
setAuthorTimelineHasMore(false)
} else {
setAuthorTimelineLoadingMore(true)
}
const requestToken = ++authorTimelineRequestTokenRef.current
try {
const limit = 20
let endTs: number | undefined = undefined
if (!reset && authorTimelinePostsRef.current.length > 0) {
endTs = authorTimelinePostsRef.current[authorTimelinePostsRef.current.length - 1].createTime - 1
}
const result = await window.electronAPI.sns.getTimeline(
limit,
0,
[target.username],
'',
undefined,
endTs
)
if (requestToken !== authorTimelineRequestTokenRef.current) return
if (!result.success || !result.timeline) {
if (reset) {
setAuthorTimelinePosts([])
setAuthorTimelineHasMore(false)
}
return
}
if (reset) {
const sorted = [...result.timeline].sort((a, b) => b.createTime - a.createTime)
setAuthorTimelinePosts(sorted)
setAuthorTimelineHasMore(result.timeline.length >= limit)
return
}
const existingIds = new Set(authorTimelinePostsRef.current.map((p) => p.id))
const uniqueOlder = result.timeline.filter((p) => !existingIds.has(p.id))
if (uniqueOlder.length > 0) {
const merged = [...authorTimelinePostsRef.current, ...uniqueOlder].sort((a, b) => b.createTime - a.createTime)
setAuthorTimelinePosts(merged)
}
if (result.timeline.length < limit) {
setAuthorTimelineHasMore(false)
}
} catch (error) {
console.error('Failed to load author timeline:', error)
if (requestToken === authorTimelineRequestTokenRef.current && reset) {
setAuthorTimelinePosts([])
setAuthorTimelineHasMore(false)
}
} finally {
if (requestToken === authorTimelineRequestTokenRef.current) {
authorTimelineLoadingRef.current = false
setAuthorTimelineLoading(false)
setAuthorTimelineLoadingMore(false)
}
}
}, [])
const openAuthorTimeline = useCallback((post: SnsPost) => {
authorTimelineRequestTokenRef.current += 1
authorTimelineLoadingRef.current = false
const target = {
username: post.username,
nickname: post.nickname,
avatarUrl: post.avatarUrl
}
setAuthorTimelineTarget(target)
setAuthorTimelinePosts([])
setAuthorTimelineHasMore(false)
setAuthorTimelineTotalPosts(null)
void loadAuthorTimelinePosts(target, { reset: true })
void loadAuthorTimelineTotalPosts(target)
}, [loadAuthorTimelinePosts, loadAuthorTimelineTotalPosts])
const loadMoreAuthorTimeline = useCallback(() => {
if (!authorTimelineTarget || authorTimelineLoading || authorTimelineLoadingMore || !authorTimelineHasMore) return
void loadAuthorTimelinePosts(authorTimelineTarget, { reset: false })
}, [authorTimelineHasMore, authorTimelineLoading, authorTimelineLoadingMore, authorTimelineTarget, loadAuthorTimelinePosts])
const handlePostDelete = useCallback((postId: string, username: string) => {
setPosts(prev => {
const next = prev.filter(p => p.id !== postId)
void persistSnsPageCache({ posts: next })
return next
})
setAuthorTimelinePosts(prev => prev.filter(p => p.id !== postId))
if (authorTimelineTarget && authorTimelineTarget.username === username) {
setAuthorTimelineTotalPosts(prev => prev === null ? null : Math.max(0, prev - 1))
}
void loadOverviewStats()
}, [authorTimelineTarget, loadOverviewStats, persistSnsPageCache])
// Initial Load & Listeners
useEffect(() => {
void hydrateSnsPageCache()
@@ -452,10 +912,23 @@ export default function SnsPage() {
loadOverviewStats()
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats])
useEffect(() => {
return () => {
contactsCountHydrationTokenRef.current += 1
if (contactsCountBatchTimerRef.current) {
window.clearTimeout(contactsCountBatchTimerRef.current)
contactsCountBatchTimerRef.current = null
}
}
}, [])
useEffect(() => {
const handleChange = () => {
cacheScopeKeyRef.current = ''
snsUserPostCountsCacheScopeKeyRef.current = ''
// wxid changed, reset everything
stopContactsCountHydration(true)
setContacts([])
setPosts([]); setHasMore(true); setHasNewer(false);
setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined);
void hydrateSnsPageCache()
@@ -465,7 +938,7 @@ export default function SnsPage() {
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts])
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts, stopContactsCountHydration])
useEffect(() => {
const timer = setTimeout(() => {
@@ -474,6 +947,24 @@ export default function SnsPage() {
return () => clearTimeout(timer)
}, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts])
useEffect(() => {
if (!authorTimelineTarget) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeAuthorTimeline()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [authorTimelineTarget, closeAuthorTimeline])
useEffect(() => {
if (authorTimelineTotalPosts === null) return
if (authorTimelinePosts.length >= authorTimelineTotalPosts) {
setAuthorTimelineHasMore(false)
}
}, [authorTimelinePosts.length, authorTimelineTotalPosts])
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) {
@@ -492,6 +983,29 @@ export default function SnsPage() {
}
}
const handleAuthorTimelineScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
if (scrollHeight - scrollTop - clientHeight < 260) {
loadMoreAuthorTimeline()
}
}
const renderAuthorTimelineStats = () => {
const loadedCount = authorTimelinePosts.length
const loadPart = authorTimelineStatsLoading
? `已加载 ${loadedCount} / 总数统计中...`
: authorTimelineTotalPosts === null
? `已加载 ${loadedCount} 条`
: `已加载 ${loadedCount} / 共 ${authorTimelineTotalPosts} 条`
if (authorTimelineLoading && loadedCount === 0) return `${loadPart} | 加载中...`
if (loadedCount === 0) return loadPart
const latest = authorTimelinePosts[0]?.createTime ?? null
const earliest = authorTimelinePosts[authorTimelinePosts.length - 1]?.createTime ?? null
return `${loadPart} | ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}`
}
return (
<div className="sns-page-layout">
<div className="sns-main-viewport">
@@ -578,14 +1092,8 @@ export default function SnsPage() {
}
}}
onDebug={(p) => setDebugPost(p)}
onDelete={(postId) => {
setPosts(prev => {
const next = prev.filter(p => p.id !== postId)
void persistSnsPageCache({ posts: next })
return next
})
loadOverviewStats()
}}
onDelete={handlePostDelete}
onOpenAuthorPosts={openAuthorTimeline}
/>
))}
</div>
@@ -644,6 +1152,7 @@ export default function SnsPage() {
contactSearch={contactSearch}
setContactSearch={setContactSearch}
loading={contactsLoading}
contactsCountProgress={contactsCountProgress}
/>
{/* Dialogs and Overlays */}
@@ -657,6 +1166,77 @@ export default function SnsPage() {
currentDate={jumpTargetDate || new Date()}
/>
{authorTimelineTarget && (
<div className="modal-overlay" onClick={closeAuthorTimeline}>
<div className="author-timeline-dialog" onClick={(e) => e.stopPropagation()}>
<div className="author-timeline-header">
<div className="author-timeline-meta">
<Avatar
src={authorTimelineTarget.avatarUrl}
name={authorTimelineTarget.nickname}
size={42}
shape="rounded"
/>
<div className="author-timeline-meta-text">
<h3>{decodeHtmlEntities(authorTimelineTarget.nickname)}</h3>
<div className="author-timeline-username">@{authorTimelineTarget.username}</div>
<div className="author-timeline-stats">{renderAuthorTimelineStats()}</div>
</div>
</div>
<button className="close-btn" onClick={closeAuthorTimeline}>
<X size={20} />
</button>
</div>
<div className="author-timeline-body" onScroll={handleAuthorTimelineScroll}>
{authorTimelinePosts.length > 0 && (
<div className="posts-list author-timeline-posts-list">
{authorTimelinePosts.map(post => (
<SnsPostItem
key={post.id}
post={{ ...post, isProtected: triggerInstalled === true }}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={(p) => setDebugPost(p)}
onDelete={handlePostDelete}
onOpenAuthorPosts={openAuthorTimeline}
hideAuthorMeta
/>
))}
</div>
)}
{authorTimelineLoading && (
<div className="status-indicator loading-more author-timeline-loading">
<RefreshCw size={16} className="spinning" />
<span>正在加载该用户朋友圈...</span>
</div>
)}
{!authorTimelineLoading && authorTimelinePosts.length === 0 && (
<div className="author-timeline-empty">该用户暂无朋友圈</div>
)}
{!authorTimelineLoading && authorTimelineHasMore && (
<button
type="button"
className="author-timeline-load-more"
onClick={loadMoreAuthorTimeline}
disabled={authorTimelineLoadingMore}
>
{authorTimelineLoadingMore ? '正在加载...' : '加载更多'}
</button>
)}
</div>
</div>
</div>
)}
{debugPost && (
<div className="modal-overlay" onClick={() => setDebugPost(null)}>
<div className="debug-dialog" onClick={(e) => e.stopPropagation()}>
+58
View File
@@ -41,6 +41,7 @@ export const CONFIG_KEYS = {
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
@@ -533,6 +534,11 @@ export interface ExportSnsStatsCacheItem {
totalFriends: number
}
export interface ExportSnsUserPostCountsCacheItem {
updatedAt: number
counts: Record<string, number>
}
export interface SnsPageOverviewCache {
totalPosts: number
totalFriends: number
@@ -740,6 +746,58 @@ export async function setExportSnsStatsCache(
await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map)
}
export async function getExportSnsUserPostCountsCache(scopeKey: string): Promise<ExportSnsUserPostCountsCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const raw = rawItem as Record<string, unknown>
const rawCounts = raw.counts
if (!rawCounts || typeof rawCounts !== 'object') return null
const counts: Record<string, number> = {}
for (const [rawUsername, rawCount] of Object.entries(rawCounts as Record<string, unknown>)) {
const username = String(rawUsername || '').trim()
if (!username) continue
const valueNum = Number(rawCount)
counts[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0
}
const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt)
? raw.updatedAt
: 0
return { updatedAt, counts }
}
export async function setExportSnsUserPostCountsCache(
scopeKey: string,
counts: Record<string, number>
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: Record<string, number> = {}
for (const [rawUsername, rawCount] of Object.entries(counts || {})) {
const username = String(rawUsername || '').trim()
if (!username) continue
const valueNum = Number(rawCount)
normalized[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0
}
map[scopeKey] = {
updatedAt: Date.now(),
counts: normalized
}
await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map)
}
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
+17 -2
View File
@@ -1,5 +1,12 @@
import type { ChatSession, Message, Contact, ContactInfo } from './models'
export interface SessionChatWindowOpenOptions {
source?: 'chat' | 'export'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: ContactInfo['type']
}
export interface ElectronAPI {
window: {
minimize: () => void
@@ -13,7 +20,7 @@ export interface ElectronAPI {
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
openSessionChatWindow: (sessionId: string) => Promise<boolean>
openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise<boolean>
}
config: {
get: (key: string) => Promise<unknown>
@@ -250,7 +257,13 @@ export interface ElectronAPI {
}>
getExportSessionStats: (
sessionIds: string[],
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean }
options?: {
includeRelations?: boolean
forceRefresh?: boolean
allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
}
) => Promise<{
success: boolean
data?: Record<string, {
@@ -776,8 +789,10 @@ export interface ElectronAPI {
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
getUserPostCounts: () => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
getUserPostStats: (username: string) => Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }>
installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }>
uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>