diff --git a/electron/main.ts b/electron/main.ts index 5900356..fed8391 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -18,6 +18,7 @@ import { KeyService } from './services/keyService' import { voiceTranscribeService } from './services/voiceTranscribeService' import { videoService } from './services/videoService' import { snsService } from './services/snsService' +import { contactExportService } from './services/contactExportService' // 配置自动更新 @@ -625,11 +626,15 @@ function registerIpcHandlers() { }) ipcMain.handle('chat:getContact', async (_, username: string) => { - return chatService.getContact(username) + return await chatService.getContact(username) }) ipcMain.handle('chat:getContactAvatar', async (_, username: string) => { - return chatService.getContactAvatar(username) + return await chatService.getContactAvatar(username) + }) + + ipcMain.handle('chat:getContacts', async () => { + return await chatService.getContacts() }) ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => { @@ -710,6 +715,10 @@ function registerIpcHandlers() { return exportService.exportSessionToChatLab(sessionId, outputPath, options) }) + ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => { + return contactExportService.exportContacts(outputDir, options) + }) + // 数据分析相关 ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => { return analyticsService.getOverallStatistics(force) diff --git a/electron/preload.ts b/electron/preload.ts index 6fa3c36..9a83ea7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -120,7 +120,8 @@ contextBridge.exposeInMainWorld('electronAPI', { return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener) }, execQuery: (kind: string, path: string | null, sql: string) => - ipcRenderer.invoke('chat:execQuery', kind, path, sql) + ipcRenderer.invoke('chat:execQuery', kind, path, sql), + getContacts: () => ipcRenderer.invoke('chat:getContacts') }, @@ -194,6 +195,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), exportSession: (sessionId: string, outputPath: string, options: any) => ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options), + exportContacts: (outputDir: string, options: any) => + ipcRenderer.invoke('export:exportContacts', outputDir, options), onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => { ipcRenderer.on('export:progress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('export:progress') diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 2c3c143..6f2abf3 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -67,6 +67,15 @@ export interface Contact { nickName: string } +export interface ContactInfo { + username: string + displayName: string + remark?: string + nickname?: string + avatarUrl?: string + type: 'friend' | 'group' | 'official' | 'other' +} + // 表情包缓存 const emojiCache: Map = new Map() const emojiDownloading: Map> = new Map() @@ -328,7 +337,7 @@ class ChatService { const cached = this.avatarCache.get(username) // 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取 // 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取 - const isValidAvatar = cached?.avatarUrl && + const isValidAvatar = cached?.avatarUrl && !cached.avatarUrl.includes('base64,ffd8') // 检测错误的 hex 格式 if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) { result[username] = { @@ -494,6 +503,153 @@ class ChatService { } } + /** + * 获取通讯录列表 + */ + async getContacts(): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error } + } + + // 使用execQuery直接查询加密的contact.db + // kind='contact', path=null表示使用已打开的contact.db + const contactQuery = ` + SELECT username, remark, nick_name, alias, local_type + FROM contact + ` + + console.log('查询contact.db...') + const contactResult = await wcdbService.execQuery('contact', null, contactQuery) + + if (!contactResult.success || !contactResult.rows) { + console.error('查询联系人失败:', contactResult.error) + return { success: false, error: contactResult.error || '查询联系人失败' } + } + + console.log('查询到', contactResult.rows.length, '条联系人记录') + const rows = contactResult.rows as Record[] + + // 调试:显示前5条数据样本 + console.log('📋 前5条数据样本:') + rows.slice(0, 5).forEach((row, idx) => { + console.log(` ${idx + 1}. username: ${row.username}, local_type: ${row.local_type}, remark: ${row.remark || '无'}, nick_name: ${row.nick_name || '无'}`) + }) + + // 调试:统计local_type分布 + const localTypeStats = new Map() + rows.forEach(row => { + const lt = row.local_type || 0 + localTypeStats.set(lt, (localTypeStats.get(lt) || 0) + 1) + }) + console.log('📊 local_type分布:', Object.fromEntries(localTypeStats)) + + // 获取会话表的最后联系时间用于排序 + const lastContactTimeMap = new Map() + const sessionResult = await wcdbService.getSessions() + if (sessionResult.success && sessionResult.sessions) { + for (const session of sessionResult.sessions as any[]) { + const username = session.username || session.user_name || session.userName || '' + const timestamp = session.sort_timestamp || session.sortTimestamp || 0 + if (username && timestamp) { + lastContactTimeMap.set(username, timestamp) + } + } + } + + // 转换为ContactInfo + const contacts: (ContactInfo & { lastContactTime: number })[] = [] + + for (const row of rows) { + const username = row.username || '' + + // 过滤系统账号和特殊账号 - 完全复制cipher的逻辑 + if (!username) continue + if (username === 'filehelper' || username === 'fmessage' || username === 'floatbottle' || + username === 'medianote' || username === 'newsapp' || username.startsWith('fake_') || + username === 'weixin' || username === 'qmessage' || username === 'qqmail' || + username === 'tmessage' || username.startsWith('wxid_') === false && + username.includes('@') === false && username.startsWith('gh_') === false && + /^[a-zA-Z0-9_-]+$/.test(username) === false) { + continue + } + + // 判断类型 - 正确规则:wxid开头且有alias的是好友 + let type: 'friend' | 'group' | 'official' | 'other' = 'other' + const localType = row.local_type || 0 + + if (username.includes('@chatroom')) { + type = 'group' + } else if (username.startsWith('gh_')) { + type = 'official' + } else if (localType === 3 || localType === 4) { + type = 'official' + } else if (username.startsWith('wxid_') && row.alias) { + // wxid开头且有alias的是好友 + type = 'friend' + } else if (localType === 1) { + // local_type=1 也是好友 + type = 'friend' + } else if (localType === 2) { + // local_type=2 是群成员但非好友,跳过 + continue + } else if (localType === 0) { + // local_type=0 可能是好友或其他,检查是否有备注或昵称 + if (row.remark || row.nick_name) { + type = 'friend' + } else { + continue + } + } else { + // 其他未知类型,跳过 + continue + } + + const displayName = row.remark || row.nick_name || row.alias || username + + contacts.push({ + username, + displayName, + remark: row.remark || undefined, + nickname: row.nick_name || undefined, + avatarUrl: undefined, + type, + lastContactTime: lastContactTimeMap.get(username) || 0 + }) + } + + console.log('过滤后得到', contacts.length, '个有效联系人') + console.log('📊 按类型统计:', { + friends: contacts.filter(c => c.type === 'friend').length, + groups: contacts.filter(c => c.type === 'group').length, + officials: contacts.filter(c => c.type === 'official').length, + other: contacts.filter(c => c.type === 'other').length + }) + + // 按最近联系时间排序 + contacts.sort((a, b) => { + const timeA = a.lastContactTime || 0 + const timeB = b.lastContactTime || 0 + if (timeA && timeB) { + return timeB - timeA + } + if (timeA && !timeB) return -1 + if (!timeA && timeB) return 1 + return a.displayName.localeCompare(b.displayName, 'zh-CN') + }) + + // 移除临时的lastContactTime字段 + const result = contacts.map(({ lastContactTime, ...rest }) => rest) + + console.log('返回', result.length, '个联系人') + return { success: true, contacts: result } + } catch (e) { + console.error('ChatService: 获取通讯录失败:', e) + return { success: false, error: String(e) } + } + } + /** * 获取消息列表(支持跨多个数据库合并,已优化) */ @@ -3098,7 +3254,7 @@ class ChatService { private resolveAccountDir(dbPath: string, wxid: string): string | null { const normalized = dbPath.replace(/[\\\\/]+$/, '') - + // 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件) // 则向上回溯到账号目录 if (basename(normalized).toLowerCase() === 'db_storage') { @@ -3108,14 +3264,14 @@ class ChatService { if (basename(dir).toLowerCase() === 'db_storage') { return dirname(dir) } - + // 否则,dbPath 应该是数据库根目录(如 xwechat_files) // 账号目录应该是 {dbPath}/{wxid} const accountDirWithWxid = join(normalized, wxid) if (existsSync(accountDirWithWxid)) { return accountDirWithWxid } - + // 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录) return normalized } diff --git a/electron/services/contactExportService.ts b/electron/services/contactExportService.ts new file mode 100644 index 0000000..6a33432 --- /dev/null +++ b/electron/services/contactExportService.ts @@ -0,0 +1,159 @@ +import * as fs from 'fs' +import * as path from 'path' +import { chatService } from './chatService' + +interface ContactExportOptions { + format: 'json' | 'csv' | 'vcf' + exportAvatars: boolean + contactTypes: { + friends: boolean + groups: boolean + officials: boolean + } +} + +/** + * 联系人导出服务 + */ +class ContactExportService { + /** + * 导出联系人 + */ + async exportContacts( + outputDir: string, + options: ContactExportOptions + ): Promise<{ success: boolean; successCount?: number; error?: string }> { + try { + // 获取所有联系人 + const contactsResult = await chatService.getContacts() + if (!contactsResult.success || !contactsResult.contacts) { + return { success: false, error: contactsResult.error || '获取联系人失败' } + } + + let contacts = contactsResult.contacts + + // 根据类型过滤 + contacts = contacts.filter(c => { + if (c.type === 'friend' && !options.contactTypes.friends) return false + if (c.type === 'group' && !options.contactTypes.groups) return false + if (c.type === 'official' && !options.contactTypes.officials) return false + return true + }) + + if (contacts.length === 0) { + return { success: false, error: '没有符合条件的联系人' } + } + + // 确保输出目录存在 + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }) + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) + let outputPath: string + + switch (options.format) { + case 'json': + outputPath = path.join(outputDir, `contacts_${timestamp}.json`) + await this.exportToJSON(contacts, outputPath) + break + case 'csv': + outputPath = path.join(outputDir, `contacts_${timestamp}.csv`) + await this.exportToCSV(contacts, outputPath) + break + case 'vcf': + outputPath = path.join(outputDir, `contacts_${timestamp}.vcf`) + await this.exportToVCF(contacts, outputPath) + break + default: + return { success: false, error: '不支持的导出格式' } + } + + return { success: true, successCount: contacts.length } + } catch (e) { + return { success: false, error: String(e) } + } + } + + /** + * 导出为JSON格式 + */ + private async exportToJSON(contacts: any[], outputPath: string): Promise { + const data = { + exportedAt: new Date().toISOString(), + count: contacts.length, + contacts: contacts.map(c => ({ + username: c.username, + displayName: c.displayName, + remark: c.remark, + nickname: c.nickname, + type: c.type + })) + } + fs.writeFileSync(outputPath, JSON.stringify(data, null, 2), 'utf-8') + } + + /** + * 导出为CSV格式 + */ + private async exportToCSV(contacts: any[], outputPath: string): Promise { + const headers = ['用户名', '显示名称', '备注', '昵称', '类型'] + const rows = contacts.map(c => [ + c.username || '', + c.displayName || '', + c.remark || '', + c.nickname || '', + this.getTypeLabel(c.type) + ]) + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) + ].join('\n') + + fs.writeFileSync(outputPath, '\uFEFF' + csvContent, 'utf-8') // 添加BOM以支持Excel + } + + /** + * 导出为VCF格式(vCard) + */ + private async exportToVCF(contacts: any[], outputPath: string): Promise { + const vcards = contacts + .filter(c => c.type === 'friend') // VCF通常只用于个人联系人 + .map(c => { + const lines = ['BEGIN:VCARD', 'VERSION:3.0'] + + // 全名 + lines.push(`FN:${c.displayName || c.username}`) + + // 昵称 + if (c.nickname) { + lines.push(`NICKNAME:${c.nickname}`) + } + + // 备注 + if (c.remark) { + lines.push(`NOTE:${c.remark}`) + } + + // 微信ID + lines.push(`X-WECHAT-ID:${c.username}`) + + lines.push('END:VCARD') + return lines.join('\r\n') + }) + + fs.writeFileSync(outputPath, vcards.join('\r\n\r\n'), 'utf-8') + } + + private getTypeLabel(type: string): string { + switch (type) { + case 'friend': return '好友' + case 'group': return '群聊' + case 'official': return '公众号' + default: return '其他' + } + } +} + +export const contactExportService = new ContactExportService() diff --git a/src/App.tsx b/src/App.tsx index c8c4074..a5f8a28 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import SettingsPage from './pages/SettingsPage' import ExportPage from './pages/ExportPage' import VideoWindow from './pages/VideoWindow' import SnsPage from './pages/SnsPage' +import ContactsPage from './pages/ContactsPage' import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId } from './stores/themeStore' @@ -344,6 +345,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 08188e5..d3890ca 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture } from 'lucide-react' +import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture, UserCircle } from 'lucide-react' import './Sidebar.scss' function Sidebar() { @@ -44,7 +44,15 @@ function Sidebar() { 朋友圈 - + {/* 通讯录 */} + + + 通讯录 + {/* 私聊分析 */} ([]) + const [filteredContacts, setFilteredContacts] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [searchKeyword, setSearchKeyword] = useState('') + const [contactTypes, setContactTypes] = useState({ + friends: true, + groups: true, + officials: true + }) + + // 导出相关状态 + const [exportFormat, setExportFormat] = useState<'json' | 'csv' | 'vcf'>('json') + const [exportAvatars, setExportAvatars] = useState(true) + const [exportFolder, setExportFolder] = useState('') + const [isExporting, setIsExporting] = useState(false) + + // 加载通讯录 + const loadContacts = useCallback(async () => { + setIsLoading(true) + try { + const result = await window.electronAPI.chat.connect() + if (!result.success) { + console.error('连接失败:', result.error) + setIsLoading(false) + return + } + const contactsResult = await window.electronAPI.chat.getContacts() + console.log('📞 getContacts结果:', contactsResult) + if (contactsResult.success && contactsResult.contacts) { + console.log('📊 总联系人数:', contactsResult.contacts.length) + console.log('📊 按类型统计:', { + friends: contactsResult.contacts.filter(c => c.type === 'friend').length, + groups: contactsResult.contacts.filter(c => c.type === 'group').length, + officials: contactsResult.contacts.filter(c => c.type === 'official').length, + other: contactsResult.contacts.filter(c => c.type === 'other').length + }) + + // 获取头像URL + const usernames = contactsResult.contacts.map(c => c.username) + if (usernames.length > 0) { + const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) + if (avatarResult.success && avatarResult.contacts) { + contactsResult.contacts.forEach(contact => { + const enriched = avatarResult.contacts?.[contact.username] + if (enriched?.avatarUrl) { + contact.avatarUrl = enriched.avatarUrl + } + }) + } + } + + setContacts(contactsResult.contacts) + setFilteredContacts(contactsResult.contacts) + } + } catch (e) { + console.error('加载通讯录失败:', e) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + loadContacts() + }, [loadContacts]) + + // 搜索和类型过滤 + useEffect(() => { + let filtered = contacts + + // 类型过滤 + filtered = filtered.filter(c => { + if (c.type === 'friend' && !contactTypes.friends) return false + if (c.type === 'group' && !contactTypes.groups) return false + if (c.type === 'official' && !contactTypes.officials) return false + return true + }) + + // 关键词过滤 + if (searchKeyword.trim()) { + const lower = searchKeyword.toLowerCase() + filtered = filtered.filter(c => + c.displayName?.toLowerCase().includes(lower) || + c.remark?.toLowerCase().includes(lower) || + c.username.toLowerCase().includes(lower) + ) + } + + setFilteredContacts(filtered) + }, [searchKeyword, contacts, contactTypes]) + + const getAvatarLetter = (name: string) => { + if (!name) return '?' + return [...name][0] || '?' + } + + const getContactTypeIcon = (type: string) => { + switch (type) { + case 'friend': return + case 'group': return + case 'official': return + default: return + } + } + + const getContactTypeName = (type: string) => { + switch (type) { + case 'friend': return '好友' + case 'group': return '群聊' + case 'official': return '公众号' + default: return '其他' + } + } + + // 选择导出文件夹 + const selectExportFolder = async () => { + try { + const result = await window.electronAPI.dialog.openDirectory({ + title: '选择导出位置' + }) + if (result && !result.canceled && result.filePaths && result.filePaths.length > 0) { + setExportFolder(result.filePaths[0]) + } + } catch (e) { + console.error('选择文件夹失败:', e) + } + } + + // 开始导出 + const startExport = async () => { + if (!exportFolder) { + alert('请先选择导出位置') + return + } + + setIsExporting(true) + try { + const exportOptions = { + format: exportFormat, + exportAvatars, + contactTypes: { + friends: contactTypes.friends, + groups: contactTypes.groups, + officials: contactTypes.officials + } + } + + const result = await window.electronAPI.export.exportContacts(exportFolder, exportOptions) + + if (result.success) { + alert(`导出成功!共导出 ${result.successCount} 个联系人`) + } else { + alert(`导出失败:${result.error}`) + } + } catch (e) { + console.error('导出失败:', e) + alert(`导出失败:${String(e)}`) + } finally { + setIsExporting(false) + } + } + + return ( +
+
+

