feat(export): optimize batch export flow and unify session detail typing

This commit is contained in:
tisonhuang
2026-03-02 18:14:11 +08:00
parent 750d6ad7eb
commit ac481c6b18
17 changed files with 2102 additions and 307 deletions

View File

@@ -23,10 +23,11 @@ function AnnualReportPage() {
setLoadError(null)
try {
const result = await window.electronAPI.annualReport.getAvailableYears()
if (result.success && result.data && result.data.length > 0) {
setAvailableYears(result.data)
setSelectedYear((prev) => prev ?? result.data[0])
setSelectedPairYear((prev) => prev ?? result.data[0])
const years = result.data
if (result.success && Array.isArray(years) && years.length > 0) {
setAvailableYears(years)
setSelectedYear((prev) => prev ?? years[0])
setSelectedPairYear((prev) => prev ?? years[0])
} else if (!result.success) {
setLoadError(result.error || '加载年度数据失败')
}

View File

@@ -2727,6 +2727,13 @@
opacity: 0.7;
}
}
.detail-stats-meta {
margin-top: -6px;
margin-bottom: 10px;
font-size: 12px;
color: var(--text-tertiary);
}
}
.detail-item {
@@ -2764,6 +2771,26 @@
}
}
.detail-inline-btn {
border: none;
background: var(--bg-secondary);
color: var(--primary);
border-radius: 6px;
padding: 4px 8px;
font-size: 12px;
line-height: 1;
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
&:hover:not(:disabled) {
background: var(--bg-hover);
}
}
.copy-btn {
display: flex;
align-items: center;
@@ -2872,6 +2899,26 @@
}
}
.group-members-status {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
.spin {
animation: spin 1s linear infinite;
}
&.warning {
color: #b45309;
background: color-mix(in srgb, #f59e0b 10%, transparent);
}
}
.group-members-list {
flex: 1;
min-height: 0;

View File

@@ -152,6 +152,7 @@ const CHAT_SESSION_LIST_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const CHAT_SESSION_PREVIEW_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION = 30
const CHAT_SESSION_PREVIEW_MAX_SESSIONS = 18
const GROUP_MEMBERS_PANEL_CACHE_TTL_MS = 10 * 60 * 1000
function buildChatSessionListCacheKey(scope: string): string {
return `weflow.chat.sessions.v1::${scope || 'default'}`
@@ -186,6 +187,17 @@ function formatYmdDateFromSeconds(timestamp?: number): string {
return `${y}-${m}-${day}`
}
function formatYmdHmDateTime(timestamp?: number): string {
if (!timestamp || !Number.isFinite(timestamp)) return '—'
const d = new Date(timestamp)
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
const h = `${d.getHours()}`.padStart(2, '0')
const min = `${d.getMinutes()}`.padStart(2, '0')
return `${y}-${m}-${day} ${h}:${min}`
}
interface ChatPageProps {
// 保留接口以备将来扩展
}
@@ -208,11 +220,36 @@ interface SessionDetail {
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
relationStatsLoaded?: boolean
statsUpdatedAt?: number
statsStale?: boolean
firstMessageTime?: number
latestMessageTime?: number
messageTables: { dbName: string; tableName: string; count: number }[]
}
interface SessionExportMetric {
totalMessages: number
voiceMessages: number
imageMessages: number
videoMessages: number
emojiMessages: number
firstTimestamp?: number
lastTimestamp?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
}
interface SessionExportCacheMeta {
updatedAt: number
stale: boolean
includeRelations: boolean
source: 'memory' | 'disk' | 'fresh'
}
interface GroupPanelMember {
username: string
displayName: string
@@ -241,6 +278,11 @@ interface SessionPreviewCachePayload {
entries: Record<string, SessionPreviewCacheEntry>
}
interface GroupMembersPanelCacheEntry {
updatedAt: number
members: GroupPanelMember[]
}
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
@@ -395,9 +437,13 @@ function ChatPage(_props: ChatPageProps) {
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
const [isLoadingDetailExtra, setIsLoadingDetailExtra] = useState(false)
const [isRefreshingDetailStats, setIsRefreshingDetailStats] = useState(false)
const [isLoadingRelationStats, setIsLoadingRelationStats] = useState(false)
const [groupPanelMembers, setGroupPanelMembers] = useState<GroupPanelMember[]>([])
const [isLoadingGroupMembers, setIsLoadingGroupMembers] = useState(false)
const [groupMembersError, setGroupMembersError] = useState<string | null>(null)
const [groupMembersLoadingHint, setGroupMembersLoadingHint] = useState('')
const [isRefreshingGroupMembers, setIsRefreshingGroupMembers] = useState(false)
const [groupMemberSearchKeyword, setGroupMemberSearchKeyword] = useState('')
const [copiedField, setCopiedField] = useState<string | null>(null)
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
@@ -478,6 +524,8 @@ function ChatPage(_props: ChatPageProps) {
const lastPreloadSessionRef = useRef<string | null>(null)
const detailRequestSeqRef = useRef(0)
const groupMembersRequestSeqRef = useRef(0)
const groupMembersPanelCacheRef = useRef<Map<string, GroupMembersPanelCacheEntry>>(new Map())
const hasInitializedGroupMembersRef = useRef(false)
const chatCacheScopeRef = useRef('default')
const previewCacheRef = useRef<Record<string, SessionPreviewCacheEntry>>({})
const previewPersistTimerRef = useRef<number | null>(null)
@@ -722,6 +770,40 @@ function ChatPage(_props: ChatPageProps) {
}
}, [])
const applySessionDetailStats = useCallback((
sessionId: string,
metric: SessionExportMetric,
cacheMeta?: SessionExportCacheMeta,
relationLoadedOverride?: boolean
) => {
setSessionDetail((prev) => {
if (!prev || prev.wxid !== sessionId) return prev
const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded)
return {
...prev,
messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : prev.messageCount,
voiceMessages: Number.isFinite(metric.voiceMessages) ? metric.voiceMessages : prev.voiceMessages,
imageMessages: Number.isFinite(metric.imageMessages) ? metric.imageMessages : prev.imageMessages,
videoMessages: Number.isFinite(metric.videoMessages) ? metric.videoMessages : prev.videoMessages,
emojiMessages: Number.isFinite(metric.emojiMessages) ? metric.emojiMessages : prev.emojiMessages,
groupMemberCount: Number.isFinite(metric.groupMemberCount) ? metric.groupMemberCount : prev.groupMemberCount,
groupMyMessages: Number.isFinite(metric.groupMyMessages) ? metric.groupMyMessages : prev.groupMyMessages,
groupActiveSpeakers: Number.isFinite(metric.groupActiveSpeakers) ? metric.groupActiveSpeakers : prev.groupActiveSpeakers,
privateMutualGroups: relationLoaded && Number.isFinite(metric.privateMutualGroups)
? metric.privateMutualGroups
: prev.privateMutualGroups,
groupMutualFriends: relationLoaded && Number.isFinite(metric.groupMutualFriends)
? metric.groupMutualFriends
: prev.groupMutualFriends,
relationStatsLoaded: relationLoaded,
statsUpdatedAt: cacheMeta?.updatedAt ?? prev.statsUpdatedAt,
statsStale: typeof cacheMeta?.stale === 'boolean' ? cacheMeta.stale : prev.statsStale,
firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : prev.firstMessageTime,
latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : prev.latestMessageTime
}
})
}, [])
// 加载会话详情
const loadSessionDetail = useCallback(async (sessionId: string) => {
const normalizedSessionId = String(sessionId || '').trim()
@@ -733,6 +815,8 @@ function ChatPage(_props: ChatPageProps) {
? Math.floor(mappedSession.messageCountHint)
: undefined
setIsRefreshingDetailStats(false)
setIsLoadingRelationStats(false)
setSessionDetail((prev) => {
const sameSession = prev?.wxid === normalizedSessionId
return {
@@ -752,6 +836,9 @@ function ChatPage(_props: ChatPageProps) {
groupMyMessages: sameSession ? prev?.groupMyMessages : undefined,
groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined,
groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined,
relationStatsLoaded: sameSession ? prev?.relationStatsLoaded : false,
statsUpdatedAt: sameSession ? prev?.statsUpdatedAt : undefined,
statsStale: sameSession ? prev?.statsStale : undefined,
firstMessageTime: sameSession ? prev?.firstMessageTime : undefined,
latestMessageTime: sameSession ? prev?.latestMessageTime : undefined,
messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : []
@@ -781,6 +868,9 @@ function ChatPage(_props: ChatPageProps) {
groupMyMessages: prev?.groupMyMessages,
groupActiveSpeakers: prev?.groupActiveSpeakers,
groupMutualFriends: prev?.groupMutualFriends,
relationStatsLoaded: prev?.relationStatsLoaded,
statsUpdatedAt: prev?.statsUpdatedAt,
statsStale: prev?.statsStale,
firstMessageTime: prev?.firstMessageTime,
latestMessageTime: prev?.latestMessageTime,
messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : []
@@ -797,47 +887,82 @@ function ChatPage(_props: ChatPageProps) {
try {
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
window.electronAPI.chat.getExportSessionStats([normalizedSessionId])
window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: false, allowStaleCache: true }
)
])
if (requestSeq !== detailRequestSeqRef.current) return
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
let next = { ...prev }
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success && extraResultSettled.value.detail) {
next = {
...next,
firstMessageTime: extraResultSettled.value.detail.firstMessageTime,
latestMessageTime: extraResultSettled.value.detail.latestMessageTime,
messageTables: Array.isArray(extraResultSettled.value.detail.messageTables) ? extraResultSettled.value.detail.messageTables : []
}
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) {
const detail = extraResultSettled.value.detail
if (detail) {
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
return {
...prev,
firstMessageTime: detail.firstMessageTime,
latestMessageTime: detail.latestMessageTime,
messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : []
}
})
}
}
if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success && statsResultSettled.value.data) {
const metric = statsResultSettled.value.data[normalizedSessionId]
if (metric) {
next = {
...next,
messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : next.messageCount,
voiceMessages: metric.voiceMessages,
imageMessages: metric.imageMessages,
videoMessages: metric.videoMessages,
emojiMessages: metric.emojiMessages,
privateMutualGroups: metric.privateMutualGroups,
groupMemberCount: metric.groupMemberCount,
groupMyMessages: metric.groupMyMessages,
groupActiveSpeakers: metric.groupActiveSpeakers,
groupMutualFriends: metric.groupMutualFriends,
firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : next.firstMessageTime,
latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : next.latestMessageTime
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)
}
if (shouldRefreshStats) {
setIsRefreshingDetailStats(true)
void (async () => {
try {
const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: refreshIncludeRelations, forceRefresh: 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
)
}
}
} catch (error) {
console.error('刷新会话统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsRefreshingDetailStats(false)
}
}
}
return next
})
})()
}
} catch (e) {
console.error('加载会话详情补充统计失败:', e)
} finally {
@@ -845,51 +970,120 @@ function ChatPage(_props: ChatPageProps) {
setIsLoadingDetailExtra(false)
}
}
}, [])
}, [applySessionDetailStats])
const loadRelationStats = useCallback(async () => {
const normalizedSessionId = String(currentSessionId || '').trim()
if (!normalizedSessionId || isLoadingRelationStats) return
const requestSeq = detailRequestSeqRef.current
setIsLoadingRelationStats(true)
try {
const relationResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: true, allowStaleCache: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
const metric = relationResult.success && relationResult.data
? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined
: undefined
const cacheMeta = relationResult.success
? relationResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
: undefined
if (metric) {
applySessionDetailStats(normalizedSessionId, metric, cacheMeta, true)
}
const needRefresh = relationResult.success &&
Array.isArray(relationResult.needsRefresh) &&
relationResult.needsRefresh.includes(normalizedSessionId)
if (needRefresh) {
setIsRefreshingDetailStats(true)
void (async () => {
try {
const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: true, forceRefresh: 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) {
setIsRefreshingDetailStats(false)
}
}
})()
}
} catch (error) {
console.error('加载会话关系统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingRelationStats(false)
}
}
}, [applySessionDetailStats, currentSessionId, isLoadingRelationStats])
const loadGroupMembersPanel = useCallback(async (chatroomId: string) => {
if (!chatroomId || !isGroupChatSession(chatroomId)) return
const requestSeq = ++groupMembersRequestSeqRef.current
setIsLoadingGroupMembers(true)
const now = Date.now()
const cached = groupMembersPanelCacheRef.current.get(chatroomId)
const cacheFresh = Boolean(cached && now - cached.updatedAt < GROUP_MEMBERS_PANEL_CACHE_TTL_MS)
const hasCachedMembers = Boolean(cached && cached.members.length > 0)
if (cacheFresh && cached) {
setGroupPanelMembers(cached.members)
setGroupMembersError(null)
setGroupMembersLoadingHint('')
setIsRefreshingGroupMembers(false)
setIsLoadingGroupMembers(false)
hasInitializedGroupMembersRef.current = true
return
}
setGroupMembersError(null)
if (hasCachedMembers && cached) {
setGroupPanelMembers(cached.members)
setIsRefreshingGroupMembers(true)
setGroupMembersLoadingHint('')
setIsLoadingGroupMembers(false)
} else {
setGroupPanelMembers([])
setIsRefreshingGroupMembers(false)
setIsLoadingGroupMembers(true)
setGroupMembersLoadingHint(
hasInitializedGroupMembersRef.current
? '加载群成员中...'
: '首次加载群成员,正在初始化索引(可能需要几秒)'
)
}
try {
const [membersResult, rankingResult, contactsResult] = await Promise.all([
window.electronAPI.groupAnalytics.getGroupMembers(chatroomId),
window.electronAPI.groupAnalytics.getGroupMessageRanking(chatroomId, 20000),
window.electronAPI.chat.getContacts()
])
const membersResult = await window.electronAPI.groupAnalytics.getGroupMembersPanelData(chatroomId)
if (requestSeq !== groupMembersRequestSeqRef.current) return
if (!membersResult.success || !Array.isArray(membersResult.data)) {
setGroupPanelMembers([])
setGroupMembersError(membersResult.error || '加载群成员失败')
if (!hasCachedMembers) {
setGroupPanelMembers([])
}
setGroupMembersError(membersResult.error || (hasCachedMembers ? '刷新群成员失败,已显示缓存数据' : '加载群成员失败'))
return
}
const messageCountMap = new Map<string, number>()
if (rankingResult.success && Array.isArray(rankingResult.data)) {
for (const rank of rankingResult.data) {
const username = String(rank.member?.username || '').trim()
if (!username) continue
const count = Number.isFinite(rank.messageCount) ? Math.max(0, Math.floor(rank.messageCount)) : 0
messageCountMap.set(username, count)
}
}
const friendSet = new Set<string>()
if (contactsResult.success && Array.isArray(contactsResult.contacts)) {
for (const contact of contactsResult.contacts) {
if (contact.type !== 'friend') continue
const username = String(contact.username || '').trim()
if (!username) continue
friendSet.add(username)
}
}
const members: GroupPanelMember[] = membersResult.data
.map((member) => {
const membersPayload = membersResult.data as GroupPanelMember[]
const members: GroupPanelMember[] = membersPayload
.map((member: GroupPanelMember): GroupPanelMember | null => {
const username = String(member.username || '').trim()
if (!username) return null
const preferredName = String(
@@ -909,12 +1103,12 @@ function ChatPage(_props: ChatPageProps) {
remark: member.remark,
groupNickname: member.groupNickname,
isOwner: Boolean(member.isOwner),
isFriend: friendSet.has(username),
messageCount: messageCountMap.get(username) || 0
isFriend: Boolean(member.isFriend),
messageCount: Number.isFinite(member.messageCount) ? Math.max(0, Math.floor(member.messageCount)) : 0
}
})
.filter((member): member is GroupPanelMember => Boolean(member))
.sort((a, b) => {
.filter((member: GroupPanelMember | null): member is GroupPanelMember => Boolean(member))
.sort((a: GroupPanelMember, b: GroupPanelMember) => {
const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner))
if (ownerDiff !== 0) return ownerDiff
@@ -926,19 +1120,33 @@ function ChatPage(_props: ChatPageProps) {
})
setGroupPanelMembers(members)
if (!rankingResult.success) {
setGroupMembersError(rankingResult.error || '群成员发言统计加载失败')
setGroupMembersError(null)
groupMembersPanelCacheRef.current.set(chatroomId, {
updatedAt: Date.now(),
members
})
if (groupMembersPanelCacheRef.current.size > 80) {
const oldestEntry = Array.from(groupMembersPanelCacheRef.current.entries())
.sort((a, b) => a[1].updatedAt - b[1].updatedAt)[0]
if (oldestEntry) {
groupMembersPanelCacheRef.current.delete(oldestEntry[0])
}
}
hasInitializedGroupMembersRef.current = true
} catch (e) {
if (requestSeq !== groupMembersRequestSeqRef.current) return
setGroupPanelMembers([])
setGroupMembersError(String(e))
if (!hasCachedMembers) {
setGroupPanelMembers([])
}
setGroupMembersError(hasCachedMembers ? '刷新群成员失败,已显示缓存数据' : String(e))
} finally {
if (requestSeq === groupMembersRequestSeqRef.current) {
setIsLoadingGroupMembers(false)
setIsRefreshingGroupMembers(false)
setGroupMembersLoadingHint('')
}
}
}, [])
}, [isGroupChatSession])
const toggleGroupMembersPanel = useCallback(() => {
if (!currentSessionId || !isGroupChatSession(currentSessionId)) return
@@ -1024,12 +1232,18 @@ function ChatPage(_props: ChatPageProps) {
pendingSessionLoadRef.current = null
setIsSessionSwitching(false)
setSessionDetail(null)
setIsRefreshingDetailStats(false)
setIsLoadingRelationStats(false)
setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setGroupPanelMembers([])
setGroupMembersError(null)
setGroupMembersLoadingHint('')
setIsRefreshingGroupMembers(false)
setGroupMemberSearchKeyword('')
groupMembersRequestSeqRef.current += 1
groupMembersPanelCacheRef.current.clear()
hasInitializedGroupMembersRef.current = false
setIsLoadingGroupMembers(false)
setCurrentSession(null)
setSessions([])
@@ -1694,9 +1908,13 @@ function ChatPage(_props: ChatPageProps) {
setShowGroupMembersPanel(false)
setGroupMemberSearchKeyword('')
setGroupMembersError(null)
setGroupMembersLoadingHint('')
setIsRefreshingGroupMembers(false)
groupMembersRequestSeqRef.current += 1
setIsLoadingGroupMembers(false)
setSessionDetail(null)
setIsRefreshingDetailStats(false)
setIsLoadingRelationStats(false)
}
// 搜索过滤
@@ -3146,10 +3364,22 @@ function ChatPage(_props: ChatPageProps) {
</div>
</div>
{isRefreshingGroupMembers && (
<div className="group-members-status" role="status" aria-live="polite">
<Loader2 size={14} className="spin" />
<span>...</span>
</div>
)}
{groupMembersError && groupPanelMembers.length > 0 && (
<div className="group-members-status warning" role="status" aria-live="polite">
<span>{groupMembersError}</span>
</div>
)}
{isLoadingGroupMembers ? (
<div className="detail-loading">
<Loader2 size={20} className="spin" />
<span>...</span>
<span>{groupMembersLoadingHint || '加载群成员中...'}</span>
</div>
) : groupMembersError && groupPanelMembers.length === 0 ? (
<div className="detail-empty">{groupMembersError}</div>
@@ -3256,6 +3486,13 @@ function ChatPage(_props: ChatPageProps) {
<MessageSquare size={14} />
<span></span>
</div>
<div className="detail-stats-meta">
{isRefreshingDetailStats
? '统计刷新中...'
: sessionDetail.statsUpdatedAt
? `${sessionDetail.statsStale ? '缓存于' : '更新于'} ${formatYmdHmDateTime(sessionDetail.statsUpdatedAt)}${sessionDetail.statsStale ? '(将后台刷新)' : ''}`
: (isLoadingDetailExtra ? '统计加载中...' : '暂无统计缓存')}
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value highlight">
@@ -3325,9 +3562,19 @@ function ChatPage(_props: ChatPageProps) {
<div className="detail-item">
<span className="label"></span>
<span className="value">
{Number.isFinite(sessionDetail.groupMutualFriends)
? (sessionDetail.groupMutualFriends as number).toLocaleString()
: (isLoadingDetailExtra ? '统计中...' : '—')}
{sessionDetail.relationStatsLoaded
? (Number.isFinite(sessionDetail.groupMutualFriends)
? (sessionDetail.groupMutualFriends as number).toLocaleString()
: '—')
: (
<button
className="detail-inline-btn"
onClick={() => { void loadRelationStats() }}
disabled={isLoadingRelationStats || isLoadingDetailExtra}
>
{isLoadingRelationStats ? '加载中...' : '点击加载'}
</button>
)}
</span>
</div>
</>
@@ -3335,9 +3582,19 @@ function ChatPage(_props: ChatPageProps) {
<div className="detail-item">
<span className="label"></span>
<span className="value">
{Number.isFinite(sessionDetail.privateMutualGroups)
? (sessionDetail.privateMutualGroups as number).toLocaleString()
: (isLoadingDetailExtra ? '统计中...' : '—')}
{sessionDetail.relationStatsLoaded
? (Number.isFinite(sessionDetail.privateMutualGroups)
? (sessionDetail.privateMutualGroups as number).toLocaleString()
: '—')
: (
<button
className="detail-inline-btn"
onClick={() => { void loadRelationStats() }}
disabled={isLoadingRelationStats || isLoadingDetailExtra}
>
{isLoadingRelationStats ? '加载中...' : '点击加载'}
</button>
)}
</span>
</div>
)}

View File

@@ -107,7 +107,16 @@ function DualReportWindow() {
setLoadingStage('完成')
if (result.success && result.data) {
setReportData(result.data)
const normalizedResponse = result.data.response
? {
...result.data.response,
slowest: result.data.response.slowest ?? result.data.response.avg
}
: undefined
setReportData({
...result.data,
response: normalizedResponse
})
setIsLoading(false)
} else {
setError(result.error || '生成报告失败')

View File

@@ -1411,6 +1411,13 @@
text-transform: uppercase;
letter-spacing: 0.4px;
}
.detail-stats-meta {
margin-top: -4px;
margin-bottom: 10px;
font-size: 12px;
color: var(--text-tertiary);
}
}
.detail-item {
@@ -1443,6 +1450,26 @@
}
}
.detail-inline-btn {
border: none;
background: var(--bg-secondary);
color: var(--primary);
border-radius: 6px;
padding: 4px 8px;
font-size: 12px;
line-height: 1;
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
&:hover:not(:disabled) {
background: var(--bg-hover);
}
}
.copy-btn {
display: flex;
align-items: center;

View File

@@ -378,6 +378,17 @@ const formatYmdDateFromSeconds = (timestamp?: number): string => {
return `${y}-${m}-${day}`
}
const formatYmdHmDateTime = (timestamp?: number): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '—'
const d = new Date(timestamp)
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
const h = `${d.getHours()}`.padStart(2, '0')
const min = `${d.getMinutes()}`.padStart(2, '0')
return `${y}-${m}-${day} ${h}:${min}`
}
const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
if (!timestamp) return ''
const diff = Math.max(0, now - timestamp)
@@ -496,11 +507,36 @@ interface SessionDetail {
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
relationStatsLoaded?: boolean
statsUpdatedAt?: number
statsStale?: boolean
firstMessageTime?: number
latestMessageTime?: number
messageTables: { dbName: string; tableName: string; count: number }[]
}
interface SessionExportMetric {
totalMessages: number
voiceMessages: number
imageMessages: number
videoMessages: number
emojiMessages: number
firstTimestamp?: number
lastTimestamp?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
}
interface SessionExportCacheMeta {
updatedAt: number
stale: boolean
includeRelations: boolean
source: 'memory' | 'disk' | 'fresh'
}
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
let timer: ReturnType<typeof setTimeout> | null = null
try {
@@ -772,6 +808,8 @@ function ExportPage() {
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 [exportFolder, setExportFolder] = useState('')
@@ -1718,6 +1756,7 @@ function ExportPage() {
next.exportVoices = payload.contentType === 'voice'
next.exportVideos = payload.contentType === 'video'
next.exportEmojis = payload.contentType === 'emoji'
next.exportVoiceAsText = false
}
}
@@ -1813,6 +1852,7 @@ function ExportPage() {
if (contentType === 'text') {
return {
...base,
contentType,
exportAvatars: true,
exportMedia: false,
exportImages: false,
@@ -1824,11 +1864,13 @@ function ExportPage() {
return {
...base,
contentType,
exportMedia: true,
exportImages: contentType === 'image',
exportVoices: contentType === 'voice',
exportVideos: contentType === 'video',
exportEmojis: contentType === 'emoji'
exportEmojis: contentType === 'emoji',
exportVoiceAsText: false
}
}
@@ -2048,6 +2090,8 @@ function ExportPage() {
}))
} else {
const doneAt = Date.now()
const successCount = result.successCount ?? 0
const failCount = result.failCount ?? 0
const contentTypes = next.payload.contentType
? [next.payload.contentType]
: inferContentTypesFromOptions(next.payload.options)
@@ -2063,16 +2107,16 @@ function ExportPage() {
updateTask(next.id, task => ({
...task,
status: 'stopped',
controlState: undefined,
finishedAt: doneAt,
progress: {
...task.progress,
current: result.successCount + result.failCount,
total: task.progress.total || next.payload.sessionIds.length,
phaseLabel: '已停止'
},
performance: finalizeTaskPerformance(task, doneAt)
}))
controlState: undefined,
finishedAt: doneAt,
progress: {
...task.progress,
current: successCount + failCount,
total: task.progress.total || next.payload.sessionIds.length,
phaseLabel: '已停止'
},
performance: finalizeTaskPerformance(task, doneAt)
}))
} else if (result.paused) {
const pendingSessionIds = Array.isArray(result.pendingSessionIds)
? result.pendingSessionIds
@@ -2112,7 +2156,7 @@ function ExportPage() {
},
progress: {
...task.progress,
current: result.successCount + result.failCount,
current: successCount + failCount,
total: task.progress.total || next.payload.sessionIds.length,
phaseLabel: '已暂停'
},
@@ -2531,6 +2575,40 @@ function ExportPage() {
return map
}, [contactsList])
const applySessionDetailStats = useCallback((
sessionId: string,
metric: SessionExportMetric,
cacheMeta?: SessionExportCacheMeta,
relationLoadedOverride?: boolean
) => {
setSessionDetail((prev) => {
if (!prev || prev.wxid !== sessionId) return prev
const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded)
return {
...prev,
messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : prev.messageCount,
voiceMessages: Number.isFinite(metric.voiceMessages) ? metric.voiceMessages : prev.voiceMessages,
imageMessages: Number.isFinite(metric.imageMessages) ? metric.imageMessages : prev.imageMessages,
videoMessages: Number.isFinite(metric.videoMessages) ? metric.videoMessages : prev.videoMessages,
emojiMessages: Number.isFinite(metric.emojiMessages) ? metric.emojiMessages : prev.emojiMessages,
groupMemberCount: Number.isFinite(metric.groupMemberCount) ? metric.groupMemberCount : prev.groupMemberCount,
groupMyMessages: Number.isFinite(metric.groupMyMessages) ? metric.groupMyMessages : prev.groupMyMessages,
groupActiveSpeakers: Number.isFinite(metric.groupActiveSpeakers) ? metric.groupActiveSpeakers : prev.groupActiveSpeakers,
privateMutualGroups: relationLoaded && Number.isFinite(metric.privateMutualGroups)
? metric.privateMutualGroups
: prev.privateMutualGroups,
groupMutualFriends: relationLoaded && Number.isFinite(metric.groupMutualFriends)
? metric.groupMutualFriends
: prev.groupMutualFriends,
relationStatsLoaded: relationLoaded,
statsUpdatedAt: cacheMeta?.updatedAt ?? prev.statsUpdatedAt,
statsStale: typeof cacheMeta?.stale === 'boolean' ? cacheMeta.stale : prev.statsStale,
firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : prev.firstMessageTime,
latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : prev.latestMessageTime
}
})
}, [])
const loadSessionDetail = useCallback(async (sessionId: string) => {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return
@@ -2543,6 +2621,8 @@ function ExportPage() {
: undefined
setCopiedDetailField(null)
setIsRefreshingSessionDetailStats(false)
setIsLoadingSessionRelationStats(false)
setSessionDetail((prev) => {
const sameSession = prev?.wxid === normalizedSessionId
return {
@@ -2562,6 +2642,9 @@ function ExportPage() {
groupMyMessages: sameSession ? prev?.groupMyMessages : undefined,
groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined,
groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined,
relationStatsLoaded: sameSession ? prev?.relationStatsLoaded : false,
statsUpdatedAt: sameSession ? prev?.statsUpdatedAt : undefined,
statsStale: sameSession ? prev?.statsStale : undefined,
firstMessageTime: sameSession ? prev?.firstMessageTime : undefined,
latestMessageTime: sameSession ? prev?.latestMessageTime : undefined,
messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : []
@@ -2591,6 +2674,9 @@ function ExportPage() {
groupMyMessages: prev?.groupMyMessages,
groupActiveSpeakers: prev?.groupActiveSpeakers,
groupMutualFriends: prev?.groupMutualFriends,
relationStatsLoaded: prev?.relationStatsLoaded,
statsUpdatedAt: prev?.statsUpdatedAt,
statsStale: prev?.statsStale,
firstMessageTime: prev?.firstMessageTime,
latestMessageTime: prev?.latestMessageTime,
messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : []
@@ -2607,47 +2693,82 @@ function ExportPage() {
try {
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
window.electronAPI.chat.getExportSessionStats([normalizedSessionId])
window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: false, allowStaleCache: true }
)
])
if (requestSeq !== detailRequestSeqRef.current) return
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
let next = { ...prev }
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success && extraResultSettled.value.detail) {
next = {
...next,
firstMessageTime: extraResultSettled.value.detail.firstMessageTime,
latestMessageTime: extraResultSettled.value.detail.latestMessageTime,
messageTables: Array.isArray(extraResultSettled.value.detail.messageTables) ? extraResultSettled.value.detail.messageTables : []
}
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) {
const detail = extraResultSettled.value.detail
if (detail) {
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
return {
...prev,
firstMessageTime: detail.firstMessageTime,
latestMessageTime: detail.latestMessageTime,
messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : []
}
})
}
}
if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success && statsResultSettled.value.data) {
const metric = statsResultSettled.value.data[normalizedSessionId]
if (metric) {
next = {
...next,
messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : next.messageCount,
voiceMessages: metric.voiceMessages,
imageMessages: metric.imageMessages,
videoMessages: metric.videoMessages,
emojiMessages: metric.emojiMessages,
privateMutualGroups: metric.privateMutualGroups,
groupMemberCount: metric.groupMemberCount,
groupMyMessages: metric.groupMyMessages,
groupActiveSpeakers: metric.groupActiveSpeakers,
groupMutualFriends: metric.groupMutualFriends,
firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : next.firstMessageTime,
latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : next.latestMessageTime
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)
}
if (shouldRefreshStats) {
setIsRefreshingSessionDetailStats(true)
void (async () => {
try {
const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: refreshIncludeRelations, forceRefresh: 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
)
}
}
} catch (error) {
console.error('导出页刷新会话统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsRefreshingSessionDetailStats(false)
}
}
}
return next
})
})()
}
} catch (error) {
console.error('导出页加载会话详情补充统计失败:', error)
} finally {
@@ -2655,7 +2776,77 @@ function ExportPage() {
setIsLoadingSessionDetailExtra(false)
}
}
}, [contactByUsername, sessionRowByUsername])
}, [applySessionDetailStats, contactByUsername, sessionRowByUsername])
const loadSessionRelationStats = useCallback(async () => {
const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
if (!normalizedSessionId || isLoadingSessionRelationStats) return
const requestSeq = detailRequestSeqRef.current
setIsLoadingSessionRelationStats(true)
try {
const relationResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: true, allowStaleCache: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
const metric = relationResult.success && relationResult.data
? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined
: undefined
const cacheMeta = relationResult.success
? relationResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
: undefined
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 }
)
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 {
if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingSessionRelationStats(false)
}
}
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
const closeSessionDetailPanel = useCallback(() => {
detailRequestSeqRef.current += 1
setShowSessionDetailPanel(false)
setIsLoadingSessionDetail(false)
setIsLoadingSessionDetailExtra(false)
setIsRefreshingSessionDetailStats(false)
setIsLoadingSessionRelationStats(false)
}, [])
const openSessionDetail = useCallback((sessionId: string) => {
if (!sessionId) return
@@ -2667,12 +2858,12 @@ function ExportPage() {
if (!showSessionDetailPanel) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setShowSessionDetailPanel(false)
closeSessionDetailPanel()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [showSessionDetailPanel])
}, [closeSessionDetailPanel, showSessionDetailPanel])
const handleCopyDetailField = useCallback(async (text: string, field: string) => {
try {
@@ -3421,7 +3612,7 @@ function ExportPage() {
{showSessionDetailPanel && (
<div
className="export-session-detail-overlay"
onClick={() => setShowSessionDetailPanel(false)}
onClick={closeSessionDetailPanel}
>
<aside
className="export-session-detail-panel"
@@ -3444,7 +3635,7 @@ function ExportPage() {
<div className="detail-header-id">{sessionDetail?.wxid || ''}</div>
</div>
</div>
<button className="close-btn" onClick={() => setShowSessionDetailPanel(false)}>
<button className="close-btn" onClick={closeSessionDetailPanel}>
<X size={16} />
</button>
</div>
@@ -3498,6 +3689,13 @@ function ExportPage() {
<MessageSquare size={14} />
<span></span>
</div>
<div className="detail-stats-meta">
{isRefreshingSessionDetailStats
? '统计刷新中...'
: sessionDetail.statsUpdatedAt
? `${sessionDetail.statsStale ? '缓存于' : '更新于'} ${formatYmdHmDateTime(sessionDetail.statsUpdatedAt)}${sessionDetail.statsStale ? '(将后台刷新)' : ''}`
: (isLoadingSessionDetailExtra ? '统计加载中...' : '暂无统计缓存')}
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value highlight">
@@ -3567,9 +3765,19 @@ function ExportPage() {
<div className="detail-item">
<span className="label"></span>
<span className="value">
{Number.isFinite(sessionDetail.groupMutualFriends)
? (sessionDetail.groupMutualFriends as number).toLocaleString()
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
{sessionDetail.relationStatsLoaded
? (Number.isFinite(sessionDetail.groupMutualFriends)
? (sessionDetail.groupMutualFriends as number).toLocaleString()
: '—')
: (
<button
className="detail-inline-btn"
onClick={() => { void loadSessionRelationStats() }}
disabled={isLoadingSessionRelationStats || isLoadingSessionDetailExtra}
>
{isLoadingSessionRelationStats ? '加载中...' : '点击加载'}
</button>
)}
</span>
</div>
</>
@@ -3577,9 +3785,19 @@ function ExportPage() {
<div className="detail-item">
<span className="label"></span>
<span className="value">
{Number.isFinite(sessionDetail.privateMutualGroups)
? (sessionDetail.privateMutualGroups as number).toLocaleString()
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
{sessionDetail.relationStatsLoaded
? (Number.isFinite(sessionDetail.privateMutualGroups)
? (sessionDetail.privateMutualGroups as number).toLocaleString()
: '—')
: (
<button
className="detail-inline-btn"
onClick={() => { void loadSessionRelationStats() }}
disabled={isLoadingSessionRelationStats || isLoadingSessionDetailExtra}
>
{isLoadingSessionRelationStats ? '加载中...' : '点击加载'}
</button>
)}
</span>
</div>
)}

View File

@@ -449,10 +449,10 @@ export default function SnsPage() {
const snsPostCountMap = new Map<string, number>(
Object.entries(snsCountsResult.data).map(([username, count]) => [username, Math.max(0, Number(count || 0))])
)
const contactsWithCounts = contactsList.map(contact => ({
const contactsWithCounts: Contact[] = contactsList.map(contact => ({
...contact,
postCount: snsPostCountMap.get(contact.username) ?? 0,
postCountStatus: 'ready'
postCountStatus: 'ready' as const
}))
setContacts(contactsWithCounts)