Merge pull request #94 from hicccc77/dev

Dev
This commit is contained in:
cc
2026-01-25 15:11:24 +08:00
committed by GitHub
24 changed files with 1710 additions and 10637 deletions

View File

@@ -209,10 +209,11 @@ function createOnboardingWindow() {
: join(process.resourcesPath, 'icon.ico') : join(process.resourcesPath, 'icon.ico')
onboardingWindow = new BrowserWindow({ onboardingWindow = new BrowserWindow({
width: 1100, width: 960,
height: 720, height: 680,
minWidth: 900, minWidth: 900,
minHeight: 600, minHeight: 620,
resizable: false,
frame: false, frame: false,
transparent: true, transparent: true,
backgroundColor: '#00000000', backgroundColor: '#00000000',

View File

@@ -8,6 +8,7 @@ interface ConfigSchema {
onboardingDone: boolean onboardingDone: boolean
imageXorKey: number imageXorKey: number
imageAesKey: string imageAesKey: string
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
// 缓存相关 // 缓存相关
cachePath: string cachePath: string
@@ -40,6 +41,7 @@ export class ConfigService {
onboardingDone: false, onboardingDone: false,
imageXorKey: 0, imageXorKey: 0,
imageAesKey: '', imageAesKey: '',
wxidConfigs: {},
cachePath: '', cachePath: '',
lastOpenedDb: '', lastOpenedDb: '',
lastSession: '', lastSession: '',

View File

@@ -77,6 +77,7 @@ export interface ExportOptions {
excelCompactColumns?: boolean excelCompactColumns?: boolean
txtColumns?: string[] txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session' sessionLayout?: 'shared' | 'per-session'
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
} }
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
@@ -170,23 +171,162 @@ class ExportService {
return this.contactCache.get(username)! return this.contactCache.get(username)!
} }
const [displayNames, avatarUrls] = await Promise.all([ const [nameResult, avatarResult] = await Promise.all([
wcdbService.getDisplayNames([username]), wcdbService.getDisplayNames([username]),
wcdbService.getAvatarUrls([username]) wcdbService.getAvatarUrls([username])
]) ])
const displayName = displayNames.success && displayNames.map const displayName = (nameResult.success && nameResult.map ? nameResult.map[username] : null) || username
? (displayNames.map[username] || username) const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined
: username
const avatarUrl = avatarUrls.success && avatarUrls.map
? avatarUrls.map[username]
: undefined
const info = { displayName, avatarUrl } const info = { displayName, avatarUrl }
this.contactCache.set(username, info) this.contactCache.set(username, info)
return info return info
} }
/**
* 解析 ext_buffer 二进制数据,提取群成员的群昵称
* ext_buffer 包含类似 protobuf 编码的数据,格式示例:
* wxid_xxx<binary>群昵称<binary>wxid_yyy<binary>群昵称...
*/
private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map<string, string> {
const nicknameMap = new Map<string, string>()
try {
// 将 buffer 转为字符串,允许部分乱码
const raw = buffer.toString('utf8')
// 提取所有 wxid 格式的字符串: wxid_ 或 wxid_后跟字母数字下划线
const wxidPattern = /wxid_[a-z0-9_]+/gi
const wxids = raw.match(wxidPattern) || []
// 对每个 wxid尝试提取其后的群昵称
for (const wxid of wxids) {
const wxidLower = wxid.toLowerCase()
const wxidIndex = raw.toLowerCase().indexOf(wxidLower)
if (wxidIndex === -1) continue
// 从 wxid 结束位置开始查找
const afterWxid = raw.slice(wxidIndex + wxid.length)
// 提取紧跟在 wxid 后面的可打印字符(中文、字母、数字等)
// 跳过前面的不可打印字符和特定控制字符
let nickname = ''
let foundStart = false
for (let i = 0; i < afterWxid.length && i < 100; i++) {
const char = afterWxid[i]
const code = char.charCodeAt(0)
// 判断是否为可打印字符(中文、字母、数字、常见符号)
const isPrintable = (
(code >= 0x4E00 && code <= 0x9FFF) || // 中文
(code >= 0x3000 && code <= 0x303F) || // CJK 符号
(code >= 0xFF00 && code <= 0xFFEF) || // 全角字符
(code >= 0x20 && code <= 0x7E) // ASCII 可打印字符
)
if (isPrintable && code !== 0x01 && code !== 0x18) {
foundStart = true
nickname += char
} else if (foundStart) {
// 遇到不可打印字符,停止
break
}
}
// 清理昵称:去除前后空白和特殊字符
nickname = nickname.trim().replace(/[\x00-\x1F\x7F]/g, '')
// 只保存有效的群昵称(长度 > 0 且 < 50
if (nickname && nickname.length > 0 && nickname.length < 50) {
nicknameMap.set(wxidLower, nickname)
}
}
} catch (e) {
// 解析失败时返回空 Map
console.error('Failed to parse ext_buffer:', e)
}
return nicknameMap
}
/**
* 从 contact.db 的 chat_room 表获取群成员的群昵称
* @param chatroomId 群聊ID (如 "xxxxx@chatroom")
* @returns Map<wxid, 群昵称>
*/
async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
console.log('========== getGroupNicknamesForRoom START ==========', chatroomId)
try {
// 查询 contact.db 的 chat_room 表
// path设为null因为contact.db已经随handle一起打开了
const sql = `SELECT ext_buffer FROM chat_room WHERE username = '${chatroomId.replace(/'/g, "''")}'`
console.log('执行SQL查询:', sql)
const result = await wcdbService.execQuery('contact', null, sql)
console.log('execQuery结果:', { success: result.success, rowCount: result.rows?.length, error: result.error })
if (!result.success || !result.rows || result.rows.length === 0) {
console.log('❌ 群昵称查询失败或无数据:', chatroomId, result.error)
return new Map<string, string>()
}
let extBuffer = result.rows[0].ext_buffer
console.log('ext_buffer原始类型:', typeof extBuffer, 'isBuffer:', Buffer.isBuffer(extBuffer))
// execQuery返回的二进制数据会被编码为字符串hex或base64
// 需要转换回Buffer
if (typeof extBuffer === 'string') {
console.log('🔄 ext_buffer是字符串尝试转换为Buffer...')
// 尝试判断是hex还是base64
if (this.looksLikeHex(extBuffer)) {
console.log('✅ 检测到hex编码使用hex解码')
extBuffer = Buffer.from(extBuffer, 'hex')
} else if (this.looksLikeBase64(extBuffer)) {
console.log('✅ 检测到base64编码使用base64解码')
extBuffer = Buffer.from(extBuffer, 'base64')
} else {
// 默认尝试hex
console.log('⚠️ 无法判断编码格式默认尝试hex')
try {
extBuffer = Buffer.from(extBuffer, 'hex')
} catch (e) {
console.log('❌ hex解码失败尝试base64')
extBuffer = Buffer.from(extBuffer, 'base64')
}
}
console.log('✅ 转换后的Buffer长度:', extBuffer.length)
}
if (!extBuffer || !Buffer.isBuffer(extBuffer)) {
console.log('❌ ext_buffer转换失败不是Buffer类型:', typeof extBuffer)
return new Map<string, string>()
}
console.log('✅ 开始解析ext_buffer, 长度:', extBuffer.length)
const nicknamesMap = this.parseGroupNicknamesFromExtBuffer(extBuffer)
console.log('✅ 解析完成, 找到', nicknamesMap.size, '个群昵称')
// 打印前5个群昵称作为示例
let count = 0
for (const [wxid, nickname] of nicknamesMap.entries()) {
if (count++ < 5) {
console.log(` - ${wxid}: "${nickname}"`)
}
}
return nicknamesMap
} catch (e) {
console.error('❌ getGroupNicknamesForRoom异常:', e)
return new Map<string, string>()
} finally {
console.log('========== getGroupNicknamesForRoom END ==========')
}
}
/** /**
* 转换微信消息类型到 ChatLab 类型 * 转换微信消息类型到 ChatLab 类型
*/ */
@@ -269,6 +409,28 @@ class ExportService {
return /^[0-9a-fA-F]+$/.test(s) return /^[0-9a-fA-F]+$/.test(s)
} }
/**
* 根据用户偏好获取显示名称
*/
private getPreferredDisplayName(
wxid: string,
nickname: string,
remark: string,
groupNickname: string,
preference: 'group-nickname' | 'remark' | 'nickname' = 'remark'
): string {
switch (preference) {
case 'group-nickname':
return groupNickname || remark || nickname || wxid
case 'remark':
return remark || nickname || wxid
case 'nickname':
return nickname || wxid
default:
return nickname || wxid
}
}
private looksLikeBase64(s: string): boolean { private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s) return /^[A-Za-z0-9+/=]+$/.test(s)
@@ -707,7 +869,7 @@ class ExportService {
if (localType === 3 && options.exportImages) { if (localType === 3 && options.exportImages) {
const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix) const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix)
if (result) { if (result) {
} }
return result return result
} }
@@ -726,7 +888,7 @@ class ExportService {
if (localType === 47 && options.exportEmojis) { if (localType === 47 && options.exportEmojis) {
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix) const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix)
if (result) { if (result) {
} }
return result return result
} }
@@ -929,13 +1091,13 @@ class ExportService {
// 下载表情 // 下载表情
if (emojiUrl) { if (emojiUrl) {
const downloaded = await this.downloadFile(emojiUrl, destPath) const downloaded = await this.downloadFile(emojiUrl, destPath)
if (downloaded) { if (downloaded) {
return { return {
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
kind: 'emoji' kind: 'emoji'
} }
} else { } else {
} }
} }
return null return null
@@ -1016,7 +1178,7 @@ class ExportService {
*/ */
private extractEmojiUrl(content: string): string | undefined { private extractEmojiUrl(content: string): string | undefined {
if (!content) return undefined if (!content) return undefined
// 参考 echotrace 的正则cdnurl\s*=\s*['"]([^'"]+)['"] // 参考 echotrace 的正则cdnurl\s*=\s*['"]([^'"]+)['"]
const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content) const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content)
if (attrMatch) { if (attrMatch) {
// 解码 &amp; 等实体 // 解码 &amp; 等实体
@@ -1181,14 +1343,14 @@ class ExportService {
// 图片消息 // 图片消息
imageMd5 = this.extractImageMd5(content) imageMd5 = this.extractImageMd5(content)
imageDatName = this.extractImageDatName(content) imageDatName = this.extractImageDatName(content)
} else if (localType === 47 && content) { } else if (localType === 47 && content) {
// 动画表情 // 动画表情
emojiCdnUrl = this.extractEmojiUrl(content) emojiCdnUrl = this.extractEmojiUrl(content)
emojiMd5 = this.extractEmojiMd5(content) emojiMd5 = this.extractEmojiMd5(content)
} else if (localType === 43 && content) { } else if (localType === 43 && content) {
// 视频消息 // 视频消息
videoMd5 = this.extractVideoMd5(content) videoMd5 = this.extractVideoMd5(content)
} }
rows.push({ rows.push({
localId, localId,
@@ -1506,11 +1668,11 @@ class ExportService {
// ========== 阶段1并行导出媒体文件 ========== // ========== 阶段1并行导出媒体文件 ==========
const mediaMessages = exportMediaEnabled const mediaMessages = exportMediaEnabled
? allMessages.filter(msg => { ? allMessages.filter(msg => {
const t = msg.localType const t = msg.localType
return (t === 3 && options.exportImages) || // 图片 return (t === 3 && options.exportImages) || // 图片
(t === 47 && options.exportEmojis) || // 表情 (t === 47 && options.exportEmojis) || // 表情
(t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字) (t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字)
}) })
: [] : []
const mediaCache = new Map<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
@@ -1693,11 +1855,11 @@ class ExportService {
// ========== 阶段1并行导出媒体文件 ========== // ========== 阶段1并行导出媒体文件 ==========
const mediaMessages = exportMediaEnabled const mediaMessages = exportMediaEnabled
? collected.rows.filter(msg => { ? collected.rows.filter(msg => {
const t = msg.localType const t = msg.localType
return (t === 3 && options.exportImages) || return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) || (t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices && !options.exportVoiceAsText) (t === 34 && options.exportVoices && !options.exportVoiceAsText)
}) })
: [] : []
const mediaCache = new Map<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
@@ -1747,6 +1909,11 @@ class ExportService {
}) })
} }
// ========== 预加载群昵称(用于名称显示偏好) ==========
const groupNicknamesMap = isGroup
? await this.getGroupNicknamesForRoom(sessionId)
: new Map<string, string>()
// ========== 阶段3构建消息列表 ========== // ========== 阶段3构建消息列表 ==========
onProgress?.({ onProgress?.({
current: 55, current: 55,
@@ -1773,6 +1940,24 @@ class ExportService {
content = this.parseMessageContent(msg.content, msg.localType) content = this.parseMessageContent(msg.content, msg.localType)
} }
// 获取发送者信息用于名称显示
const senderWxid = msg.senderUsername
const contact = await wcdbService.getContact(senderWxid)
const senderNickname = contact.success && contact.contact?.nickName
? contact.contact.nickName
: (senderInfo.displayName || senderWxid)
const senderRemark = contact.success && contact.contact?.remark ? contact.contact.remark : ''
const senderGroupNickname = groupNicknamesMap.get(senderWxid?.toLowerCase() || '') || ''
// 使用用户偏好的显示名称
const senderDisplayName = this.getPreferredDisplayName(
senderWxid,
senderNickname,
senderRemark,
senderGroupNickname,
options.displayNamePreference || 'remark'
)
allMessages.push({ allMessages.push({
localId: allMessages.length + 1, localId: allMessages.length + 1,
createTime: msg.createTime, createTime: msg.createTime,
@@ -1782,7 +1967,7 @@ class ExportService {
content, content,
isSend: msg.isSend ? 1 : 0, isSend: msg.isSend ? 1 : 0,
senderUsername: msg.senderUsername, senderUsername: msg.senderUsername,
senderDisplayName: senderInfo.displayName, senderDisplayName,
source, source,
senderAvatarKey: msg.senderUsername senderAvatarKey: msg.senderUsername
}) })
@@ -1799,14 +1984,33 @@ 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 sessionNickname = sessionContact.success && sessionContact.contact?.nickName
? sessionContact.contact.nickName
: sessionInfo.displayName
const sessionRemark = sessionContact.success && sessionContact.contact?.remark
? sessionContact.contact.remark
: ''
const sessionGroupNickname = isGroup
? (groupNicknamesMap.get(sessionId.toLowerCase()) || '')
: ''
// 使用用户偏好的显示名称
const sessionDisplayName = this.getPreferredDisplayName(
sessionId,
sessionNickname,
sessionRemark,
sessionGroupNickname,
options.displayNamePreference || 'remark'
)
const detailedExport: any = { const detailedExport: any = {
chatlab,
meta,
session: { session: {
wxid: sessionId, wxid: sessionId,
nickname: sessionInfo.displayName, nickname: sessionNickname,
remark: sessionInfo.displayName, remark: sessionRemark,
displayName: sessionInfo.displayName, displayName: sessionDisplayName,
type: isGroup ? '群聊' : '私聊', type: isGroup ? '群聊' : '私聊',
lastTimestamp: collected.lastTime, lastTimestamp: collected.lastTime,
messageCount: allMessages.length, messageCount: allMessages.length,
@@ -1886,6 +2090,7 @@ class ExportService {
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
onProgress?.({ onProgress?.({
current: 30, current: 30,
total: 100, total: 100,
@@ -1962,7 +2167,7 @@ class ExportService {
// 表头行 // 表头行
const headers = useCompactColumns const headers = useCompactColumns
? ['序号', '时间', '发送者身份', '消息类型', '内容'] ? ['序号', '时间', '发送者身份', '消息类型', '内容']
: ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容'] : ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容']
const headerRow = worksheet.getRow(currentRow) const headerRow = worksheet.getRow(currentRow)
headerRow.height = 22 headerRow.height = 22
@@ -1990,11 +2195,20 @@ class ExportService {
worksheet.getColumn(3).width = 18 // 发送者昵称 worksheet.getColumn(3).width = 18 // 发送者昵称
worksheet.getColumn(4).width = 25 // 发送者微信ID worksheet.getColumn(4).width = 25 // 发送者微信ID
worksheet.getColumn(5).width = 18 // 发送者备注 worksheet.getColumn(5).width = 18 // 发送者备注
worksheet.getColumn(6).width = 15 // 发送者身份 worksheet.getColumn(6).width = 18 // 群昵称
worksheet.getColumn(7).width = 12 // 消息类型 worksheet.getColumn(7).width = 15 // 发送者身份
worksheet.getColumn(8).width = 50 // 内容 worksheet.getColumn(8).width = 12 // 消息类型
worksheet.getColumn(9).width = 50 // 内容
} }
// 预加载群昵称 (仅群聊且完整列模式)
console.log('🔍 预加载群昵称检查: isGroup=', isGroup, 'useCompactColumns=', useCompactColumns, 'sessionId=', sessionId)
const groupNicknamesMap = (isGroup && !useCompactColumns)
? await this.getGroupNicknamesForRoom(sessionId)
: new Map<string, string>()
console.log('🔍 群昵称Map大小:', groupNicknamesMap.size)
// 填充数据 // 填充数据
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
@@ -2004,11 +2218,11 @@ class ExportService {
// ========== 并行预处理:媒体文件 ========== // ========== 并行预处理:媒体文件 ==========
const mediaMessages = exportMediaEnabled const mediaMessages = exportMediaEnabled
? sortedMessages.filter(msg => { ? sortedMessages.filter(msg => {
const t = msg.localType const t = msg.localType
return (t === 3 && options.exportImages) || return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) || (t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices && !options.exportVoiceAsText) (t === 34 && options.exportVoices && !options.exportVoiceAsText)
}) })
: [] : []
const mediaCache = new Map<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
@@ -2074,6 +2288,8 @@ class ExportService {
let senderWxid: string let senderWxid: string
let senderNickname: string let senderNickname: string
let senderRemark: string = '' let senderRemark: string = ''
let senderGroupNickname: string = '' // 群昵称
if (msg.isSend) { if (msg.isSend) {
// 我发送的消息 // 我发送的消息
@@ -2113,6 +2329,12 @@ class ExportService {
} }
} }
// 获取群昵称 (仅群聊且完整列模式)
if (isGroup && !useCompactColumns && senderWxid) {
senderGroupNickname = groupNicknamesMap.get(senderWxid.toLowerCase()) || ''
}
const row = worksheet.getRow(currentRow) const row = worksheet.getRow(currentRow)
row.height = 24 row.height = 24
@@ -2125,7 +2347,7 @@ class ExportService {
// 调试日志 // 调试日志
if (msg.localType === 3 || msg.localType === 47) { if (msg.localType === 3 || msg.localType === 47) {
} }
worksheet.getCell(currentRow, 1).value = i + 1 worksheet.getCell(currentRow, 1).value = i + 1
worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime) worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
@@ -2137,13 +2359,14 @@ class ExportService {
worksheet.getCell(currentRow, 3).value = senderNickname worksheet.getCell(currentRow, 3).value = senderNickname
worksheet.getCell(currentRow, 4).value = senderWxid worksheet.getCell(currentRow, 4).value = senderWxid
worksheet.getCell(currentRow, 5).value = senderRemark worksheet.getCell(currentRow, 5).value = senderRemark
worksheet.getCell(currentRow, 6).value = senderRole worksheet.getCell(currentRow, 6).value = senderGroupNickname
worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType) worksheet.getCell(currentRow, 7).value = senderRole
worksheet.getCell(currentRow, 8).value = contentValue worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType)
worksheet.getCell(currentRow, 9).value = contentValue
} }
// 设置每个单元格的样式 // 设置每个单元格的样式
const maxColumns = useCompactColumns ? 5 : 8 const maxColumns = useCompactColumns ? 5 : 9
for (let col = 1; col <= maxColumns; col++) { for (let col = 1; col <= maxColumns; col++) {
const cell = worksheet.getCell(currentRow, col) const cell = worksheet.getCell(currentRow, col)
cell.font = { name: 'Calibri', size: 11 } cell.font = { name: 'Calibri', size: 11 }
@@ -2225,11 +2448,11 @@ class ExportService {
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
const mediaMessages = exportMediaEnabled const mediaMessages = exportMediaEnabled
? sortedMessages.filter(msg => { ? sortedMessages.filter(msg => {
const t = msg.localType const t = msg.localType
return (t === 3 && options.exportImages) || return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) || (t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices && !options.exportVoiceAsText) (t === 34 && options.exportVoices && !options.exportVoiceAsText)
}) })
: [] : []
const mediaCache = new Map<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
@@ -2399,12 +2622,12 @@ class ExportService {
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
const mediaMessages = exportMediaEnabled const mediaMessages = exportMediaEnabled
? sortedMessages.filter(msg => { ? sortedMessages.filter(msg => {
const t = msg.localType const t = msg.localType
return (t === 3 && options.exportImages) || return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) || (t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices) || (t === 34 && options.exportVoices) ||
t === 43 t === 43
}) })
: [] : []
const mediaCache = new Map<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
@@ -2733,15 +2956,15 @@ class ExportService {
fs.mkdirSync(outputDir, { recursive: true }) fs.mkdirSync(outputDir, { recursive: true })
} }
const exportMediaEnabled = options.exportMedia === true && const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportEmojis) Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
const sessionLayout = exportMediaEnabled const sessionLayout = exportMediaEnabled
? (options.sessionLayout ?? 'per-session') ? (options.sessionLayout ?? 'per-session')
: 'shared' : 'shared'
for (let i = 0; i < sessionIds.length; i++) { for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i] const sessionId = sessionIds[i]
const sessionInfo = await this.getContactInfo(sessionId) const sessionInfo = await this.getContactInfo(sessionId)
onProgress?.({ onProgress?.({
current: i + 1, current: i + 1,
@@ -2750,13 +2973,13 @@ class ExportService {
phase: 'exporting' phase: 'exporting'
}) })
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
const useSessionFolder = sessionLayout === 'per-session' const useSessionFolder = sessionLayout === 'per-session'
const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir
if (useSessionFolder && !fs.existsSync(sessionDir)) { if (useSessionFolder && !fs.existsSync(sessionDir)) {
fs.mkdirSync(sessionDir, { recursive: true }) fs.mkdirSync(sessionDir, { recursive: true })
} }
let ext = '.json' let ext = '.json'
if (options.format === 'chatlab-jsonl') ext = '.jsonl' if (options.format === 'chatlab-jsonl') ext = '.jsonl'

View File

@@ -882,16 +882,17 @@ export class KeyService {
return null return null
} }
private isAlphaNumAscii(byte: number): boolean { private isAlphaNumLower(byte: number): boolean {
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x30 && byte <= 0x39) // 只匹配小写字母 a-z 和数字 0-9AES密钥格式
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x30 && byte <= 0x39)
} }
private isUtf16AsciiKey(buf: Buffer, start: number): boolean { private isUtf16LowerKey(buf: Buffer, start: number): boolean {
if (start + 64 > buf.length) return false if (start + 64 > buf.length) return false
for (let j = 0; j < 32; j++) { for (let j = 0; j < 32; j++) {
const charByte = buf[start + j * 2] const charByte = buf[start + j * 2]
const nullByte = buf[start + j * 2 + 1] const nullByte = buf[start + j * 2 + 1]
if (nullByte !== 0x00 || !this.isAlphaNumAscii(charByte)) { if (nullByte !== 0x00 || !this.isAlphaNumLower(charByte)) {
return false return false
} }
} }
@@ -924,8 +925,6 @@ export class KeyService {
const regions: Array<[number, number]> = [] const regions: Array<[number, number]> = []
const MEM_COMMIT = 0x1000 const MEM_COMMIT = 0x1000
const MEM_PRIVATE = 0x20000 const MEM_PRIVATE = 0x20000
const MEM_MAPPED = 0x40000
const MEM_IMAGE = 0x1000000
const PAGE_NOACCESS = 0x01 const PAGE_NOACCESS = 0x01
const PAGE_GUARD = 0x100 const PAGE_GUARD = 0x100
@@ -940,10 +939,9 @@ export class KeyService {
const protect = info.Protect const protect = info.Protect
const type = info.Type const type = info.Type
const regionSize = Number(info.RegionSize) const regionSize = Number(info.RegionSize)
if (state === MEM_COMMIT && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) { // 只收集已提交的私有内存(大幅减少扫描区域)
if (type === MEM_PRIVATE || type === MEM_MAPPED || type === MEM_IMAGE) { if (state === MEM_COMMIT && type === MEM_PRIVATE && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) {
regions.push([Number(info.BaseAddress), regionSize]) regions.push([Number(info.BaseAddress), regionSize])
}
} }
const nextAddress = address + regionSize const nextAddress = address + regionSize
@@ -972,86 +970,51 @@ export class KeyService {
try { try {
const allRegions = this.getMemoryRegions(hProcess) const allRegions = this.getMemoryRegions(hProcess)
const totalRegions = allRegions.length
let scannedCount = 0
let skippedCount = 0
// 优化1: 只保留小内存区域(< 10MB- 密钥通常在小区域,可大幅减少扫描时间 for (const [baseAddress, regionSize] of allRegions) {
const filteredRegions = allRegions.filter(([_, size]) => size <= 10 * 1024 * 1024) // 跳过太大的内存区域(> 100MB
if (regionSize > 100 * 1024 * 1024) {
skippedCount++
continue
}
// 优化2: 优先级排序 - 按大小升序,先扫描小区域(密钥通常在较小区域) scannedCount++
const sortedRegions = filteredRegions.sort((a, b) => a[1] - b[1]) if (scannedCount % 10 === 0) {
onProgress?.(scannedCount, totalRegions, `正在扫描微信内存... (${scannedCount}/${totalRegions})`)
await new Promise(resolve => setImmediate(resolve))
}
// 优化3: 计算总字节数用于精确进度报告 const memory = this.readProcessMemory(hProcess, baseAddress, regionSize)
const totalBytes = sortedRegions.reduce((sum, [_, size]) => sum + size, 0) if (!memory) continue
let processedBytes = 0
// 优化4: 减小分块大小到 1MB参考 wx_key 项目) // 直接在原始字节中搜索32字节的小写字母数字序列
const chunkSize = 1 * 1024 * 1024 for (let i = 0; i < memory.length - 34; i++) {
const overlap = 65 // 检查前导字符(不是小写字母或数字)
let currentRegion = 0 if (this.isAlphaNumLower(memory[i])) continue
for (const [baseAddress, regionSize] of sortedRegions) { // 检查接下来32个字节是否都是小写字母或数字
currentRegion++ let valid = true
const progress = totalBytes > 0 ? Math.floor((processedBytes / totalBytes) * 100) : 0 for (let j = 1; j <= 32; j++) {
onProgress?.(progress, 100, `扫描内存 ${progress}% (${currentRegion}/${sortedRegions.length})`) if (!this.isAlphaNumLower(memory[i + j])) {
valid = false
break
}
}
if (!valid) continue
// 每个区域都让出主线程确保UI流畅 // 检查尾部字符(不是小写字母或数字)
await new Promise(resolve => setImmediate(resolve)) if (i + 33 < memory.length && this.isAlphaNumLower(memory[i + 33])) {
let offset = 0
let trailing: Buffer | null = null
while (offset < regionSize) {
const remaining = regionSize - offset
const currentChunkSize = remaining > chunkSize ? chunkSize : remaining
const chunk = this.readProcessMemory(hProcess, baseAddress + offset, currentChunkSize)
if (!chunk || !chunk.length) {
offset += currentChunkSize
trailing = null
continue continue
} }
let dataToScan: Buffer const keyBytes = memory.subarray(i + 1, i + 33)
if (trailing && trailing.length) { if (this.verifyKey(ciphertext, keyBytes)) {
dataToScan = Buffer.concat([trailing, chunk]) return keyBytes.toString('ascii')
} else {
dataToScan = chunk
} }
for (let i = 0; i < dataToScan.length - 34; i++) {
if (this.isAlphaNumAscii(dataToScan[i])) continue
let valid = true
for (let j = 1; j <= 32; j++) {
if (!this.isAlphaNumAscii(dataToScan[i + j])) {
valid = false
break
}
}
if (valid && this.isAlphaNumAscii(dataToScan[i + 33])) {
valid = false
}
if (valid) {
const keyBytes = dataToScan.subarray(i + 1, i + 33)
if (this.verifyKey(ciphertext, keyBytes)) {
return keyBytes.toString('ascii')
}
}
}
for (let i = 0; i < dataToScan.length - 65; i++) {
if (!this.isUtf16AsciiKey(dataToScan, i)) continue
const keyBytes = Buffer.alloc(32)
for (let j = 0; j < 32; j++) {
keyBytes[j] = dataToScan[i + j * 2]
}
if (this.verifyKey(ciphertext, keyBytes)) {
return keyBytes.toString('ascii')
}
}
const start = dataToScan.length - overlap
trailing = dataToScan.subarray(start < 0 ? 0 : start)
offset += currentChunkSize
} }
// 更新已处理字节数
processedBytes += regionSize
} }
return null return null
} finally { } finally {

View File

@@ -20,6 +20,7 @@ export class WcdbCore {
private currentWxid: string | null = null private currentWxid: string | null = null
// 函数引用 // 函数引用
private wcdbInitProtection: any = null
private wcdbInit: any = null private wcdbInit: any = null
private wcdbShutdown: any = null private wcdbShutdown: any = null
private wcdbOpenAccount: any = null private wcdbOpenAccount: any = null
@@ -243,6 +244,18 @@ export class WcdbCore {
this.lib = this.koffi.load(dllPath) this.lib = this.koffi.load(dllPath)
// InitProtection (Added for security)
try {
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
const protectionOk = this.wcdbInitProtection(dllDir)
if (!protectionOk) {
console.error('Core security check failed')
return false
}
} catch (e) {
console.warn('InitProtection symbol not found:', e)
}
// 定义类型 // 定义类型
// wcdb_status wcdb_init() // wcdb_status wcdb_init()
this.wcdbInit = this.lib.func('int32 wcdb_init()') this.wcdbInit = this.lib.func('int32 wcdb_init()')

9797
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.3.2", "version": "1.4.0",
"description": "WeFlow", "description": "WeFlow",
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"author": "cc", "author": "cc",
"//": "二改不应改变此处的作者与应用信息",
"scripts": { "scripts": {
"postinstall": "echo 'No native modules to rebuild'", "postinstall": "echo 'No native modules to rebuild'",
"rebuild": "echo 'No native modules to rebuild'", "rebuild": "echo 'No native modules to rebuild'",

Binary file not shown.

View File

@@ -185,9 +185,15 @@ function App() {
const decryptKey = await configService.getDecryptKey() const decryptKey = await configService.getDecryptKey()
const wxid = await configService.getMyWxid() const wxid = await configService.getMyWxid()
const onboardingDone = await configService.getOnboardingDone() const onboardingDone = await configService.getOnboardingDone()
const wxidConfig = wxid ? await configService.getWxidConfig(wxid) : null
const effectiveDecryptKey = wxidConfig?.decryptKey || decryptKey
if (wxidConfig?.decryptKey && wxidConfig.decryptKey !== decryptKey) {
await configService.setDecryptKey(wxidConfig.decryptKey)
}
// 如果配置完整,自动测试连接 // 如果配置完整,自动测试连接
if (dbPath && decryptKey && wxid) { if (dbPath && effectiveDecryptKey && wxid) {
if (!onboardingDone) { if (!onboardingDone) {
await configService.setOnboardingDone(true) await configService.setOnboardingDone(true)
} }

View File

@@ -1,24 +1,24 @@
.sidebar { .sidebar {
width: 200px; width: 220px;
background: var(--bg-secondary); background: var(--bg-secondary);
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 16px 0; padding: 16px 0;
transition: width 0.25s ease; transition: width 0.25s ease;
&.collapsed { &.collapsed {
width: 64px; width: 64px;
.nav-menu, .nav-menu,
.sidebar-footer { .sidebar-footer {
padding: 0 8px; padding: 0 8px;
} }
.nav-label { .nav-label {
display: none; display: none;
} }
.nav-item { .nav-item {
justify-content: center; justify-content: center;
padding: 10px; padding: 10px;
@@ -32,14 +32,14 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
padding: 0 8px; padding: 0 12px;
} }
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 10px 16px; padding: 10px 12px;
border-radius: 9999px; border-radius: 9999px;
color: var(--text-secondary); color: var(--text-secondary);
text-decoration: none; text-decoration: none;
@@ -49,13 +49,12 @@
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
font-family: inherit; font-family: inherit;
width: 100%;
&:hover { &:hover {
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-primary); color: var(--text-primary);
} }
&.active { &.active {
background: var(--primary); background: var(--primary);
color: white; color: white;
@@ -99,9 +98,9 @@
border-radius: 9999px; border-radius: 9999px;
transition: all 0.2s ease; transition: all 0.2s ease;
margin-top: 4px; margin-top: 4px;
&:hover { &:hover {
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-primary); color: var(--text-primary);
} }
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react' import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
@@ -16,7 +16,7 @@ function AnalyticsPage() {
const themeMode = useThemeStore((state) => state.themeMode) const themeMode = useThemeStore((state) => state.themeMode)
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore() const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore()
const loadData = async (forceRefresh = false) => { const loadData = useCallback(async (forceRefresh = false) => {
if (isLoaded && !forceRefresh) return if (isLoaded && !forceRefresh) return
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
@@ -55,14 +55,22 @@ function AnalyticsPage() {
setIsLoading(false) setIsLoading(false)
if (removeListener) removeListener() if (removeListener) removeListener()
} }
} }, [isLoaded, markLoaded, setRankings, setStatistics, setTimeDistribution])
const location = useLocation() const location = useLocation()
useEffect(() => { useEffect(() => {
const force = location.state?.forceRefresh === true const force = location.state?.forceRefresh === true
loadData(force) loadData(force)
}, [location.state]) }, [location.state, loadData])
useEffect(() => {
const handleChange = () => {
loadData(true)
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadData])
const handleRefresh = () => loadData(true) const handleRefresh = () => loadData(true)

View File

@@ -1076,8 +1076,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; gap: 10px;
background: rgba(10, 10, 10, 0.28); background: var(--bg-tertiary);
backdrop-filter: blur(6px);
transition: opacity 200ms ease; transition: opacity 200ms ease;
z-index: 2; z-index: 2;
} }

View File

@@ -245,6 +245,38 @@ function ChatPage(_props: ChatPageProps) {
} }
}, [loadMyAvatar]) }, [loadMyAvatar])
const handleAccountChanged = useCallback(async () => {
senderAvatarCache.clear()
senderAvatarLoading.clear()
preloadImageKeysRef.current.clear()
lastPreloadSessionRef.current = null
setSessionDetail(null)
setCurrentSession(null)
setSessions([])
setFilteredSessions([])
setMessages([])
setSearchKeyword('')
setConnectionError(null)
setConnected(false)
setConnecting(false)
setHasMoreMessages(true)
setHasMoreLater(false)
await connect()
}, [
connect,
setConnected,
setConnecting,
setConnectionError,
setCurrentSession,
setFilteredSessions,
setHasMoreLater,
setHasMoreMessages,
setMessages,
setSearchKeyword,
setSessionDetail,
setSessions
])
// 加载会话列表(优化:先返回基础数据,异步加载联系人信息) // 加载会话列表(优化:先返回基础数据,异步加载联系人信息)
const loadSessions = async (options?: { silent?: boolean }) => { const loadSessions = async (options?: { silent?: boolean }) => {
if (options?.silent) { if (options?.silent) {
@@ -842,6 +874,14 @@ function ChatPage(_props: ChatPageProps) {
} }
}, []) }, [])
useEffect(() => {
const handleChange = () => {
void handleAccountChanged()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [handleAccountChanged])
useEffect(() => { useEffect(() => {
const nextSet = new Set<string>() const nextSet = new Set<string>()
for (const msg of messages) { for (const msg of messages) {

View File

@@ -16,6 +16,11 @@ function DataManagementPage() {
setWxid(id) setWxid(id)
} }
loadConfig() loadConfig()
const handleChange = () => {
loadConfig()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, []) }, [])
return ( return (

View File

@@ -396,6 +396,99 @@
} }
} }
.select-field {
position: relative;
}
.select-trigger {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
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-label {
font-weight: 500;
}
.option-desc {
font-size: 12px;
color: var(--text-tertiary);
}
.select-option.active .option-desc {
color: var(--primary);
}
.media-options { .media-options {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1130,11 +1223,11 @@
} }
} }
input:checked + .slider { input:checked+.slider {
background-color: var(--primary); background-color: var(--primary);
} }
input:checked + .slider::before { input:checked+.slider::before {
transform: translateX(20px); transform: translateX(20px);
} }
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react' import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
import * as configService from '../services/config' import * as configService from '../services/config'
import './ExportPage.scss' import './ExportPage.scss'
@@ -23,6 +23,7 @@ interface ExportOptions {
exportVoiceAsText: boolean exportVoiceAsText: boolean
excelCompactColumns: boolean excelCompactColumns: boolean
txtColumns: string[] txtColumns: string[]
displayNamePreference: 'group-nickname' | 'remark' | 'nickname'
} }
interface ExportResult { interface ExportResult {
@@ -49,6 +50,8 @@ function ExportPage() {
const [calendarDate, setCalendarDate] = useState(new Date()) const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true) const [selectingStart, setSelectingStart] = useState(true)
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'excel', format: 'excel',
@@ -64,7 +67,8 @@ function ExportPage() {
exportEmojis: true, exportEmojis: true,
exportVoiceAsText: true, exportVoiceAsText: true,
excelCompactColumns: true, excelCompactColumns: true,
txtColumns: defaultTxtColumns txtColumns: defaultTxtColumns,
displayNamePreference: 'remark'
}) })
const buildDateRangeFromPreset = (preset: string) => { const buildDateRangeFromPreset = (preset: string) => {
@@ -164,6 +168,19 @@ function ExportPage() {
loadExportDefaults() loadExportDefaults()
}, [loadSessions, loadExportPath, loadExportDefaults]) }, [loadSessions, loadExportPath, loadExportDefaults])
useEffect(() => {
const handleChange = () => {
setSelectedSessions(new Set())
setSearchKeyword('')
setExportResult(null)
setSessions([])
setFilteredSessions([])
loadSessions()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadSessions])
useEffect(() => { useEffect(() => {
const removeListener = window.electronAPI.export.onProgress?.((payload) => { const removeListener = window.electronAPI.export.onProgress?.((payload) => {
setExportProgress({ setExportProgress({
@@ -176,6 +193,16 @@ function ExportPage() {
removeListener?.() removeListener?.()
} }
}, []) }, [])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) {
setShowDisplayNameSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showDisplayNameSelect])
useEffect(() => { useEffect(() => {
if (!searchKeyword.trim()) { if (!searchKeyword.trim()) {
@@ -258,6 +285,7 @@ function ExportPage() {
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
excelCompactColumns: options.excelCompactColumns, excelCompactColumns: options.excelCompactColumns,
txtColumns: options.txtColumns, txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference,
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),
@@ -389,6 +417,25 @@ function ExportPage() {
{ value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' }, { value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' },
{ value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' } { value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' }
] ]
const displayNameOptions = [
{
value: 'group-nickname',
label: '群昵称优先',
desc: '仅群聊有效,私聊显示备注/昵称'
},
{
value: 'remark',
label: '备注优先',
desc: '有备注显示备注,否则显示昵称'
},
{
value: 'nickname',
label: '微信昵称',
desc: '始终显示微信昵称'
}
]
const displayNameOption = displayNameOptions.find(option => option.value === options.displayNamePreference)
const displayNameLabel = displayNameOption?.label || '备注优先'
return ( return (
<div className="export-page"> <div className="export-page">
@@ -503,6 +550,44 @@ function ExportPage() {
</div> </div>
</div> </div>
{/* 发送者名称显示偏好 */}
{(options.format === 'html' || options.format === 'json' || options.format === 'txt') && (
<div className="setting-section">
<h3></h3>
<p className="setting-subtitle"></p>
<div className="select-field" ref={displayNameDropdownRef}>
<button
type="button"
className={`select-trigger ${showDisplayNameSelect ? 'open' : ''}`}
onClick={() => setShowDisplayNameSelect(!showDisplayNameSelect)}
>
<span className="select-value">{displayNameLabel}</span>
<ChevronDown size={16} />
</button>
{showDisplayNameSelect && (
<div className="select-dropdown">
{displayNameOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${options.displayNamePreference === option.value ? 'active' : ''}`}
onClick={() => {
setOptions({
...options,
displayNamePreference: option.value as ExportOptions['displayNamePreference']
})
setShowDisplayNameSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
)}
<div className="setting-section"> <div className="setting-section">
<h3></h3> <h3></h3>
<p className="setting-subtitle">//</p> <p className="setting-subtitle">//</p>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react' import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
@@ -56,7 +56,7 @@ function GroupAnalyticsPage() {
useEffect(() => { useEffect(() => {
loadGroups() loadGroups()
}, []) }, [loadGroups])
useEffect(() => { useEffect(() => {
if (searchQuery) { if (searchQuery) {
@@ -93,7 +93,7 @@ function GroupAnalyticsPage() {
} }
}, [dateRangeReady]) }, [dateRangeReady])
const loadGroups = async () => { const loadGroups = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const result = await window.electronAPI.groupAnalytics.getGroupChats() const result = await window.electronAPI.groupAnalytics.getGroupChats()
@@ -106,7 +106,23 @@ function GroupAnalyticsPage() {
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
} }, [])
useEffect(() => {
const handleChange = () => {
setGroups([])
setFilteredGroups([])
setSelectedGroup(null)
setSelectedFunction(null)
setMembers([])
setRankings([])
setActiveHours({})
setMediaStats(null)
void loadGroups()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadGroups])
const handleGroupSelect = (group: GroupChatInfo) => { const handleGroupSelect = (group: GroupChatInfo) => {
if (selectedGroup?.username !== group.username) { if (selectedGroup?.username !== group.username) {

View File

@@ -1156,7 +1156,6 @@
input { input {
flex: 1; flex: 1;
padding-right: 36px;
} }
} }

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore'
import { useThemeStore, themes } from '../stores/themeStore' import { useThemeStore, themes } from '../stores/themeStore'
import { useAnalyticsStore } from '../stores/analyticsStore' import { useAnalyticsStore } from '../stores/analyticsStore'
import { dialog } from '../services/ipc' import { dialog } from '../services/ipc'
@@ -28,7 +29,8 @@ interface WxidOption {
} }
function SettingsPage() { function SettingsPage() {
const { setDbConnected, setLoading, reset } = useAppStore() const { isDbConnected, setDbConnected, setLoading, reset } = useAppStore()
const resetChatStore = useChatStore((state) => state.reset)
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache) const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
@@ -40,7 +42,6 @@ function SettingsPage() {
const [wxid, setWxid] = useState('') const [wxid, setWxid] = useState('')
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([]) const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
const [showWxidSelect, setShowWxidSelect] = useState(false) const [showWxidSelect, setShowWxidSelect] = useState(false)
const wxidDropdownRef = useRef<HTMLDivElement>(null)
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false) const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false) const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
@@ -92,9 +93,6 @@ function SettingsPage() {
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node const target = e.target as Node
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(target)) {
setShowWxidSelect(false)
}
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) { if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
setShowExportFormatSelect(false) setShowExportFormatSelect(false)
} }
@@ -107,7 +105,7 @@ function SettingsPage() {
} }
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showWxidSelect, showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect]) }, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
@@ -142,14 +140,24 @@ function SettingsPage() {
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText() const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns() const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
if (savedKey) setDecryptKey(savedKey)
if (savedPath) setDbPath(savedPath) if (savedPath) setDbPath(savedPath)
if (savedWxid) setWxid(savedWxid) if (savedWxid) setWxid(savedWxid)
if (savedCachePath) setCachePath(savedCachePath) if (savedCachePath) setCachePath(savedCachePath)
if (savedImageXorKey != null) {
setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`) const wxidConfig = savedWxid ? await configService.getWxidConfig(savedWxid) : null
const decryptKeyToUse = wxidConfig?.decryptKey ?? savedKey ?? ''
const imageXorKeyToUse = typeof wxidConfig?.imageXorKey === 'number'
? wxidConfig.imageXorKey
: savedImageXorKey
const imageAesKeyToUse = wxidConfig?.imageAesKey ?? savedImageAesKey ?? ''
setDecryptKey(decryptKeyToUse)
if (typeof imageXorKeyToUse === 'number') {
setImageXorKey(`0x${imageXorKeyToUse.toString(16).toUpperCase().padStart(2, '0')}`)
} else {
setImageXorKey('')
} }
if (savedImageAesKey) setImageAesKey(savedImageAesKey) setImageAesKey(imageAesKeyToUse)
setLogEnabled(savedLogEnabled) setLogEnabled(savedLogEnabled)
setAutoTranscribeVoice(savedAutoTranscribe) setAutoTranscribeVoice(savedAutoTranscribe)
setTranscribeLanguages(savedTranscribeLanguages) setTranscribeLanguages(savedTranscribeLanguages)
@@ -255,6 +263,103 @@ function SettingsPage() {
setTimeout(() => setMessage(null), 3000) setTimeout(() => setMessage(null), 3000)
} }
type WxidKeys = {
decryptKey: string
imageXorKey: number | null
imageAesKey: string
}
const formatImageXorKey = (value: number) => `0x${value.toString(16).toUpperCase().padStart(2, '0')}`
const parseImageXorKey = (value: string) => {
if (!value) return null
const parsed = parseInt(value.replace(/^0x/i, ''), 16)
return Number.isNaN(parsed) ? null : parsed
}
const buildKeysFromState = (): WxidKeys => ({
decryptKey: decryptKey || '',
imageXorKey: parseImageXorKey(imageXorKey),
imageAesKey: imageAesKey || ''
})
const buildKeysFromConfig = (wxidConfig: configService.WxidConfig | null): WxidKeys => ({
decryptKey: wxidConfig?.decryptKey || '',
imageXorKey: typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : null,
imageAesKey: wxidConfig?.imageAesKey || ''
})
const applyKeysToState = (keys: WxidKeys) => {
setDecryptKey(keys.decryptKey)
if (typeof keys.imageXorKey === 'number') {
setImageXorKey(formatImageXorKey(keys.imageXorKey))
} else {
setImageXorKey('')
}
setImageAesKey(keys.imageAesKey)
}
const syncKeysToConfig = async (keys: WxidKeys) => {
await configService.setDecryptKey(keys.decryptKey)
await configService.setImageXorKey(typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0)
await configService.setImageAesKey(keys.imageAesKey)
}
const applyWxidSelection = async (
selectedWxid: string,
options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string }
) => {
if (!selectedWxid) return
const currentWxid = wxid
const isSameWxid = currentWxid === selectedWxid
if (currentWxid && currentWxid !== selectedWxid) {
const currentKeys = buildKeysFromState()
await configService.setWxidConfig(currentWxid, {
decryptKey: currentKeys.decryptKey,
imageXorKey: typeof currentKeys.imageXorKey === 'number' ? currentKeys.imageXorKey : 0,
imageAesKey: currentKeys.imageAesKey
})
}
const preferCurrentKeys = options?.preferCurrentKeys ?? false
const keys = preferCurrentKeys
? buildKeysFromState()
: buildKeysFromConfig(await configService.getWxidConfig(selectedWxid))
setWxid(selectedWxid)
applyKeysToState(keys)
await configService.setMyWxid(selectedWxid)
await syncKeysToConfig(keys)
await configService.setWxidConfig(selectedWxid, {
decryptKey: keys.decryptKey,
imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0,
imageAesKey: keys.imageAesKey
})
setShowWxidSelect(false)
if (isDbConnected) {
try {
await window.electronAPI.chat.close()
const result = await window.electronAPI.chat.connect()
setDbConnected(result.success, dbPath || undefined)
if (!result.success && result.error) {
showMessage(result.error, false)
}
} catch (e) {
showMessage(`切换账号后重新连接失败: ${e}`, false)
setDbConnected(false)
}
}
if (!isSameWxid) {
clearAnalyticsStoreCache()
resetChatStore()
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } }))
}
if (options?.showToast ?? true) {
showMessage(options?.toastText || `已选择账号:${selectedWxid}`, true)
}
}
const handleAutoDetectPath = async () => { const handleAutoDetectPath = async () => {
if (isDetectingPath) return if (isDetectingPath) return
setIsDetectingPath(true) setIsDetectingPath(true)
@@ -268,11 +373,10 @@ function SettingsPage() {
const wxids = await window.electronAPI.dbPath.scanWxids(result.path) const wxids = await window.electronAPI.dbPath.scanWxids(result.path)
setWxidOptions(wxids) setWxidOptions(wxids)
if (wxids.length === 1) { if (wxids.length === 1) {
setWxid(wxids[0].wxid) await applyWxidSelection(wxids[0].wxid, {
await configService.setMyWxid(wxids[0].wxid) toastText: `已检测到账号:${wxids[0].wxid}`
showMessage(`已检测到账号:${wxids[0].wxid}`, true) })
} else if (wxids.length > 1) { } else if (wxids.length > 1) {
// 多账号时弹出选择对话框
setShowWxidSelect(true) setShowWxidSelect(true)
} }
} else { } else {
@@ -297,7 +401,10 @@ function SettingsPage() {
} }
} }
const handleScanWxid = async (silent = false) => { const handleScanWxid = async (
silent = false,
options?: { preferCurrentKeys?: boolean; showDialog?: boolean }
) => {
if (!dbPath) { if (!dbPath) {
if (!silent) showMessage('请先选择数据库目录', false) if (!silent) showMessage('请先选择数据库目录', false)
return return
@@ -305,12 +412,14 @@ function SettingsPage() {
try { try {
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
setWxidOptions(wxids) setWxidOptions(wxids)
const allowDialog = options?.showDialog ?? !silent
if (wxids.length === 1) { if (wxids.length === 1) {
setWxid(wxids[0].wxid) await applyWxidSelection(wxids[0].wxid, {
await configService.setMyWxid(wxids[0].wxid) preferCurrentKeys: options?.preferCurrentKeys ?? false,
if (!silent) showMessage(`已检测到账号:${wxids[0].wxid}`, true) showToast: !silent,
} else if (wxids.length > 1) { toastText: `已检测到账号:${wxids[0].wxid}`
// 多账号时弹出选择对话框 })
} else if (wxids.length > 1 && allowDialog) {
setShowWxidSelect(true) setShowWxidSelect(true)
} else { } else {
if (!silent) showMessage('未检测到账号目录,请检查路径', false) if (!silent) showMessage('未检测到账号目录,请检查路径', false)
@@ -321,10 +430,7 @@ function SettingsPage() {
} }
const handleSelectWxid = async (selectedWxid: string) => { const handleSelectWxid = async (selectedWxid: string) => {
setWxid(selectedWxid) await applyWxidSelection(selectedWxid)
await configService.setMyWxid(selectedWxid)
setShowWxidSelect(false)
showMessage(`已选择账号:${selectedWxid}`, true)
} }
const handleSelectCachePath = async () => { const handleSelectCachePath = async () => {
@@ -397,7 +503,7 @@ function SettingsPage() {
setDecryptKey(result.key) setDecryptKey(result.key)
setDbKeyStatus('密钥获取成功') setDbKeyStatus('密钥获取成功')
showMessage('已自动获取解密密钥', true) showMessage('已自动获取解密密钥', true)
await handleScanWxid(true) await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false })
} else { } else {
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
setIsManualStartPrompt(true) setIsManualStartPrompt(true)
@@ -483,19 +589,14 @@ function SettingsPage() {
await configService.setDbPath(dbPath) await configService.setDbPath(dbPath)
await configService.setMyWxid(wxid) await configService.setMyWxid(wxid)
await configService.setCachePath(cachePath) await configService.setCachePath(cachePath)
if (imageXorKey) { const parsedXorKey = parseImageXorKey(imageXorKey)
const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16) await configService.setImageXorKey(typeof parsedXorKey === 'number' ? parsedXorKey : 0)
if (!Number.isNaN(parsed)) { await configService.setImageAesKey(imageAesKey || '')
await configService.setImageXorKey(parsed) await configService.setWxidConfig(wxid, {
} decryptKey,
} else { imageXorKey: typeof parsedXorKey === 'number' ? parsedXorKey : 0,
await configService.setImageXorKey(0) imageAesKey
} })
if (imageAesKey) {
await configService.setImageAesKey(imageAesKey)
} else {
await configService.setImageAesKey('')
}
await configService.setWhisperModelDir(whisperModelDir) await configService.setWhisperModelDir(whisperModelDir)
await configService.setAutoTranscribeVoice(autoTranscribeVoice) await configService.setAutoTranscribeVoice(autoTranscribeVoice)
await configService.setTranscribeLanguages(transcribeLanguages) await configService.setTranscribeLanguages(transcribeLanguages)
@@ -688,37 +789,13 @@ function SettingsPage() {
<div className="form-group"> <div className="form-group">
<label> wxid</label> <label> wxid</label>
<span className="form-hint"></span> <span className="form-hint"></span>
<div className="wxid-input-wrapper" ref={wxidDropdownRef}> <div className="wxid-input-wrapper">
<input <input
type="text" type="text"
placeholder="例如: wxid_xxxxxx" placeholder="例如: wxid_xxxxxx"
value={wxid} value={wxid}
onChange={(e) => setWxid(e.target.value)} onChange={(e) => setWxid(e.target.value)}
/> />
<button
type="button"
className={`wxid-dropdown-btn ${showWxidSelect ? 'open' : ''}`}
onClick={() => wxidOptions.length > 0 ? setShowWxidSelect(!showWxidSelect) : handleScanWxid()}
title={wxidOptions.length > 0 ? "选择已检测到的账号" : "扫描账号"}
>
<ChevronDown size={16} />
</button>
{showWxidSelect && wxidOptions.length > 0 && (
<div className="wxid-dropdown">
{wxidOptions.map((opt) => (
<div
key={opt.wxid}
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
onClick={() => handleSelectWxid(opt.wxid)}
>
<span className="wxid-value">{opt.wxid}</span>
<span className="wxid-time">
{new Date(opt.modifiedTime).toLocaleDateString()}
</span>
</div>
))}
</div>
)}
</div> </div>
<button className="btn btn-secondary btn-sm" onClick={() => handleScanWxid()}><Search size={14} /> wxid</button> <button className="btn btn-secondary btn-sm" onClick={() => handleScanWxid()}><Search size={14} /> wxid</button>
</div> </div>

View File

@@ -194,7 +194,7 @@ export default function SnsPage() {
}, [selectedUsernames, searchKeyword, jumpTargetDate]) }, [selectedUsernames, searchKeyword, jumpTargetDate])
// 获取联系人列表 // 获取联系人列表
const loadContacts = async () => { const loadContacts = useCallback(async () => {
setContactsLoading(true) setContactsLoading(true)
try { try {
const result = await window.electronAPI.chat.getSessions() const result = await window.electronAPI.chat.getSessions()
@@ -237,7 +237,7 @@ export default function SnsPage() {
} finally { } finally {
setContactsLoading(false) setContactsLoading(false)
} }
} }, [])
// 初始加载 // 初始加载
useEffect(() => { useEffect(() => {
@@ -255,7 +255,22 @@ export default function SnsPage() {
}; };
checkSchema(); checkSchema();
loadContacts() loadContacts()
}, []) }, [loadContacts])
useEffect(() => {
const handleChange = () => {
setPosts([])
setHasMore(true)
setHasNewer(false)
setSelectedUsernames([])
setSearchKeyword('')
setJumpTargetDate(null)
loadContacts()
loadPosts({ reset: true })
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadContacts, loadPosts])
useEffect(() => { useEffect(() => {
loadPosts({ reset: true }) loadPosts({ reset: true })

File diff suppressed because it is too large Load Diff

View File

@@ -269,15 +269,14 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
await configService.setDecryptKey(decryptKey) await configService.setDecryptKey(decryptKey)
await configService.setMyWxid(wxid) await configService.setMyWxid(wxid)
await configService.setCachePath(cachePath) await configService.setCachePath(cachePath)
if (imageXorKey) { const parsedXorKey = imageXorKey ? parseInt(imageXorKey.replace(/^0x/i, ''), 16) : null
const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16) await configService.setImageXorKey(typeof parsedXorKey === 'number' && !Number.isNaN(parsedXorKey) ? parsedXorKey : 0)
if (!Number.isNaN(parsed)) { await configService.setImageAesKey(imageAesKey || '')
await configService.setImageXorKey(parsed) await configService.setWxidConfig(wxid, {
} decryptKey,
} imageXorKey: typeof parsedXorKey === 'number' && !Number.isNaN(parsedXorKey) ? parsedXorKey : 0,
if (imageAesKey) { imageAesKey
await configService.setImageAesKey(imageAesKey) })
}
await configService.setOnboardingDone(true) await configService.setOnboardingDone(true)
setDbConnected(true, dbPath) setDbConnected(true, dbPath)
@@ -313,6 +312,67 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
if (isDbConnected) { if (isDbConnected) {
return ( return (
<div className={rootClassName}> <div className={rootClassName}>
<div className="welcome-container">
{showWindowControls && (
<div className="window-controls">
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
<Minus size={14} />
</button>
<button type="button" className="window-btn is-close" onClick={handleCloseWindow} aria-label="关闭">
<X size={14} />
</button>
</div>
)}
<div className="welcome-sidebar">
<div className="sidebar-header">
<img src="./logo.png" alt="WeFlow" className="sidebar-logo" />
<div className="sidebar-brand">
<span className="brand-name">WeFlow</span>
<span className="brand-tag">Connected</span>
</div>
</div>
<div className="sidebar-spacer" style={{ flex: 1 }} />
<div className="sidebar-footer">
<ShieldCheck size={14} />
<span></span>
</div>
</div>
<div className="welcome-content success-content">
<div className="success-body">
<div className="success-icon">
<CheckCircle2 size={48} />
</div>
<h1 className="success-title"></h1>
<p className="success-desc">使</p>
<button
className="btn btn-primary btn-large"
onClick={() => {
if (standalone) {
setIsClosing(true)
setTimeout(() => {
window.electronAPI.window.completeOnboarding()
}, 450)
} else {
navigate('/home')
}
}}
>
<ArrowRight size={18} />
</button>
</div>
</div>
</div>
</div>
)
}
return (
<div className={rootClassName}>
<div className="welcome-container">
{showWindowControls && ( {showWindowControls && (
<div className="window-controls"> <div className="window-controls">
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化"> <button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
@@ -323,234 +383,204 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</button> </button>
</div> </div>
)} )}
<div className="welcome-shell"> <div className="welcome-sidebar">
<div className="welcome-panel"> <div className="sidebar-header">
<div className="panel-header"> <img src="./logo.png" alt="WeFlow" className="sidebar-logo" />
<img src="./logo.png" alt="WeFlow" className="panel-logo" /> <div className="sidebar-brand">
<div> <span className="brand-name">WeFlow</span>
<p className="panel-kicker">WeFlow</p> <span className="brand-tag">Setup</span>
<h1></h1>
</div>
</div> </div>
<div className="panel-note">
<CheckCircle2 size={16} />
<span></span>
</div>
<button
className="btn btn-primary btn-full"
onClick={() => {
if (standalone) {
setIsClosing(true)
setTimeout(() => {
window.electronAPI.window.completeOnboarding()
}, 450)
} else {
navigate('/home')
}
}}
>
</button>
</div> </div>
</div>
</div>
)
}
return ( <div className="sidebar-nav">
<div className={rootClassName}>
{showWindowControls && (
<div className="window-controls">
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
<Minus size={14} />
</button>
<button type="button" className="window-btn is-close" onClick={handleCloseWindow} aria-label="关闭">
<X size={14} />
</button>
</div>
)}
<div className="welcome-shell">
<div className="welcome-panel">
<div className="panel-header">
<img src="./logo.png" alt="WeFlow" className="panel-logo" />
<div>
<p className="panel-kicker"></p>
<h1>WeFlow </h1>
<p className="panel-subtitle"></p>
</div>
</div>
<div className="step-list">
{steps.map((step, index) => ( {steps.map((step, index) => (
<div key={step.id} className={`step-item ${index === stepIndex ? 'active' : ''} ${index < stepIndex ? 'done' : ''}`}> <div key={step.id} className={`nav-item ${index === stepIndex ? 'active' : ''} ${index < stepIndex ? 'completed' : ''}`}>
<div className="step-index">{index < stepIndex ? <CheckCircle2 size={14} /> : index + 1}</div> <div className="nav-indicator">
<div> {index < stepIndex ? <CheckCircle2 size={14} /> : <div className="dot" />}
<div className="step-title">{step.title}</div> </div>
<div className="step-desc">{step.desc}</div> <div className="nav-info">
<div className="nav-title">{step.title}</div>
<div className="nav-desc">{step.desc}</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
<div className="panel-foot">
<ShieldCheck size={16} /> <div className="sidebar-footer">
<ShieldCheck size={14} />
<span></span> <span></span>
</div> </div>
</div> </div>
<div className="setup-card"> <div className="welcome-content">
<div className="setup-header"> <div className="content-header">
<div className="setup-icon">
{currentStep.id === 'intro' && <Sparkles size={18} />}
{currentStep.id === 'db' && <Database size={18} />}
{currentStep.id === 'cache' && <HardDrive size={18} />}
{currentStep.id === 'key' && <KeyRound size={18} />}
{currentStep.id === 'image' && <ShieldCheck size={18} />}
</div>
<div> <div>
<h2>{currentStep.title}</h2> <h2>{currentStep.title}</h2>
<p>{currentStep.desc}</p> <p className="header-desc">{currentStep.desc}</p>
</div> </div>
</div> </div>
{currentStep.id === 'intro' && ( <div className="content-body">
<div className="setup-body"> {currentStep.id === 'intro' && (
<div className="intro-card"> <div className="intro-block">
<Wand2 size={18} /> {/* 内容移至底部 */}
<div> </div>
<h3></h3> )}
<p></p>
{currentStep.id === 'db' && (
<div className="form-group">
<label className="field-label"></label>
<div className="input-group">
<input
type="text"
className="field-input"
placeholder="例如C:\\Users\\xxx\\Documents\\xwechat_files"
value={dbPath}
onChange={(e) => setDbPath(e.target.value)}
/>
</div> </div>
</div> <div className="action-row">
</div> <button className="btn btn-secondary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
)} <FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'}
</button>
{currentStep.id === 'db' && ( <button className="btn btn-secondary" onClick={handleSelectPath}>
<div className="setup-body"> <FolderOpen size={16} /> ...
<label className="field-label"></label>
<input
type="text"
className="field-input"
placeholder="例如C:\\Users\\xxx\\Documents\\xwechat_files"
value={dbPath}
onChange={(e) => setDbPath(e.target.value)}
/>
<div className="button-row">
<button className="btn btn-secondary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
<FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'}
</button>
<button className="btn btn-primary" onClick={handleSelectPath}>
<FolderOpen size={16} />
</button>
</div>
<div className="field-hint">--</div>
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}> --</div>
</div>
)}
{currentStep.id === 'cache' && (
<div className="setup-body">
<label className="field-label"></label>
<input
type="text"
className="field-input"
placeholder="留空使用默认目录"
value={cachePath}
onChange={(e) => setCachePath(e.target.value)}
/>
<div className="button-row">
<button className="btn btn-primary" onClick={handleSelectCachePath}>
<FolderOpen size={16} />
</button>
<button className="btn btn-secondary" onClick={() => setCachePath('')}>
<RotateCcw size={16} /> 使
</button>
</div>
<div className="field-hint">使</div>
</div>
)}
{currentStep.id === 'key' && (
<div className="setup-body">
<label className="field-label"> wxid</label>
<input
type="text"
className="field-input"
placeholder="获取密钥后将自动填充"
value={wxid}
onChange={(e) => setWxid(e.target.value)}
/>
<label className="field-label"></label>
<div className="field-with-toggle">
<input
type={showDecryptKey ? 'text' : 'password'}
className="field-input"
placeholder="64 位十六进制密钥"
value={decryptKey}
onChange={(e) => setDecryptKey(e.target.value.trim())}
/>
<button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}>
{showDecryptKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{isManualStartPrompt ? (
<div className="manual-prompt">
<p className="prompt-text"></p>
<button className="btn btn-primary" onClick={handleManualConfirm}>
</button> </button>
</div> </div>
) : (
<button className="btn btn-secondary btn-inline" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}> <div className="field-hint">--</div>
{isFetchingDbKey ? '获取中...' : '自动获取密钥'} <div className="field-hint warning">
</div>
</div>
)}
{currentStep.id === 'cache' && (
<div className="form-group">
<label className="field-label"></label>
<div className="input-group">
<input
type="text"
className="field-input"
placeholder="留空即使用默认目录"
value={cachePath}
onChange={(e) => setCachePath(e.target.value)}
/>
</div>
<div className="action-row">
<button className="btn btn-secondary" onClick={handleSelectCachePath}>
<FolderOpen size={16} />
</button>
<button className="btn btn-secondary" onClick={() => setCachePath('')}>
<RotateCcw size={16} />
</button>
</div>
<div className="field-hint"></div>
</div>
)}
{currentStep.id === 'key' && (
<div className="form-group">
<label className="field-label"> (Wxid)</label>
<input
type="text"
className="field-input"
placeholder="等待获取..."
value={wxid}
readOnly
onChange={(e) => setWxid(e.target.value)}
/>
<label className="field-label mt-4"></label>
<div className="field-with-toggle">
<input
type={showDecryptKey ? 'text' : 'password'}
className="field-input"
placeholder="64 位十六进制密钥"
value={decryptKey}
onChange={(e) => setDecryptKey(e.target.value.trim())}
/>
<button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}>
{showDecryptKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<div className="key-actions">
{isManualStartPrompt ? (
<div className="manual-prompt">
<p></p>
<button className="btn btn-primary" onClick={handleManualConfirm}>
</button>
</div>
) : (
<button className="btn btn-secondary btn-block" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}>
{isFetchingDbKey ? '正在获取...' : '自动获取密钥'}
</button>
)}
</div>
{dbKeyStatus && <div className="status-message">{dbKeyStatus}</div>}
<div className="field-hint"></div>
</div>
)}
{currentStep.id === 'image' && (
<div className="form-group">
<div className="grid-2">
<div>
<label className="field-label"> XOR </label>
<input
type="text"
className="field-input"
placeholder="0x..."
value={imageXorKey}
onChange={(e) => setImageXorKey(e.target.value)}
/>
</div>
<div>
<label className="field-label"> AES </label>
<input
type="text"
className="field-input"
placeholder="16位密钥"
value={imageAesKey}
onChange={(e) => setImageAesKey(e.target.value)}
/>
</div>
</div>
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
{isFetchingImageKey ? '扫描中...' : '自动获取图片密钥'}
</button> </button>
)}
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>} {imageKeyStatus && <div className="status-message">{imageKeyStatus}</div>}
<div className="field-hint"></div> <div className="field-hint"></div>
<div className="field-hint"><span style={{color: 'red'}}>hook安装成功</span></div> </div>
</div> )}
)} </div>
{currentStep.id === 'image' && (
<div className="setup-body">
<label className="field-label"> XOR </label>
<input
type="text"
className="field-input"
placeholder="例如0xA4"
value={imageXorKey}
onChange={(e) => setImageXorKey(e.target.value)}
/>
<label className="field-label"> AES </label>
<input
type="text"
className="field-input"
placeholder="16 位密钥"
value={imageAesKey}
onChange={(e) => setImageAesKey(e.target.value)}
/>
<button className="btn btn-secondary btn-inline" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button>
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
<div className="field-hint"></div>
{isFetchingImageKey && <div className="field-hint status-text">...</div>}
</div>
)}
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
<div className="setup-actions"> {currentStep.id === 'intro' && (
<button className="btn btn-tertiary" onClick={handleBack} disabled={stepIndex === 0}> <div className="intro-footer">
<p></p>
<p>WeFlow 访</p>
</div>
)}
<div className="content-actions">
<button className="btn btn-ghost" onClick={handleBack} disabled={stepIndex === 0}>
<ArrowLeft size={16} /> <ArrowLeft size={16} />
</button> </button>
{stepIndex < steps.length - 1 ? ( {stepIndex < steps.length - 1 ? (
<button className="btn btn-primary" onClick={handleNext} disabled={!canGoNext()}> <button className="btn btn-primary" onClick={handleNext} disabled={!canGoNext()}>
<ArrowRight size={16} /> <ArrowRight size={16} />
</button> </button>
) : ( ) : (
<button className="btn btn-primary" onClick={handleConnect} disabled={isConnecting || !canGoNext()}> <button className="btn btn-primary" onClick={handleConnect} disabled={isConnecting || !canGoNext()}>
{isConnecting ? '连接中...' : '测试并完成'} {isConnecting ? '连接中...' : '完成配置'} <ArrowRight size={16} />
</button> </button>
)} )}
</div> </div>

View File

@@ -6,6 +6,7 @@ export const CONFIG_KEYS = {
DECRYPT_KEY: 'decryptKey', DECRYPT_KEY: 'decryptKey',
DB_PATH: 'dbPath', DB_PATH: 'dbPath',
MY_WXID: 'myWxid', MY_WXID: 'myWxid',
WXID_CONFIGS: 'wxidConfigs',
THEME: 'theme', THEME: 'theme',
THEME_ID: 'themeId', THEME_ID: 'themeId',
LAST_SESSION: 'lastSession', LAST_SESSION: 'lastSession',
@@ -31,6 +32,13 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns' EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns'
} as const } as const
export interface WxidConfig {
decryptKey?: string
imageXorKey?: number
imageAesKey?: string
updatedAt?: number
}
// 获取解密密钥 // 获取解密密钥
export async function getDecryptKey(): Promise<string | null> { export async function getDecryptKey(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.DECRYPT_KEY) const value = await config.get(CONFIG_KEYS.DECRYPT_KEY)
@@ -64,6 +72,32 @@ export async function setMyWxid(wxid: string): Promise<void> {
await config.set(CONFIG_KEYS.MY_WXID, wxid) await config.set(CONFIG_KEYS.MY_WXID, wxid)
} }
export async function getWxidConfigs(): Promise<Record<string, WxidConfig>> {
const value = await config.get(CONFIG_KEYS.WXID_CONFIGS)
if (value && typeof value === 'object') {
return value as Record<string, WxidConfig>
}
return {}
}
export async function getWxidConfig(wxid: string): Promise<WxidConfig | null> {
if (!wxid) return null
const configs = await getWxidConfigs()
return configs[wxid] || null
}
export async function setWxidConfig(wxid: string, configValue: WxidConfig): Promise<void> {
if (!wxid) return
const configs = await getWxidConfigs()
const previous = configs[wxid] || {}
configs[wxid] = {
...previous,
...configValue,
updatedAt: Date.now()
}
await config.set(CONFIG_KEYS.WXID_CONFIGS, configs)
}
// 获取主题 // 获取主题
export async function getTheme(): Promise<'light' | 'dark'> { export async function getTheme(): Promise<'light' | 'dark'> {
const value = await config.get(CONFIG_KEYS.THEME) const value = await config.get(CONFIG_KEYS.THEME)

View File

@@ -354,6 +354,7 @@ export interface ExportOptions {
excelCompactColumns?: boolean excelCompactColumns?: boolean
txtColumns?: string[] txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session' sessionLayout?: 'shared' | 'per-session'
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
} }
export interface ExportProgress { export interface ExportProgress {