mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev
This commit is contained in:
@@ -38,9 +38,10 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
|
||||
<p align="center">
|
||||
<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 align="center">一群满了加二群</p>
|
||||
<p align="center">扫到哪个算哪个</p>
|
||||
|
||||
## 主要功能
|
||||
|
||||
|
||||
@@ -19,6 +19,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'
|
||||
|
||||
|
||||
// 配置自动更新
|
||||
@@ -137,6 +138,28 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -626,11 +649,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) => {
|
||||
@@ -719,6 +746,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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string, string> = 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) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息列表(支持跨多个数据库合并,已优化)
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,7 @@ interface ConfigSchema {
|
||||
whisperDownloadSource: string
|
||||
autoTranscribeVoice: boolean
|
||||
transcribeLanguages: string[]
|
||||
exportDefaultConcurrency: number
|
||||
}
|
||||
|
||||
export class ConfigService {
|
||||
@@ -54,7 +55,8 @@ export class ConfigService {
|
||||
whisperModelDir: '',
|
||||
whisperDownloadSource: 'tsinghua',
|
||||
autoTranscribeVoice: false,
|
||||
transcribeLanguages: ['zh']
|
||||
transcribeLanguages: ['zh'],
|
||||
exportDefaultConcurrency: 2
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
159
electron/services/contactExportService.ts
Normal file
159
electron/services/contactExportService.ts
Normal 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()
|
||||
@@ -79,6 +79,7 @@ export interface ExportOptions {
|
||||
txtColumns?: string[]
|
||||
sessionLayout?: 'shared' | 'per-session'
|
||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||
exportConcurrency?: number
|
||||
}
|
||||
|
||||
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 }> {
|
||||
const rows: any[] = []
|
||||
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
|
||||
const senderSet = new Set<string>()
|
||||
let firstTime: 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 actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
|
||||
const memberInfo = await this.getContactInfo(actualSender)
|
||||
if (!memberSet.has(actualSender)) {
|
||||
memberSet.set(actualSender, {
|
||||
member: {
|
||||
platformId: actualSender,
|
||||
accountName: memberInfo.displayName
|
||||
},
|
||||
avatarUrl: memberInfo.avatarUrl
|
||||
})
|
||||
}
|
||||
senderSet.add(actualSender)
|
||||
|
||||
// 提取媒体相关字段
|
||||
let imageMd5: string | undefined
|
||||
@@ -1376,6 +1369,30 @@ class ExportService {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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 格式)
|
||||
*/
|
||||
@@ -1662,6 +1687,12 @@ class ExportService {
|
||||
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
const allMessages = collected.rows
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (allMessages.length === 0) {
|
||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||
}
|
||||
|
||||
if (isGroup) {
|
||||
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
||||
}
|
||||
@@ -1847,6 +1878,16 @@ class ExportService {
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
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?.({
|
||||
current: 0,
|
||||
total: 100,
|
||||
@@ -1859,6 +1900,12 @@ class ExportService {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// ========== 阶段1:并行导出媒体文件 ==========
|
||||
@@ -1951,7 +1998,7 @@ class ExportService {
|
||||
|
||||
// 获取发送者信息用于名称显示
|
||||
const senderWxid = msg.senderUsername
|
||||
const contact = await wcdbService.getContact(senderWxid)
|
||||
const contact = await getContactCached(senderWxid)
|
||||
const senderNickname = contact.success && contact.contact?.nickName
|
||||
? contact.contact.nickName
|
||||
: (senderInfo.displayName || senderWxid)
|
||||
@@ -1994,7 +2041,7 @@ class ExportService {
|
||||
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
|
||||
? sessionContact.contact.nickName
|
||||
: sessionInfo.displayName
|
||||
@@ -2014,7 +2061,9 @@ class ExportService {
|
||||
options.displayNamePreference || 'remark'
|
||||
)
|
||||
|
||||
const weflow = this.getWeflowHeader()
|
||||
const detailedExport: any = {
|
||||
weflow,
|
||||
session: {
|
||||
wxid: sessionId,
|
||||
nickname: sessionNickname,
|
||||
@@ -2085,8 +2134,18 @@ class ExportService {
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
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 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)
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (collected.rows.length === 0) {
|
||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 30,
|
||||
@@ -2315,7 +2378,7 @@ class ExportService {
|
||||
senderWxid = msg.senderUsername
|
||||
|
||||
// 用 getContact 获取联系人详情,分别取昵称和备注
|
||||
const contactDetail = await wcdbService.getContact(msg.senderUsername)
|
||||
const contactDetail = await getContactCached(msg.senderUsername)
|
||||
if (contactDetail.success && contactDetail.contact) {
|
||||
// nickName 才是真正的昵称
|
||||
senderNickname = contactDetail.contact.nickName || msg.senderUsername
|
||||
@@ -2330,7 +2393,7 @@ class ExportService {
|
||||
} else {
|
||||
// 单聊对方消息 - 用 getContact 获取联系人详情
|
||||
senderWxid = sessionId
|
||||
const contactDetail = await wcdbService.getContact(sessionId)
|
||||
const contactDetail = await getContactCached(sessionId)
|
||||
if (contactDetail.success && contactDetail.contact) {
|
||||
senderNickname = contactDetail.contact.nickName || sessionId
|
||||
senderRemark = contactDetail.contact.remark || ''
|
||||
@@ -2351,12 +2414,15 @@ class ExportService {
|
||||
const row = worksheet.getRow(currentRow)
|
||||
row.height = 24
|
||||
|
||||
const contentValue = this.formatPlainExportContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId)
|
||||
)
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaItem = mediaCache.get(mediaKey)
|
||||
const contentValue = mediaItem?.relativePath
|
||||
|| this.formatPlainExportContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId)
|
||||
)
|
||||
|
||||
// 调试日志
|
||||
if (msg.localType === 3 || msg.localType === 47) {
|
||||
@@ -2495,6 +2561,12 @@ class ExportService {
|
||||
}
|
||||
|
||||
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 { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
@@ -2563,12 +2635,15 @@ class ExportService {
|
||||
|
||||
for (let i = 0; i < sortedMessages.length; i++) {
|
||||
const msg = sortedMessages[i]
|
||||
const contentValue = this.formatPlainExportContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId)
|
||||
)
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaItem = mediaCache.get(mediaKey)
|
||||
const contentValue = mediaItem?.relativePath
|
||||
|| this.formatPlainExportContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId)
|
||||
)
|
||||
|
||||
let senderRole: string
|
||||
let senderWxid: string
|
||||
@@ -2581,7 +2656,7 @@ class ExportService {
|
||||
senderNickname = myInfo.displayName || cleanedMyWxid
|
||||
} else if (isGroup && msg.senderUsername) {
|
||||
senderWxid = msg.senderUsername
|
||||
const contactDetail = await wcdbService.getContact(msg.senderUsername)
|
||||
const contactDetail = await getContactCached(msg.senderUsername)
|
||||
if (contactDetail.success && contactDetail.contact) {
|
||||
senderNickname = contactDetail.contact.nickName || msg.senderUsername
|
||||
senderRemark = contactDetail.contact.remark || ''
|
||||
@@ -2592,7 +2667,7 @@ class ExportService {
|
||||
}
|
||||
} else {
|
||||
senderWxid = sessionId
|
||||
const contactDetail = await wcdbService.getContact(sessionId)
|
||||
const contactDetail = await getContactCached(sessionId)
|
||||
if (contactDetail.success && contactDetail.contact) {
|
||||
senderNickname = contactDetail.contact.nickName || sessionId
|
||||
senderRemark = contactDetail.contact.remark || ''
|
||||
@@ -2670,6 +2745,12 @@ class ExportService {
|
||||
}
|
||||
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (collected.rows.length === 0) {
|
||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||
}
|
||||
|
||||
if (isGroup) {
|
||||
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
||||
}
|
||||
@@ -3017,13 +3098,20 @@ class ExportService {
|
||||
const sessionLayout = exportMediaEnabled
|
||||
? (options.sessionLayout ?? 'per-session')
|
||||
: '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++) {
|
||||
const sessionId = sessionIds[i]
|
||||
await parallelLimit(sessionIds, sessionConcurrency, async (sessionId) => {
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
|
||||
onProgress?.({
|
||||
current: i + 1,
|
||||
current: completedCount,
|
||||
total: sessionIds.length,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting'
|
||||
@@ -3065,7 +3153,15 @@ class ExportService {
|
||||
failCount++
|
||||
console.error(`导出 ${sessionId} 失败:`, result.error)
|
||||
}
|
||||
}
|
||||
|
||||
completedCount++
|
||||
onProgress?.({
|
||||
current: completedCount,
|
||||
total: sessionIds.length,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting'
|
||||
})
|
||||
})
|
||||
|
||||
onProgress?.({
|
||||
current: sessionIds.length,
|
||||
|
||||
@@ -415,8 +415,14 @@ export class ImageDecryptService {
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
||||
return hardlinkPath
|
||||
}
|
||||
// hardlink 找到的是缩略图,但要求高清图,直接返回 null,不再搜索
|
||||
// hardlink 找到的是缩略图,但要求高清图:尝试同目录查找高清变体
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -432,6 +438,11 @@ export class ImageDecryptService {
|
||||
return fallbackPath
|
||||
}
|
||||
if (!allowThumbnail && isThumb) {
|
||||
const hdPath = this.findHdVariantInSameDir(fallbackPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -449,15 +460,20 @@ export class ImageDecryptService {
|
||||
this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
||||
return hardlinkPath
|
||||
}
|
||||
// hardlink 找到的是缩略图,但要求高清图,直接返回 null
|
||||
// hardlink 找到的是缩略图,但要求高清图:尝试同目录查找高清变体
|
||||
if (!allowThumbnail && isThumb) {
|
||||
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||
}
|
||||
|
||||
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
|
||||
// 如果要求高清图但 hardlink 没找到,也不要搜索全盘了(搜索太慢)
|
||||
if (!allowThumbnail) {
|
||||
return null
|
||||
}
|
||||
@@ -467,6 +483,9 @@ export class ImageDecryptService {
|
||||
const cached = this.resolvedCache.get(imageDatName)
|
||||
if (cached && existsSync(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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 在同目录中尝试查找高清图变体
|
||||
* 缩略图: 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(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
|
||||
cacheKey: string,
|
||||
|
||||
9803
package-lock.json
generated
Normal file
9803
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"description": "WeFlow",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": "cc",
|
||||
|
||||
@@ -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'
|
||||
@@ -349,6 +350,7 @@ function App() {
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/export" element={<ExportPage />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
</Routes>
|
||||
</RouteGuard>
|
||||
</main>
|
||||
|
||||
@@ -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() {
|
||||
<span className="nav-label">朋友圈</span>
|
||||
</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
|
||||
|
||||
544
src/pages/ContactsPage.scss
Normal file
544
src/pages/ContactsPage.scss
Normal 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
384
src/pages/ContactsPage.tsx
Normal 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
|
||||
@@ -24,6 +24,7 @@ interface ExportOptions {
|
||||
excelCompactColumns: boolean
|
||||
txtColumns: string[]
|
||||
displayNamePreference: 'group-nickname' | 'remark' | 'nickname'
|
||||
exportConcurrency: number
|
||||
}
|
||||
|
||||
interface ExportResult {
|
||||
@@ -68,7 +69,8 @@ function ExportPage() {
|
||||
exportVoiceAsText: true,
|
||||
excelCompactColumns: true,
|
||||
txtColumns: defaultTxtColumns,
|
||||
displayNamePreference: 'remark'
|
||||
displayNamePreference: 'remark',
|
||||
exportConcurrency: 2
|
||||
})
|
||||
|
||||
const buildDateRangeFromPreset = (preset: string) => {
|
||||
@@ -133,14 +135,16 @@ function ExportPage() {
|
||||
savedMedia,
|
||||
savedVoiceAsText,
|
||||
savedExcelCompactColumns,
|
||||
savedTxtColumns
|
||||
savedTxtColumns,
|
||||
savedConcurrency
|
||||
] = await Promise.all([
|
||||
configService.getExportDefaultFormat(),
|
||||
configService.getExportDefaultDateRange(),
|
||||
configService.getExportDefaultMedia(),
|
||||
configService.getExportDefaultVoiceAsText(),
|
||||
configService.getExportDefaultExcelCompactColumns(),
|
||||
configService.getExportDefaultTxtColumns()
|
||||
configService.getExportDefaultTxtColumns(),
|
||||
configService.getExportDefaultConcurrency()
|
||||
])
|
||||
|
||||
const preset = savedRange || 'today'
|
||||
@@ -155,7 +159,8 @@ function ExportPage() {
|
||||
exportMedia: savedMedia ?? false,
|
||||
exportVoiceAsText: savedVoiceAsText ?? true,
|
||||
excelCompactColumns: savedExcelCompactColumns ?? true,
|
||||
txtColumns
|
||||
txtColumns,
|
||||
exportConcurrency: savedConcurrency ?? 2
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('加载导出默认设置失败:', e)
|
||||
@@ -286,6 +291,7 @@ function ExportPage() {
|
||||
excelCompactColumns: options.excelCompactColumns,
|
||||
txtColumns: options.txtColumns,
|
||||
displayNamePreference: options.displayNamePreference,
|
||||
exportConcurrency: options.exportConcurrency,
|
||||
sessionLayout,
|
||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||
|
||||
@@ -56,7 +56,7 @@ function GroupAnalyticsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups()
|
||||
}, [loadGroups])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
|
||||
@@ -77,6 +77,7 @@ function SettingsPage() {
|
||||
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true)
|
||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
||||
|
||||
const [isLoading, setIsLoadingState] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
@@ -151,6 +152,7 @@ function SettingsPage() {
|
||||
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
|
||||
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
|
||||
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
||||
const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency()
|
||||
|
||||
if (savedPath) setDbPath(savedPath)
|
||||
if (savedWxid) setWxid(savedWxid)
|
||||
@@ -178,6 +180,7 @@ function SettingsPage() {
|
||||
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
||||
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true)
|
||||
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
||||
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
|
||||
|
||||
// 如果语言列表为空,保存默认值
|
||||
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||
|
||||
@@ -29,7 +29,8 @@ export const CONFIG_KEYS = {
|
||||
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
||||
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
|
||||
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
||||
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns'
|
||||
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
|
||||
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency'
|
||||
} as const
|
||||
|
||||
export interface WxidConfig {
|
||||
@@ -352,3 +353,15 @@ export async function getExportDefaultTxtColumns(): Promise<string[] | null> {
|
||||
export async function setExportDefaultTxtColumns(columns: string[]): Promise<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
13
src/types/electron.d.ts
vendored
13
src/types/electron.d.ts
vendored
@@ -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<Contact | 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 }>
|
||||
downloadEmoji: (cdnUrl: string, md5?: string) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||
close: () => Promise<boolean>
|
||||
@@ -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: {
|
||||
@@ -373,6 +383,7 @@ export interface ExportOptions {
|
||||
txtColumns?: string[]
|
||||
sessionLayout?: 'shared' | 'per-session'
|
||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||
exportConcurrency?: number
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user