From da7d3544363e921b5fb833db5cef8e95a9f34b53 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 10:23:36 +0800 Subject: [PATCH] feat(counts): unify contacts and export tab counters --- electron/main.ts | 4 + electron/preload.ts | 1 + electron/services/chatService.ts | 136 ++++++++++----------------- src/pages/ContactsPage.scss | 11 +++ src/pages/ContactsPage.tsx | 24 ++++- src/pages/ExportPage.tsx | 57 +++-------- src/stores/contactTypeCountsStore.ts | 115 ++++++++++++++++++++++ src/types/electron.d.ts | 10 ++ 8 files changed, 222 insertions(+), 136 deletions(-) create mode 100644 src/stores/contactTypeCountsStore.ts diff --git a/electron/main.ts b/electron/main.ts index e73a715..5985639 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -920,6 +920,10 @@ function registerIpcHandlers() { return chatService.getExportTabCounts() }) + ipcMain.handle('chat:getContactTypeCounts', async () => { + return chatService.getContactTypeCounts() + }) + ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => { return chatService.getSessionMessageCounts(sessionIds) }) diff --git a/electron/preload.ts b/electron/preload.ts index 999486f..8723db5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -132,6 +132,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessions: () => ipcRenderer.invoke('chat:getSessions'), getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames), getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), + getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'), getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), enrichSessionsContactInfo: (usernames: string[]) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 00958cf..788234e 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -762,111 +762,73 @@ class ChatService { } /** - * 获取导出页会话分类数量(轻量接口,优先用于顶部 Tab 数量展示) + * 获取联系人类型数量(好友、群聊、公众号、曾经的好友) */ - async getExportTabCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { + async getContactTypeCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { try { const connectResult = await this.ensureConnected() if (!connectResult.success) { return { success: false, error: connectResult.error } } - const sessionResult = await wcdbService.getSessions() - if (!sessionResult.success || !sessionResult.sessions) { - return { success: false, error: sessionResult.error || '获取会话失败' } + const excludeExpr = Array.from(FRIEND_EXCLUDE_USERNAMES) + .map((username) => `'${this.escapeSqlString(username)}'`) + .join(',') + + const countsSql = ` + SELECT + SUM(CASE WHEN username LIKE '%@chatroom' THEN 1 ELSE 0 END) AS group_count, + SUM(CASE WHEN username LIKE 'gh_%' THEN 1 ELSE 0 END) AS official_count, + SUM( + CASE + WHEN username NOT LIKE '%@chatroom' + AND username NOT LIKE 'gh_%' + AND local_type = 1 + AND username NOT IN (${excludeExpr}) + THEN 1 ELSE 0 + END + ) AS private_count, + SUM( + CASE + WHEN username NOT LIKE '%@chatroom' + AND username NOT LIKE 'gh_%' + AND local_type = 0 + AND COALESCE(quan_pin, '') != '' + THEN 1 ELSE 0 + END + ) AS former_friend_count + FROM contact + WHERE username IS NOT NULL + AND username != '' + ` + + const result = await wcdbService.execQuery('contact', null, countsSql) + if (!result.success || !result.rows || result.rows.length === 0) { + return { success: false, error: result.error || '获取联系人类型数量失败' } } + const row = result.rows[0] as Record const counts: ExportTabCounts = { - private: 0, - group: 0, - official: 0, - former_friend: 0 - } - - const nonGroupUsernames: string[] = [] - const usernameSet = new Set() - - for (const row of sessionResult.sessions as Record[]) { - const username = - row.username || - row.user_name || - row.userName || - row.usrName || - row.UsrName || - row.talker || - row.talker_id || - row.talkerId || - '' - - if (!this.shouldKeepSession(username)) continue - if (usernameSet.has(username)) continue - usernameSet.add(username) - - if (username.endsWith('@chatroom')) { - counts.group += 1 - } else { - nonGroupUsernames.push(username) - } - } - - if (nonGroupUsernames.length === 0) { - return { success: true, counts } - } - - const contactTypeMap = new Map() - const chunkSize = 400 - - for (let i = 0; i < nonGroupUsernames.length; i += chunkSize) { - const chunk = nonGroupUsernames.slice(i, i + chunkSize) - if (chunk.length === 0) continue - - const usernamesExpr = chunk.map((name) => `'${this.escapeSqlString(name)}'`).join(',') - const contactSql = ` - SELECT username, local_type, quan_pin - FROM contact - WHERE username IN (${usernamesExpr}) - ` - - const contactResult = await wcdbService.execQuery('contact', null, contactSql) - if (!contactResult.success || !contactResult.rows) { - continue - } - - for (const row of contactResult.rows as Record[]) { - const username = String(row.username || '').trim() - if (!username) continue - - if (username.startsWith('gh_')) { - contactTypeMap.set(username, 'official') - continue - } - - const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) - const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim() - if (localType === 0 && quanPin) { - contactTypeMap.set(username, 'former_friend') - } - } - } - - for (const username of nonGroupUsernames) { - const type = contactTypeMap.get(username) - if (type === 'official') { - counts.official += 1 - } else if (type === 'former_friend') { - counts.former_friend += 1 - } else { - counts.private += 1 - } + private: this.getRowInt(row, ['private_count', 'privateCount'], 0), + group: this.getRowInt(row, ['group_count', 'groupCount'], 0), + official: this.getRowInt(row, ['official_count', 'officialCount'], 0), + former_friend: this.getRowInt(row, ['former_friend_count', 'formerFriendCount'], 0) } return { success: true, counts } } catch (e) { - console.error('ChatService: 获取导出页会话分类数量失败:', e) + console.error('ChatService: 获取联系人类型数量失败:', e) return { success: false, error: String(e) } } } + /** + * 获取导出页会话分类数量(轻量接口,优先用于顶部 Tab 数量展示) + */ + async getExportTabCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { + return this.getContactTypeCounts() + } + /** * 批量获取会话消息总数(轻量接口,用于列表优先排序) */ diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index 6bb4844..541f428 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -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 { diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 570b40c..77271c0 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -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) => { 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() {
diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 9b704c8..1812efd 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -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([]) - const [prefetchedTabCounts, setPrefetchedTabCounts] = useState | null>(null) const [sessionMessageCounts, setSessionMessageCounts] = useState>({}) const [sessionMetrics, setSessionMetrics] = useState>({}) 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(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>((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 = { - 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 diff --git a/src/stores/contactTypeCountsStore.ts b/src/stores/contactTypeCountsStore.ts new file mode 100644 index 0000000..d3252fc --- /dev/null +++ b/src/stores/contactTypeCountsStore.ts @@ -0,0 +1,115 @@ +import { create } from 'zustand' +import type { ContactInfo } from '../types/models' + +export interface ContactTypeTabCounts { + private: number + group: number + official: number + former_friend: number +} + +export interface ContactTypeCardCounts { + friends: number + groups: number + officials: number + deletedFriends: number +} + +const emptyTabCounts: ContactTypeTabCounts = { + private: 0, + group: 0, + official: 0, + former_friend: 0 +} + +let inflightPromise: Promise | null = null + +const normalizeCounts = (counts?: Partial | null): ContactTypeTabCounts => { + return { + private: Number.isFinite(counts?.private) ? Math.max(0, Math.floor(Number(counts?.private))) : 0, + group: Number.isFinite(counts?.group) ? Math.max(0, Math.floor(Number(counts?.group))) : 0, + official: Number.isFinite(counts?.official) ? Math.max(0, Math.floor(Number(counts?.official))) : 0, + former_friend: Number.isFinite(counts?.former_friend) ? Math.max(0, Math.floor(Number(counts?.former_friend))) : 0 + } +} + +export const toContactTypeTabCountsFromContacts = (contacts: ContactInfo[]): ContactTypeTabCounts => { + const next = { ...emptyTabCounts } + for (const contact of contacts || []) { + if (contact.type === 'friend') next.private += 1 + if (contact.type === 'group') next.group += 1 + if (contact.type === 'official') next.official += 1 + if (contact.type === 'former_friend') next.former_friend += 1 + } + return next +} + +export const toContactTypeCardCounts = (counts: ContactTypeTabCounts): ContactTypeCardCounts => { + return { + friends: counts.private, + groups: counts.group, + officials: counts.official, + deletedFriends: counts.former_friend + } +} + +interface ContactTypeCountsState { + tabCounts: ContactTypeTabCounts + isLoading: boolean + isReady: boolean + updatedAt: number + setTabCounts: (counts: ContactTypeTabCounts) => void + syncFromContacts: (contacts: ContactInfo[]) => void + ensureLoaded: (options?: { force?: boolean }) => Promise +} + +export const useContactTypeCountsStore = create((set, get) => ({ + tabCounts: { ...emptyTabCounts }, + isLoading: false, + isReady: false, + updatedAt: 0, + setTabCounts: (counts) => { + const normalized = normalizeCounts(counts) + set({ + tabCounts: normalized, + isReady: true, + updatedAt: Date.now() + }) + }, + syncFromContacts: (contacts) => { + const fromContacts = toContactTypeTabCountsFromContacts(contacts || []) + get().setTabCounts(fromContacts) + }, + ensureLoaded: async (options) => { + if (!options?.force && get().isReady) { + return get().tabCounts + } + if (inflightPromise) { + return inflightPromise + } + + set({ isLoading: true }) + inflightPromise = (async () => { + try { + const result = await window.electronAPI.chat.getContactTypeCounts() + if (result?.success && result.counts) { + const normalized = normalizeCounts(result.counts) + set({ + tabCounts: normalized, + isReady: true, + updatedAt: Date.now() + }) + return normalized + } + } catch (error) { + console.error('加载联系人类型计数失败:', error) + } + return get().tabCounts + })().finally(() => { + inflightPromise = null + set({ isLoading: false }) + }) + + return inflightPromise + } +})) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 88bc819..f7a1e57 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -89,6 +89,16 @@ export interface ElectronAPI { } error?: string }> + getContactTypeCounts: () => Promise<{ + success: boolean + counts?: { + private: number + group: number + official: number + former_friend: number + } + error?: string + }> getSessionMessageCounts: (sessionIds: string[]) => Promise<{ success: boolean counts?: Record