This commit is contained in:
cc
2026-01-28 23:07:42 +08:00
21 changed files with 11334 additions and 52 deletions

BIN
4wm.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -38,9 +38,10 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
<p align="center"> <p align="center">
<img src="2wm.png" alt="WeFlow 微信交流群二维码(一群)" width="220" style="margin-right: 16px;"> <img src="2wm.png" alt="WeFlow 微信交流群二维码(一群)" width="220" style="margin-right: 16px;">
<img src="3wm.png" alt="WeFlow 微信交流群二维码(二群)" width="220"> <img src="3wm.png" alt="WeFlow 微信交流群二维码(二群)" width="220" style="margin-right: 16px;">
<img src="4wm.jpg" alt="WeFlow 微信交流群二维码(三群)" width="220"
</p> </p>
<p align="center">一群满了加二群</p> <p align="center">扫到哪个算哪个</p>
## 主要功能 ## 主要功能

View File

@@ -19,6 +19,7 @@ import { KeyService } from './services/keyService'
import { voiceTranscribeService } from './services/voiceTranscribeService' import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService' import { videoService } from './services/videoService'
import { snsService } from './services/snsService' import { snsService } from './services/snsService'
import { contactExportService } from './services/contactExportService'
// 配置自动更新 // 配置自动更新
@@ -137,6 +138,28 @@ function createWindow(options: { autoShow?: boolean } = {}) {
win.loadFile(join(__dirname, '../dist/index.html')) win.loadFile(join(__dirname, '../dist/index.html'))
} }
// 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权
session.defaultSession.webRequest.onBeforeSendHeaders(
{
urls: [
'*://*.qpic.cn/*',
'*://*.qlogo.cn/*',
'*://*.wechat.com/*',
'*://*.weixin.qq.com/*'
]
},
(details, callback) => {
details.requestHeaders['User-Agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351"
details.requestHeaders['Accept'] = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
details.requestHeaders['Accept-Encoding'] = "gzip, deflate, br"
details.requestHeaders['Accept-Language'] = "zh-CN,zh;q=0.9"
details.requestHeaders['Referer'] = "https://servicewechat.com/"
details.requestHeaders['Connection'] = "keep-alive"
details.requestHeaders['Range'] = "bytes=0-"
callback({ cancel: false, requestHeaders: details.requestHeaders })
}
)
return win return win
} }
@@ -626,11 +649,15 @@ function registerIpcHandlers() {
}) })
ipcMain.handle('chat:getContact', async (_, username: string) => { ipcMain.handle('chat:getContact', async (_, username: string) => {
return chatService.getContact(username) return await chatService.getContact(username)
}) })
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => { 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) => { ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
@@ -719,6 +746,10 @@ function registerIpcHandlers() {
return exportService.exportSessionToChatLab(sessionId, outputPath, options) 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) => { ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => {
return analyticsService.getOverallStatistics(force) return analyticsService.getOverallStatistics(force)

View File

@@ -120,7 +120,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener) return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
}, },
execQuery: (kind: string, path: string | null, sql: string) => 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), ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
exportSession: (sessionId: string, outputPath: string, options: any) => exportSession: (sessionId: string, outputPath: string, options: any) =>
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options), 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) => { onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
ipcRenderer.on('export:progress', (_, payload) => callback(payload)) ipcRenderer.on('export:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('export:progress') return () => ipcRenderer.removeAllListeners('export:progress')

View File

@@ -67,6 +67,15 @@ export interface Contact {
nickName: string nickName: string
} }
export interface ContactInfo {
username: string
displayName: string
remark?: string
nickname?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'other'
}
// 表情包缓存 // 表情包缓存
const emojiCache: Map<string, string> = new Map() const emojiCache: Map<string, string> = new Map()
const emojiDownloading: Map<string, Promise<string | null>> = new Map() const emojiDownloading: Map<string, Promise<string | null>> = new Map()
@@ -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<string, any>[]
// 调试显示前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<number, number>()
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<string, number>()
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) }
}
}
/** /**
* 获取消息列表(支持跨多个数据库合并,已优化) * 获取消息列表(支持跨多个数据库合并,已优化)
*/ */

View File

