feat(counts): unify contacts and export tab counters

This commit is contained in:
tisonhuang
2026-03-02 10:23:36 +08:00
parent 794a306f89
commit da7d354436
8 changed files with 222 additions and 136 deletions

View File

@@ -148,6 +148,17 @@
svg {
opacity: 0.7;
transition: transform 0.2s;
flex-shrink: 0;
}
.chip-label {
min-width: 0;
}
.chip-count {
margin-left: auto;
text-align: right;
font-variant-numeric: tabular-nums;
}
&:hover {

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from
import { useNavigate } from 'react-router-dom'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react'
import { useChatStore } from '../stores/chatStore'
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import './ContactsPage.scss'
interface ContactInfo {
@@ -58,6 +59,8 @@ function ContactsPage() {
})
const [scrollTop, setScrollTop] = useState(0)
const [listViewportHeight, setListViewportHeight] = useState(480)
const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts)
const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts)
const applyEnrichedContacts = useCallback((enrichedMap: Record<string, ContactEnrichInfo>) => {
if (!enrichedMap || Object.keys(enrichedMap).length === 0) return
@@ -151,6 +154,7 @@ function ContactsPage() {
if (loadVersionRef.current !== loadVersion) return
if (contactsResult.success && contactsResult.contacts) {
setContacts(contactsResult.contacts)
syncContactTypeCounts(contactsResult.contacts)
setSelectedUsernames(new Set())
setSelectedContact(prev => {
if (!prev) return prev
@@ -167,7 +171,7 @@ function ContactsPage() {
setIsLoading(false)
}
}
}, [enrichContactsInBackground])
}, [enrichContactsInBackground, syncContactTypeCounts])
useEffect(() => {
loadContacts()
@@ -206,6 +210,8 @@ function ContactsPage() {
return filtered
}, [contacts, contactTypes, debouncedSearchKeyword])
const contactTypeCounts = useMemo(() => toContactTypeCardCounts(sharedTabCounts), [sharedTabCounts])
useEffect(() => {
if (!listRef.current) return
listRef.current.scrollTop = 0
@@ -428,19 +434,27 @@ function ContactsPage() {
<div className="type-filters">
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
<User size={16} /><span></span>
<User size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.friends}</span>
</label>
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
<Users size={16} /><span></span>
<Users size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.groups}</span>
</label>
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
<MessageSquare size={16} /><span></span>
<MessageSquare size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.officials}</span>
</label>
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
<UserX size={16} /><span></span>
<UserX size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.deletedFriends}</span>
</label>
</div>

View File

@@ -22,6 +22,7 @@ import {
import type { ChatSession as AppChatSession, ContactInfo } from '../types/models'
import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron'
import * as configService from '../services/config'
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import './ExportPage.scss'
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
@@ -321,12 +322,10 @@ function ExportPage() {
const [isLoading, setIsLoading] = useState(true)
const [isSessionEnriching, setIsSessionEnriching] = useState(false)
const [isTabCountsLoading, setIsTabCountsLoading] = useState(true)
const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true)
const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true)
const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false)
const [sessions, setSessions] = useState<SessionRow[]>([])
const [prefetchedTabCounts, setPrefetchedTabCounts] = useState<Record<ConversationTab, number> | null>(null)
const [sessionMessageCounts, setSessionMessageCounts] = useState<Record<string, number>>({})
const [sessionMetrics, setSessionMetrics] = useState<Record<string, SessionMetrics>>({})
const [searchKeyword, setSearchKeyword] = useState('')
@@ -374,6 +373,11 @@ function ExportPage() {
})
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
const [nowTick, setNowTick] = useState(Date.now())
const tabCounts = useContactTypeCountsStore(state => state.tabCounts)
const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading)
const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady)
const ensureSharedTabCountsLoaded = useContactTypeCountsStore(state => state.ensureLoaded)
const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts)
const progressUnsubscribeRef = useRef<(() => void) | null>(null)
const runningTaskIdRef = useRef<string | null>(null)
@@ -516,20 +520,6 @@ function ExportPage() {
}
}, [])
const loadTabCounts = useCallback(async () => {
setIsTabCountsLoading(true)
try {
const result = await window.electronAPI.chat.getExportTabCounts()
if (result.success && result.counts) {
setPrefetchedTabCounts(result.counts)
}
} catch (error) {
console.error('加载导出页会话分类数量失败:', error)
} finally {
setIsTabCountsLoading(false)
}
}, [])
const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => {
if (!options?.silent) {
setIsSnsStatsLoading(true)
@@ -641,6 +631,9 @@ function ExportPage() {
if (isStale()) return
const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : []
if (contacts.length > 0) {
syncContactTypeCounts(contacts)
}
const nextContactMap = contacts.reduce<Record<string, ContactInfo>>((map, contact) => {
map[contact.username] = contact
return map
@@ -694,11 +687,11 @@ function ExportPage() {
} finally {
if (!isStale()) setIsLoading(false)
}
}, [])
}, [syncContactTypeCounts])
useEffect(() => {
void loadBaseConfig()
void loadTabCounts()
void ensureSharedTabCountsLoaded()
void loadSessions()
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
@@ -707,7 +700,7 @@ function ExportPage() {
}, 120)
return () => window.clearTimeout(timer)
}, [loadTabCounts, loadBaseConfig, loadSessions, loadSnsStats])
}, [ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats])
useEffect(() => {
preselectAppliedRef.current = false
@@ -1363,29 +1356,6 @@ function ExportPage() {
return set
}, [tasks])
const sessionTabCounts = useMemo(() => {
const counts: Record<ConversationTab, number> = {
private: 0,
group: 0,
official: 0,
former_friend: 0
}
for (const session of sessions) {
counts[session.kind] += 1
}
return counts
}, [sessions])
const tabCounts = useMemo(() => {
if (sessions.length > 0) {
return sessionTabCounts
}
if (prefetchedTabCounts) {
return prefetchedTabCounts
}
return sessionTabCounts
}, [sessions.length, sessionTabCounts, prefetchedTabCounts])
const contentCards = useMemo(() => {
const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group')
const totalSessions = scopeSessions.length
@@ -1617,8 +1587,7 @@ function ExportPage() {
const formatCandidateOptions = exportDialog.scope === 'sns'
? formatOptions.filter(option => option.value === 'html' || option.value === 'json')
: formatOptions
const hasTabCountsSource = prefetchedTabCounts !== null || sessions.length > 0
const isTabCountComputing = isTabCountsLoading && !hasTabCountsSource
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
const isSessionCardStatsLoading = isLoading || isBaseConfigLoading
const isSnsCardStatsLoading = !hasSeededSnsStats
const taskRunningCount = tasks.filter(task => task.status === 'running').length