mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
@@ -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',
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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-9(AES密钥格式)
|
||||||
|
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,11 +939,10 @@ 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
|
||||||
if (nextAddress <= address) break
|
if (nextAddress <= address) break
|
||||||
@@ -972,87 +970,52 @@ 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) {
|
||||||
// 优化2: 优先级排序 - 按大小升序,先扫描小区域(密钥通常在较小区域)
|
skippedCount++
|
||||||
const sortedRegions = filteredRegions.sort((a, b) => a[1] - b[1])
|
|
||||||
|
|
||||||
// 优化3: 计算总字节数用于精确进度报告
|
|
||||||
const totalBytes = sortedRegions.reduce((sum, [_, size]) => sum + size, 0)
|
|
||||||
let processedBytes = 0
|
|
||||||
|
|
||||||
// 优化4: 减小分块大小到 1MB(参考 wx_key 项目)
|
|
||||||
const chunkSize = 1 * 1024 * 1024
|
|
||||||
const overlap = 65
|
|
||||||
let currentRegion = 0
|
|
||||||
|
|
||||||
for (const [baseAddress, regionSize] of sortedRegions) {
|
|
||||||
currentRegion++
|
|
||||||
const progress = totalBytes > 0 ? Math.floor((processedBytes / totalBytes) * 100) : 0
|
|
||||||
onProgress?.(progress, 100, `扫描内存 ${progress}% (${currentRegion}/${sortedRegions.length})`)
|
|
||||||
|
|
||||||
// 每个区域都让出主线程,确保UI流畅
|
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
|
||||||
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
|
scannedCount++
|
||||||
if (trailing && trailing.length) {
|
if (scannedCount % 10 === 0) {
|
||||||
dataToScan = Buffer.concat([trailing, chunk])
|
onProgress?.(scannedCount, totalRegions, `正在扫描微信内存... (${scannedCount}/${totalRegions})`)
|
||||||
} else {
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
dataToScan = chunk
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < dataToScan.length - 34; i++) {
|
const memory = this.readProcessMemory(hProcess, baseAddress, regionSize)
|
||||||
if (this.isAlphaNumAscii(dataToScan[i])) continue
|
if (!memory) continue
|
||||||
|
|
||||||
|
// 直接在原始字节中搜索32字节的小写字母数字序列
|
||||||
|
for (let i = 0; i < memory.length - 34; i++) {
|
||||||
|
// 检查前导字符(不是小写字母或数字)
|
||||||
|
if (this.isAlphaNumLower(memory[i])) continue
|
||||||
|
|
||||||
|
// 检查接下来32个字节是否都是小写字母或数字
|
||||||
let valid = true
|
let valid = true
|
||||||
for (let j = 1; j <= 32; j++) {
|
for (let j = 1; j <= 32; j++) {
|
||||||
if (!this.isAlphaNumAscii(dataToScan[i + j])) {
|
if (!this.isAlphaNumLower(memory[i + j])) {
|
||||||
valid = false
|
valid = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (valid && this.isAlphaNumAscii(dataToScan[i + 33])) {
|
if (!valid) continue
|
||||||
valid = false
|
|
||||||
|
// 检查尾部字符(不是小写字母或数字)
|
||||||
|
if (i + 33 < memory.length && this.isAlphaNumLower(memory[i + 33])) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if (valid) {
|
|
||||||
const keyBytes = dataToScan.subarray(i + 1, i + 33)
|
const keyBytes = memory.subarray(i + 1, i + 33)
|
||||||
if (this.verifyKey(ciphertext, keyBytes)) {
|
if (this.verifyKey(ciphertext, keyBytes)) {
|
||||||
return keyBytes.toString('ascii')
|
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 {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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
9797
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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.
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.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;
|
||||||
@@ -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,7 +49,6 @@
|
|||||||
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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1156,7 +1156,6 @@
|
|||||||
|
|
||||||
input {
|
input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding-right: 36px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
if (isDbConnected) {
|
if (isDbConnected) {
|
||||||
return (
|
return (
|
||||||
<div className={rootClassName}>
|
<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,21 +323,33 @@ 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">Connected</span>
|
||||||
<h1>已连接数据库</h1>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-note">
|
|
||||||
<CheckCircle2 size={16} />
|
<div className="sidebar-spacer" style={{ flex: 1 }} />
|
||||||
<span>配置已完成,可直接进入首页</span>
|
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<ShieldCheck size={14} />
|
||||||
|
<span>本地安全存储</span>
|
||||||
</div>
|
</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
|
<button
|
||||||
className="btn btn-primary btn-full"
|
className="btn btn-primary btn-large"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (standalone) {
|
if (standalone) {
|
||||||
setIsClosing(true)
|
setIsClosing(true)
|
||||||
@@ -349,16 +361,18 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
进入首页
|
进入首页 <ArrowRight size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={rootClassName}>
|
<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="最小化">
|
||||||
@@ -369,63 +383,54 @@ 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">首次配置</p>
|
<span className="brand-tag">Setup</span>
|
||||||
<h1>WeFlow 初始引导</h1>
|
|
||||||
<p className="panel-subtitle">一步一步完成数据库与密钥设置</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="step-list">
|
|
||||||
|
<div className="sidebar-nav">
|
||||||
{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>
|
||||||
|
|
||||||
|
<div className="content-body">
|
||||||
{currentStep.id === 'intro' && (
|
{currentStep.id === 'intro' && (
|
||||||
<div className="setup-body">
|
<div className="intro-block">
|
||||||
<div className="intro-card">
|
{/* 内容移至底部 */}
|
||||||
<Wand2 size={18} />
|
|
||||||
<div>
|
|
||||||
<h3>准备好了吗?</h3>
|
|
||||||
<p>接下来只需配置数据库目录和获取解密密钥。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep.id === 'db' && (
|
{currentStep.id === 'db' && (
|
||||||
<div className="setup-body">
|
<div className="form-group">
|
||||||
<label className="field-label">数据库根目录</label>
|
<label className="field-label">数据库根目录</label>
|
||||||
|
<div className="input-group">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
@@ -433,52 +438,60 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
value={dbPath}
|
value={dbPath}
|
||||||
onChange={(e) => setDbPath(e.target.value)}
|
onChange={(e) => setDbPath(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="button-row">
|
</div>
|
||||||
|
<div className="action-row">
|
||||||
<button className="btn btn-secondary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
|
<button className="btn btn-secondary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
|
||||||
<FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'}
|
<FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary" onClick={handleSelectPath}>
|
<button className="btn btn-secondary" onClick={handleSelectPath}>
|
||||||
<FolderOpen size={16} /> 浏览选择
|
<FolderOpen size={16} /> 浏览...
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field-hint">请选择微信-设置-存储位置对应的目录</div>
|
<div className="field-hint">请选择微信-设置-存储位置对应的目录</div>
|
||||||
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}>⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</div>
|
<div className="field-hint warning">
|
||||||
|
⚠️ 目录路径不可包含中文,如有中文请先在微信中迁移至全英文目录
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep.id === 'cache' && (
|
{currentStep.id === 'cache' && (
|
||||||
<div className="setup-body">
|
<div className="form-group">
|
||||||
<label className="field-label">缓存目录</label>
|
<label className="field-label">缓存目录</label>
|
||||||
|
<div className="input-group">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
placeholder="留空使用默认目录"
|
placeholder="留空即使用默认目录"
|
||||||
value={cachePath}
|
value={cachePath}
|
||||||
onChange={(e) => setCachePath(e.target.value)}
|
onChange={(e) => setCachePath(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="button-row">
|
</div>
|
||||||
<button className="btn btn-primary" onClick={handleSelectCachePath}>
|
<div className="action-row">
|
||||||
<FolderOpen size={16} /> 浏览选择
|
<button className="btn btn-secondary" onClick={handleSelectCachePath}>
|
||||||
|
<FolderOpen size={16} /> 浏览
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={() => setCachePath('')}>
|
<button className="btn btn-secondary" onClick={() => setCachePath('')}>
|
||||||
<RotateCcw size={16} /> 使用默认
|
<RotateCcw size={16} /> 重置默认
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="field-hint">用于头像、表情与图片缓存,留空使用默认目录</div>
|
<div className="field-hint">用于头像、表情与图片缓存</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep.id === 'key' && (
|
{currentStep.id === 'key' && (
|
||||||
<div className="setup-body">
|
<div className="form-group">
|
||||||
<label className="field-label">微信账号 wxid</label>
|
<label className="field-label">微信账号 (Wxid)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
placeholder="获取密钥后将自动填充"
|
placeholder="等待获取..."
|
||||||
value={wxid}
|
value={wxid}
|
||||||
|
readOnly
|
||||||
onChange={(e) => setWxid(e.target.value)}
|
onChange={(e) => setWxid(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<label className="field-label">解密密钥</label>
|
|
||||||
|
<label className="field-label mt-4">解密密钥</label>
|
||||||
<div className="field-with-toggle">
|
<div className="field-with-toggle">
|
||||||
<input
|
<input
|
||||||
type={showDecryptKey ? 'text' : 'password'}
|
type={showDecryptKey ? 'text' : 'password'}
|
||||||
@@ -488,69 +501,86 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
onChange={(e) => setDecryptKey(e.target.value.trim())}
|
onChange={(e) => setDecryptKey(e.target.value.trim())}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}>
|
<button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}>
|
||||||
{showDecryptKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
{showDecryptKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="key-actions">
|
||||||
{isManualStartPrompt ? (
|
{isManualStartPrompt ? (
|
||||||
<div className="manual-prompt">
|
<div className="manual-prompt">
|
||||||
<p className="prompt-text">未能自动启动微信,请手动启动并登录后点击下方确认</p>
|
<p>未能自动启动微信,请手动启动并登录</p>
|
||||||
<button className="btn btn-primary" onClick={handleManualConfirm}>
|
<button className="btn btn-primary" onClick={handleManualConfirm}>
|
||||||
我已启动微信,继续检测
|
我已登录,继续
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn btn-secondary btn-inline" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}>
|
<button className="btn btn-secondary btn-block" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}>
|
||||||
{isFetchingDbKey ? '获取中...' : '自动获取密钥'}
|
{isFetchingDbKey ? '正在获取...' : '自动获取密钥'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
|
{dbKeyStatus && <div className="status-message">{dbKeyStatus}</div>}
|
||||||
<div className="field-hint">获取密钥会自动识别最近登录的账号</div>
|
<div className="field-hint">点击自动获取后微信将重启,请留意弹窗提示</div>
|
||||||
<div className="field-hint">点击自动获取后微信将重新启动,当页面提示<span style={{color: 'red'}}>hook安装成功,现在登录微信</span>后再点击登录</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep.id === 'image' && (
|
{currentStep.id === 'image' && (
|
||||||
<div className="setup-body">
|
<div className="form-group">
|
||||||
|
<div className="grid-2">
|
||||||
|
<div>
|
||||||
<label className="field-label">图片 XOR 密钥</label>
|
<label className="field-label">图片 XOR 密钥</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
placeholder="例如:0xA4"
|
placeholder="0x..."
|
||||||
value={imageXorKey}
|
value={imageXorKey}
|
||||||
onChange={(e) => setImageXorKey(e.target.value)}
|
onChange={(e) => setImageXorKey(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<label className="field-label">图片 AES 密钥</label>
|
<label className="field-label">图片 AES 密钥</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
placeholder="16 位密钥"
|
placeholder="16位密钥"
|
||||||
value={imageAesKey}
|
value={imageAesKey}
|
||||||
onChange={(e) => setImageAesKey(e.target.value)}
|
onChange={(e) => setImageAesKey(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-secondary btn-inline" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
</div>
|
||||||
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
</div>
|
||||||
|
|
||||||
|
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
||||||
|
{isFetchingImageKey ? '扫描中...' : '自动获取图片密钥'}
|
||||||
</button>
|
</button>
|
||||||
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
|
|
||||||
<div className="field-hint">请在电脑微信中打开查看几个图片后再点击获取秘钥,如获取失败请重复以上操作</div>
|
{imageKeyStatus && <div className="status-message">{imageKeyStatus}</div>}
|
||||||
{isFetchingImageKey && <div className="field-hint status-text">正在扫描内存,请稍候...</div>}
|
<div className="field-hint">请在微信中打开几张图片后再点击获取</div>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user