@@ -26,6 +26,7 @@ interface ConfigSchema {
whisperDownloadSource: string whisperDownloadSource: string
autoTranscribeVoice: boolean autoTranscribeVoice: boolean
transcribeLanguages: string[] transcribeLanguages: string[]
exportDefaultConcurrency: number
} }
export class ConfigService { export class ConfigService {
@@ -54,7 +55,8 @@ export class ConfigService {
whisperModelDir: '', whisperModelDir: '',
whisperDownloadSource: 'tsinghua', whisperDownloadSource: 'tsinghua',
autoTranscribeVoice: false, autoTranscribeVoice: false,
transcribeLanguages: ['zh'] transcribeLanguages: ['zh'],
exportDefaultConcurrency: 2
} }
}) })
} }

View File

@@ -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<void> {
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<void> {
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<void> {
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()

View File

@@ -79,6 +79,7 @@ export interface ExportOptions {
txtColumns?: string[] txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session' sessionLayout?: 'shared' | 'per-session'
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number
} }
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
@@ -1289,6 +1290,7 @@ class ExportService {
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> { ): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
const rows: any[] = [] const rows: any[] = []
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>() const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
const senderSet = new Set<string>()
let firstTime: number | null = null let firstTime: number | null = null
let lastTime: number | null = null let lastTime: number | null = null
@@ -1322,16 +1324,7 @@ class ExportService {
const localId = parseInt(row.local_id || row.localId || '0', 10) const localId = parseInt(row.local_id || row.localId || '0', 10)
const actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId) const actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
const memberInfo = await this.getContactInfo(actualSender) senderSet.add(actualSender)
if (!memberSet.has(actualSender)) {
memberSet.set(actualSender, {
member: {
platformId: actualSender,
accountName: memberInfo.displayName
},
avatarUrl: memberInfo.avatarUrl
})
}
// 提取媒体相关字段 // 提取媒体相关字段
let imageMd5: string | undefined let imageMd5: string | undefined
@@ -1376,6 +1369,30 @@ class ExportService {
await wcdbService.closeMessageCursor(cursor.cursor) await wcdbService.closeMessageCursor(cursor.cursor)
} }
if (senderSet.size > 0) {
const usernames = Array.from(senderSet)
const [nameResult, avatarResult] = await Promise.all([
wcdbService.getDisplayNames(usernames),
wcdbService.getAvatarUrls(usernames)
])
const nameMap = nameResult.success && nameResult.map ? nameResult.map : {}
const avatarMap = avatarResult.success && avatarResult.map ? avatarResult.map : {}
for (const username of usernames) {
const displayName = nameMap[username] || username
const avatarUrl = avatarMap[username]
memberSet.set(username, {
member: {
platformId: username,
accountName: displayName
},
avatarUrl
})
this.contactCache.set(username, { displayName, avatarUrl })
}
}
return { rows, memberSet, firstTime, lastTime } return { rows, memberSet, firstTime, lastTime }
} }
@@ -1606,6 +1623,14 @@ class ExportService {
} }
} }
private getWeflowHeader(): { version: string; exportedAt: number; generator: string } {
return {
version: '1.0.3',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'WeFlow'
}
}
/** /**
* 生成通用的导出元数据 (参考 ChatLab 格式) * 生成通用的导出元数据 (参考 ChatLab 格式)
*/ */
@@ -1662,6 +1687,12 @@ class ExportService {
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages = collected.rows const allMessages = collected.rows
// 如果没有消息,不创建文件
if (allMessages.length === 0) {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
if (isGroup) { if (isGroup) {
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
} }
@@ -1847,6 +1878,16 @@ class ExportService {
const sessionInfo = await this.getContactInfo(sessionId) const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid) const myInfo = await this.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) {
return contactCache.get(username)!
}
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
onProgress?.({ onProgress?.({
current: 0, current: 0,
total: 100, total: 100,
@@ -1859,6 +1900,12 @@ class ExportService {
} }
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
// ========== 阶段1并行导出媒体文件 ========== // ========== 阶段1并行导出媒体文件 ==========
@@ -1951,7 +1998,7 @@ class ExportService {
// 获取发送者信息用于名称显示 // 获取发送者信息用于名称显示
const senderWxid = msg.senderUsername const senderWxid = msg.senderUsername
const contact = await wcdbService.getContact(senderWxid) const contact = await getContactCached(senderWxid)
const senderNickname = contact.success && contact.contact?.nickName const senderNickname = contact.success && contact.contact?.nickName
? contact.contact.nickName ? contact.contact.nickName
: (senderInfo.displayName || senderWxid) : (senderInfo.displayName || senderWxid)
@@ -1994,7 +2041,7 @@ class ExportService {
const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup) const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup)
// 获取会话的昵称和备注信息 // 获取会话的昵称和备注信息
const sessionContact = await wcdbService.getContact(sessionId) const sessionContact = await getContactCached(sessionId)
const sessionNickname = sessionContact.success && sessionContact.contact?.nickName const sessionNickname = sessionContact.success && sessionContact.contact?.nickName
? sessionContact.contact.nickName ? sessionContact.contact.nickName
: sessionInfo.displayName : sessionInfo.displayName
@@ -2014,7 +2061,9 @@ class ExportService {
options.displayNamePreference || 'remark' options.displayNamePreference || 'remark'
) )
const weflow = this.getWeflowHeader()
const detailedExport: any = { const detailedExport: any = {
weflow,
session: { session: {
wxid: sessionId, wxid: sessionId,
nickname: sessionNickname, nickname: sessionNickname,
@@ -2085,8 +2134,18 @@ class ExportService {
const sessionInfo = await this.getContactInfo(sessionId) const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid) const myInfo = await this.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) {
return contactCache.get(username)!
}
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
// 获取会话的备注信息 // 获取会话的备注信息
const sessionContact = await wcdbService.getContact(sessionId) const sessionContact = await getContactCached(sessionId)
const sessionRemark = sessionContact.success && sessionContact.contact?.remark ? sessionContact.contact.remark : '' const sessionRemark = sessionContact.success && sessionContact.contact?.remark ? sessionContact.contact.remark : ''
const sessionNickname = sessionContact.success && sessionContact.contact?.nickName ? sessionContact.contact.nickName : sessionId const sessionNickname = sessionContact.success && sessionContact.contact?.nickName ? sessionContact.contact.nickName : sessionId
@@ -2103,6 +2162,10 @@ class ExportService {
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
onProgress?.({ onProgress?.({
current: 30, current: 30,
@@ -2315,7 +2378,7 @@ class ExportService {
senderWxid = msg.senderUsername senderWxid = msg.senderUsername
// 用 getContact 获取联系人详情,分别取昵称和备注 // 用 getContact 获取联系人详情,分别取昵称和备注
const contactDetail = await wcdbService.getContact(msg.senderUsername) const contactDetail = await getContactCached(msg.senderUsername)
if (contactDetail.success && contactDetail.contact) { if (contactDetail.success && contactDetail.contact) {
// nickName 才是真正的昵称 // nickName 才是真正的昵称
senderNickname = contactDetail.contact.nickName || msg.senderUsername senderNickname = contactDetail.contact.nickName || msg.senderUsername
@@ -2330,7 +2393,7 @@ class ExportService {
} else { } else {
// 单聊对方消息 - 用 getContact 获取联系人详情 // 单聊对方消息 - 用 getContact 获取联系人详情
senderWxid = sessionId senderWxid = sessionId
const contactDetail = await wcdbService.getContact(sessionId) const contactDetail = await getContactCached(sessionId)
if (contactDetail.success && contactDetail.contact) { if (contactDetail.success && contactDetail.contact) {
senderNickname = contactDetail.contact.nickName || sessionId senderNickname = contactDetail.contact.nickName || sessionId
senderRemark = contactDetail.contact.remark || '' senderRemark = contactDetail.contact.remark || ''
@@ -2351,7 +2414,10 @@ class ExportService {
const row = worksheet.getRow(currentRow) const row = worksheet.getRow(currentRow)
row.height = 24 row.height = 24
const contentValue = this.formatPlainExportContent( const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey)
const contentValue = mediaItem?.relativePath
|| this.formatPlainExportContent(
msg.content, msg.content,
msg.localType, msg.localType,
options, options,
@@ -2495,6 +2561,12 @@ class ExportService {
} }
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
@@ -2563,7 +2635,10 @@ class ExportService {
for (let i = 0; i < sortedMessages.length; i++) { for (let i = 0; i < sortedMessages.length; i++) {
const msg = sortedMessages[i] const msg = sortedMessages[i]
const contentValue = this.formatPlainExportContent( const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey)
const contentValue = mediaItem?.relativePath
|| this.formatPlainExportContent(
msg.content, msg.content,
msg.localType, msg.localType,
options, options,
@@ -2581,7 +2656,7 @@ class ExportService {
senderNickname = myInfo.displayName || cleanedMyWxid senderNickname = myInfo.displayName || cleanedMyWxid
} else if (isGroup && msg.senderUsername) { } else if (isGroup && msg.senderUsername) {
senderWxid = msg.senderUsername senderWxid = msg.senderUsername
const contactDetail = await wcdbService.getContact(msg.senderUsername) const contactDetail = await getContactCached(msg.senderUsername)
if (contactDetail.success && contactDetail.contact) { if (contactDetail.success && contactDetail.contact) {
senderNickname = contactDetail.contact.nickName || msg.senderUsername senderNickname = contactDetail.contact.nickName || msg.senderUsername
senderRemark = contactDetail.contact.remark || '' senderRemark = contactDetail.contact.remark || ''
@@ -2592,7 +2667,7 @@ class ExportService {
} }
} else { } else {
senderWxid = sessionId senderWxid = sessionId
const contactDetail = await wcdbService.getContact(sessionId) const contactDetail = await getContactCached(sessionId)
if (contactDetail.success && contactDetail.contact) { if (contactDetail.success && contactDetail.contact) {
senderNickname = contactDetail.contact.nickName || sessionId senderNickname = contactDetail.contact.nickName || sessionId
senderRemark = contactDetail.contact.remark || '' senderRemark = contactDetail.contact.remark || ''
@@ -2670,6 +2745,12 @@ class ExportService {
} }
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
if (isGroup) { if (isGroup) {
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
} }
@@ -3017,13 +3098,20 @@ class ExportService {
const sessionLayout = exportMediaEnabled const sessionLayout = exportMediaEnabled
? (options.sessionLayout ?? 'per-session') ? (options.sessionLayout ?? 'per-session')
: 'shared' : 'shared'
let completedCount = 0
const rawConcurrency = typeof options.exportConcurrency === 'number'
? Math.floor(options.exportConcurrency)
: 2
const clampedConcurrency = Math.max(1, Math.min(rawConcurrency, 6))
const sessionConcurrency = (exportMediaEnabled && sessionLayout === 'shared')
? 1
: clampedConcurrency
for (let i = 0; i < sessionIds.length; i++) { await parallelLimit(sessionIds, sessionConcurrency, async (sessionId) => {
const sessionId = sessionIds[i]
const sessionInfo = await this.getContactInfo(sessionId) const sessionInfo = await this.getContactInfo(sessionId)
onProgress?.({ onProgress?.({
current: i + 1, current: completedCount,
total: sessionIds.length, total: sessionIds.length,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
phase: 'exporting' phase: 'exporting'
@@ -3065,7 +3153,15 @@ class ExportService {
failCount++ failCount++
console.error(`导出 ${sessionId} 失败:`, result.error) console.error(`导出 ${sessionId} 失败:`, result.error)
} }
}
completedCount++
onProgress?.({
current: completedCount,
total: sessionIds.length,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
})
onProgress?.({ onProgress?.({
current: sessionIds.length, current: sessionIds.length,

View File

@@ -415,8 +415,14 @@ export class ImageDecryptService {
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath return hardlinkPath
} }
// hardlink 找到的是缩略图,但要求高清图,直接返回 null不再搜索 // hardlink 找到的是缩略图,但要求高清图:尝试同目录查找高清变体
if (!allowThumbnail && isThumb) { if (!allowThumbnail && isThumb) {
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageMd5, hdPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
return null return null
} }
} }
@@ -432,6 +438,11 @@ export class ImageDecryptService {
return fallbackPath return fallbackPath
} }
if (!allowThumbnail && isThumb) { if (!allowThumbnail && isThumb) {
const hdPath = this.findHdVariantInSameDir(fallbackPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
return null return null
} }
} }
@@ -449,15 +460,20 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, hardlinkPath) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath return hardlinkPath
} }
// hardlink 找到的是缩略图,但要求高清图,直接返回 null // hardlink 找到的是缩略图,但要求高清图:尝试同目录查找高清变体
if (!allowThumbnail && isThumb) { if (!allowThumbnail && isThumb) {
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
return null return null
} }
} }
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
} }
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) // 如果要求高清图但 hardlink 没找到,也不要搜索全盘了(搜索太慢)
if (!allowThumbnail) { if (!allowThumbnail) {
return null return null
} }
@@ -467,6 +483,9 @@ export class ImageDecryptService {
const cached = this.resolvedCache.get(imageDatName) const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) { if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
// 缓存的是缩略图,尝试同目录找高清变体
const hdPath = this.findHdVariantInSameDir(cached)
if (hdPath) return hdPath
} }
} }
@@ -511,6 +530,38 @@ export class ImageDecryptService {
return this.searchDatFile(accountDir, imageDatName, true, true) return this.searchDatFile(accountDir, imageDatName, true, true)
} }
/**
* 在同目录中尝试查找高清图变体
* 缩略图: xxx_t.dat / xxx.t.dat -> 高清图: xxx_h.dat / xxx.h.dat / xxx.dat
*/
private findHdVariantInSameDir(thumbPath: string): string | null {
try {
const dir = dirname(thumbPath)
const fileName = basename(thumbPath).toLowerCase()
let baseName = fileName
if (baseName.endsWith('_t.dat')) {
baseName = baseName.slice(0, -6)
} else if (baseName.endsWith('.t.dat')) {
baseName = baseName.slice(0, -6)
} else {
return null
}
const variants = [
`${baseName}_h.dat`,
`${baseName}.h.dat`,
`${baseName}.dat`
]
for (const variant of variants) {
const candidate = join(dir, variant)
if (existsSync(candidate)) return candidate
}
} catch { }
return null
}
private async checkHasUpdate( private async checkHasUpdate(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
cacheKey: string, cacheKey: string,

9803
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.4.0", "version": "1.4.1",
"description": "WeFlow", "description": "WeFlow",
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"author": "cc", "author": "cc",

View File

@@ -17,6 +17,7 @@ import SettingsPage from './pages/SettingsPage'
import ExportPage from './pages/ExportPage' import ExportPage from './pages/ExportPage'
import VideoWindow from './pages/VideoWindow' import VideoWindow from './pages/VideoWindow'
import SnsPage from './pages/SnsPage' import SnsPage from './pages/SnsPage'
import ContactsPage from './pages/ContactsPage'
import { useAppStore } from './stores/appStore' import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId } from './stores/themeStore' import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
@@ -349,6 +350,7 @@ function App() {
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<ExportPage />} /> <Route path="/export" element={<ExportPage />} />
<Route path="/sns" element={<SnsPage />} /> <Route path="/sns" element={<SnsPage />} />
<Route path="/contacts" element={<ContactsPage />} />
</Routes> </Routes>
</RouteGuard> </RouteGuard>
</main> </main>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { NavLink, useLocation } from 'react-router-dom' 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' import './Sidebar.scss'
function Sidebar() { function Sidebar() {
@@ -44,7 +44,15 @@ function Sidebar() {
<span className="nav-label"></span> <span className="nav-label"></span>
</NavLink> </NavLink>
{/* 通讯录 */}
<NavLink
to="/contacts"
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
title={collapsed ? '通讯录' : undefined}
>
<span className="nav-icon"><UserCircle size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 私聊分析 */} {/* 私聊分析 */}
<NavLink <NavLink

544
src/pages/ContactsPage.scss Normal file
View File

@@ -0,0 +1,544 @@
.contacts-page {
display: flex;
height: calc(100% + 48px);
margin: -24px;
background: var(--bg-primary);
overflow: hidden;
// 左侧联系人面板
.contacts-panel {
width: 380px;
min-width: 380px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
background: var(--card-bg);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.icon-btn {
width: 32px;
height: 32px;
border: none;
background: var(--bg-tertiary);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spin {
animation: contactsSpin 1s linear infinite;
}
}
}
.search-bar {
display: flex;
align-items: center;
gap: 10px;
margin: 16px 20px;
padding: 10px 14px;
background: var(--bg-secondary);
border-radius: 10px;
border: 1px solid var(--border-color);
transition: border-color 0.2s;
&:focus-within {
border-color: var(--primary);
}
svg {
color: var(--text-tertiary);
flex-shrink: 0;
}
input {
flex: 1;
border: none;
background: none;
outline: none;
font-size: 14px;
color: var(--text-primary);
&::placeholder {
color: var(--text-tertiary);
}
}
.clear-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.type-filters {
display: flex;
gap: 12px;
padding: 0 20px 12px;
flex-wrap: nowrap;
overflow-x: auto;
/* Allow horizontal scroll if needed on very small screens */
/* Hide scrollbar */
&::-webkit-scrollbar {
display: none;
}
.filter-checkbox {
display: flex;
/* Changed to flex with padding */
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
font-size: 14px;
color: var(--text-primary);
padding: 6px 12px;
background: var(--bg-secondary);
border-radius: 8px;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
}
input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
opacity: 1;
/* Make visible */
position: static;
/* Make static */
pointer-events: auto;
/* Enable pointer events */
}
svg {
color: var(--text-secondary);
flex-shrink: 0;
margin-left: 2px;
}
}
}
.contacts-count {
padding: 0 20px 12px;
font-size: 13px;
color: var(--text-secondary);
}
.loading-state,
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-tertiary);
font-size: 14px;
.spin {
animation: contactsSpin 1s linear infinite;
}
}
.contacts-list {
flex: 1;
overflow-y: auto;
padding: 0 12px 12px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 3px;
opacity: 0.3;
}
}
.contact-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 10px;
transition: all 0.2s;
margin-bottom: 4px;
&:hover {
background: var(--bg-hover);
}
.contact-avatar {
width: 44px;
height: 44px;
border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: #fff;
font-size: 16px;
font-weight: 600;
}
}
.contact-info {
flex: 1;
min-width: 0;
}
.contact-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contact-remark {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.contact-type {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
&.friend {
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
}
&.group {
background: rgba(52, 211, 153, 0.1);
color: rgb(52, 211, 153);
}
&.official {
background: rgba(251, 191, 36, 0.1);
color: rgb(251, 191, 36);
}
svg {
flex-shrink: 0;
}
}
}
// 右侧设置面板
.settings-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.settings-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 3px;
}
}
.setting-section {
margin-bottom: 28px;
h3 {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 14px;
}
}
.format-select {
position: relative;
/* margin-bottom 移到 .setting-section */
.select-trigger {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
/* Rounded pill shape */
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--text-tertiary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.select-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
box-shadow: var(--shadow-md);
z-index: 20;
max-height: 260px;
overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.select-option {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
transition: all 0.15s;
color: var(--text-primary);
font-size: 14px;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
.option-desc {
color: var(--primary);
}
}
}
.option-label {
font-weight: 500;
}
.option-desc {
font-size: 12px;
color: var(--text-tertiary);
}
}
.checkbox-item {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 14px;
color: var(--text-primary);
input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
}
}
.export-path-display {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: var(--bg-secondary);
border-radius: 10px;
font-size: 13px;
color: var(--text-primary);
margin-bottom: 12px;
svg {
color: var(--primary);
flex-shrink: 0;
}
span {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.select-folder-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
svg {
color: var(--primary);
}
}
&:active {
transform: scale(0.98);
}
svg {
color: var(--text-secondary);
transition: color 0.2s;
}
}
.export-action {
padding: 20px 24px;
border-top: 1px solid var(--border-color);
}
.export-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 24px;
background: var(--primary);
color: #fff;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: var(--primary-hover);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spin {
animation: contactsSpin 1s linear infinite;
}
}
}
@keyframes contactsSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

384
src/pages/ContactsPage.tsx Normal file
View File

@@ -0,0 +1,384 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown } from 'lucide-react'
import './ContactsPage.scss'
interface ContactInfo {
username: string
displayName: string
remark?: string
nickname?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'other'
}
function ContactsPage() {
const [contacts, setContacts] = useState<ContactInfo[]>([])
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
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 [showFormatSelect, setShowFormatSelect] = useState(false)
const formatDropdownRef = useRef<HTMLDivElement>(null)
// 加载通讯录
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])
// 点击外部关闭下拉菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) {
setShowFormatSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showFormatSelect])
const getAvatarLetter = (name: string) => {
if (!name) return '?'
return [...name][0] || '?'
}
const getContactTypeIcon = (type: string) => {
switch (type) {
case 'friend': return <User size={14} />
case 'group': return <Users size={14} />
case 'official': return <MessageSquare size={14} />
default: return <User size={14} />
}
}
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)
}
}
const exportFormatOptions = [
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整联系人信息' },
{ value: 'csv', label: 'CSV (Excel)', desc: '电子表格格式适合Excel查看' },
{ value: 'vcf', label: 'VCF (vCard)', desc: '标准名片格式,支持导入手机' }
]
const getOptionLabel = (value: string) => {
return exportFormatOptions.find(opt => opt.value === value)?.label || value
}
return (
<div className="contacts-page">
{/* 左侧:联系人列表 */}
<div className="contacts-panel">
<div className="panel-header">
<h2></h2>
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
</button>
</div>
<div className="search-bar">
<Search size={16} />
<input
type="text"
placeholder="搜索联系人..."
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
/>
{searchKeyword && (
<button className="clear-btn" onClick={() => setSearchKeyword('')}>
<X size={14} />
</button>
)}
</div>
<div className="type-filters">
<label className="filter-checkbox">
<input
type="checkbox"
checked={contactTypes.friends}
onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })}
/>
<User size={16} />
<span></span>
</label>
<label className="filter-checkbox">
<input
type="checkbox"
checked={contactTypes.groups}
onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })}
/>
<Users size={16} />
<span></span>
</label>
<label className="filter-checkbox">
<input
type="checkbox"
checked={contactTypes.officials}
onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })}
/>
<MessageSquare size={16} />
<span></span>
</label>
</div>
<div className="contacts-count">
{filteredContacts.length}
</div>
{isLoading ? (
<div className="loading-state">
<Loader2 size={32} className="spin" />
<span>...</span>
</div>
) : filteredContacts.length === 0 ? (
<div className="empty-state">
<span></span>
</div>
) : (
<div className="contacts-list">
{filteredContacts.map(contact => (
<div key={contact.username} className="contact-item">
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
{contact.remark && contact.remark !== contact.displayName && (
<div className="contact-remark">: {contact.remark}</div>
)}
</div>
<div className={`contact-type ${contact.type}`}>
{getContactTypeIcon(contact.type)}
<span>{getContactTypeName(contact.type)}</span>
</div>
</div>
))}
</div>
)}
</div>
{/* 右侧:导出设置 */}
<div className="settings-panel">
<div className="panel-header">
<h2></h2>
</div>
<div className="settings-content">
<div className="setting-section">
<h3></h3>
<div className="format-select" ref={formatDropdownRef}>
<button
type="button"
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
onClick={() => setShowFormatSelect(!showFormatSelect)}
>
<span className="select-value">{getOptionLabel(exportFormat)}</span>
<ChevronDown size={16} />
</button>
{showFormatSelect && (
<div className="select-dropdown">
{exportFormatOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${exportFormat === option.value ? 'active' : ''}`}
onClick={() => {
setExportFormat(option.value as 'json' | 'csv' | 'vcf')
setShowFormatSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="setting-section">
<h3></h3>
<label className="checkbox-item">
<input
type="checkbox"
checked={exportAvatars}
onChange={e => setExportAvatars(e.target.checked)}
/>
<span></span>
</label>
</div>
<div className="setting-section">
<h3></h3>
<div className="export-path-display">
<FolderOpen size={16} />
<span>{exportFolder || '未设置'}</span>
</div>
<button className="select-folder-btn" onClick={selectExportFolder}>
<FolderOpen size={16} />
<span></span>
</button>
</div>
</div>
<div className="export-action">
<button
className="export-btn"
onClick={startExport}
disabled={!exportFolder || isExporting}
>
{isExporting ? (
<>
<Loader2 size={18} className="spin" />
<span>...</span>
</>
) : (
<>
<Download size={18} />
<span></span>
</>
)}
</button>
</div>
</div>
</div>
)
}
export default ContactsPage

View File

@@ -24,6 +24,7 @@ interface ExportOptions {
excelCompactColumns: boolean excelCompactColumns: boolean
txtColumns: string[] txtColumns: string[]
displayNamePreference: 'group-nickname' | 'remark' | 'nickname' displayNamePreference: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency: number
} }
interface ExportResult { interface ExportResult {
@@ -68,7 +69,8 @@ function ExportPage() {
exportVoiceAsText: true, exportVoiceAsText: true,
excelCompactColumns: true, excelCompactColumns: true,
txtColumns: defaultTxtColumns, txtColumns: defaultTxtColumns,
displayNamePreference: 'remark' displayNamePreference: 'remark',
exportConcurrency: 2
}) })
const buildDateRangeFromPreset = (preset: string) => { const buildDateRangeFromPreset = (preset: string) => {
@@ -133,14 +135,16 @@ function ExportPage() {
savedMedia, savedMedia,
savedVoiceAsText, savedVoiceAsText,
savedExcelCompactColumns, savedExcelCompactColumns,
savedTxtColumns savedTxtColumns,
savedConcurrency
] = await Promise.all([ ] = await Promise.all([
configService.getExportDefaultFormat(), configService.getExportDefaultFormat(),
configService.getExportDefaultDateRange(), configService.getExportDefaultDateRange(),
configService.getExportDefaultMedia(), configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(), configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns(), configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns() configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency()
]) ])
const preset = savedRange || 'today' const preset = savedRange || 'today'
@@ -155,7 +159,8 @@ function ExportPage() {
exportMedia: savedMedia ?? false, exportMedia: savedMedia ?? false,
exportVoiceAsText: savedVoiceAsText ?? true, exportVoiceAsText: savedVoiceAsText ?? true,
excelCompactColumns: savedExcelCompactColumns ?? true, excelCompactColumns: savedExcelCompactColumns ?? true,
txtColumns txtColumns,
exportConcurrency: savedConcurrency ?? 2
})) }))
} catch (e) { } catch (e) {
console.error('加载导出默认设置失败:', e) console.error('加载导出默认设置失败:', e)
@@ -286,6 +291,7 @@ function ExportPage() {
excelCompactColumns: options.excelCompactColumns, excelCompactColumns: options.excelCompactColumns,
txtColumns: options.txtColumns, txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference, displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency,
sessionLayout, sessionLayout,
dateRange: options.useAllTime ? null : options.dateRange ? { dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000), start: Math.floor(options.dateRange.start.getTime() / 1000),

View File

@@ -56,7 +56,7 @@ function GroupAnalyticsPage() {
useEffect(() => { useEffect(() => {
loadGroups() loadGroups()
}, [loadGroups]) }, [])
useEffect(() => { useEffect(() => {
if (searchQuery) { if (searchQuery) {

View File

@@ -77,6 +77,7 @@ function SettingsPage() {
const [exportDefaultMedia, setExportDefaultMedia] = useState(false) const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [isLoading, setIsLoadingState] = useState(false) const [isLoading, setIsLoadingState] = useState(false)
const [isTesting, setIsTesting] = useState(false) const [isTesting, setIsTesting] = useState(false)
@@ -151,6 +152,7 @@ function SettingsPage() {
const savedExportDefaultMedia = await configService.getExportDefaultMedia() const savedExportDefaultMedia = await configService.getExportDefaultMedia()
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText() const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns() const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency()
if (savedPath) setDbPath(savedPath) if (savedPath) setDbPath(savedPath)
if (savedWxid) setWxid(savedWxid) if (savedWxid) setWxid(savedWxid)
@@ -178,6 +180,7 @@ function SettingsPage() {
setExportDefaultMedia(savedExportDefaultMedia ?? false) setExportDefaultMedia(savedExportDefaultMedia ?? false)
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true) setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true)
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true) setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
// 如果语言列表为空,保存默认值 // 如果语言列表为空,保存默认值
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) { if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {

View File

@@ -29,7 +29,8 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia', EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText', EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns' EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency'
} as const } as const
export interface WxidConfig { export interface WxidConfig {
@@ -352,3 +353,15 @@ export async function getExportDefaultTxtColumns(): Promise<string[] | null> {
export async function setExportDefaultTxtColumns(columns: string[]): Promise<void> { export async function setExportDefaultTxtColumns(columns: string[]): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns) await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns)
} }
// 获取导出默认并发数
export async function getExportDefaultConcurrency(): Promise<number | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY)
if (typeof value === 'number' && Number.isFinite(value)) return value
return null
}
// 设置导出默认并发数
export async function setExportDefaultConcurrency(concurrency: number): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
}

View File

@@ -1,4 +1,4 @@
import type { ChatSession, Message, Contact } from './models' import type { ChatSession, Message, Contact, ContactInfo } from './models'
export interface ElectronAPI { export interface ElectronAPI {
window: { window: {
@@ -76,6 +76,11 @@ export interface ElectronAPI {
}> }>
getContact: (username: string) => Promise<Contact | null> getContact: (username: string) => Promise<Contact | null>
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null> 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 }> getMyAvatarUrl: () => Promise<{ success: boolean; avatarUrl?: string; error?: string }>
downloadEmoji: (cdnUrl: string, md5?: string) => Promise<{ success: boolean; localPath?: string; error?: string }> downloadEmoji: (cdnUrl: string, md5?: string) => Promise<{ success: boolean; localPath?: string; error?: string }>
close: () => Promise<boolean> close: () => Promise<boolean>
@@ -315,6 +320,11 @@ export interface ElectronAPI {
success: boolean success: boolean
error?: string 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 onProgress: (callback: (payload: ExportProgress) => void) => () => void
} }
whisper: { whisper: {
@@ -373,6 +383,7 @@ export interface ExportOptions {
txtColumns?: string[] txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session' sessionLayout?: 'shared' | 'per-session'
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number
} }
export interface ExportProgress { export interface ExportProgress {

View File

@@ -23,6 +23,15 @@ export interface Contact {
smallHeadUrl: string smallHeadUrl: string
} }
export interface ContactInfo {
username: string
displayName: string
remark?: string
nickname?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'other'
}
// 消息 // 消息
export interface Message { export interface Message {
localId: number localId: number