diff --git a/electron/main.ts b/electron/main.ts index 95a06c4..5900356 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -209,10 +209,11 @@ function createOnboardingWindow() { : join(process.resourcesPath, 'icon.ico') onboardingWindow = new BrowserWindow({ - width: 1100, - height: 720, + width: 960, + height: 680, minWidth: 900, - minHeight: 600, + minHeight: 620, + resizable: false, frame: false, transparent: true, backgroundColor: '#00000000', diff --git a/electron/services/config.ts b/electron/services/config.ts index c6233ef..3ba3a14 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -8,6 +8,7 @@ interface ConfigSchema { onboardingDone: boolean imageXorKey: number imageAesKey: string + wxidConfigs: Record // 缓存相关 cachePath: string @@ -40,6 +41,7 @@ export class ConfigService { onboardingDone: false, imageXorKey: 0, imageAesKey: '', + wxidConfigs: {}, cachePath: '', lastOpenedDb: '', lastSession: '', diff --git a/electron/services/exportHtmlStyles.ts b/electron/services/exportHtmlStyles.ts new file mode 100644 index 0000000..adb3e61 --- /dev/null +++ b/electron/services/exportHtmlStyles.ts @@ -0,0 +1,302 @@ +export const EXPORT_HTML_STYLES = `:root { + color-scheme: light; + --bg: #f6f7fb; + --card: #ffffff; + --text: #1f2a37; + --muted: #6b7280; + --accent: #4f46e5; + --sent: #dbeafe; + --received: #ffffff; + --border: #e5e7eb; + --shadow: 0 12px 30px rgba(15, 23, 42, 0.08); + --radius: 16px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); +} + +.page { + max-width: 1080px; + margin: 32px auto 60px; + padding: 0 20px; +} + +.header { + background: var(--card); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 24px; + margin-bottom: 24px; +} + +.title { + font-size: 24px; + font-weight: 600; + margin: 0 0 8px; +} + +.meta { + color: var(--muted); + font-size: 14px; + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 20px; +} + +.control { + display: flex; + flex-direction: column; + gap: 6px; +} + +.control label { + font-size: 13px; + color: var(--muted); +} + +.control input, +.control select, +.control button { + border-radius: 12px; + border: 1px solid var(--border); + padding: 10px 12px; + font-size: 14px; + font-family: inherit; +} + +.control button { + background: var(--accent); + color: #fff; + border: none; + cursor: pointer; + transition: transform 0.1s ease; +} + +.control button:active { + transform: scale(0.98); +} + +.stats { + font-size: 13px; + color: var(--muted); + display: flex; + align-items: flex-end; +} + +.message-list { + display: flex; + flex-direction: column; + gap: 18px; +} + +.message { + display: flex; + flex-direction: column; + gap: 8px; +} + +.message.hidden { + display: none; +} + +.message-time { + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} + +.message-row { + display: flex; + gap: 12px; + align-items: flex-end; +} + +.message.sent .message-row { + flex-direction: row-reverse; +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 12px; + background: #eef2ff; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + color: #475569; + font-weight: 600; +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.bubble { + max-width: min(70%, 720px); + background: var(--received); + border-radius: 18px; + padding: 12px 14px; + border: 1px solid var(--border); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06); +} + +.message.sent .bubble { + background: var(--sent); + border-color: transparent; +} + +.sender-name { + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} + +.message-content { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 14px; + line-height: 1.6; +} + +.message-text { + word-break: break-word; +} + +.inline-emoji { + width: 22px; + height: 22px; + vertical-align: text-bottom; + margin: 0 2px; +} + +.message-media { + border-radius: 14px; + max-width: 100%; +} + +.previewable { + cursor: zoom-in; +} + +.message-media.image, +.message-media.emoji { + max-height: 260px; + object-fit: contain; + background: #f1f5f9; + padding: 6px; +} + +.message-media.emoji { + max-height: 160px; + width: auto; +} + +.message-media.video { + max-height: 360px; + background: #111827; +} + +.message-media.audio { + width: 260px; +} + +.image-preview { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.7); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 999; +} + +.image-preview.active { + opacity: 1; + pointer-events: auto; +} + +.image-preview img { + max-width: min(90vw, 1200px); + max-height: 90vh; + border-radius: 18px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35); + background: #0f172a; + transition: transform 0.1s ease; + cursor: zoom-out; +} + +body[data-theme="cloud-dancer"] { + --accent: #6b8cff; + --sent: #e0e7ff; + --received: #ffffff; + --border: #d8e0f7; + --bg: #f6f7fb; +} + +body[data-theme="corundum-blue"] { + --accent: #2563eb; + --sent: #dbeafe; + --received: #ffffff; + --border: #c7d2fe; + --bg: #eef2ff; +} + +body[data-theme="kiwi-green"] { + --accent: #16a34a; + --sent: #dcfce7; + --received: #ffffff; + --border: #bbf7d0; + --bg: #f0fdf4; +} + +body[data-theme="spicy-red"] { + --accent: #e11d48; + --sent: #ffe4e6; + --received: #ffffff; + --border: #fecdd3; + --bg: #fff1f2; +} + +body[data-theme="teal-water"] { + --accent: #0f766e; + --sent: #ccfbf1; + --received: #ffffff; + --border: #99f6e4; + --bg: #f0fdfa; +} + +.highlight { + outline: 2px solid var(--accent); + outline-offset: 4px; + border-radius: 18px; +} + +.empty { + text-align: center; + color: var(--muted); + padding: 40px; +} +`; diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 88b79f3..7e57c05 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -10,6 +10,7 @@ import { wcdbService } from './wcdbService' import { imageDecryptService } from './imageDecryptService' import { chatService } from './chatService' import { videoService } from './videoService' +import { EXPORT_HTML_STYLES } from './exportHtmlStyles' // ChatLab 格式类型定义 interface ChatLabHeader { @@ -76,6 +77,7 @@ export interface ExportOptions { excelCompactColumns?: boolean txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' + displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' } const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ @@ -169,23 +171,162 @@ class ExportService { return this.contactCache.get(username)! } - const [displayNames, avatarUrls] = await Promise.all([ + const [nameResult, avatarResult] = await Promise.all([ wcdbService.getDisplayNames([username]), wcdbService.getAvatarUrls([username]) ]) - const displayName = displayNames.success && displayNames.map - ? (displayNames.map[username] || username) - : username - const avatarUrl = avatarUrls.success && avatarUrls.map - ? avatarUrls.map[username] - : undefined + const displayName = (nameResult.success && nameResult.map ? nameResult.map[username] : null) || username + const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined const info = { displayName, avatarUrl } this.contactCache.set(username, info) return info } + /** + * 解析 ext_buffer 二进制数据,提取群成员的群昵称 + * ext_buffer 包含类似 protobuf 编码的数据,格式示例: + * wxid_xxx群昵称wxid_yyy群昵称... + */ + private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map { + const nicknameMap = new Map() + + 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 + */ + async getGroupNicknamesForRoom(chatroomId: string): Promise> { + 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() + } + + 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() + } + + 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() + } finally { + console.log('========== getGroupNicknamesForRoom END ==========') + } + } + /** * 转换微信消息类型到 ChatLab 类型 */ @@ -268,6 +409,28 @@ class ExportService { 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 { if (s.length % 4 !== 0) return false return /^[A-Za-z0-9+/=]+$/.test(s) @@ -608,15 +771,17 @@ class ExportService { if (fs.existsSync(filePath)) { try { const content = fs.readFileSync(filePath, 'utf-8') - this.htmlStyleCache = content - return content + if (content.trim().length > 0) { + this.htmlStyleCache = content + return content + } } catch { continue } } } - this.htmlStyleCache = '' - return '' + this.htmlStyleCache = EXPORT_HTML_STYLES + return this.htmlStyleCache } private normalizeAppMessageContent(content: string): string { @@ -704,7 +869,7 @@ class ExportService { if (localType === 3 && options.exportImages) { const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix) if (result) { - } + } return result } @@ -723,7 +888,7 @@ class ExportService { if (localType === 47 && options.exportEmojis) { const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix) if (result) { - } + } return result } @@ -926,13 +1091,13 @@ class ExportService { // 下载表情 if (emojiUrl) { const downloaded = await this.downloadFile(emojiUrl, destPath) - if (downloaded) { - return { - relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), - kind: 'emoji' - } - } else { - } + if (downloaded) { + return { + relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), + kind: 'emoji' + } + } else { + } } return null @@ -1013,7 +1178,7 @@ class ExportService { */ private extractEmojiUrl(content: string): string | undefined { if (!content) return undefined - // 参考 echotrace 的正则:cdnurl\s*=\s*['"]([^'"]+)['"] + // 参考 echotrace 的正则:cdnurl\s*=\s*['"]([^'"]+)['"] const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content) if (attrMatch) { // 解码 & 等实体 @@ -1178,14 +1343,14 @@ class ExportService { // 图片消息 imageMd5 = this.extractImageMd5(content) imageDatName = this.extractImageDatName(content) - } else if (localType === 47 && content) { + } else if (localType === 47 && content) { // 动画表情 emojiCdnUrl = this.extractEmojiUrl(content) emojiMd5 = this.extractEmojiMd5(content) - } else if (localType === 43 && content) { + } else if (localType === 43 && content) { // 视频消息 videoMd5 = this.extractVideoMd5(content) - } + } rows.push({ localId, @@ -1503,11 +1668,11 @@ class ExportService { // ========== 阶段1:并行导出媒体文件 ========== const mediaMessages = exportMediaEnabled ? allMessages.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || // 图片 - (t === 47 && options.exportEmojis) || // 表情 - (t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字) - }) + const t = msg.localType + return (t === 3 && options.exportImages) || // 图片 + (t === 47 && options.exportEmojis) || // 表情 + (t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字) + }) : [] const mediaCache = new Map() @@ -1690,11 +1855,11 @@ class ExportService { // ========== 阶段1:并行导出媒体文件 ========== const mediaMessages = exportMediaEnabled ? collected.rows.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || - (t === 47 && options.exportEmojis) || - (t === 34 && options.exportVoices && !options.exportVoiceAsText) - }) + const t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 34 && options.exportVoices && !options.exportVoiceAsText) + }) : [] const mediaCache = new Map() @@ -1744,6 +1909,11 @@ class ExportService { }) } + // ========== 预加载群昵称(用于名称显示偏好) ========== + const groupNicknamesMap = isGroup + ? await this.getGroupNicknamesForRoom(sessionId) + : new Map() + // ========== 阶段3:构建消息列表 ========== onProgress?.({ current: 55, @@ -1770,6 +1940,24 @@ class ExportService { 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({ localId: allMessages.length + 1, createTime: msg.createTime, @@ -1779,7 +1967,7 @@ class ExportService { content, isSend: msg.isSend ? 1 : 0, senderUsername: msg.senderUsername, - senderDisplayName: senderInfo.displayName, + senderDisplayName, source, senderAvatarKey: msg.senderUsername }) @@ -1796,14 +1984,33 @@ class ExportService { 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 = { - chatlab, - meta, session: { wxid: sessionId, - nickname: sessionInfo.displayName, - remark: sessionInfo.displayName, - displayName: sessionInfo.displayName, + nickname: sessionNickname, + remark: sessionRemark, + displayName: sessionDisplayName, type: isGroup ? '群聊' : '私聊', lastTimestamp: collected.lastTime, messageCount: allMessages.length, @@ -1883,6 +2090,7 @@ class ExportService { const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + onProgress?.({ current: 30, total: 100, @@ -1959,7 +2167,7 @@ class ExportService { // 表头行 const headers = useCompactColumns ? ['序号', '时间', '发送者身份', '消息类型', '内容'] - : ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容'] + : ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容'] const headerRow = worksheet.getRow(currentRow) headerRow.height = 22 @@ -1987,11 +2195,20 @@ class ExportService { worksheet.getColumn(3).width = 18 // 发送者昵称 worksheet.getColumn(4).width = 25 // 发送者微信ID worksheet.getColumn(5).width = 18 // 发送者备注 - worksheet.getColumn(6).width = 15 // 发送者身份 - worksheet.getColumn(7).width = 12 // 消息类型 - worksheet.getColumn(8).width = 50 // 内容 + worksheet.getColumn(6).width = 18 // 群昵称 + worksheet.getColumn(7).width = 15 // 发送者身份 + 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() + console.log('🔍 群昵称Map大小:', groupNicknamesMap.size) + + // 填充数据 const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) @@ -2001,11 +2218,11 @@ class ExportService { // ========== 并行预处理:媒体文件 ========== const mediaMessages = exportMediaEnabled ? sortedMessages.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || - (t === 47 && options.exportEmojis) || - (t === 34 && options.exportVoices && !options.exportVoiceAsText) - }) + const t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 34 && options.exportVoices && !options.exportVoiceAsText) + }) : [] const mediaCache = new Map() @@ -2071,6 +2288,8 @@ class ExportService { let senderWxid: string let senderNickname: string let senderRemark: string = '' + let senderGroupNickname: string = '' // 群昵称 + if (msg.isSend) { // 我发送的消息 @@ -2110,6 +2329,12 @@ class ExportService { } } + // 获取群昵称 (仅群聊且完整列模式) + if (isGroup && !useCompactColumns && senderWxid) { + senderGroupNickname = groupNicknamesMap.get(senderWxid.toLowerCase()) || '' + } + + const row = worksheet.getRow(currentRow) row.height = 24 @@ -2122,7 +2347,7 @@ class ExportService { // 调试日志 if (msg.localType === 3 || msg.localType === 47) { - } + } worksheet.getCell(currentRow, 1).value = i + 1 worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime) @@ -2134,13 +2359,14 @@ class ExportService { worksheet.getCell(currentRow, 3).value = senderNickname worksheet.getCell(currentRow, 4).value = senderWxid worksheet.getCell(currentRow, 5).value = senderRemark - worksheet.getCell(currentRow, 6).value = senderRole - worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType) - worksheet.getCell(currentRow, 8).value = contentValue + worksheet.getCell(currentRow, 6).value = senderGroupNickname + worksheet.getCell(currentRow, 7).value = senderRole + 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++) { const cell = worksheet.getCell(currentRow, col) cell.font = { name: 'Calibri', size: 11 } @@ -2222,11 +2448,11 @@ class ExportService { const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const mediaMessages = exportMediaEnabled ? sortedMessages.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || - (t === 47 && options.exportEmojis) || - (t === 34 && options.exportVoices && !options.exportVoiceAsText) - }) + const t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 34 && options.exportVoices && !options.exportVoiceAsText) + }) : [] const mediaCache = new Map() @@ -2396,12 +2622,12 @@ class ExportService { const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const mediaMessages = exportMediaEnabled ? sortedMessages.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || - (t === 47 && options.exportEmojis) || - (t === 34 && options.exportVoices) || - t === 43 - }) + const t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 34 && options.exportVoices) || + t === 43 + }) : [] const mediaCache = new Map() @@ -2730,15 +2956,15 @@ class ExportService { fs.mkdirSync(outputDir, { recursive: true }) } - const exportMediaEnabled = options.exportMedia === true && - Boolean(options.exportImages || options.exportVoices || options.exportEmojis) - const sessionLayout = exportMediaEnabled - ? (options.sessionLayout ?? 'per-session') - : 'shared' + const exportMediaEnabled = options.exportMedia === true && + Boolean(options.exportImages || options.exportVoices || options.exportEmojis) + const sessionLayout = exportMediaEnabled + ? (options.sessionLayout ?? 'per-session') + : 'shared' - for (let i = 0; i < sessionIds.length; i++) { - const sessionId = sessionIds[i] - const sessionInfo = await this.getContactInfo(sessionId) + for (let i = 0; i < sessionIds.length; i++) { + const sessionId = sessionIds[i] + const sessionInfo = await this.getContactInfo(sessionId) onProgress?.({ current: i + 1, @@ -2747,13 +2973,13 @@ class ExportService { phase: 'exporting' }) - const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') - const useSessionFolder = sessionLayout === 'per-session' - const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir + const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') + const useSessionFolder = sessionLayout === 'per-session' + const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir - if (useSessionFolder && !fs.existsSync(sessionDir)) { - fs.mkdirSync(sessionDir, { recursive: true }) - } + if (useSessionFolder && !fs.existsSync(sessionDir)) { + fs.mkdirSync(sessionDir, { recursive: true }) + } let ext = '.json' if (options.format === 'chatlab-jsonl') ext = '.jsonl' diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 72ac8c2..de123ca 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -882,16 +882,17 @@ export class KeyService { return null } - private isAlphaNumAscii(byte: number): boolean { - return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x30 && byte <= 0x39) + private isAlphaNumLower(byte: number): boolean { + // 只匹配小写字母 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 for (let j = 0; j < 32; j++) { const charByte = buf[start + j * 2] const nullByte = buf[start + j * 2 + 1] - if (nullByte !== 0x00 || !this.isAlphaNumAscii(charByte)) { + if (nullByte !== 0x00 || !this.isAlphaNumLower(charByte)) { return false } } @@ -924,8 +925,6 @@ export class KeyService { const regions: Array<[number, number]> = [] const MEM_COMMIT = 0x1000 const MEM_PRIVATE = 0x20000 - const MEM_MAPPED = 0x40000 - const MEM_IMAGE = 0x1000000 const PAGE_NOACCESS = 0x01 const PAGE_GUARD = 0x100 @@ -940,10 +939,9 @@ export class KeyService { const protect = info.Protect const type = info.Type 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) { - regions.push([Number(info.BaseAddress), regionSize]) - } + // 只收集已提交的私有内存(大幅减少扫描区域) + if (state === MEM_COMMIT && type === MEM_PRIVATE && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) { + regions.push([Number(info.BaseAddress), regionSize]) } const nextAddress = address + regionSize @@ -972,86 +970,51 @@ export class KeyService { try { const allRegions = this.getMemoryRegions(hProcess) + const totalRegions = allRegions.length + let scannedCount = 0 + let skippedCount = 0 - // 优化1: 只保留小内存区域(< 10MB)- 密钥通常在小区域,可大幅减少扫描时间 - const filteredRegions = allRegions.filter(([_, size]) => size <= 10 * 1024 * 1024) + for (const [baseAddress, regionSize] of allRegions) { + // 跳过太大的内存区域(> 100MB) + if (regionSize > 100 * 1024 * 1024) { + skippedCount++ + continue + } - // 优化2: 优先级排序 - 按大小升序,先扫描小区域(密钥通常在较小区域) - const sortedRegions = filteredRegions.sort((a, b) => a[1] - b[1]) + scannedCount++ + if (scannedCount % 10 === 0) { + onProgress?.(scannedCount, totalRegions, `正在扫描微信内存... (${scannedCount}/${totalRegions})`) + await new Promise(resolve => setImmediate(resolve)) + } - // 优化3: 计算总字节数用于精确进度报告 - const totalBytes = sortedRegions.reduce((sum, [_, size]) => sum + size, 0) - let processedBytes = 0 + const memory = this.readProcessMemory(hProcess, baseAddress, regionSize) + if (!memory) continue - // 优化4: 减小分块大小到 1MB(参考 wx_key 项目) - const chunkSize = 1 * 1024 * 1024 - const overlap = 65 - let currentRegion = 0 + // 直接在原始字节中搜索32字节的小写字母数字序列 + for (let i = 0; i < memory.length - 34; i++) { + // 检查前导字符(不是小写字母或数字) + if (this.isAlphaNumLower(memory[i])) continue - for (const [baseAddress, regionSize] of sortedRegions) { - currentRegion++ - const progress = totalBytes > 0 ? Math.floor((processedBytes / totalBytes) * 100) : 0 - onProgress?.(progress, 100, `扫描内存 ${progress}% (${currentRegion}/${sortedRegions.length})`) + // 检查接下来32个字节是否都是小写字母或数字 + let valid = true + for (let j = 1; j <= 32; j++) { + if (!this.isAlphaNumLower(memory[i + j])) { + valid = false + break + } + } + if (!valid) continue - // 每个区域都让出主线程,确保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 + // 检查尾部字符(不是小写字母或数字) + if (i + 33 < memory.length && this.isAlphaNumLower(memory[i + 33])) { continue } - let dataToScan: Buffer - if (trailing && trailing.length) { - dataToScan = Buffer.concat([trailing, chunk]) - } else { - dataToScan = chunk + const keyBytes = memory.subarray(i + 1, i + 33) + if (this.verifyKey(ciphertext, keyBytes)) { + return keyBytes.toString('ascii') } - - 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 } finally { diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 93952e8..3e6ebd1 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -20,6 +20,7 @@ export class WcdbCore { private currentWxid: string | null = null // 函数引用 + private wcdbInitProtection: any = null private wcdbInit: any = null private wcdbShutdown: any = null private wcdbOpenAccount: any = null @@ -243,6 +244,18 @@ export class WcdbCore { 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() this.wcdbInit = this.lib.func('int32 wcdb_init()') diff --git a/package-lock.json b/package-lock.json index d4b7d0b..c602133 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "weflow", - "version": "1.3.2", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "weflow", - "version": "1.3.1", + "version": "1.4.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.5.0", @@ -8537,6 +8537,12 @@ "sherpa-onnx-win-x64": "^1.12.23" } }, + "node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": { + "optional": true + }, + "node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-linux-arm64": { + "optional": true + }, "node_modules/sherpa-onnx-win-ia32": { "version": "1.12.23", "resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz", diff --git a/package.json b/package.json index 56f06d2..9011c0e 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "weflow", - "version": "1.3.2", + "version": "1.4.0", "description": "WeFlow", "main": "dist-electron/main.js", "author": "cc", + "//": "二改不应改变此处的作者与应用信息", "scripts": { "postinstall": "echo 'No native modules to rebuild'", "rebuild": "echo 'No native modules to rebuild'", diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 2692201..e9e509e 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/App.tsx b/src/App.tsx index 5bad3d5..c8c4074 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -185,9 +185,15 @@ function App() { const decryptKey = await configService.getDecryptKey() const wxid = await configService.getMyWxid() 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) { await configService.setOnboardingDone(true) } diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index dc76196..671372a 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -1,24 +1,24 @@ .sidebar { - width: 200px; + width: 220px; background: var(--bg-secondary); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; padding: 16px 0; transition: width 0.25s ease; - + &.collapsed { width: 64px; - + .nav-menu, .sidebar-footer { padding: 0 8px; } - + .nav-label { display: none; } - + .nav-item { justify-content: center; padding: 10px; @@ -32,14 +32,14 @@ display: flex; flex-direction: column; gap: 4px; - padding: 0 8px; + padding: 0 12px; } .nav-item { display: flex; align-items: center; gap: 12px; - padding: 10px 16px; + padding: 10px 12px; border-radius: 9999px; color: var(--text-secondary); text-decoration: none; @@ -49,13 +49,12 @@ background: transparent; cursor: pointer; font-family: inherit; - width: 100%; - + &:hover { background: var(--bg-tertiary); color: var(--text-primary); } - + &.active { background: var(--primary); color: white; @@ -99,9 +98,9 @@ border-radius: 9999px; transition: all 0.2s ease; margin-top: 4px; - + &:hover { background: var(--bg-tertiary); color: var(--text-primary); } -} +} \ No newline at end of file diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 2bf1868..6b30cab 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useLocation } from 'react-router-dom' import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react' import ReactECharts from 'echarts-for-react' @@ -16,7 +16,7 @@ function AnalyticsPage() { const themeMode = useThemeStore((state) => state.themeMode) 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 setIsLoading(true) setError(null) @@ -55,14 +55,22 @@ function AnalyticsPage() { setIsLoading(false) if (removeListener) removeListener() } - } + }, [isLoaded, markLoaded, setRankings, setStatistics, setTimeDistribution]) const location = useLocation() useEffect(() => { const force = location.state?.forceRefresh === true 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) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 80fb8ca..ce324a5 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1076,8 +1076,7 @@ align-items: center; justify-content: center; gap: 10px; - background: rgba(10, 10, 10, 0.28); - backdrop-filter: blur(6px); + background: var(--bg-tertiary); transition: opacity 200ms ease; z-index: 2; } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index c5a4467..9142d7b 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -245,6 +245,38 @@ function ChatPage(_props: ChatPageProps) { } }, [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 }) => { 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(() => { const nextSet = new Set() for (const msg of messages) { diff --git a/src/pages/DataManagementPage.tsx b/src/pages/DataManagementPage.tsx index 86357bb..2afb507 100644 --- a/src/pages/DataManagementPage.tsx +++ b/src/pages/DataManagementPage.tsx @@ -16,6 +16,11 @@ function DataManagementPage() { setWxid(id) } loadConfig() + const handleChange = () => { + loadConfig() + } + window.addEventListener('wxid-changed', handleChange as EventListener) + return () => window.removeEventListener('wxid-changed', handleChange as EventListener) }, []) return ( diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index ddedbd9..cdbe7f6 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -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 { display: flex; flex-wrap: wrap; @@ -1130,11 +1223,11 @@ } } - input:checked + .slider { + input:checked+.slider { background-color: var(--primary); } - input:checked + .slider::before { + input:checked+.slider::before { transform: translateX(20px); } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 088c0ef..d1f5bad 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -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 * as configService from '../services/config' import './ExportPage.scss' @@ -23,6 +23,7 @@ interface ExportOptions { exportVoiceAsText: boolean excelCompactColumns: boolean txtColumns: string[] + displayNamePreference: 'group-nickname' | 'remark' | 'nickname' } interface ExportResult { @@ -49,6 +50,8 @@ function ExportPage() { const [calendarDate, setCalendarDate] = useState(new Date()) const [selectingStart, setSelectingStart] = useState(true) const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) + const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) + const displayNameDropdownRef = useRef(null) const [options, setOptions] = useState({ format: 'excel', @@ -64,7 +67,8 @@ function ExportPage() { exportEmojis: true, exportVoiceAsText: true, excelCompactColumns: true, - txtColumns: defaultTxtColumns + txtColumns: defaultTxtColumns, + displayNamePreference: 'remark' }) const buildDateRangeFromPreset = (preset: string) => { @@ -164,6 +168,19 @@ function ExportPage() { 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(() => { const removeListener = window.electronAPI.export.onProgress?.((payload) => { setExportProgress({ @@ -176,6 +193,16 @@ function ExportPage() { 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(() => { if (!searchKeyword.trim()) { @@ -258,6 +285,7 @@ function ExportPage() { exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 excelCompactColumns: options.excelCompactColumns, txtColumns: options.txtColumns, + displayNamePreference: options.displayNamePreference, sessionLayout, dateRange: options.useAllTime ? null : options.dateRange ? { start: Math.floor(options.dateRange.start.getTime() / 1000), @@ -389,6 +417,25 @@ function ExportPage() { { value: 'excel', label: 'Excel', icon: FileSpreadsheet, 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 (
@@ -503,6 +550,44 @@ function ExportPage() {
+ {/* 发送者名称显示偏好 */} + {(options.format === 'html' || options.format === 'json' || options.format === 'txt') && ( +
+

发送者名称显示

+

选择导出时优先显示的名称

+
+ + {showDisplayNameSelect && ( +
+ {displayNameOptions.map(option => ( + + ))} +
+ )} +
+
+ )}

媒体文件

导出图片/语音/表情并在记录内写入相对路径

diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index cd4d973..3cb07c1 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -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 { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' @@ -56,7 +56,7 @@ function GroupAnalyticsPage() { useEffect(() => { loadGroups() - }, []) + }, [loadGroups]) useEffect(() => { if (searchQuery) { @@ -93,7 +93,7 @@ function GroupAnalyticsPage() { } }, [dateRangeReady]) - const loadGroups = async () => { + const loadGroups = useCallback(async () => { setIsLoading(true) try { const result = await window.electronAPI.groupAnalytics.getGroupChats() @@ -106,7 +106,23 @@ function GroupAnalyticsPage() { } finally { 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) => { if (selectedGroup?.username !== group.username) { diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index e5a4bed..89f68e2 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -1156,7 +1156,6 @@ input { flex: 1; - padding-right: 36px; } } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 18b2979..8bd2d45 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { useAppStore } from '../stores/appStore' +import { useChatStore } from '../stores/chatStore' import { useThemeStore, themes } from '../stores/themeStore' import { useAnalyticsStore } from '../stores/analyticsStore' import { dialog } from '../services/ipc' @@ -28,7 +29,8 @@ interface WxidOption { } 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 clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache) @@ -40,7 +42,6 @@ function SettingsPage() { const [wxid, setWxid] = useState('') const [wxidOptions, setWxidOptions] = useState([]) const [showWxidSelect, setShowWxidSelect] = useState(false) - const wxidDropdownRef = useRef(null) const [showExportFormatSelect, setShowExportFormatSelect] = useState(false) const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false) const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) @@ -92,9 +93,6 @@ function SettingsPage() { useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node - if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(target)) { - setShowWxidSelect(false) - } if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) { setShowExportFormatSelect(false) } @@ -107,7 +105,7 @@ function SettingsPage() { } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showWxidSelect, showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect]) + }, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect]) useEffect(() => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { @@ -142,14 +140,24 @@ function SettingsPage() { const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText() const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns() - if (savedKey) setDecryptKey(savedKey) if (savedPath) setDbPath(savedPath) if (savedWxid) setWxid(savedWxid) 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) setAutoTranscribeVoice(savedAutoTranscribe) setTranscribeLanguages(savedTranscribeLanguages) @@ -255,6 +263,103 @@ function SettingsPage() { 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 () => { if (isDetectingPath) return setIsDetectingPath(true) @@ -268,11 +373,10 @@ function SettingsPage() { const wxids = await window.electronAPI.dbPath.scanWxids(result.path) setWxidOptions(wxids) if (wxids.length === 1) { - setWxid(wxids[0].wxid) - await configService.setMyWxid(wxids[0].wxid) - showMessage(`已检测到账号:${wxids[0].wxid}`, true) + await applyWxidSelection(wxids[0].wxid, { + toastText: `已检测到账号:${wxids[0].wxid}` + }) } else if (wxids.length > 1) { - // 多账号时弹出选择对话框 setShowWxidSelect(true) } } 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 (!silent) showMessage('请先选择数据库目录', false) return @@ -305,12 +412,14 @@ function SettingsPage() { try { const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) setWxidOptions(wxids) + const allowDialog = options?.showDialog ?? !silent if (wxids.length === 1) { - setWxid(wxids[0].wxid) - await configService.setMyWxid(wxids[0].wxid) - if (!silent) showMessage(`已检测到账号:${wxids[0].wxid}`, true) - } else if (wxids.length > 1) { - // 多账号时弹出选择对话框 + await applyWxidSelection(wxids[0].wxid, { + preferCurrentKeys: options?.preferCurrentKeys ?? false, + showToast: !silent, + toastText: `已检测到账号:${wxids[0].wxid}` + }) + } else if (wxids.length > 1 && allowDialog) { setShowWxidSelect(true) } else { if (!silent) showMessage('未检测到账号目录,请检查路径', false) @@ -321,10 +430,7 @@ function SettingsPage() { } const handleSelectWxid = async (selectedWxid: string) => { - setWxid(selectedWxid) - await configService.setMyWxid(selectedWxid) - setShowWxidSelect(false) - showMessage(`已选择账号:${selectedWxid}`, true) + await applyWxidSelection(selectedWxid) } const handleSelectCachePath = async () => { @@ -397,7 +503,7 @@ function SettingsPage() { setDecryptKey(result.key) setDbKeyStatus('密钥获取成功') showMessage('已自动获取解密密钥', true) - await handleScanWxid(true) + await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false }) } else { if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { setIsManualStartPrompt(true) @@ -483,19 +589,14 @@ function SettingsPage() { await configService.setDbPath(dbPath) await configService.setMyWxid(wxid) await configService.setCachePath(cachePath) - if (imageXorKey) { - const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16) - if (!Number.isNaN(parsed)) { - await configService.setImageXorKey(parsed) - } - } else { - await configService.setImageXorKey(0) - } - if (imageAesKey) { - await configService.setImageAesKey(imageAesKey) - } else { - await configService.setImageAesKey('') - } + const parsedXorKey = parseImageXorKey(imageXorKey) + await configService.setImageXorKey(typeof parsedXorKey === 'number' ? parsedXorKey : 0) + await configService.setImageAesKey(imageAesKey || '') + await configService.setWxidConfig(wxid, { + decryptKey, + imageXorKey: typeof parsedXorKey === 'number' ? parsedXorKey : 0, + imageAesKey + }) await configService.setWhisperModelDir(whisperModelDir) await configService.setAutoTranscribeVoice(autoTranscribeVoice) await configService.setTranscribeLanguages(transcribeLanguages) @@ -688,37 +789,13 @@ function SettingsPage() {
微信账号标识 -
+
setWxid(e.target.value)} /> - - {showWxidSelect && wxidOptions.length > 0 && ( -
- {wxidOptions.map((opt) => ( -
handleSelectWxid(opt.wxid)} - > - {opt.wxid} - - {new Date(opt.modifiedTime).toLocaleDateString()} - -
- ))} -
- )}
diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 1a8bc23..e7947dc 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -194,7 +194,7 @@ export default function SnsPage() { }, [selectedUsernames, searchKeyword, jumpTargetDate]) // 获取联系人列表 - const loadContacts = async () => { + const loadContacts = useCallback(async () => { setContactsLoading(true) try { const result = await window.electronAPI.chat.getSessions() @@ -237,7 +237,7 @@ export default function SnsPage() { } finally { setContactsLoading(false) } - } + }, []) // 初始加载 useEffect(() => { @@ -255,7 +255,22 @@ export default function SnsPage() { }; checkSchema(); loadContacts() - }, []) + }, [loadContacts]) + + useEffect(() => { + const handleChange = () => { + setPosts([]) + setHasMore(true) + setHasNewer(false) + setSelectedUsernames([]) + setSearchKeyword('') + setJumpTargetDate(undefined) + loadContacts() + loadPosts({ reset: true }) + } + window.addEventListener('wxid-changed', handleChange as EventListener) + return () => window.removeEventListener('wxid-changed', handleChange as EventListener) + }, [loadContacts, loadPosts]) useEffect(() => { loadPosts({ reset: true }) diff --git a/src/pages/WelcomePage.scss b/src/pages/WelcomePage.scss index fd3f586..6114fbf 100644 --- a/src/pages/WelcomePage.scss +++ b/src/pages/WelcomePage.scss @@ -1,358 +1,438 @@ .welcome-page { min-height: 100vh; - background: radial-gradient(circle at top left, rgba(255, 255, 255, 0.6), transparent 55%), - radial-gradient(circle at 80% 20%, rgba(139, 115, 85, 0.18), transparent 45%), - var(--bg-gradient); display: flex; align-items: center; justify-content: center; + background: radial-gradient(circle at top left, rgba(255, 255, 255, 0.6), transparent 55%), + radial-gradient(circle at 80% 20%, rgba(139, 115, 85, 0.18), transparent 45%), + var(--bg-gradient); position: relative; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; overflow: hidden; + + &.is-standalone { + background: transparent; // Allow window transparency if configured + } + + &::before { + content: ''; + position: absolute; + width: 600px; + height: 600px; + background: radial-gradient(circle, rgba(var(--primary-rgb, 139, 115, 85), 0.15), transparent 70%); + top: -20%; + left: -10%; + z-index: 0; + pointer-events: none; + } + + &::after { + content: ''; + position: absolute; + width: 500px; + height: 500px; + background: radial-gradient(circle, rgba(100, 100, 255, 0.05), transparent 70%); + bottom: -10%; + right: -5%; + z-index: 0; + pointer-events: none; + } } -.welcome-page.is-standalone { - width: 100%; - height: 100%; - border-radius: 22px; - padding: 20px; - -webkit-app-region: drag; -} - -.welcome-page.is-standalone .welcome-shell { - -webkit-app-region: no-drag; -} - -.welcome-page.is-standalone .window-controls { +.window-controls { position: absolute; - top: 18px; - right: 18px; - display: inline-flex; + top: 16px; + right: 16px; + display: flex; gap: 8px; - padding: 6px; - border-radius: 999px; - background: rgba(25, 25, 25, 0.45); - border: 1px solid rgba(255, 255, 255, 0.08); - backdrop-filter: blur(10px); - z-index: 3; + z-index: 100; -webkit-app-region: no-drag; } -.welcome-page.is-standalone .window-btn { - width: 28px; - height: 28px; - border-radius: 999px; +.window-btn { + width: 32px; + height: 32px; + border-radius: 50%; border: none; - display: grid; - place-items: center; - color: rgba(255, 255, 255, 0.85); - background: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.2); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); cursor: pointer; - transition: transform 0.18s ease, background 0.18s ease; + transition: all 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.4); + color: var(--text-primary); + } + + &.is-close:hover { + background: #ff5f57; + color: white; + } } -.welcome-page.is-standalone .window-btn:hover { - transform: translateY(-1px); - background: rgba(255, 255, 255, 0.18); -} -.welcome-page.is-standalone .window-btn.is-close:hover { - background: rgba(219, 92, 92, 0.35); -} - -.welcome-page.is-closing { - animation: fadeOut 0.45s ease forwards; -} - -.welcome-page::before, -.welcome-page::after { - content: ''; - position: absolute; - border-radius: 999px; - background: rgba(255, 255, 255, 0.3); - filter: blur(0px); - opacity: 0.5; - pointer-events: none; -} - -.welcome-page::before { - width: 320px; - height: 320px; - top: -120px; - right: 10%; - background: rgba(139, 115, 85, 0.15); -} - -.welcome-page::after { - width: 220px; - height: 220px; - bottom: -80px; - left: 12%; -} - -.welcome-shell { - width: min(980px, 92vw); - display: grid; - grid-template-columns: 0.95fr 1.05fr; - gap: 28px; - z-index: 1; - animation: fadeUp 0.6s ease-out; -} - -.welcome-panel, -.setup-card { - background: var(--card-bg); +/* Unified Card Container */ +.welcome-container { + width: 900px; + max-width: 100vw; + height: 620px; + max-height: 100vh; + display: flex; + background: none; + /* Changed from semi-transparent */ + backdrop-filter: none; + /* Removed from container */ + -webkit-backdrop-filter: none; border-radius: 24px; - box-shadow: var(--shadow-md); - border: 1px solid var(--border-color); - backdrop-filter: blur(16px); + box-shadow: + 0 20px 40px -10px rgba(0, 0, 0, 0.2), + /* Slightly deeper shadow */ + 0 0 0 1px rgba(255, 255, 255, 0.2) inset; + border: 1px solid rgba(255, 255, 255, 0.15); + overflow: hidden; + z-index: 10; + position: relative; + animation: scaleIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; } -.welcome-panel { - padding: 28px; +/* Sidebar (Left) */ +.welcome-sidebar { + width: 280px; + background: var(--bg-primary); + border-right: 1px solid rgba(0, 0, 0, 0.06); + padding: 32px 24px; display: flex; flex-direction: column; - gap: 20px; + gap: 24px; + flex-shrink: 0; + -webkit-app-region: drag; + + [data-mode="dark"] & { + background: #18181b; + border-right-color: rgba(255, 255, 255, 0.08); + } } -.panel-header { +.sidebar-header { display: flex; - gap: 16px; align-items: center; -} - -.panel-logo { - width: 56px; - height: 56px; - border-radius: 16px; - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); -} - -.panel-kicker { - font-size: 12px; - letter-spacing: 2px; - text-transform: uppercase; - color: var(--text-tertiary); - margin: 0 0 4px; -} - -.panel-subtitle { - font-size: 14px; - color: var(--text-secondary); - margin: 6px 0 0; -} - -.welcome-panel h1 { - margin: 0; - font-size: 24px; - color: var(--text-primary); -} - -.step-list { - display: flex; - flex-direction: column; - gap: 14px; -} - -.step-item { - display: flex; gap: 12px; - align-items: center; - padding: 12px 14px; - border-radius: 16px; - background: rgba(255, 255, 255, 0.55); - transition: transform 0.2s ease, background 0.2s ease; + margin-bottom: 12px; } -[data-mode="dark"] .step-item { - background: rgba(255, 255, 255, 0.06); -} - -.step-item.active { - background: var(--primary-light); - transform: translateX(4px); -} - -.step-item.done { - opacity: 0.85; -} - -.step-index { - width: 28px; - height: 28px; +.sidebar-logo { + width: 40px; + height: 40px; border-radius: 10px; - display: grid; - place-items: center; - background: var(--primary-gradient); - color: #fff; - font-size: 12px; - font-weight: 600; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); } -.step-title { - font-size: 14px; - font-weight: 600; - color: var(--text-primary); -} - -.step-desc { - font-size: 12px; - color: var(--text-tertiary); -} - -.panel-foot { - display: flex; - align-items: center; - gap: 10px; - font-size: 12px; - color: var(--text-tertiary); - padding-top: 8px; - border-top: 1px dashed var(--border-color); -} - -.setup-card { - padding: 28px; +.sidebar-brand { display: flex; flex-direction: column; - gap: 20px; } -.setup-header { - display: flex; - gap: 14px; - align-items: center; -} - -.setup-header h2 { - margin: 0; - font-size: 22px; - color: var(--text-primary); -} - -.setup-header p { - margin: 6px 0 0; - color: var(--text-secondary); - font-size: 13px; -} - -.setup-icon { - width: 44px; - height: 44px; - border-radius: 16px; - display: grid; - place-items: center; - background: var(--primary-light); - color: var(--primary); -} - -.setup-body { - display: flex; - flex-direction: column; - gap: 12px; -} - -.intro-card { - display: flex; - gap: 12px; - align-items: flex-start; - padding: 16px; - border-radius: 16px; - background: rgba(255, 255, 255, 0.6); - color: var(--text-secondary); -} - -[data-mode="dark"] .intro-card { - background: rgba(255, 255, 255, 0.06); -} - -.intro-card h3 { - margin: 0 0 4px; +.brand-name { + font-weight: 700; font-size: 16px; color: var(--text-primary); + letter-spacing: -0.02em; } -.intro-card p { - margin: 0; - font-size: 13px; +.brand-tag { + font-size: 11px; + font-weight: 500; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: 2px; + + [data-mode="dark"] .welcome-sidebar & { + color: rgba(255, 255, 255, 0.45); + } +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 8px; + position: relative; + + // Track line + &::before { + content: ''; + position: absolute; + left: 11px; + top: 20px; + bottom: 20px; + width: 2px; + background: var(--border-color); + z-index: 0; + opacity: 0.5; + } + + // 嵌套在 .sidebar-nav 内部,避免污染全局 .nav-item 样式 + .nav-item { + display: flex; + align-items: flex-start; + gap: 12px; + position: relative; + z-index: 1; + padding: 8px 12px 8px 0; + opacity: 0.75; + transition: all 0.3s ease; + border-radius: 12px; + + [data-mode="dark"] .welcome-sidebar & { + opacity: 0.7; + } + + &.active, + &.completed { + opacity: 1; + } + + &.active { + background: var(--primary); + padding: 12px 16px; + margin-left: -4px; + margin-right: -4px; + + // 遮挡流程线 + &::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 20px; + background: var(--primary); + border-radius: 12px 0 0 12px; + z-index: -1; + } + } + + &.active .nav-info { + transform: translateX(4px); + } + } +} + +.nav-indicator { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-primary); + border-radius: 50%; + border: 2px solid var(--border-color); + flex-shrink: 0; + margin-top: 2px; + transition: all 0.3s ease; + + [data-mode="dark"] .welcome-sidebar & { + border-color: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.03); + } + + .nav-item.active & { + border-color: rgba(255, 255, 255, 0.5); + background: rgba(255, 255, 255, 0.2); + } + + .nav-item.completed & { + background: var(--primary); + border-color: var(--primary); + color: white; + } +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-tertiary); + transition: all 0.3s; + + [data-mode="dark"] .welcome-sidebar & { + background: rgba(255, 255, 255, 0.3); + } + + .nav-item.active & { + background: #ffffff; + transform: scale(1.2); + } +} + +.nav-info { + display: flex; + flex-direction: column; + transition: transform 0.3s ease; +} + +.nav-title { + font-size: 14px; + font-weight: 600; + color: #1a1a1a; + + [data-mode="dark"] .welcome-sidebar & { + color: #ffffff; + } + + .nav-item.active & { + color: #ffffff; + } +} + +.nav-desc { + font-size: 12px; + color: #666666; + margin-top: 2px; + + [data-mode="dark"] .welcome-sidebar & { + color: rgba(255, 255, 255, 0.55); + } + + .nav-item.active & { + color: rgba(255, 255, 255, 0.85); + } +} + + +.sidebar-footer { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #777777; + /* 浅色模式下使用更深的颜色 */ + padding-top: 20px; + border-top: 1px dashed var(--border-color); + + [data-mode="dark"] .welcome-sidebar & { + color: rgba(255, 255, 255, 0.5); + border-top-color: rgba(255, 255, 255, 0.1); + } + + span { + opacity: 1; + /* 移除额外透明度 */ + } +} + + +/* Content Area (Right) */ +.welcome-content { + flex: 1; + padding: 40px 48px; + display: flex; + flex-direction: column; + overflow-y: auto; + position: relative; + background: var(--bg-primary); + /* Opaque background */ + + &.success-content { + align-items: center; + justify-content: center; + text-align: center; + } +} + +.content-header { + margin-bottom: 32px; + animation: slideIn 0.4s ease-out; + -webkit-app-region: drag; + + h2 { + font-size: 24px; + font-weight: 700; + margin: 0 0 8px; + color: var(--text-primary); + } + + .header-desc { + color: var(--text-secondary); + font-size: 15px; + margin: 0; + } +} + +.step-icon-wrapper { + margin-bottom: 20px; + width: 54px; + height: 54px; + border-radius: 16px; + background: var(--primary-light); + color: var(--primary); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8px 20px -6px rgba(var(--primary-rgb, 139, 115, 85), 0.3); +} + +.content-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 24px; + animation: fadeIn 0.4s ease-out 0.1s backwards; +} + + +/* Form & Inputs */ +.form-group { + display: flex; + flex-direction: column; + gap: 8px; } .field-label { font-size: 13px; font-weight: 600; - color: var(--text-primary); + color: var(--text-secondary); + margin-left: 2px; +} + +.input-group { + position: relative; + display: flex; + align-items: center; } .field-input { width: 100%; - padding: 12px 16px; - border-radius: 14px; + padding: 14px 16px; + border-radius: 12px; border: 1px solid var(--border-color); - background: var(--bg-primary); + background: rgba(255, 255, 255, 0.6); color: var(--text-primary); - font-size: 14px; - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} + font-size: 15px; + transition: all 0.2s ease; -.field-input:focus { - outline: none; - border-color: var(--primary); - box-shadow: 0 0 0 3px var(--primary-light); -} + [data-mode="dark"] & { + background: rgba(0, 0, 0, 0.2); + } -.field-hint { - font-size: 12px; - color: var(--text-tertiary); -} + &:focus { + outline: none; + border-color: var(--primary); + background: #fff; + box-shadow: 0 0 0 3px var(--primary-light); -.status-text { - color: var(--text-secondary); -} + [data-mode="dark"] & { + background: rgba(0, 0, 0, 0.4); + } + } -.wxid-options { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 2px; -} - -.wxid-option { - border: 1px solid var(--border-color); - background: var(--bg-tertiary); - border-radius: 14px; - padding: 10px 14px; - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 12px; - width: 100%; - min-height: 44px; - cursor: pointer; - transition: transform 0.18s ease, border-color 0.2s ease, box-shadow 0.2s ease; - text-align: left; -} - -.wxid-option:hover { - transform: translateY(-1px); - border-color: rgba(139, 115, 85, 0.4); - box-shadow: 0 8px 16px rgba(15, 15, 15, 0.08); -} - -.wxid-option.is-selected { - border-color: var(--primary); - box-shadow: 0 0 0 3px var(--primary-light); -} - -.wxid-option-name { - font-size: 14px; - font-weight: 600; - color: var(--text-primary); -} - -.wxid-option-time { - font-size: 11px; - color: var(--text-tertiary); - align-self: flex-end; - text-align: right; - white-space: nowrap; + &::placeholder { + color: var(--text-tertiary); + } } .field-with-toggle { @@ -364,130 +444,310 @@ right: 12px; top: 50%; transform: translateY(-50%); + background: none; border: none; - background: transparent; - color: var(--text-secondary); + color: var(--text-tertiary); + padding: 4px; cursor: pointer; + + &:hover { + color: var(--text-secondary); + } } -.button-row { - display: flex; - gap: 10px; - flex-wrap: wrap; +.grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; } -.welcome-page .btn { - padding: 10px 18px; - border-radius: 999px; - border: none; - cursor: pointer; +.mt-4 { + margin-top: 16px; +} + +.field-hint { font-size: 13px; - font-weight: 600; - display: inline-flex; - gap: 8px; - align-items: center; - transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; + color: var(--text-tertiary); + margin-top: 4px; + line-height: 1.5; + + &.warning { + color: #ff8800; + } + + &.status-text { + margin-top: 12px; + padding: 8px 12px; + background: var(--bg-tertiary); + border-radius: 8px; + } } -.welcome-page .btn:disabled { - opacity: 0.6; - cursor: not-allowed; - box-shadow: none; -} - -.welcome-page .btn-primary { - color: #fff; - background: var(--primary-gradient); - box-shadow: 0 10px 18px rgba(139, 115, 85, 0.25); -} - -.welcome-page .btn-primary:hover:not(:disabled) { - transform: translateY(-1px); -} - -.welcome-page .btn-secondary { - color: var(--text-primary); +.status-message { + padding: 10px 14px; background: var(--bg-tertiary); -} - -.welcome-page .btn-tertiary { + border-radius: 10px; color: var(--text-secondary); - background: transparent; - border: 1px solid var(--border-color); + font-size: 13px; + margin-top: 8px; + border: 1px solid rgba(0, 0, 0, 0.04); } -.welcome-page .btn-inline { - align-self: flex-start; -} - -.welcome-page .btn-full { - width: 100%; - justify-content: center; -} - -.setup-actions { +.error-message { + padding: 12px 16px; + background: rgba(255, 59, 48, 0.1); + color: #ff3b30; + border-radius: 12px; + font-size: 14px; + margin-top: 16px; display: flex; - justify-content: space-between; align-items: center; +} + +.intro-footer { + margin-top: auto; + text-align: center; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.6; + + p { + margin: 0 0 4px; + } +} + +/* Actions */ +.action-row { + display: flex; gap: 12px; margin-top: 8px; } -.error-message { - background: rgba(250, 81, 81, 0.1); - color: var(--danger); - padding: 10px 14px; - border-radius: 12px; - font-size: 13px; +.content-actions { + margin-top: auto; + padding-top: 32px; + display: flex; + justify-content: space-between; + align-items: center; } -.manual-prompt { - background: rgba(139, 115, 85, 0.1); - border: 1px dashed rgba(139, 115, 85, 0.3); - padding: 16px; - border-radius: 16px; +.key-actions { + margin-top: 12px; +} + +/* Buttons */ +.btn { + height: 40px; + padding: 0 20px; + border-radius: 10px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.2, 0.8, 0.2, 1); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: none; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:active:not(:disabled) { + transform: scale(0.96); + } +} + +.btn-primary { + background: var(--primary); + color: #fff; + box-shadow: 0 4px 12px rgba(var(--primary-rgb, 139, 115, 85), 0.3); + + &:hover:not(:disabled) { + background: var(--primary-hover, #a0825d); + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(var(--primary-rgb, 139, 115, 85), 0.4); + } +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid transparent; + + &:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--border-color); + } +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); + + &:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.04); + color: var(--text-primary); + } +} + +.btn-block { + width: 100%; +} + +.btn-large { + height: 48px; + padding: 0 32px; + font-size: 16px; + border-radius: 12px; +} + + +/* Intro Specific */ +.intro-block { + text-align: center; + padding: 20px 0; display: flex; flex-direction: column; - gap: 12px; - margin: 4px 0; + align-items: center; +} - .prompt-text { - font-size: 13px; - color: var(--text-secondary); - line-height: 1.5; - margin: 0; - } +.intro-illustration { + width: 120px; + height: 120px; + background: radial-gradient(circle, var(--primary-light) 0%, transparent 70%); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 24px; + color: var(--primary); + border-radius: 50%; + animation: float 6s ease-in-out infinite; +} - .btn { - width: 100%; - justify-content: center; +.intro-block h3 { + font-size: 20px; + color: var(--text-primary); + margin-bottom: 12px; +} + +.intro-block p { + color: var(--text-secondary); + line-height: 1.6; + max-width: 400px; + margin: 0; +} + +/* Success State */ +.success-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + max-width: 400px; +} + +.success-icon { + width: 80px; + height: 80px; + color: #34c759; + background: rgba(52, 199, 89, 0.1); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; + animation: successPop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.success-title { + font-size: 28px; + color: var(--text-primary); + margin: 0; +} + +.success-desc { + text-align: center; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 24px; +} + + +/* Manual Prompt */ +.manual-prompt { + background: #fff8f0; + border: 1px dashed #ffd8a8; + padding: 16px; + border-radius: 12px; + text-align: center; + + p { + color: #d97706; + margin-bottom: 12px; + font-size: 14px; } } -@media (max-width: 900px) { - .welcome-shell { - grid-template-columns: 1fr; - } -} - -@keyframes fadeUp { +/* Animations */ +@keyframes scaleIn { from { opacity: 0; - transform: translateY(12px); + transform: scale(0.95); } + + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { opacity: 1; transform: translateY(0); } } -@keyframes fadeOut { +@keyframes fadeIn { from { - opacity: 1; - transform: scale(1); - } - to { opacity: 0; - transform: scale(0.98); + } + + to { + opacity: 1; } } + +@keyframes float { + 0% { + transform: translateY(0); + } + + 50% { + transform: translateY(-10px); + } + + 100% { + transform: translateY(0); + } +} + +@keyframes successPop { + 0% { + transform: scale(0.5); + opacity: 0; + } + + 100% { + transform: scale(1); + opacity: 1; + } +} \ No newline at end of file diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 3f5ac52..a2967fd 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -269,15 +269,14 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { await configService.setDecryptKey(decryptKey) await configService.setMyWxid(wxid) await configService.setCachePath(cachePath) - if (imageXorKey) { - const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16) - if (!Number.isNaN(parsed)) { - await configService.setImageXorKey(parsed) - } - } - if (imageAesKey) { - await configService.setImageAesKey(imageAesKey) - } + const parsedXorKey = imageXorKey ? parseInt(imageXorKey.replace(/^0x/i, ''), 16) : null + await configService.setImageXorKey(typeof parsedXorKey === 'number' && !Number.isNaN(parsedXorKey) ? parsedXorKey : 0) + await configService.setImageAesKey(imageAesKey || '') + await configService.setWxidConfig(wxid, { + decryptKey, + imageXorKey: typeof parsedXorKey === 'number' && !Number.isNaN(parsedXorKey) ? parsedXorKey : 0, + imageAesKey + }) await configService.setOnboardingDone(true) setDbConnected(true, dbPath) @@ -313,6 +312,67 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { if (isDbConnected) { return (
+
+ {showWindowControls && ( +
+ + +
+ )} +
+
+ WeFlow +
+ WeFlow + Connected +
+
+ +
+ +
+ + 本地安全存储 +
+
+ +
+
+
+ +
+

配置已完成

+

数据库已连接,你可以直接进入首页使用全部功能。

+ + +
+
+
+
+ ) + } + + return ( +
+
{showWindowControls && (
)} -
-
-
- WeFlow -
-

WeFlow

-

已连接数据库

-
+
+
+ WeFlow +
+ WeFlow + Setup
-
- - 配置已完成,可直接进入首页 -
-
-
-
- ) - } - return ( -
- {showWindowControls && ( -
- - -
- )} -
-
-
- WeFlow -
-

首次配置

-

WeFlow 初始引导

-

一步一步完成数据库与密钥设置

-
-
-
+
{steps.map((step, index) => ( -
-
{index < stepIndex ? : index + 1}
-
-
{step.title}
-
{step.desc}
+
+
+ {index < stepIndex ? :
} +
+
+
{step.title}
+
{step.desc}
))}
-
- + +
+ 数据仅在本地处理,不上传服务器
-
-
-
- {currentStep.id === 'intro' && } - {currentStep.id === 'db' && } - {currentStep.id === 'cache' && } - {currentStep.id === 'key' && } - {currentStep.id === 'image' && } -
+
+

{currentStep.title}

-

{currentStep.desc}

+

{currentStep.desc}

- {currentStep.id === 'intro' && ( -
-
- -
-

准备好了吗?

-

接下来只需配置数据库目录和获取解密密钥。

+
+ {currentStep.id === 'intro' && ( +
+ {/* 内容移至底部 */} +
+ )} + + {currentStep.id === 'db' && ( +
+ +
+ setDbPath(e.target.value)} + />
-
-
- )} - - {currentStep.id === 'db' && ( -
- - setDbPath(e.target.value)} - /> -
- - -
-
请选择微信-设置-存储位置对应的目录
-
⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录
-
- )} - - {currentStep.id === 'cache' && ( -
- - setCachePath(e.target.value)} - /> -
- - -
-
用于头像、表情与图片缓存,留空使用默认目录
-
- )} - - {currentStep.id === 'key' && ( -
- - setWxid(e.target.value)} - /> - -
- setDecryptKey(e.target.value.trim())} - /> - -
- - {isManualStartPrompt ? ( -
-

未能自动启动微信,请手动启动并登录后点击下方确认

- +
- ) : ( -
+ )} + + {currentStep.id === 'cache' && ( +
+ +
+ setCachePath(e.target.value)} + /> +
+
+ + +
+
用于头像、表情与图片缓存
+
+ )} + + {currentStep.id === 'key' && ( +
+ + setWxid(e.target.value)} + /> + + +
+ setDecryptKey(e.target.value.trim())} + /> + +
+ +
+ {isManualStartPrompt ? ( +
+

未能自动启动微信,请手动启动并登录

+ +
+ ) : ( + + )} +
+ + {dbKeyStatus &&
{dbKeyStatus}
} +
点击自动获取后微信将重启,请留意弹窗提示
+
+ )} + + {currentStep.id === 'image' && ( +
+
+
+ + setImageXorKey(e.target.value)} + /> +
+
+ + setImageAesKey(e.target.value)} + /> +
+
+ + - )} - {dbKeyStatus &&
{dbKeyStatus}
} -
获取密钥会自动识别最近登录的账号
-
点击自动获取后微信将重新启动,当页面提示hook安装成功,现在登录微信后再点击登录
-
- )} - - {currentStep.id === 'image' && ( -
- - setImageXorKey(e.target.value)} - /> - - setImageAesKey(e.target.value)} - /> - - {imageKeyStatus &&
{imageKeyStatus}
} -
请在电脑微信中打开查看几个图片后再点击获取秘钥,如获取失败请重复以上操作
- {isFetchingImageKey &&
正在扫描内存,请稍候...
} -
- )} + {imageKeyStatus &&
{imageKeyStatus}
} +
请在微信中打开几张图片后再点击获取
+
+ )} +
{error &&
{error}
} -
- + {stepIndex < steps.length - 1 ? ( ) : ( )}
diff --git a/src/services/config.ts b/src/services/config.ts index f8361dd..063ff68 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -6,6 +6,7 @@ export const CONFIG_KEYS = { DECRYPT_KEY: 'decryptKey', DB_PATH: 'dbPath', MY_WXID: 'myWxid', + WXID_CONFIGS: 'wxidConfigs', THEME: 'theme', THEME_ID: 'themeId', LAST_SESSION: 'lastSession', @@ -31,6 +32,13 @@ export const CONFIG_KEYS = { EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns' } as const +export interface WxidConfig { + decryptKey?: string + imageXorKey?: number + imageAesKey?: string + updatedAt?: number +} + // 获取解密密钥 export async function getDecryptKey(): Promise { const value = await config.get(CONFIG_KEYS.DECRYPT_KEY) @@ -64,6 +72,32 @@ export async function setMyWxid(wxid: string): Promise { await config.set(CONFIG_KEYS.MY_WXID, wxid) } +export async function getWxidConfigs(): Promise> { + const value = await config.get(CONFIG_KEYS.WXID_CONFIGS) + if (value && typeof value === 'object') { + return value as Record + } + return {} +} + +export async function getWxidConfig(wxid: string): Promise { + if (!wxid) return null + const configs = await getWxidConfigs() + return configs[wxid] || null +} + +export async function setWxidConfig(wxid: string, configValue: WxidConfig): Promise { + 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'> { const value = await config.get(CONFIG_KEYS.THEME) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index cce6785..abd1d6f 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -354,6 +354,7 @@ export interface ExportOptions { excelCompactColumns?: boolean txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' + displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' } export interface ExportProgress {