通讯录

+ +
+ +
+
+ + setSearchKeyword(e.target.value)} + /> + {searchKeyword && ( + + )} +
+ +
+ + + +
+ +
+ 共 {filteredContacts.length} 个联系人 +
+
+ + {/* 导出区域 */} +
+

导出通讯录

+ +
+ + +
+ +
+ +
+ +
+ +
+ + +
+ +
+ {isLoading ? ( +
+ + 加载中... +
+ ) : filteredContacts.length === 0 ? ( +
+ 暂无联系人 +
+ ) : ( +
+ {filteredContacts.map(contact => ( +
+
+ {contact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(contact.displayName)} + )} +
+
+
{contact.displayName}
+ {contact.remark && contact.remark !== contact.displayName && ( +
备注: {contact.remark}
+ )} +
+
+ {getContactTypeIcon(contact.type)} + {getContactTypeName(contact.type)} +
+
+ ))} +
+ )} +
+
+ ) +} + +export default ContactsPage diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index abd1d6f..2622cce 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1,4 +1,4 @@ -import type { ChatSession, Message, Contact } from './models' +import type { ChatSession, Message, Contact, ContactInfo } from './models' export interface ElectronAPI { window: { @@ -76,6 +76,11 @@ export interface ElectronAPI { }> getContact: (username: string) => Promise getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null> + getContacts: () => Promise<{ + success: boolean + contacts?: ContactInfo[] + error?: string + }> getMyAvatarUrl: () => Promise<{ success: boolean; avatarUrl?: string; error?: string }> downloadEmoji: (cdnUrl: string, md5?: string) => Promise<{ success: boolean; localPath?: string; error?: string }> close: () => Promise @@ -315,6 +320,11 @@ export interface ElectronAPI { success: boolean error?: string }> + exportContacts: (outputDir: string, options: { format: 'json' | 'csv' | 'vcf'; exportAvatars: boolean; contactTypes: { friends: boolean; groups: boolean; officials: boolean } }) => Promise<{ + success: boolean + successCount?: number + error?: string + }> onProgress: (callback: (payload: ExportProgress) => void) => () => void } whisper: { diff --git a/src/types/models.ts b/src/types/models.ts index 46c3d42..45ec73d 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -23,6 +23,15 @@ export interface Contact { smallHeadUrl: string } +export interface ContactInfo { + username: string + displayName: string + remark?: string + nickname?: string + avatarUrl?: string + type: 'friend' | 'group' | 'official' | 'other' +} + // 消息 export interface Message { localId: number