diff --git a/electron/main.ts b/electron/main.ts index 158ef4e..16d26ba 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,3 +1,4 @@ +import './preload-env' import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron' import { Worker } from 'worker_threads' import { join, dirname } from 'path' @@ -368,6 +369,66 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe return win } +/** + * 创建独立的聊天记录窗口 + */ +function createChatHistoryWindow(sessionId: string, messageId: number) { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + // 根据系统主题设置窗口背景色 + const isDark = nativeTheme.shouldUseDarkColors + + const win = new BrowserWindow({ + width: 600, + height: 800, + minWidth: 400, + minHeight: 500, + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false + }, + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#00000000', + symbolColor: isDark ? '#ffffff' : '#1a1a1a', + height: 32 + }, + show: false, + backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0', + autoHideMenuBar: true + }) + + win.once('ready-to-show', () => { + win.show() + }) + + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-history/${sessionId}/${messageId}`) + + win.webContents.on('before-input-event', (event, input) => { + if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { + if (win.webContents.isDevToolsOpened()) { + win.webContents.closeDevTools() + } else { + win.webContents.openDevTools() + } + event.preventDefault() + } + }) + } else { + win.loadFile(join(__dirname, '../dist/index.html'), { + hash: `/chat-history/${sessionId}/${messageId}` + }) + } + + return win +} + function showMainWindow() { shouldShowMain = true if (mainWindowReady) { @@ -474,7 +535,7 @@ function registerIpcHandlers() { // 监听下载进度 autoUpdater.on('download-progress', (progress) => { - win?.webContents.send('app:downloadProgress', progress.percent) + win?.webContents.send('app:downloadProgress', progress) }) // 下载完成后自动安装 @@ -529,6 +590,12 @@ function registerIpcHandlers() { createVideoPlayerWindow(videoPath, videoWidth, videoHeight) }) + // 打开聊天记录窗口 + ipcMain.handle('window:openChatHistoryWindow', (_, sessionId: string, messageId: number) => { + createChatHistoryWindow(sessionId, messageId) + return true + }) + // 根据视频尺寸调整窗口大小 ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => { const win = BrowserWindow.fromWebContents(event.sender) @@ -697,7 +764,7 @@ function registerIpcHandlers() { }) }) - ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => { + ipcMain.handle('chat:getMessage', async (_, sessionId: string, localId: number) => { return chatService.getMessageById(sessionId, localId) }) diff --git a/electron/preload-env.ts b/electron/preload-env.ts new file mode 100644 index 0000000..70d36d0 --- /dev/null +++ b/electron/preload-env.ts @@ -0,0 +1,39 @@ +import { join, dirname } from 'path' + +/** + * 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL + * 解决系统中存在冲突版本的 DLL 导致的应用崩溃问题 + */ +function enforceLocalDllPriority() { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const sep = process.platform === 'win32' ? ';' : ':' + + let possiblePaths: string[] = [] + + if (isDev) { + // 开发环境 + possiblePaths.push(join(process.cwd(), 'resources')) + } else { + // 生产环境 + possiblePaths.push(dirname(process.execPath)) + if (process.resourcesPath) { + possiblePaths.push(process.resourcesPath) + } + } + + const dllPaths = possiblePaths.join(sep) + + if (process.env.PATH) { + process.env.PATH = dllPaths + sep + process.env.PATH + } else { + process.env.PATH = dllPaths + } + + console.log('[WeFlow] Environment PATH updated to enforce local DLL priority:', dllPaths) +} + +try { + enforceLocalDllPriority() +} catch (e) { + console.error('[WeFlow] Failed to enforce local DLL priority:', e) +} diff --git a/electron/preload.ts b/electron/preload.ts index 61710a3..4e37c02 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -29,7 +29,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getVersion: () => ipcRenderer.invoke('app:getVersion'), checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), - onDownloadProgress: (callback: (progress: number) => void) => { + onDownloadProgress: (callback: (progress: any) => void) => { ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress)) return () => ipcRenderer.removeAllListeners('app:downloadProgress') }, @@ -57,7 +57,9 @@ contextBridge.exposeInMainWorld('electronAPI', { openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), resizeToFitVideo: (videoWidth: number, videoHeight: number) => - ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight) + ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight), + openChatHistoryWindow: (sessionId: string, messageId: number) => + ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId) }, // 数据库路径 @@ -121,7 +123,9 @@ contextBridge.exposeInMainWorld('electronAPI', { }, execQuery: (kind: string, path: string | null, sql: string) => ipcRenderer.invoke('chat:execQuery', kind, path, sql), - getContacts: () => ipcRenderer.invoke('chat:getContacts') + getContacts: () => ipcRenderer.invoke('chat:getContacts'), + getMessage: (sessionId: string, localId: number) => + ipcRenderer.invoke('chat:getMessage', sessionId, localId) }, diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 6f2abf3..688c5bd 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -58,6 +58,26 @@ export interface Message { encrypVer?: number cdnThumbUrl?: string voiceDurationSeconds?: number + // Type 49 细分字段 + linkTitle?: string // 链接/文件标题 + linkUrl?: string // 链接 URL + linkThumb?: string // 链接缩略图 + fileName?: string // 文件名 + fileSize?: number // 文件大小 + fileExt?: string // 文件扩展名 + xmlType?: string // XML 中的 type 字段 + // 名片消息 + cardUsername?: string // 名片的微信ID + cardNickname?: string // 名片的昵称 + // 聊天记录 + chatRecordTitle?: string // 聊天记录标题 + chatRecordList?: Array<{ + datatype: number + sourcename: string + sourcetime: string + datadesc: string + datatitle?: string + }> } export interface Contact { @@ -106,6 +126,9 @@ class ChatService { timeColumn?: string name2IdTable?: string }>() + // 缓存会话表信息,避免每次查询 + private sessionTablesCache = new Map>() + private readonly sessionTablesCacheTtl = 300000 // 5分钟 constructor() { this.configService = new ConfigService() @@ -1023,6 +1046,26 @@ class ChatService { let encrypVer: number | undefined let cdnThumbUrl: string | undefined let voiceDurationSeconds: number | undefined + // Type 49 细分字段 + let linkTitle: string | undefined + let linkUrl: string | undefined + let linkThumb: string | undefined + let fileName: string | undefined + let fileSize: number | undefined + let fileExt: string | undefined + let xmlType: string | undefined + // 名片消息 + let cardUsername: string | undefined + let cardNickname: string | undefined + // 聊天记录 + let chatRecordTitle: string | undefined + let chatRecordList: Array<{ + datatype: number + sourcename: string + sourcetime: string + datadesc: string + datatitle?: string + }> | undefined if (localType === 47 && content) { const emojiInfo = this.parseEmojiInfo(content) @@ -1040,6 +1083,23 @@ class ChatService { videoMd5 = this.parseVideoMd5(content) } else if (localType === 34 && content) { voiceDurationSeconds = this.parseVoiceDurationSeconds(content) + } else if (localType === 42 && content) { + // 名片消息 + const cardInfo = this.parseCardInfo(content) + cardUsername = cardInfo.username + cardNickname = cardInfo.nickname + } else if (localType === 49 && content) { + // Type 49 消息(链接、文件、小程序、转账等) + const type49Info = this.parseType49Message(content) + xmlType = type49Info.xmlType + linkTitle = type49Info.linkTitle + linkUrl = type49Info.linkUrl + linkThumb = type49Info.linkThumb + fileName = type49Info.fileName + fileSize = type49Info.fileSize + fileExt = type49Info.fileExt + chatRecordTitle = type49Info.chatRecordTitle + chatRecordList = type49Info.chatRecordList } else if (localType === 244813135921 || (content && content.includes('57'))) { const quoteInfo = this.parseQuoteMessage(content) quotedContent = quoteInfo.content @@ -1066,7 +1126,18 @@ class ChatService { voiceDurationSeconds, aesKey, encrypVer, - cdnThumbUrl + cdnThumbUrl, + linkTitle, + linkUrl, + linkThumb, + fileName, + fileSize, + fileExt, + xmlType, + cardUsername, + cardNickname, + chatRecordTitle, + chatRecordList }) const last = messages[messages.length - 1] if ((last.localType === 3 || last.localType === 34) && (last.localId === 0 || last.createTime === 0)) { @@ -1126,7 +1197,7 @@ class ChatService { const title = this.extractXmlValue(content, 'title') return title || '[引用消息]' case 266287972401: - return '[拍一拍]' + return this.cleanPatMessage(content) case 81604378673: return '[聊天记录]' case 8594229559345: @@ -1164,17 +1235,35 @@ class ChatService { return `[链接] ${title}` case '6': return `[文件] ${title}` + case '19': + return `[聊天记录] ${title}` case '33': case '36': return `[小程序] ${title}` case '57': // 引用消息,title 就是回复的内容 return title + case '2000': + return `[转账] ${title}` default: return title } } - return '[消息]' + + // 如果没有 title,根据 type 返回默认标签 + switch (type) { + case '6': + return '[文件]' + case '19': + return '[聊天记录]' + case '33': + case '36': + return '[小程序]' + case '2000': + return '[转账]' + default: + return '[消息]' + } } /** @@ -1458,6 +1547,185 @@ class ChatService { } } + /** + * 解析名片消息 + * 格式: + */ + private parseCardInfo(content: string): { username?: string; nickname?: string } { + try { + if (!content) return {} + + // 提取 username + const username = this.extractXmlAttribute(content, 'msg', 'username') || undefined + + // 提取 nickname + const nickname = this.extractXmlAttribute(content, 'msg', 'nickname') || undefined + + return { username, nickname } + } catch (e) { + console.error('[ChatService] 名片解析失败:', e) + return {} + } + } + + /** + * 解析 Type 49 消息(链接、文件、小程序、转账等) + * 根据 X 区分不同类型 + */ + private parseType49Message(content: string): { + xmlType?: string + linkTitle?: string + linkUrl?: string + linkThumb?: string + fileName?: string + fileSize?: number + fileExt?: string + chatRecordTitle?: string + chatRecordList?: Array<{ + datatype: number + sourcename: string + sourcetime: string + datadesc: string + datatitle?: string + }> + } { + try { + if (!content) return {} + + // 提取 appmsg 中的 type + const xmlType = this.extractXmlValue(content, 'type') + if (!xmlType) return {} + + const result: any = { xmlType } + + // 提取通用字段 + const title = this.extractXmlValue(content, 'title') + const url = this.extractXmlValue(content, 'url') + + switch (xmlType) { + case '6': { + // 文件消息 + result.fileName = title || this.extractXmlValue(content, 'filename') + result.linkTitle = result.fileName + + // 提取文件大小 + const fileSizeStr = this.extractXmlValue(content, 'totallen') || + this.extractXmlValue(content, 'filesize') + if (fileSizeStr) { + const size = parseInt(fileSizeStr, 10) + if (!isNaN(size)) { + result.fileSize = size + } + } + + // 提取文件扩展名 + const fileExt = this.extractXmlValue(content, 'fileext') + if (fileExt) { + result.fileExt = fileExt + } else if (result.fileName) { + // 从文件名提取扩展名 + const match = /\.([^.]+)$/.exec(result.fileName) + if (match) { + result.fileExt = match[1] + } + } + break + } + + case '19': { + // 聊天记录 + result.chatRecordTitle = title || '聊天记录' + + // 解析聊天记录列表 + const recordList: Array<{ + datatype: number + sourcename: string + sourcetime: string + datadesc: string + datatitle?: string + }> = [] + + // 查找所有 标签 + const recordItemRegex = /([\s\S]*?)<\/recorditem>/gi + let match: RegExpExecArray | null + + while ((match = recordItemRegex.exec(content)) !== null) { + const itemXml = match[1] + + const datatypeStr = this.extractXmlValue(itemXml, 'datatype') + const sourcename = this.extractXmlValue(itemXml, 'sourcename') + const sourcetime = this.extractXmlValue(itemXml, 'sourcetime') + const datadesc = this.extractXmlValue(itemXml, 'datadesc') + const datatitle = this.extractXmlValue(itemXml, 'datatitle') + + if (sourcename && datadesc) { + recordList.push({ + datatype: datatypeStr ? parseInt(datatypeStr, 10) : 0, + sourcename, + sourcetime: sourcetime || '', + datadesc, + datatitle: datatitle || undefined + }) + } + } + + if (recordList.length > 0) { + result.chatRecordList = recordList + } + break + } + + case '33': + case '36': { + // 小程序 + result.linkTitle = title + result.linkUrl = url + + // 提取缩略图 + const thumbUrl = this.extractXmlValue(content, 'thumburl') || + this.extractXmlValue(content, 'cdnthumburl') + if (thumbUrl) { + result.linkThumb = thumbUrl + } + break + } + + case '2000': { + // 转账 + result.linkTitle = title || '[转账]' + + // 可以提取转账金额等信息 + const payMemo = this.extractXmlValue(content, 'pay_memo') + const feedesc = this.extractXmlValue(content, 'feedesc') + + if (payMemo) { + result.linkTitle = payMemo + } else if (feedesc) { + result.linkTitle = feedesc + } + break + } + + default: { + // 其他类型,提取通用字段 + result.linkTitle = title + result.linkUrl = url + + const thumbUrl = this.extractXmlValue(content, 'thumburl') || + this.extractXmlValue(content, 'cdnthumburl') + if (thumbUrl) { + result.linkThumb = thumbUrl + } + } + } + + return result + } catch (e) { + console.error('[ChatService] Type 49 消息解析失败:', e) + return {} + } + } + //手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback) private async findMediaDbsManually(): Promise { try { @@ -1815,6 +2083,37 @@ class ChatService { } } + /** + * 清理拍一拍消息 + * 格式示例: 我拍了拍 "梨绒" ງ໐໐໓ ຖiງht620000wxid_... + */ + private cleanPatMessage(content: string): string { + if (!content) return '[拍一拍]' + + // 1. 尝试匹配标准的 "A拍了拍B" 格式 + // 这里的正则比较宽泛,为了兼容不同的语言环境 + const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content) + if (match) { + return `[拍一拍] ${match[1].trim()}` + } + + // 2. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码) + let cleaned = content.replace(/wxid_[a-zA-Z0-9_-]+/g, '') // 移除 wxid + cleaned = cleaned.replace(/[ງ໐໓ຖiht]+/g, ' ') // 移除已知的乱码字符 + cleaned = cleaned.replace(/\d{6,}/g, '') // 移除长数字 + cleaned = cleaned.replace(/\s+/g, ' ').trim() // 清理空格 + + // 移除不可见字符 + cleaned = this.cleanUtf16(cleaned) + + // 如果清理后还有内容,返回 + if (cleaned && cleaned.length > 1 && !cleaned.includes('xml')) { + return `[拍一拍] ${cleaned}` + } + + return '[拍一拍]' + } + /** * 解码消息内容(处理 BLOB 和压缩数据) */ @@ -2479,7 +2778,7 @@ class ChatService { /** * getVoiceData (绕过WCDB的buggy getVoiceData,直接用execQuery读取) */ - async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number): Promise<{ success: boolean; data?: string; error?: string }> { + async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number, senderWxidOpt?: string): Promise<{ success: boolean; data?: string; error?: string }> { const startTime = Date.now() try { const localId = parseInt(msgId, 10) @@ -2488,7 +2787,7 @@ class ChatService { } let msgCreateTime = createTime - let senderWxid: string | null = null + let senderWxid: string | null = senderWxidOpt || null // 如果前端没传 createTime,才需要查询消息(这个很慢) if (!msgCreateTime) { @@ -2559,7 +2858,7 @@ class ChatService { console.log(`[Voice] getVoiceDataFromMediaDb: ${t4 - t3}ms`) if (!silkData) { - return { success: false, error: '未找到语音数据' } + return { success: false, error: '未找到语音数据 (请确保已在微信中播放过该语音)' } } const t5 = Date.now() @@ -2627,11 +2926,20 @@ class ChatService { const t2 = Date.now() console.log(`[Voice] listMediaDbs: ${t2 - t1}ms`) - if (!mediaDbsResult.success || !mediaDbsResult.data || mediaDbsResult.data.length === 0) { + let files = mediaDbsResult.success && mediaDbsResult.data ? (mediaDbsResult.data as string[]) : [] + + // Fallback: 如果 WCDB DLL 没找到,手动查找 + if (files.length === 0) { + console.warn('[Voice] listMediaDbs returned empty, trying manual search') + files = await this.findMediaDbsManually() + } + + if (files.length === 0) { + console.error('[Voice] No media DBs found') return null } - mediaDbFiles = mediaDbsResult.data as string[] + mediaDbFiles = files this.mediaDbsCache = mediaDbFiles // 永久缓存 } @@ -3010,7 +3318,8 @@ class ChatService { sessionId: string, msgId: string, createTime?: number, - onPartial?: (text: string) => void + onPartial?: (text: string) => void, + senderWxid?: string ): Promise<{ success: boolean; transcript?: string; error?: string }> { const startTime = Date.now() console.log(`[Transcribe] 开始转写: sessionId=${sessionId}, msgId=${msgId}, createTime=${createTime}`) @@ -3082,7 +3391,7 @@ class ChatService { console.log(`[Transcribe] WAV缓存未命中,调用 getVoiceData`) const t3 = Date.now() // 调用 getVoiceData 获取并解码 - const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId) + const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId, senderWxid) const t4 = Date.now() console.log(`[Transcribe] getVoiceData: ${t4 - t3}ms, success=${voiceResult.success}`) @@ -3157,19 +3466,35 @@ class ChatService { async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { try { - // 1. 获取该会话所在的消息表 - // 注意:这里使用 getMessageTableStats 而不是 getMessageTables,因为前者包含 db_path - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { - return { success: false, error: '未找到会话消息表' } + // 1. 尝试从缓存获取会话表信息 + let tables = this.sessionTablesCache.get(sessionId) + + if (!tables) { + // 缓存未命中,查询数据库 + const tableStats = await wcdbService.getMessageTableStats(sessionId) + if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { + return { success: false, error: '未找到会话消息表' } + } + + // 提取表信息并缓存 + tables = tableStats.tables + .map(t => ({ + tableName: t.table_name || t.name, + dbPath: t.db_path + })) + .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> + + if (tables.length > 0) { + this.sessionTablesCache.set(sessionId, tables) + // 设置过期清理 + setTimeout(() => { + this.sessionTablesCache.delete(sessionId) + }, this.sessionTablesCacheTtl) + } } // 2. 遍历表查找消息 (通常只有一个主表,但可能有归档) - for (const tableInfo of tableStats.tables) { - const tableName = tableInfo.table_name || tableInfo.name - const dbPath = tableInfo.db_path - if (!tableName || !dbPath) continue - + for (const { tableName, dbPath } of tables) { // 构造查询 const sql = `SELECT * FROM ${tableName} WHERE local_id = ${localId} LIMIT 1` const result = await wcdbService.execQuery('message', dbPath, sql) diff --git a/electron/services/config.ts b/electron/services/config.ts index 3ba3a14..2be308d 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -9,12 +9,12 @@ interface ConfigSchema { imageXorKey: number imageAesKey: string wxidConfigs: Record - + // 缓存相关 cachePath: string lastOpenedDb: string lastSession: string - + // 界面相关 theme: 'light' | 'dark' | 'system' themeId: string @@ -26,6 +26,12 @@ interface ConfigSchema { whisperDownloadSource: string autoTranscribeVoice: boolean transcribeLanguages: string[] + exportDefaultConcurrency: number + + // 安全相关 + authEnabled: boolean + authPassword: string // SHA-256 hash + authUseHello: boolean } export class ConfigService { @@ -54,7 +60,12 @@ export class ConfigService { whisperModelDir: '', whisperDownloadSource: 'tsinghua', autoTranscribeVoice: false, - transcribeLanguages: ['zh'] + transcribeLanguages: ['zh'], + exportDefaultConcurrency: 2, + + authEnabled: false, + authPassword: '', + authUseHello: false } }) } diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index 9d49f21..038ee2f 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -18,8 +18,7 @@ export class DbPathService { // 微信4.x 数据目录 possiblePaths.push(join(home, 'Documents', 'xwechat_files')) - // 旧版微信数据目录 - possiblePaths.push(join(home, 'Documents', 'WeChat Files')) + for (const path of possiblePaths) { if (existsSync(path)) { @@ -27,7 +26,7 @@ export class DbPathService { if (rootName !== 'xwechat_files' && rootName !== 'wechat files') { continue } - + // 检查是否有有效的账号目录 const accounts = this.findAccountDirs(path) if (accounts.length > 0) { @@ -47,10 +46,10 @@ export class DbPathService { */ findAccountDirs(rootPath: string): string[] { const accounts: string[] = [] - + try { const entries = readdirSync(rootPath) - + for (const entry of entries) { const entryPath = join(rootPath, entry) let stat: ReturnType @@ -59,7 +58,7 @@ export class DbPathService { } catch { continue } - + if (stat.isDirectory()) { if (!this.isPotentialAccountName(entry)) continue @@ -69,8 +68,8 @@ export class DbPathService { } } } - } catch {} - + } catch { } + return accounts } @@ -124,7 +123,7 @@ export class DbPathService { */ scanWxids(rootPath: string): WxidInfo[] { const wxids: WxidInfo[] = [] - + try { if (this.isAccountDir(rootPath)) { const wxid = basename(rootPath) @@ -133,14 +132,14 @@ export class DbPathService { } const accounts = this.findAccountDirs(rootPath) - + for (const account of accounts) { const fullPath = join(rootPath, account) const modifiedTime = this.getAccountModifiedTime(fullPath) wxids.push({ wxid: account, modifiedTime }) } - } catch {} - + } catch { } + return wxids.sort((a, b) => { if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime return a.wxid.localeCompare(b.wxid) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index f5e750b..868a46f 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 { voiceTranscribeService } from './voiceTranscribeService' import { EXPORT_HTML_STYLES } from './exportHtmlStyles' // ChatLab 格式类型定义 @@ -78,6 +79,7 @@ export interface ExportOptions { txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' + exportConcurrency?: number } const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ @@ -290,7 +292,7 @@ class ExportService { extBuffer = Buffer.from(extBuffer, 'base64') } else { // 默认尝试hex - console.log('⚠️ 无法判断编码格式,默认尝试hex') + console.log(' 无法判断编码格式,默认尝试hex') try { extBuffer = Buffer.from(extBuffer, 'hex') } catch (e) { @@ -1032,15 +1034,15 @@ class ExportService { /** * 转写语音为文字 */ - private async transcribeVoice(sessionId: string, msgId: string): Promise { + private async transcribeVoice(sessionId: string, msgId: string, createTime: number, senderWxid: string | null): Promise { try { - const transcript = await chatService.getVoiceTranscript(sessionId, msgId) + const transcript = await chatService.getVoiceTranscript(sessionId, msgId, createTime, undefined, senderWxid || undefined) if (transcript.success && transcript.transcript) { return `[语音转文字] ${transcript.transcript}` } - return '[语音消息 - 转文字失败]' + return `[语音消息 - 转文字失败: ${transcript.error || '未知错误'}]` } catch (e) { - return '[语音消息 - 转文字失败]' + return `[语音消息 - 转文字失败: ${String(e)}]` } } @@ -1288,6 +1290,7 @@ class ExportService { ): Promise<{ rows: any[]; memberSet: Map; firstTime: number | null; lastTime: number | null }> { const rows: any[] = [] const memberSet = new Map() + const senderSet = new Set() let firstTime: number | null = null let lastTime: number | null = null @@ -1321,16 +1324,7 @@ class ExportService { const localId = parseInt(row.local_id || row.localId || '0', 10) const actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId) - const memberInfo = await this.getContactInfo(actualSender) - if (!memberSet.has(actualSender)) { - memberSet.set(actualSender, { - member: { - platformId: actualSender, - accountName: memberInfo.displayName - }, - avatarUrl: memberInfo.avatarUrl - }) - } + senderSet.add(actualSender) // 提取媒体相关字段 let imageMd5: string | undefined @@ -1375,6 +1369,30 @@ class ExportService { await wcdbService.closeMessageCursor(cursor.cursor) } + if (senderSet.size > 0) { + const usernames = Array.from(senderSet) + const [nameResult, avatarResult] = await Promise.all([ + wcdbService.getDisplayNames(usernames), + wcdbService.getAvatarUrls(usernames) + ]) + + const nameMap = nameResult.success && nameResult.map ? nameResult.map : {} + const avatarMap = avatarResult.success && avatarResult.map ? avatarResult.map : {} + + for (const username of usernames) { + const displayName = nameMap[username] || username + const avatarUrl = avatarMap[username] + memberSet.set(username, { + member: { + platformId: username, + accountName: displayName + }, + avatarUrl + }) + this.contactCache.set(username, { displayName, avatarUrl }) + } + } + return { rows, memberSet, firstTime, lastTime } } @@ -1663,6 +1681,10 @@ class ExportService { phase: 'preparing' }) + if (options.exportVoiceAsText) { + await this.ensureVoiceModel(onProgress) + } + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const allMessages = collected.rows @@ -1733,7 +1755,7 @@ class ExportService { // 并行转写语音,限制 4 个并发(转写比较耗资源) const VOICE_CONCURRENCY = 4 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) }) } @@ -1856,6 +1878,16 @@ class ExportService { const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) + const contactCache = new Map() + const getContactCached = async (username: string) => { + if (contactCache.has(username)) { + return contactCache.get(username)! + } + const result = await wcdbService.getContact(username) + contactCache.set(username, result) + return result + } + onProgress?.({ current: 0, total: 100, @@ -1863,6 +1895,10 @@ class ExportService { phase: 'preparing' }) + if (options.exportVoiceAsText) { + await this.ensureVoiceModel(onProgress) + } + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) // 如果没有消息,不创建文件 @@ -1924,7 +1960,7 @@ class ExportService { const VOICE_CONCURRENCY = 4 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) }) } @@ -1962,7 +1998,7 @@ class ExportService { // 获取发送者信息用于名称显示 const senderWxid = msg.senderUsername - const contact = await wcdbService.getContact(senderWxid) + const contact = await getContactCached(senderWxid) const senderNickname = contact.success && contact.contact?.nickName ? contact.contact.nickName : (senderInfo.displayName || senderWxid) @@ -2005,7 +2041,7 @@ class ExportService { const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup) // 获取会话的昵称和备注信息 - const sessionContact = await wcdbService.getContact(sessionId) + const sessionContact = await getContactCached(sessionId) const sessionNickname = sessionContact.success && sessionContact.contact?.nickName ? sessionContact.contact.nickName : sessionInfo.displayName @@ -2098,8 +2134,18 @@ class ExportService { const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) + const contactCache = new Map() + const getContactCached = async (username: string) => { + if (contactCache.has(username)) { + return contactCache.get(username)! + } + const result = await wcdbService.getContact(username) + contactCache.set(username, result) + return result + } + // 获取会话的备注信息 - const sessionContact = await wcdbService.getContact(sessionId) + const sessionContact = await getContactCached(sessionId) const sessionRemark = sessionContact.success && sessionContact.contact?.remark ? sessionContact.contact.remark : '' const sessionNickname = sessionContact.success && sessionContact.contact?.nickName ? sessionContact.contact.nickName : sessionId @@ -2110,6 +2156,10 @@ class ExportService { phase: 'preparing' }) + if (options.exportVoiceAsText) { + await this.ensureVoiceModel(onProgress) + } + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) // 如果没有消息,不创建文件 @@ -2228,11 +2278,11 @@ class ExportService { } // 预加载群昵称 (仅群聊且完整列模式) - console.log('🔍 预加载群昵称检查: isGroup=', isGroup, 'useCompactColumns=', useCompactColumns, 'sessionId=', sessionId) + console.log('预加载群昵称检查: isGroup=', isGroup, 'useCompactColumns=', useCompactColumns, 'sessionId=', sessionId) const groupNicknamesMap = (isGroup && !useCompactColumns) ? await this.getGroupNicknamesForRoom(sessionId) : new Map() - console.log('🔍 群昵称Map大小:', groupNicknamesMap.size) + console.log('群昵称Map大小:', groupNicknamesMap.size) // 填充数据 @@ -2293,7 +2343,7 @@ class ExportService { const VOICE_CONCURRENCY = 4 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) }) } @@ -2328,7 +2378,7 @@ class ExportService { senderWxid = msg.senderUsername // 用 getContact 获取联系人详情,分别取昵称和备注 - const contactDetail = await wcdbService.getContact(msg.senderUsername) + const contactDetail = await getContactCached(msg.senderUsername) if (contactDetail.success && contactDetail.contact) { // nickName 才是真正的昵称 senderNickname = contactDetail.contact.nickName || msg.senderUsername @@ -2343,7 +2393,7 @@ class ExportService { } else { // 单聊对方消息 - 用 getContact 获取联系人详情 senderWxid = sessionId - const contactDetail = await wcdbService.getContact(sessionId) + const contactDetail = await getContactCached(sessionId) if (contactDetail.success && contactDetail.contact) { senderNickname = contactDetail.contact.nickName || sessionId senderRemark = contactDetail.contact.remark || '' @@ -2364,12 +2414,15 @@ class ExportService { const row = worksheet.getRow(currentRow) row.height = 24 - const contentValue = this.formatPlainExportContent( - msg.content, - msg.localType, - options, - voiceTranscriptMap.get(msg.localId) - ) + const mediaKey = `${msg.localType}_${msg.localId}` + const mediaItem = mediaCache.get(mediaKey) + const contentValue = mediaItem?.relativePath + || this.formatPlainExportContent( + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(msg.localId) + ) // 调试日志 if (msg.localType === 3 || msg.localType === 47) { @@ -2443,6 +2496,41 @@ class ExportService { } } + /** + * 确保语音转写模型已下载 + */ + private async ensureVoiceModel(onProgress?: (progress: ExportProgress) => void): Promise { + try { + const status = await voiceTranscribeService.getModelStatus() + if (status.success && status.exists) { + return true + } + + onProgress?.({ + current: 0, + total: 100, + currentSession: '正在下载 AI 模型', + phase: 'preparing' + }) + + const downloadResult = await voiceTranscribeService.downloadModel((progress: any) => { + if (progress.percent !== undefined) { + onProgress?.({ + current: progress.percent, + total: 100, + currentSession: `正在下载 AI 模型 (${progress.percent.toFixed(0)}%)`, + phase: 'preparing' + }) + } + }) + + return downloadResult.success + } catch (e) { + console.error('Auto download model failed:', e) + return false + } + } + /** * 导出单个会话为 TXT 格式(默认与 Excel 精简列一致) */ @@ -2468,6 +2556,10 @@ class ExportService { phase: 'preparing' }) + if (options.exportVoiceAsText) { + await this.ensureVoiceModel(onProgress) + } + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) // 如果没有消息,不创建文件 @@ -2527,7 +2619,7 @@ class ExportService { const VOICE_CONCURRENCY = 4 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) }) } @@ -2543,12 +2635,15 @@ class ExportService { for (let i = 0; i < sortedMessages.length; i++) { const msg = sortedMessages[i] - const contentValue = this.formatPlainExportContent( - msg.content, - msg.localType, - options, - voiceTranscriptMap.get(msg.localId) - ) + const mediaKey = `${msg.localType}_${msg.localId}` + const mediaItem = mediaCache.get(mediaKey) + const contentValue = mediaItem?.relativePath + || this.formatPlainExportContent( + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(msg.localId) + ) let senderRole: string let senderWxid: string @@ -2561,7 +2656,7 @@ class ExportService { senderNickname = myInfo.displayName || cleanedMyWxid } else if (isGroup && msg.senderUsername) { senderWxid = msg.senderUsername - const contactDetail = await wcdbService.getContact(msg.senderUsername) + const contactDetail = await getContactCached(msg.senderUsername) if (contactDetail.success && contactDetail.contact) { senderNickname = contactDetail.contact.nickName || msg.senderUsername senderRemark = contactDetail.contact.remark || '' @@ -2572,7 +2667,7 @@ class ExportService { } } else { senderWxid = sessionId - const contactDetail = await wcdbService.getContact(sessionId) + const contactDetail = await getContactCached(sessionId) if (contactDetail.success && contactDetail.contact) { senderNickname = contactDetail.contact.nickName || sessionId senderRemark = contactDetail.contact.remark || '' @@ -2645,6 +2740,10 @@ class ExportService { phase: 'preparing' }) + if (options.exportVoiceAsText) { + await this.ensureVoiceModel(onProgress) + } + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) // 如果没有消息,不创建文件 @@ -2711,7 +2810,7 @@ class ExportService { const VOICE_CONCURRENCY = 4 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) }) } @@ -2999,13 +3098,20 @@ class ExportService { const sessionLayout = exportMediaEnabled ? (options.sessionLayout ?? 'per-session') : 'shared' + let completedCount = 0 + const rawConcurrency = typeof options.exportConcurrency === 'number' + ? Math.floor(options.exportConcurrency) + : 2 + const clampedConcurrency = Math.max(1, Math.min(rawConcurrency, 6)) + const sessionConcurrency = (exportMediaEnabled && sessionLayout === 'shared') + ? 1 + : clampedConcurrency - for (let i = 0; i < sessionIds.length; i++) { - const sessionId = sessionIds[i] + await parallelLimit(sessionIds, sessionConcurrency, async (sessionId) => { const sessionInfo = await this.getContactInfo(sessionId) onProgress?.({ - current: i + 1, + current: completedCount, total: sessionIds.length, currentSession: sessionInfo.displayName, phase: 'exporting' @@ -3047,7 +3153,15 @@ class ExportService { failCount++ console.error(`导出 ${sessionId} 失败:`, result.error) } - } + + completedCount++ + onProgress?.({ + current: completedCount, + total: sessionIds.length, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + }) onProgress?.({ current: sessionIds.length, diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 341474d..b7ba5ba 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -1461,4 +1461,4 @@ export class WcdbCore { return { success: false, error: String(e) } } } -} +} \ No newline at end of file diff --git a/installer.nsh b/installer.nsh index 748eda0..b7c8f63 100644 --- a/installer.nsh +++ b/installer.nsh @@ -47,11 +47,11 @@ ManifestDPIAware true DetailPrint "Visual C++ Redistributable 安装成功" MessageBox MB_OK|MB_ICONINFORMATION "Visual C++ 运行库安装成功!" ${Else} - MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,您可能需要手动安装。" + MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,你可能需要手动安装。" ${EndIf} Delete "$TEMP\vc_redist.x64.exe" ${Else} - MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n您可以稍后手动下载安装 Visual C++ Redistributable。" + MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n你可以稍后手动下载安装 Visual C++ Redistributable。" ${EndIf} Goto doneVC diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index cd29973..3b3614f 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 a5f8a28..90f8562 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import ExportPage from './pages/ExportPage' import VideoWindow from './pages/VideoWindow' import SnsPage from './pages/SnsPage' import ContactsPage from './pages/ContactsPage' +import ChatHistoryPage from './pages/ChatHistoryPage' import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId } from './stores/themeStore' @@ -25,26 +26,43 @@ import * as configService from './services/config' import { Download, X, Shield } from 'lucide-react' import './App.scss' +import UpdateDialog from './components/UpdateDialog' +import UpdateProgressCapsule from './components/UpdateProgressCapsule' +import LockScreen from './components/LockScreen' + function App() { const navigate = useNavigate() const location = useLocation() - const { setDbConnected } = useAppStore() + const { + setDbConnected, + updateInfo, + setUpdateInfo, + isDownloading, + setIsDownloading, + downloadProgress, + setDownloadProgress, + showUpdateDialog, + setShowUpdateDialog, + setUpdateError + } = useAppStore() + const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const isAgreementWindow = location.pathname === '/agreement-window' const isOnboardingWindow = location.pathname === '/onboarding-window' const isVideoPlayerWindow = location.pathname === '/video-player-window' + const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') const [themeHydrated, setThemeHydrated] = useState(false) + // 锁定状态 + const [isLocked, setIsLocked] = useState(false) + const [lockAvatar, setLockAvatar] = useState(undefined) + const [lockUseHello, setLockUseHello] = useState(false) + // 协议同意状态 const [showAgreement, setShowAgreement] = useState(false) const [agreementChecked, setAgreementChecked] = useState(false) const [agreementLoading, setAgreementLoading] = useState(true) - // 更新提示状态 - const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null) - const [isDownloading, setIsDownloading] = useState(false) - const [downloadProgress, setDownloadProgress] = useState(0) - useEffect(() => { const root = document.documentElement const body = document.body @@ -149,8 +167,12 @@ function App() { // 监听启动时的更新通知 useEffect(() => { - const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info) => { - setUpdateInfo(info) + const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info: any) => { + // 发现新版本时自动打开更新弹窗 + if (info) { + setUpdateInfo({ ...info, hasUpdate: true }) + setShowUpdateDialog(true) + } }) const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => { setDownloadProgress(progress) @@ -159,16 +181,20 @@ function App() { removeUpdateListener?.() removeProgressListener?.() } - }, []) + }, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog]) const handleUpdateNow = async () => { + setShowUpdateDialog(false) setIsDownloading(true) - setDownloadProgress(0) + setDownloadProgress({ percent: 0 }) try { await window.electronAPI.app.downloadAndInstall() - } catch (e) { + } catch (e: any) { console.error('更新失败:', e) setIsDownloading(false) + // Extract clean error message if possible + const errorMsg = e.message || String(e) + setUpdateError(errorMsg.includes('暂时禁用') ? '自动更新已暂时禁用' : errorMsg) } } @@ -232,6 +258,34 @@ function App() { autoConnect() }, [isAgreementWindow, isOnboardingWindow, navigate, setDbConnected]) + // 检查应用锁 + useEffect(() => { + if (isAgreementWindow || isOnboardingWindow || isVideoPlayerWindow) return + + const checkLock = async () => { + // 并行获取配置,减少等待 + const [enabled, useHello] = await Promise.all([ + configService.getAuthEnabled(), + configService.getAuthUseHello() + ]) + + if (enabled) { + setLockUseHello(useHello) + setIsLocked(true) + // 尝试获取头像 + try { + const result = await window.electronAPI.chat.getMyAvatarUrl() + if (result && result.success && result.avatarUrl) { + setLockAvatar(result.avatarUrl) + } + } catch (e) { + console.error('获取锁屏头像失败', e) + } + } + } + checkLock() + }, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow]) + // 独立协议窗口 if (isAgreementWindow) { return @@ -246,11 +300,26 @@ function App() { return } + // 独立聊天记录窗口 + if (isChatHistoryWindow) { + return + } + // 主窗口 - 完整布局 return (
+ {isLocked && ( + setIsLocked(false)} + avatar={lockAvatar} + useHello={lockUseHello} + /> + )} + {/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */} + + {/* 用户协议弹窗 */} {showAgreement && !agreementLoading && (
@@ -272,13 +341,13 @@ function App() {

1. 数据安全

-

本软件所有数据处理均在本地完成,不会上传任何聊天记录、个人信息到服务器。您的数据完全由您自己掌控。

+

本软件所有数据处理均在本地完成,不会上传任何聊天记录、个人信息到服务器。你的数据完全由你自己掌控。

2. 使用须知

-

本软件仅供个人学习研究使用,请勿用于任何非法用途。使用本软件解密、查看、分析的数据应为您本人所有或已获得授权。

+

本软件仅供个人学习研究使用,请勿用于任何非法用途。使用本软件解密、查看、分析的数据应为你本人所有或已获得授权。

3. 免责声明

-

因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保您的使用行为符合当地法律法规。

+

因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保你的使用行为符合当地法律法规。

4. 隐私保护

本软件不收集任何用户数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。

@@ -302,31 +371,15 @@ function App() {
)} - {/* 更新提示条 */} - {updateInfo && ( -
- - 发现新版本 v{updateInfo.version} - - {isDownloading ? ( -
-
-
-
- {downloadProgress.toFixed(0)}% -
- ) : ( - <> - - - - )} -
- )} + {/* 更新提示对话框 */} + setShowUpdateDialog(false)} + onUpdate={handleUpdateNow} + isDownloading={isDownloading} + progress={downloadProgress} + />
@@ -346,6 +399,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/components/LivePhotoIcon.tsx b/src/components/LivePhotoIcon.tsx new file mode 100644 index 0000000..ce904b9 --- /dev/null +++ b/src/components/LivePhotoIcon.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +interface LivePhotoIconProps { + size?: number | string; + className?: string; + style?: React.CSSProperties; +} + +export const LivePhotoIcon: React.FC = ({ size = 24, className = '', style = {} }) => { + return ( + + + + + + + + + + ); +}; diff --git a/src/components/LockScreen.scss b/src/components/LockScreen.scss new file mode 100644 index 0000000..a2546a9 --- /dev/null +++ b/src/components/LockScreen.scss @@ -0,0 +1,185 @@ +.lock-screen { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: var(--bg-primary); + z-index: 9999; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + user-select: none; + -webkit-app-region: drag; + transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1); + backdrop-filter: blur(25px) saturate(180%); + background-color: var(--bg-primary); + // 让背景带一点透明度以增强毛玻璃效果 + opacity: 1; + + &.unlocked { + opacity: 0; + pointer-events: none; + backdrop-filter: blur(0) saturate(100%); + transform: scale(1.02); + + .lock-content { + transform: translateY(-20px) scale(0.95); + filter: blur(10px); + opacity: 0; + } + } + + .lock-content { + display: flex; + flex-direction: column; + align-items: center; + width: 320px; + -webkit-app-region: no-drag; + animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1); + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + + .lock-avatar { + width: 100px; + height: 100px; + border-radius: 50%; + margin-bottom: 24px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + border: 4px solid var(--bg-total); + background-color: var(--bg-secondary); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + } + + .lock-title { + font-size: 24px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 32px; + } + + .lock-form { + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + + .input-group { + position: relative; + width: 100%; + + input { + width: 100%; + height: 48px; + padding: 0 16px; + padding-right: 48px; + border-radius: 12px; + border: 1px solid var(--border-color); + background-color: var(--bg-input); + color: var(--text-primary); + font-size: 16px; + outline: none; + transition: all 0.2s; + + &:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px var(--primary-color-alpha); + } + } + + .submit-btn { + position: absolute; + right: 8px; + top: 8px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: none; + background: var(--primary-color); + color: white; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.9; + } + } + } + + .hello-btn { + width: 100%; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: 12px; + border: 1px solid var(--border-color); + background-color: var(--bg-secondary); + color: var(--text-primary); + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background-color: var(--bg-hover); + transform: translateY(-1px); + } + + &.loading { + opacity: 0.7; + pointer-events: none; + } + } + } + + .lock-error { + margin-top: 16px; + color: #ff4d4f; + font-size: 14px; + animation: shake 0.5s ease-in-out; + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shake { + + 0%, + 100% { + transform: translateX(0); + } + + 10%, + 30%, + 50%, + 70%, + 90% { + transform: translateX(-4px); + } + + 20%, + 40%, + 60%, + 80% { + transform: translateX(4px); + } +} \ No newline at end of file diff --git a/src/components/LockScreen.tsx b/src/components/LockScreen.tsx new file mode 100644 index 0000000..88b74fb --- /dev/null +++ b/src/components/LockScreen.tsx @@ -0,0 +1,212 @@ +import { useState, useEffect, useRef } from 'react' +import * as configService from '../services/config' +import { ArrowRight, Fingerprint, Lock, ShieldCheck } from 'lucide-react' +import './LockScreen.scss' + +interface LockScreenProps { + onUnlock: () => void + avatar?: string + useHello?: boolean +} + +async function sha256(message: string) { + const msgBuffer = new TextEncoder().encode(message) + const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + return hashHex +} + +export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) { + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [isVerifying, setIsVerifying] = useState(false) + const [isUnlocked, setIsUnlocked] = useState(false) + const [showHello, setShowHello] = useState(false) + const [helloAvailable, setHelloAvailable] = useState(false) + + // 用于取消 WebAuthn 请求 + const abortControllerRef = useRef(null) + const inputRef = useRef(null) + + useEffect(() => { + // 快速检查配置并启动 + quickStartHello() + inputRef.current?.focus() + + return () => { + // 组件卸载时取消请求 + abortControllerRef.current?.abort() + } + }, []) + + const handleUnlock = () => { + setIsUnlocked(true) + setTimeout(() => { + onUnlock() + }, 1500) + } + + const quickStartHello = async () => { + try { + // 如果父组件已经告诉我们要用 Hello,直接开始,不等待 IPC + let shouldUseHello = useHello + + // 为了稳健,如果 prop 没传(虽然现在都传了),再 check 一次 config + if (!shouldUseHello) { + shouldUseHello = await configService.getAuthUseHello() + } + + if (shouldUseHello) { + // 标记为可用,显示按钮 + setHelloAvailable(true) + setShowHello(true) + // 立即执行验证 (0延迟) + verifyHello() + + // 后台再次确认可用性,如果其实不可用,再隐藏? + // 或者信任用户的配置。为了速度,我们优先信任配置。 + if (window.PublicKeyCredential) { + PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() + .then(available => { + if (!available) { + // 如果系统报告不支持,但配置开了,我们可能需要提示? + // 暂时保持开启状态,反正 verifyHello 会报错 + } + }) + } + } + } catch (e) { + console.error('Quick start hello failed', e) + } + } + + const verifyHello = async () => { + if (isVerifying || isUnlocked) return + + // 取消之前的请求(如果有) + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + + const abortController = new AbortController() + abortControllerRef.current = abortController + + setIsVerifying(true) + setError('') + try { + const challenge = new Uint8Array(32) + window.crypto.getRandomValues(challenge) + + const rpId = 'localhost' + const credential = await navigator.credentials.get({ + publicKey: { + challenge, + rpId, + userVerification: 'required', + }, + signal: abortController.signal + }) + + if (credential) { + handleUnlock() + } + } catch (e: any) { + if (e.name === 'AbortError') { + console.log('Hello verification aborted') + return + } + if (e.name === 'NotAllowedError') { + console.log('User cancelled Hello verification') + } else { + console.error('Hello verification error:', e) + // 仅在非手动取消时显示错误 + if (e.name !== 'AbortError') { + setError(`验证失败: ${e.message || e.name}`) + } + } + } finally { + if (!abortController.signal.aborted) { + setIsVerifying(false) + } + } + } + + const handlePasswordSubmit = async (e?: React.FormEvent) => { + e?.preventDefault() + if (!password || isUnlocked) return + + // 如果正在进行 Hello 验证,取消它 + if (abortControllerRef.current) { + abortControllerRef.current.abort() + abortControllerRef.current = null + } + + // 不再检查 isVerifying,因为我们允许打断 Hello + setIsVerifying(true) + setError('') + + try { + const storedHash = await configService.getAuthPassword() + const inputHash = await sha256(password) + + if (inputHash === storedHash) { + handleUnlock() + } else { + setError('密码错误') + setPassword('') + setIsVerifying(false) + // 如果密码错误,是否重新触发 Hello? + // 用户可能想重试密码,暂时不自动触发 + } + } catch (e) { + setError('验证失败') + setIsVerifying(false) + } + } + + return ( +
+
+
+ {avatar ? ( + User + ) : ( + + )} +
+ +

WeFlow 已锁定

+ +
+
+ setPassword(e.target.value)} + // 移除 disabled,允许用户随时输入 + /> + +
+ + {showHello && ( + + )} +
+ + {error &&
{error}
} +
+
+ ) +} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 77fe4e3..570e6e9 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -1,10 +1,14 @@ import './TitleBar.scss' -function TitleBar() { +interface TitleBarProps { + title?: string +} + +function TitleBar({ title }: TitleBarProps = {}) { return (
WeFlow - WeFlow + {title || 'WeFlow'}
) } diff --git a/src/components/UpdateDialog.scss b/src/components/UpdateDialog.scss new file mode 100644 index 0000000..452447a --- /dev/null +++ b/src/components/UpdateDialog.scss @@ -0,0 +1,251 @@ +.update-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + animation: fadeIn 0.3s ease-out; + + .update-dialog { + width: 680px; + background: #f5f5f5; + border-radius: 24px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); + overflow: hidden; + position: relative; + animation: slideUp 0.3s ease-out; + display: flex; + flex-direction: column; + + /* Top Section (White/Gradient) */ + .dialog-header { + background: #ffffff; + padding: 40px 20px 30px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + position: relative; + + /* Subtle radial gradient effect in top left as seen in image */ + &::before { + content: ''; + position: absolute; + top: -50px; + left: -50px; + width: 200px; + height: 200px; + background: radial-gradient(circle, rgba(255, 235, 220, 0.4) 0%, rgba(255, 255, 255, 0) 70%); + opacity: 0.8; + pointer-events: none; + } + + .version-tag { + background: #f0eee9; + color: #8c7b6e; + padding: 4px 16px; + border-radius: 12px; + font-size: 13px; + font-weight: 600; + margin-bottom: 24px; + letter-spacing: 0.5px; + } + + h2 { + font-size: 32px; + font-weight: 800; + color: #333333; + margin: 0 0 12px; + letter-spacing: -0.5px; + } + + .subtitle { + font-size: 15px; + color: #999999; + font-weight: 400; + } + } + + /* Content Section (Light Gray) */ + .dialog-content { + background: #f2f2f2; + padding: 24px 40px 40px; + flex: 1; + display: flex; + flex-direction: column; + + .update-notes-container { + display: flex; + align-items: flex-start; + padding: 20px 0; + margin-bottom: 30px; + + .icon-box { + background: #fbfbfb; // Beige-ish white + width: 48px; + height: 48px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 20px; + flex-shrink: 0; + color: #8c7b6e; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03); + + svg { + opacity: 0.8; + } + } + + .text-box { + flex: 1; + + h3 { + font-size: 18px; + font-weight: 700; + color: #333333; + margin: 0 0 8px; + } + + p { + font-size: 14px; + color: #666666; + line-height: 1.6; + margin: 0; + } + + ul { + margin: 8px 0 0 18px; + padding: 0; + + li { + font-size: 14px; + color: #666666; + line-height: 1.6; + } + } + } + } + + .progress-section { + margin-bottom: 30px; + + .progress-info-row { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + font-size: 12px; + color: #888; + font-weight: 500; + } + + .progress-bar-bg { + height: 6px; + background: #e0e0e0; + border-radius: 3px; + overflow: hidden; + + .progress-bar-fill { + height: 100%; + background: #000000; + border-radius: 3px; + transition: width 0.3s ease; + } + } + + .status-text { + text-align: center; + margin-top: 12px; + font-size: 13px; + color: #666; + } + } + + .actions { + display: flex; + justify-content: center; + + .btn-update { + background: #000000; + color: #ffffff; + border: none; + padding: 16px 48px; + border-radius: 20px; // Pill shape + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); + } + + &:active { + transform: translateY(0); + } + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; + } + } + } + } + + .close-btn { + position: absolute; + top: 16px; + right: 16px; + background: rgba(0, 0, 0, 0.05); + border: none; + color: #999; + cursor: pointer; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + z-index: 10; + + &:hover { + background: rgba(0, 0, 0, 0.1); + color: #333; + transform: rotate(90deg); + } + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} \ No newline at end of file diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx new file mode 100644 index 0000000..78f3db5 --- /dev/null +++ b/src/components/UpdateDialog.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useState } from 'react' +import { Quote, X } from 'lucide-react' +import './UpdateDialog.scss' + +interface UpdateInfo { + version?: string + releaseNotes?: string +} + +interface UpdateDialogProps { + open: boolean + updateInfo: UpdateInfo | null + onClose: () => void + onUpdate: () => void + isDownloading: boolean + progress: number | { + percent: number + bytesPerSecond?: number + transferred?: number + total?: number + remaining?: number // seconds + } +} + +const UpdateDialog: React.FC = ({ + open, + updateInfo, + onClose, + onUpdate, + isDownloading, + progress +}) => { + if (!open || !updateInfo) return null + + // Safe normalize progress + const safeProgress = typeof progress === 'number' ? { percent: progress } : (progress || { percent: 0 }) + const percent = safeProgress.percent || 0 + const bytesPerSecond = safeProgress.bytesPerSecond + const total = safeProgress.total + const transferred = safeProgress.transferred + const remaining = safeProgress.remaining + + // Format bytes + const formatBytes = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + const unitIndex = Math.max(0, Math.min(i, sizes.length - 1)) + return parseFloat((bytes / Math.pow(k, unitIndex)).toFixed(1)) + ' ' + sizes[unitIndex] + } + + // Format speed + const formatSpeed = (bytesPerSecond: number) => { + return `${formatBytes(bytesPerSecond)}/s` + } + + // Format time + const formatTime = (seconds: number) => { + if (!Number.isFinite(seconds)) return '计算中...' + if (seconds < 60) return `${Math.ceil(seconds)} 秒` + const minutes = Math.floor(seconds / 60) + const remainingSeconds = Math.ceil(seconds % 60) + return `${minutes} 分 ${remainingSeconds} 秒` + } + + return ( +
+
+ {!isDownloading && ( + + )} + +
+
+ 新版本 {updateInfo.version} +
+

欢迎体验全新的 WeFlow

+
我们带来了一些改进
+
+ +
+
+
+ +
+
+

优化

+ {updateInfo.releaseNotes ? ( +
+ ) : ( +

修复了一些已知问题,提升了稳定性。

+ )} +
+
+ + {isDownloading ? ( +
+
+ {bytesPerSecond ? formatSpeed(bytesPerSecond) : '下载中...'} + {total ? `${formatBytes(transferred || 0)} / ${formatBytes(total)}` : `${percent.toFixed(1)}%`} + {remaining !== undefined && 剩余 {formatTime(remaining)}} +
+ +
+
+
+ + {/* Fallback status text if detailed info is missing */} + {(!bytesPerSecond && !total) && ( +
{percent.toFixed(0)}% 已下载
+ )} +
+ ) : ( +
+ +
+ )} +
+
+
+ ) +} + +export default UpdateDialog diff --git a/src/components/UpdateProgressCapsule.scss b/src/components/UpdateProgressCapsule.scss new file mode 100644 index 0000000..b58f6b1 --- /dev/null +++ b/src/components/UpdateProgressCapsule.scss @@ -0,0 +1,192 @@ +.update-progress-capsule { + position: fixed; + top: 38px; // Just below title bar + left: 50%; + transform: translateX(-50%); + z-index: 9998; + cursor: pointer; + animation: capsuleSlideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1); + user-select: none; + + &:hover { + .capsule-content { + background: rgba(255, 255, 255, 0.95); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12); + transform: scale(1.02); + } + } + + .capsule-content { + background: var(--bg-primary); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + padding: 8px 18px; + border-radius: 24px; + border: 1px solid var(--border-color); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12); + display: flex; + align-items: center; + gap: 12px; + height: 40px; + position: relative; + overflow: hidden; + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + + .icon-wrapper { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-primary); + + .download-icon { + animation: capsulePulse 2s infinite ease-in-out; + } + } + + .info-wrapper { + display: flex; + align-items: baseline; + gap: 10px; + z-index: 1; + + .percent-text { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + } + + .speed-text { + font-size: 13px; + color: var(--text-tertiary); + font-weight: 500; + font-variant-numeric: tabular-nums; + } + + .error-text { + font-size: 15px; + color: #ff4d4f; + font-weight: 600; + } + + .available-text { + font-size: 15px; + color: var(--text-primary); + font-weight: 600; + } + } + + .progress-bg { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(0, 0, 0, 0.05); + + .progress-fill { + height: 100%; + background: var(--primary); + transition: width 0.3s ease; + } + } + + .capsule-close { + background: none; + border: none; + padding: 4px; + margin-left: -4px; + margin-right: -8px; + cursor: pointer; + opacity: 0.5; + transition: all 0.2s ease; + display: flex; + align-items: center; + color: var(--text-secondary); + + &:hover { + opacity: 1; + background: var(--bg-tertiary); + border-radius: 50%; + } + } + } + + // State Modifiers + &.state-available { + .capsule-content { + background: var(--primary); + border-color: rgba(255, 255, 255, 0.1); + color: white; + + .icon-wrapper { + color: white; + } + + .info-wrapper { + .available-text { + color: white; + } + } + + .capsule-close { + color: rgba(255, 255, 255, 0.8); + + &:hover { + background: rgba(255, 255, 255, 0.1); + } + } + } + } + + &.state-downloading { + .capsule-content { + background: var(--bg-primary); + } + } + + &.state-error { + .capsule-content { + background: #fff1f0; + border-color: #ffa39e; + + .icon-wrapper { + color: #ff4d4f; + } + + .info-wrapper .error-text { + color: #cf1322; + } + + .capsule-close { + color: #cf1322; + } + } + } +} + +@keyframes capsuleSlideDown { + from { + transform: translate(-50%, -40px); + opacity: 0; + } + + to { + transform: translate(-50%, 0); + opacity: 1; + } +} + +@keyframes capsulePulse { + + 0%, + 100% { + transform: translateY(0); + opacity: 1; + } + + 50% { + transform: translateY(2px); + opacity: 0.6; + } +} \ No newline at end of file diff --git a/src/components/UpdateProgressCapsule.tsx b/src/components/UpdateProgressCapsule.tsx new file mode 100644 index 0000000..8402680 --- /dev/null +++ b/src/components/UpdateProgressCapsule.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import { useAppStore } from '../stores/appStore' +import { Download, X, AlertCircle, Info } from 'lucide-react' +import './UpdateProgressCapsule.scss' + +const UpdateProgressCapsule: React.FC = () => { + const { + isDownloading, + downloadProgress, + showUpdateDialog, + setShowUpdateDialog, + updateInfo, + setUpdateInfo, + updateError, + setUpdateError + } = useAppStore() + + // Control visibility + // If dialog is open, we usually hide the capsule UNLESS we want it as a mini-indicator + // For now, let's hide it if the dialog is open + if (showUpdateDialog) return null + + // State mapping + const hasError = !!updateError + const hasUpdate = !!updateInfo && updateInfo.hasUpdate + + if (!hasError && !isDownloading && !hasUpdate) return null + + // Safe normalize progress + const safeProgress = typeof downloadProgress === 'number' ? { percent: downloadProgress } : (downloadProgress || { percent: 0 }) + const percent = safeProgress.percent || 0 + const bytesPerSecond = safeProgress.bytesPerSecond + + const formatBytes = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + const unitIndex = Math.max(0, Math.min(i, sizes.length - 1)) + return parseFloat((bytes / Math.pow(k, unitIndex)).toFixed(1)) + ' ' + sizes[unitIndex] + } + + const formatSpeed = (bps: number) => { + return `${formatBytes(bps)}/s` + } + + const handleClose = (e: React.MouseEvent) => { + e.stopPropagation() + if (hasError) { + setUpdateError(null) + } else if (hasUpdate && !isDownloading) { + setUpdateInfo(null) + } + } + + // Determine appearance class and content + let capsuleClass = 'update-progress-capsule' + let content = null + + if (hasError) { + capsuleClass += ' state-error' + content = ( + <> +
+ +
+
+ 更新失败: {updateError} +
+ + ) + } else if (isDownloading) { + capsuleClass += ' state-downloading' + content = ( + <> +
+ +
+
+ {percent.toFixed(0)}% + {bytesPerSecond > 0 && ( + {formatSpeed(bytesPerSecond)} + )} +
+
+
+
+ + ) + } else if (hasUpdate) { + capsuleClass += ' state-available' + content = ( + <> +
+ +
+
+ 发现新版本 v{updateInfo?.version} +
+ + ) + } + + return ( +
setShowUpdateDialog(true)}> +
+ {content} + {!isDownloading && ( + + )} +
+
+ ) +} + +export default UpdateProgressCapsule diff --git a/src/pages/AgreementPage.tsx b/src/pages/AgreementPage.tsx index 242e361..664e083 100644 --- a/src/pages/AgreementPage.tsx +++ b/src/pages/AgreementPage.tsx @@ -9,40 +9,40 @@ function AgreementPage() {
{/* 协议内容 - 请替换为完整的协议文本 */}

用户协议

- +

一、总则

-

欢迎使用WeFlow(WeFlow)软件。请在使用本软件前仔细阅读本协议。一旦您开始使用本软件,即表示您已充分理解并同意本协议的全部内容。

- +

欢迎使用WeFlow(WeFlow)软件。请在使用本软件前仔细阅读本协议。一旦你开始使用本软件,即表示你已充分理解并同意本协议的全部内容。

+

二、软件说明

WeFlow是一款本地化的微信聊天记录查看与分析工具,所有数据处理均在用户本地设备上完成。

- +

三、使用条款

1. 本软件仅供个人学习、研究使用,严禁用于任何商业用途或非法目的。

2. 用户应确保所查看、分析的数据为本人所有或已获得合法授权。

3. 用户不得利用本软件侵犯他人隐私、窃取他人信息或从事其他违法活动。

- +

四、免责声明

1. 本软件按"现状"提供,开发者不对软件的适用性、可靠性、准确性作任何明示或暗示的保证。

2. 因使用或无法使用本软件而产生的任何直接、间接、偶然、特殊或后果性损害,开发者不承担任何责任。

3. 用户因违反本协议或相关法律法规而产生的一切后果由用户自行承担。

- +

五、知识产权

本软件的所有权、知识产权及相关权益均归开发者所有。未经授权,不得复制、修改、传播本软件。

- +

隐私政策

- +

一、数据收集

本软件不收集、不上传、不存储任何用户个人信息或聊天数据。所有数据处理均在本地完成。

- +

二、数据安全

-

您的聊天记录和个人数据完全存储在您的本地设备上,本软件不会将任何数据传输至外部服务器。

- +

你的聊天记录和个人数据完全存储在你的本地设备上,本软件不会将任何数据传输至外部服务器。

+

三、网络请求

本软件仅在检查更新时会访问更新服务器获取版本信息,不涉及任何用户数据的传输。

- +

四、第三方服务

本软件不集成任何第三方数据分析、广告或追踪服务。

- +

最后更新日期:2025年1月

diff --git a/src/pages/AnalyticsWelcomePage.tsx b/src/pages/AnalyticsWelcomePage.tsx index ea85aff..38a5f9f 100644 --- a/src/pages/AnalyticsWelcomePage.tsx +++ b/src/pages/AnalyticsWelcomePage.tsx @@ -34,8 +34,8 @@ function AnalyticsWelcomePage() {

私聊数据分析

- WeFlow 可以分析您的聊天记录,生成详细的统计报表。
- 您可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。 + WeFlow 可以分析你的聊天记录,生成详细的统计报表。
+ 你可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。

diff --git a/src/pages/ChatHistoryPage.scss b/src/pages/ChatHistoryPage.scss new file mode 100644 index 0000000..74c2af6 --- /dev/null +++ b/src/pages/ChatHistoryPage.scss @@ -0,0 +1,132 @@ +.chat-history-page { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--bg-primary); + + .history-list { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + + .status-msg { + text-align: center; + padding: 40px 20px; + color: var(--text-tertiary); + font-size: 14px; + + &.error { + color: var(--danger); + } + + &.empty { + color: var(--text-tertiary); + } + } + } + + .history-item { + display: flex; + gap: 12px; + align-items: flex-start; + + .avatar { + width: 40px; + height: 40px; + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; + background: var(--bg-tertiary); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .avatar-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + font-size: 16px; + font-weight: 500; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + } + } + + .content-wrapper { + flex: 1; + min-width: 0; + + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; + + .sender { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + } + + .time { + font-size: 12px; + color: var(--text-tertiary); + flex-shrink: 0; + margin-left: 8px; + } + } + + .bubble { + background: var(--bg-secondary); + padding: 10px 14px; + border-radius: 18px 18px 18px 4px; + word-wrap: break-word; + max-width: 100%; + display: inline-block; + + &.image-bubble { + padding: 0; + background: transparent; + } + + .text-content { + font-size: 14px; + line-height: 1.6; + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-word; + } + + .media-content { + img { + max-width: 100%; + max-height: 300px; + border-radius: 8px; + display: block; + } + + .media-tip { + padding: 8px 12px; + color: var(--text-tertiary); + font-size: 13px; + } + } + + .media-placeholder { + font-size: 14px; + color: var(--text-secondary); + padding: 4px 0; + } + } + } + } +} diff --git a/src/pages/ChatHistoryPage.tsx b/src/pages/ChatHistoryPage.tsx new file mode 100644 index 0000000..45404e8 --- /dev/null +++ b/src/pages/ChatHistoryPage.tsx @@ -0,0 +1,250 @@ +import { useEffect, useState } from 'react' +import { useParams, useLocation } from 'react-router-dom' +import { ChatRecordItem } from '../types/models' +import TitleBar from '../components/TitleBar' +import './ChatHistoryPage.scss' + +export default function ChatHistoryPage() { + const params = useParams<{ sessionId: string; messageId: string }>() + const location = useLocation() + const [recordList, setRecordList] = useState([]) + const [loading, setLoading] = useState(true) + const [title, setTitle] = useState('聊天记录') + const [error, setError] = useState('') + + // 简单的 XML 标签内容提取 + const extractXmlValue = (xml: string, tag: string): string => { + const match = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`).exec(xml) + return match ? match[1] : '' + } + + // 简单的 HTML 实体解码 + const decodeHtmlEntities = (text?: string): string | undefined => { + if (!text) return text + return text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + } + + // 前端兜底解析合并转发聊天记录 + const parseChatHistory = (content: string): ChatRecordItem[] | undefined => { + try { + const type = extractXmlValue(content, 'type') + if (type !== '19') return undefined + + const match = /[\s\S]*?[\s\S]*?<\/recorditem>/.exec(content) + if (!match) return undefined + + const innerXml = match[1] + const items: ChatRecordItem[] = [] + const itemRegex = /([\s\S]*?)<\/dataitem>/g + let itemMatch: RegExpExecArray | null + + while ((itemMatch = itemRegex.exec(innerXml)) !== null) { + const attrs = itemMatch[1] + const body = itemMatch[2] + + const datatypeMatch = /datatype="(\d+)"/.exec(attrs) + const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0 + + const sourcename = extractXmlValue(body, 'sourcename') + const sourcetime = extractXmlValue(body, 'sourcetime') + const sourceheadurl = extractXmlValue(body, 'sourceheadurl') + const datadesc = extractXmlValue(body, 'datadesc') + const datatitle = extractXmlValue(body, 'datatitle') + const fileext = extractXmlValue(body, 'fileext') + const datasize = parseInt(extractXmlValue(body, 'datasize') || '0') + const messageuuid = extractXmlValue(body, 'messageuuid') + + const dataurl = extractXmlValue(body, 'dataurl') + const datathumburl = extractXmlValue(body, 'datathumburl') || extractXmlValue(body, 'thumburl') + const datacdnurl = extractXmlValue(body, 'datacdnurl') || extractXmlValue(body, 'cdnurl') + const aeskey = extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey') + const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5') + const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0') + const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0') + const duration = parseInt(extractXmlValue(body, 'duration') || '0') + + items.push({ + datatype, + sourcename, + sourcetime, + sourceheadurl, + datadesc: decodeHtmlEntities(datadesc), + datatitle: decodeHtmlEntities(datatitle), + fileext, + datasize, + messageuuid, + dataurl: decodeHtmlEntities(dataurl), + datathumburl: decodeHtmlEntities(datathumburl), + datacdnurl: decodeHtmlEntities(datacdnurl), + aeskey: decodeHtmlEntities(aeskey), + md5, + imgheight, + imgwidth, + duration + }) + } + + return items.length > 0 ? items : undefined + } catch (e) { + console.error('前端解析聊天记录失败:', e) + return undefined + } + } + + // 统一从路由参数或 pathname 中解析 sessionId / messageId + const getIds = () => { + const sessionId = params.sessionId || '' + const messageId = params.messageId || '' + + if (sessionId && messageId) { + return { sid: sessionId, mid: messageId } + } + + // 独立窗口场景下没有 Route 包裹,用 pathname 手动解析 + const match = /^\/chat-history\/([^/]+)\/([^/]+)/.exec(location.pathname) + if (match) { + return { sid: match[1], mid: match[2] } + } + + return { sid: '', mid: '' } + } + + useEffect(() => { + const loadData = async () => { + const { sid, mid } = getIds() + if (!sid || !mid) { + setError('无效的聊天记录链接') + setLoading(false) + return + } + try { + const result = await window.electronAPI.chat.getMessage(sid, parseInt(mid, 10)) + if (result.success && result.message) { + const msg = result.message + // 优先使用后端解析好的列表 + let records: ChatRecordItem[] | undefined = msg.chatRecordList + + // 如果后端没有解析到,则在前端兜底解析一次 + if ((!records || records.length === 0) && msg.content) { + records = parseChatHistory(msg.content) || [] + } + + if (records && records.length > 0) { + setRecordList(records) + const match = /(.*?)<\/title>/.exec(msg.content || '') + if (match) setTitle(match[1]) + } else { + setError('暂时无法解析这条聊天记录') + } + } else { + setError(result.error || '获取消息失败') + } + } catch (e) { + console.error(e) + setError('加载详情失败') + } finally { + setLoading(false) + } + } + loadData() + }, [params.sessionId, params.messageId, location.pathname]) + + return ( + <div className="chat-history-page"> + <TitleBar title={title} /> + <div className="history-list"> + {loading ? ( + <div className="status-msg">加载中...</div> + ) : error ? ( + <div className="status-msg error">{error}</div> + ) : recordList.length === 0 ? ( + <div className="status-msg empty">暂无可显示的聊天记录</div> + ) : ( + recordList.map((item, i) => ( + <HistoryItem key={i} item={item} /> + )) + )} + </div> + </div> + ) +} + +function HistoryItem({ item }: { item: ChatRecordItem }) { + // sourcetime 在合并转发里有两种格式: + // 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46" + let time = '' + if (item.sourcetime) { + if (/^\d+$/.test(item.sourcetime)) { + time = new Date(parseInt(item.sourcetime, 10) * 1000).toLocaleString() + } else { + time = item.sourcetime + } + } + + const renderContent = () => { + if (item.datatype === 1) { + // 文本消息 + return <div className="text-content">{item.datadesc || ''}</div> + } + if (item.datatype === 3) { + // 图片 + const src = item.datathumburl || item.datacdnurl + if (src) { + return ( + <div className="media-content"> + <img + src={src} + alt="图片" + referrerPolicy="no-referrer" + onError={(e) => { + const target = e.target as HTMLImageElement + target.style.display = 'none' + const placeholder = document.createElement('div') + placeholder.className = 'media-tip' + placeholder.textContent = '图片无法加载' + target.parentElement?.appendChild(placeholder) + }} + /> + </div> + ) + } + return <div className="media-placeholder">[图片]</div> + } + if (item.datatype === 43) { + return <div className="media-placeholder">[视频] {item.datatitle}</div> + } + if (item.datatype === 34) { + return <div className="media-placeholder">[语音] {item.duration ? (item.duration / 1000).toFixed(0) + '"' : ''}</div> + } + // Fallback + return <div className="text-content">{item.datadesc || item.datatitle || '[不支持的消息类型]'}</div> + } + + return ( + <div className="history-item"> + <div className="avatar"> + {item.sourceheadurl ? ( + <img src={item.sourceheadurl} alt="" referrerPolicy="no-referrer" /> + ) : ( + <div className="avatar-placeholder"> + {item.sourcename?.slice(0, 1)} + </div> + )} + </div> + <div className="content-wrapper"> + <div className="header"> + <span className="sender">{item.sourcename || '未知发送者'}</span> + <span className="time">{time}</span> + </div> + <div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}> + {renderContent()} + </div> + </div> + </div> + ) +} diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index ce324a5..ea3329d 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -834,92 +834,93 @@ // 链接卡片消息样式 .link-message { - cursor: pointer; + width: 280px; background: var(--card-bg); border-radius: 8px; overflow: hidden; - border: 1px solid var(--border-color); + cursor: pointer; transition: all 0.2s ease; - max-width: 300px; - margin-top: 4px; + border: 1px solid var(--border-color); &:hover { background: var(--bg-hover); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + border-color: var(--primary); } .link-header { + padding: 10px 12px 6px; display: flex; - align-items: flex-start; - padding: 12px; - gap: 12px; + gap: 8px; + + .link-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + flex: 1; + } } - .link-content { - flex: 1; - min-width: 0; - } - - .link-title { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); - margin-bottom: 4px; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: 1.4; - } - - .link-desc { - font-size: 12px; - color: var(--text-secondary); - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: 1.4; - opacity: 0.8; - } - - .link-icon { - flex-shrink: 0; - width: 40px; - height: 40px; - background: var(--bg-tertiary); - border-radius: 6px; + .link-body { + padding: 6px 12px 10px; display: flex; - align-items: center; - justify-content: center; - color: var(--text-secondary); + gap: 10px; - svg { - opacity: 0.8; + .link-desc { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + flex: 1; + } + + .link-thumb { + width: 48px; + height: 48px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; + background: var(--bg-tertiary); + } + + .link-thumb-placeholder { + width: 48px; + height: 48px; + border-radius: 4px; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-tertiary); + + svg { + opacity: 0.5; + } } } } // 适配发送出去的消息中的链接卡片 .message-bubble.sent .link-message { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.2); + background: var(--card-bg); + border: 1px solid var(--border-color); + + .link-title { + color: var(--text-primary); + } - .link-title, .link-desc { - color: #fff; - } - - .link-icon { - background: rgba(255, 255, 255, 0.2); - color: #fff; - } - - &:hover { - background: rgba(255, 255, 255, 0.2); + color: var(--text-secondary); } } @@ -2170,4 +2171,304 @@ .spin { animation: spin 1s linear infinite; } -} \ No newline at end of file +} + + +// 名片消息 +.card-message { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: var(--bg-tertiary); + border-radius: 8px; + min-width: 200px; + + .card-icon { + flex-shrink: 0; + color: var(--primary); + } + + .card-info { + flex: 1; + min-width: 0; + } + + .card-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 2px; + } + + .card-label { + font-size: 12px; + color: var(--text-tertiary); + } +} + +// 通话消息 +.call-message { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + color: var(--text-secondary); + font-size: 13px; + + svg { + flex-shrink: 0; + } +} + +// 文件消息 +// 文件消息 +.file-message { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: var(--bg-tertiary); + border-radius: 8px; + min-width: 220px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: var(--bg-hover); + } + + .file-icon { + flex-shrink: 0; + color: var(--primary); + } + + .file-info { + flex: 1; + min-width: 0; + } + + .file-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .file-meta { + font-size: 12px; + color: var(--text-tertiary); + } +} + +// 发送的文件消息样式 +.message-bubble.sent .file-message { + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.1); + + .file-name { + color: #333; + } + + .file-meta { + color: #999; + } +} + +// 聊天记录消息 - 复用 link-message 基础样式 +.chat-record-message { + cursor: pointer; + + .link-header { + padding-bottom: 4px; + } + + .chat-record-preview { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + } + + .chat-record-meta-line { + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .chat-record-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 70px; + overflow: hidden; + } + + .chat-record-item { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .source-name { + color: var(--text-primary); + font-weight: 500; + margin-right: 4px; + } + + .chat-record-more { + font-size: 12px; + color: var(--primary); + } + + .chat-record-desc { + font-size: 12px; + color: var(--text-secondary); + } + + .chat-record-icon { + width: 40px; + height: 40px; + border-radius: 10px; + background: var(--primary-gradient); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + flex-shrink: 0; + } +} + +// 小程序消息 +.miniapp-message { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: var(--bg-tertiary); + border-radius: 8px; + min-width: 200px; + + .miniapp-icon { + flex-shrink: 0; + color: var(--primary); + } + + .miniapp-info { + flex: 1; + min-width: 0; + } + + .miniapp-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .miniapp-label { + font-size: 12px; + color: var(--text-tertiary); + } +} + +// 转账消息卡片 +.transfer-message { + width: 240px; + background: linear-gradient(135deg, #f59e42 0%, #f5a742 100%); + border-radius: 12px; + padding: 14px 16px; + display: flex; + gap: 12px; + align-items: center; + cursor: default; + + &.received { + background: linear-gradient(135deg, #b8b8b8 0%, #a8a8a8 100%); + } + + .transfer-icon { + flex-shrink: 0; + + svg { + width: 32px; + height: 32px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1)); + } + } + + .transfer-info { + flex: 1; + color: white; + + .transfer-amount { + font-size: 18px; + font-weight: 600; + margin-bottom: 2px; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + } + + .transfer-memo { + font-size: 13px; + margin-bottom: 8px; + opacity: 0.95; + } + + .transfer-label { + font-size: 12px; + opacity: 0.85; + } + } +} + +// 发送消息中的特殊消息类型适配(除了文件和转账) +.message-bubble.sent { + .card-message, + .chat-record-message, + .miniapp-message { + background: rgba(255, 255, 255, 0.15); + + .card-name, + .miniapp-title, + .source-name { + color: white; + } + + .card-label, + .miniapp-label, + .chat-record-item, + .chat-record-meta-line, + .chat-record-desc { + color: rgba(255, 255, 255, 0.8); + } + + .card-icon, + .miniapp-icon, + .chat-record-icon { + color: white; + } + + .chat-record-more { + color: rgba(255, 255, 255, 0.9); + } + } + + .call-message { + color: rgba(255, 255, 255, 0.9); + + svg { + color: white; + } + } +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 9142d7b..8709aca 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -22,6 +22,15 @@ function isSystemMessage(localType: number): boolean { return SYSTEM_MESSAGE_TYPES.includes(localType) } +// 格式化文件大小 +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] +} + interface ChatPageProps { // 保留接口以备将来扩展 } @@ -1476,6 +1485,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o const isImage = message.localType === 3 const isVideo = message.localType === 43 const isVoice = message.localType === 34 + const isCard = message.localType === 42 + const isCall = message.localType === 50 + const isType49 = message.localType === 49 const isSent = message.isSend === 1 const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined) const [senderName, setSenderName] = useState<string | undefined>(undefined) @@ -2438,6 +2450,268 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o ) } + // 名片消息 + if (isCard) { + const cardName = message.cardNickname || message.cardUsername || '未知联系人' + return ( + <div className="card-message"> + <div className="card-icon"> + <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> + <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> + <circle cx="12" cy="7" r="4" /> + </svg> + </div> + <div className="card-info"> + <div className="card-name">{cardName}</div> + <div className="card-label">个人名片</div> + </div> + </div> + ) + } + + // 通话消息 + if (isCall) { + return ( + <div className="call-message"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" /> + </svg> + <span>{message.parsedContent || '[通话]'}</span> + </div> + ) + } + + // 链接消息 (AppMessage) + const isAppMsg = message.rawContent?.includes('<appmsg') || (message.parsedContent && message.parsedContent.includes('<appmsg')) + + if (isAppMsg) { + let title = '链接' + let desc = '' + let url = '' + let appMsgType = '' + + try { + const content = message.rawContent || message.parsedContent || '' + // 简单清理 XML 前缀(如 wxid:) + const xmlContent = content.substring(content.indexOf('<msg>')) + + const parser = new DOMParser() + const doc = parser.parseFromString(xmlContent, 'text/xml') + + title = doc.querySelector('title')?.textContent || '链接' + desc = doc.querySelector('des')?.textContent || '' + url = doc.querySelector('url')?.textContent || '' + appMsgType = doc.querySelector('appmsg > type')?.textContent || doc.querySelector('type')?.textContent || '' + } catch (e) { + console.error('解析 AppMsg 失败:', e) + } + + // 聊天记录 (type=19) + if (appMsgType === '19') { + const recordList = message.chatRecordList || [] + const displayTitle = title || '群聊的聊天记录' + const metaText = + recordList.length > 0 + ? `共 ${recordList.length} 条聊天记录` + : desc || '聊天记录' + + const previewItems = recordList.slice(0, 4) + + return ( + <div + className="link-message chat-record-message" + onClick={(e) => { + e.stopPropagation() + // 打开聊天记录窗口 + window.electronAPI.window.openChatHistoryWindow(session.username, message.localId) + }} + title="点击查看详细聊天记录" + > + <div className="link-header"> + <div className="link-title" title={displayTitle}> + {displayTitle} + </div> + </div> + <div className="link-body"> + <div className="chat-record-preview"> + {previewItems.length > 0 ? ( + <> + <div className="chat-record-meta-line" title={metaText}> + {metaText} + </div> + <div className="chat-record-list"> + {previewItems.map((item, i) => ( + <div key={i} className="chat-record-item"> + <span className="source-name"> + {item.sourcename ? `${item.sourcename}: ` : ''} + </span> + {item.datadesc || item.datatitle || '[媒体消息]'} + </div> + ))} + {recordList.length > previewItems.length && ( + <div className="chat-record-more">还有 {recordList.length - previewItems.length} 条…</div> + )} + </div> + </> + ) : ( + <div className="chat-record-desc"> + {desc || '点击打开查看完整聊天记录'} + </div> + )} + </div> + <div className="chat-record-icon"> + <MessageSquare size={18} /> + </div> + </div> + </div> + ) + } + + // 文件消息 (type=6) + if (appMsgType === '6') { + const fileName = message.fileName || title || '文件' + const fileSize = message.fileSize + const fileExt = message.fileExt || fileName.split('.').pop()?.toLowerCase() || '' + + // 根据扩展名选择图标 + const getFileIcon = () => { + const archiveExts = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2'] + if (archiveExts.includes(fileExt)) { + return ( + <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> + <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> + <polyline points="7 10 12 15 17 10" /> + <line x1="12" y1="15" x2="12" y2="3" /> + </svg> + ) + } + return ( + <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> + <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" /> + <polyline points="13 2 13 9 20 9" /> + </svg> + ) + } + + return ( + <div className="file-message"> + <div className="file-icon"> + {getFileIcon()} + </div> + <div className="file-info"> + <div className="file-name" title={fileName}>{fileName}</div> + <div className="file-meta"> + {fileSize ? formatFileSize(fileSize) : ''} + </div> + </div> + </div> + ) + } + + // 转账消息 (type=2000) + if (appMsgType === '2000') { + try { + const content = message.rawContent || message.content || message.parsedContent || '' + + // 添加调试日志 + console.log('[Transfer Debug] Raw content:', content.substring(0, 500)) + + const parser = new DOMParser() + const doc = parser.parseFromString(content, 'text/xml') + + const feedesc = doc.querySelector('feedesc')?.textContent || '' + const payMemo = doc.querySelector('pay_memo')?.textContent || '' + const paysubtype = doc.querySelector('paysubtype')?.textContent || '1' + + console.log('[Transfer Debug] Parsed:', { feedesc, payMemo, paysubtype, title }) + + // paysubtype: 1=待收款, 3=已收款 + const isReceived = paysubtype === '3' + + // 如果 feedesc 为空,使用 title 作为降级 + const displayAmount = feedesc || title || '微信转账' + + return ( + <div className={`transfer-message ${isReceived ? 'received' : ''}`}> + <div className="transfer-icon"> + <svg width="32" height="32" viewBox="0 0 40 40" fill="none"> + <circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" /> + <path d="M12 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> + </svg> + </div> + <div className="transfer-info"> + <div className="transfer-amount">{displayAmount}</div> + {payMemo && <div className="transfer-memo">{payMemo}</div>} + <div className="transfer-label">{isReceived ? '已收款' : '微信转账'}</div> + </div> + </div> + ) + } catch (e) { + console.error('[Transfer Debug] Parse error:', e) + // 解析失败时的降级处理 + const feedesc = title || '微信转账' + return ( + <div className="transfer-message"> + <div className="transfer-icon"> + <svg width="32" height="32" viewBox="0 0 40 40" fill="none"> + <circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" /> + <path d="M12 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> + </svg> + </div> + <div className="transfer-info"> + <div className="transfer-amount">{feedesc}</div> + <div className="transfer-label">微信转账</div> + </div> + </div> + ) + } + } + + // 小程序 (type=33/36) + if (appMsgType === '33' || appMsgType === '36') { + return ( + <div className="miniapp-message"> + <div className="miniapp-icon"> + <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> + <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/> + </svg> + </div> + <div className="miniapp-info"> + <div className="miniapp-title">{title}</div> + <div className="miniapp-label">小程序</div> + </div> + </div> + ) + } + + // 有 URL 的链接消息 + if (url) { + return ( + <div + className="link-message" + onClick={(e) => { + e.stopPropagation() + if (window.electronAPI?.shell?.openExternal) { + window.electronAPI.shell.openExternal(url) + } else { + window.open(url, '_blank') + } + }} + > + <div className="link-header"> + <div className="link-title" title={title}>{title}</div> + </div> + <div className="link-body"> + <div className="link-desc" title={desc}>{desc}</div> + <div className="link-thumb-placeholder"> + <Link size={24} /> + </div> + </div> + </div> + ) + } + } + // 表情包消息 if (isEmoji) { // ... (keep existing emoji logic) @@ -2492,67 +2766,6 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o ) } - // 解析引用消息(Links / App Messages) - // localType: 21474836529 corresponds to AppMessage which often contains links - if (isLinkMessage) { - try { - // 清理内容:移除可能的 wxid 前缀,找到 XML 起始位置 - let contentToParse = message.rawContent || message.parsedContent || ''; - const xmlStartIndex = contentToParse.indexOf('<'); - if (xmlStartIndex >= 0) { - contentToParse = contentToParse.substring(xmlStartIndex); - } - - // 处理 HTML 转义字符 - if (contentToParse.includes('<')) { - contentToParse = contentToParse - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, "'"); - } - - const parser = new DOMParser(); - const doc = parser.parseFromString(contentToParse, "text/xml"); - const appMsg = doc.querySelector('appmsg'); - - if (appMsg) { - const title = doc.querySelector('title')?.textContent || '未命名链接'; - const des = doc.querySelector('des')?.textContent || '无描述'; - const url = doc.querySelector('url')?.textContent || ''; - - return ( - <div - className="link-message" - onClick={(e) => { - e.stopPropagation(); - if (url) { - // 优先使用 electron 接口打开外部浏览器 - if (window.electronAPI?.shell?.openExternal) { - window.electronAPI.shell.openExternal(url); - } else { - window.open(url, '_blank'); - } - } - }} - > - <div className="link-header"> - <div className="link-content"> - <div className="link-title" title={title}>{title}</div> - <div className="link-desc" title={des}>{des}</div> - </div> - <div className="link-icon"> - <Link size={24} /> - </div> - </div> - </div> - ); - } - } catch (e) { - console.error('Failed to parse app message', e); - } - } // 普通消息 return <div className="bubble-content">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div> } diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index 647423e..d64dc46 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -111,52 +111,59 @@ .type-filters { display: flex; - gap: 12px; - padding: 0 20px 12px; + gap: 8px; + padding: 0 20px 16px; flex-wrap: nowrap; overflow-x: auto; - /* Allow horizontal scroll if needed on very small screens */ - /* Hide scrollbar */ &::-webkit-scrollbar { display: none; } - .filter-checkbox { + .filter-chip { display: flex; - /* Changed to flex with padding */ align-items: center; - gap: 8px; + gap: 6px; + padding: 8px 14px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 10px; cursor: pointer; user-select: none; - font-size: 14px; - color: var(--text-primary); - padding: 6px 12px; - background: var(--bg-secondary); - border-radius: 8px; - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - } + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + transition: all 0.2s ease; + white-space: nowrap; input[type="checkbox"] { - width: 16px; - height: 16px; - accent-color: var(--primary); - cursor: pointer; - opacity: 1; - /* Make visible */ - position: static; - /* Make static */ - pointer-events: auto; - /* Enable pointer events */ + display: none; } svg { - color: var(--text-secondary); - flex-shrink: 0; - margin-left: 2px; + opacity: 0.7; + transition: transform 0.2s; + } + + &:hover { + background: var(--bg-hover); + border-color: var(--text-tertiary); + color: var(--text-primary); + + svg { + transform: translateY(-1px); + } + } + + &.active { + background: var(--primary-light); + border-color: var(--primary); + color: var(--primary); + + svg { + opacity: 1; + color: var(--primary); + } } } } diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 3e6998f..dd6dd7d 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -224,7 +224,7 @@ function ContactsPage() { </div> <div className="type-filters"> - <label className="filter-checkbox"> + <label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}> <input type="checkbox" checked={contactTypes.friends} @@ -233,7 +233,7 @@ function ContactsPage() { <User size={16} /> <span>好友</span> </label> - <label className="filter-checkbox"> + <label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}> <input type="checkbox" checked={contactTypes.groups} @@ -242,7 +242,7 @@ function ContactsPage() { <Users size={16} /> <span>群聊</span> </label> - <label className="filter-checkbox"> + <label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}> <input type="checkbox" checked={contactTypes.officials} diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index cdbe7f6..81dcf6a 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -338,61 +338,33 @@ } } - .time-options { - display: flex; - flex-direction: column; - gap: 12px; - } - - .checkbox-item { + .time-range-picker-item { display: flex; align-items: center; - gap: 10px; + justify-content: space-between; + padding: 14px 16px; cursor: pointer; - font-size: 14px; - color: var(--text-primary); - - input[type="checkbox"] { - width: 18px; - height: 18px; - accent-color: var(--primary); - cursor: pointer; - } - - svg { - color: var(--text-secondary); - } - - &.main-toggle { - padding: 12px 16px; - background: var(--bg-secondary); - border-radius: 10px; - } - } - - .date-range { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 16px; - background: var(--bg-secondary); - border-radius: 10px; - font-size: 14px; - color: var(--text-primary); - cursor: pointer; - transition: all 0.2s; + transition: background 0.2s; + background: transparent; &:hover { background: var(--bg-hover); } - svg { - color: var(--text-tertiary); - flex-shrink: 0; + .time-picker-info { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: var(--text-primary); + + svg { + color: var(--primary); + } } - span { - flex: 1; + svg { + color: var(--text-tertiary); } } @@ -1184,50 +1156,4 @@ color: var(--text-tertiary); } -// Switch 开关样式 -.switch { - position: relative; - display: inline-block; - width: 44px; - height: 24px; - flex-shrink: 0; - - input { - opacity: 0; - width: 0; - height: 0; - } - - .slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--bg-tertiary); - transition: 0.3s; - border-radius: 24px; - - &::before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 3px; - bottom: 3px; - background-color: white; - transition: 0.3s; - border-radius: 50%; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - } - } - - input:checked+.slider { - background-color: var(--primary); - } - - input:checked+.slider::before { - transform: translateX(20px); - } -} +// 全局样式已在 main.scss 中定义 \ No newline at end of file diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index d1f5bad..4f86aa4 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -24,6 +24,7 @@ interface ExportOptions { excelCompactColumns: boolean txtColumns: string[] displayNamePreference: 'group-nickname' | 'remark' | 'nickname' + exportConcurrency: number } interface ExportResult { @@ -68,7 +69,8 @@ function ExportPage() { exportVoiceAsText: true, excelCompactColumns: true, txtColumns: defaultTxtColumns, - displayNamePreference: 'remark' + displayNamePreference: 'remark', + exportConcurrency: 2 }) const buildDateRangeFromPreset = (preset: string) => { @@ -133,14 +135,16 @@ function ExportPage() { savedMedia, savedVoiceAsText, savedExcelCompactColumns, - savedTxtColumns + savedTxtColumns, + savedConcurrency ] = await Promise.all([ configService.getExportDefaultFormat(), configService.getExportDefaultDateRange(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), configService.getExportDefaultExcelCompactColumns(), - configService.getExportDefaultTxtColumns() + configService.getExportDefaultTxtColumns(), + configService.getExportDefaultConcurrency() ]) const preset = savedRange || 'today' @@ -155,7 +159,8 @@ function ExportPage() { exportMedia: savedMedia ?? false, exportVoiceAsText: savedVoiceAsText ?? true, excelCompactColumns: savedExcelCompactColumns ?? true, - txtColumns + txtColumns, + exportConcurrency: savedConcurrency ?? 2 })) } catch (e) { console.error('加载导出默认设置失败:', e) @@ -286,6 +291,7 @@ function ExportPage() { excelCompactColumns: options.excelCompactColumns, txtColumns: options.txtColumns, displayNamePreference: options.displayNamePreference, + exportConcurrency: options.exportConcurrency, sessionLayout, dateRange: options.useAllTime ? null : options.dateRange ? { start: Math.floor(options.dateRange.start.getTime() / 1000), @@ -531,21 +537,34 @@ function ExportPage() { <div className="setting-section"> <h3>时间范围</h3> - <div className="time-options"> - <label className="checkbox-item"> - <input - type="checkbox" - checked={options.useAllTime} - onChange={e => setOptions({ ...options, useAllTime: e.target.checked })} - /> - <span>导出全部时间</span> - </label> - {!options.useAllTime && options.dateRange && ( - <div className="date-range" onClick={() => setShowDatePicker(true)}> - <Calendar size={16} /> - <span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span> - <ChevronDown size={14} /> + <p className="setting-subtitle">选择要导出的消息时间区间</p> + <div className="media-options-card"> + <div className="media-switch-row"> + <div className="media-switch-info"> + <span className="media-switch-title">导出全部时间</span> + <span className="media-switch-desc">关闭此项以选择特定的起止日期</span> </div> + <label className="switch"> + <input + type="checkbox" + checked={options.useAllTime} + onChange={e => setOptions({ ...options, useAllTime: e.target.checked })} + /> + <span className="switch-slider"></span> + </label> + </div> + + {!options.useAllTime && options.dateRange && ( + <> + <div className="media-option-divider"></div> + <div className="time-range-picker-item" onClick={() => setShowDatePicker(true)}> + <div className="time-picker-info"> + <Calendar size={16} /> + <span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span> + </div> + <ChevronDown size={14} /> + </div> + </> )} </div> </div> @@ -603,7 +622,7 @@ function ExportPage() { checked={options.exportMedia} onChange={e => setOptions({ ...options, exportMedia: e.target.checked })} /> - <span className="slider"></span> + <span className="switch-slider"></span> </label> </div> @@ -683,7 +702,7 @@ function ExportPage() { checked={options.exportAvatars} onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })} /> - <span className="slider"></span> + <span className="switch-slider"></span> </label> </div> </div> diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 89f68e2..c598716 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -603,54 +603,7 @@ } } -.switch { - position: relative; - width: 46px; - height: 24px; - display: inline-block; - user-select: none; -} - -.switch-input { - opacity: 0; - width: 0; - height: 0; -} - -.switch-slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 999px; - transition: all 0.2s ease; -} - -.switch-slider::before { - content: ''; - position: absolute; - height: 18px; - width: 18px; - left: 3px; - top: 2px; - background: var(--text-tertiary); - border-radius: 50%; - transition: all 0.2s ease; -} - -.switch-input:checked+.switch-slider { - background: var(--primary); - border-color: var(--primary); -} - -.switch-input:checked+.switch-slider::before { - transform: translateX(22px); - background: #ffffff; -} +// 全局样式已在 main.scss 中定义 .log-actions { display: flex; @@ -1311,4 +1264,4 @@ border-top: 1px solid var(--border-primary); display: flex; justify-content: flex-end; -} +} \ No newline at end of file diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 8bd2d45..8f1c78a 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -7,12 +7,13 @@ import { dialog } from '../services/ipc' import * as configService from '../services/config' import { Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, - RotateCcw, Trash2, Save, Plug, Check, Sun, Moon, - Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic + RotateCcw, Trash2, Plug, Check, Sun, Moon, + Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic, + ShieldCheck, Fingerprint, Lock, KeyRound } from 'lucide-react' import './SettingsPage.scss' -type SettingsTab = 'appearance' | 'database' | 'whisper' | 'export' | 'cache' | 'about' +type SettingsTab = 'appearance' | 'database' | 'whisper' | 'export' | 'cache' | 'security' | 'about' const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, @@ -20,6 +21,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'whisper', label: '语音识别模型', icon: Mic }, { id: 'export', label: '导出', icon: Download }, { id: 'cache', label: '缓存', icon: HardDrive }, + { id: 'security', label: '安全', icon: ShieldCheck }, { id: 'about', label: '关于', icon: Info } ] @@ -29,7 +31,22 @@ interface WxidOption { } function SettingsPage() { - const { isDbConnected, setDbConnected, setLoading, reset } = useAppStore() + const { + isDbConnected, + setDbConnected, + setLoading, + reset, + updateInfo, + setUpdateInfo, + isDownloading, + setIsDownloading, + downloadProgress, + setDownloadProgress, + showUpdateDialog, + setShowUpdateDialog, + setUpdateError + } = useAppStore() + const resetChatStore = useChatStore((state) => state.reset) const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache) @@ -62,6 +79,7 @@ function SettingsPage() { const [exportDefaultMedia, setExportDefaultMedia] = useState(false) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) + const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) const [isLoading, setIsLoadingState] = useState(false) const [isTesting, setIsTesting] = useState(false) @@ -69,10 +87,7 @@ function SettingsPage() { const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) const [isFetchingImageKey, setIsFetchingImageKey] = useState(false) const [isCheckingUpdate, setIsCheckingUpdate] = useState(false) - const [isDownloading, setIsDownloading] = useState(false) - const [downloadProgress, setDownloadProgress] = useState(0) const [appVersion, setAppVersion] = useState('') - const [updateInfo, setUpdateInfo] = useState<{ hasUpdate: boolean; version?: string; releaseNotes?: string } | null>(null) const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null) const [showDecryptKey, setShowDecryptKey] = useState(false) const [dbKeyStatus, setDbKeyStatus] = useState('') @@ -82,8 +97,31 @@ function SettingsPage() { const [isClearingImageCache, setIsClearingImageCache] = useState(false) const [isClearingAllCache, setIsClearingAllCache] = useState(false) + // 安全设置 state + const [authEnabled, setAuthEnabled] = useState(false) + const [authUseHello, setAuthUseHello] = useState(false) + const [helloAvailable, setHelloAvailable] = useState(false) + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [isSettingHello, setIsSettingHello] = useState(false) + const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache + // 检查 Hello 可用性 + useEffect(() => { + if (window.PublicKeyCredential) { + void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable) + } + }, []) + + async function sha256(message: string) { + const msgBuffer = new TextEncoder().encode(message) + const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + return hashHex + } + useEffect(() => { loadConfig() loadAppVersion() @@ -139,6 +177,12 @@ function SettingsPage() { const savedExportDefaultMedia = await configService.getExportDefaultMedia() const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText() const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns() + const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency() + + const savedAuthEnabled = await configService.getAuthEnabled() + const savedAuthUseHello = await configService.getAuthUseHello() + setAuthEnabled(savedAuthEnabled) + setAuthUseHello(savedAuthUseHello) if (savedPath) setDbPath(savedPath) if (savedWxid) setWxid(savedWxid) @@ -166,6 +210,7 @@ function SettingsPage() { setExportDefaultMedia(savedExportDefaultMedia ?? false) setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true) setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true) + setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2) // 如果语言列表为空,保存默认值 if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) { @@ -176,7 +221,7 @@ function SettingsPage() { if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir) - } catch (e) { + } catch (e: any) { console.error('加载配置失败:', e) } } @@ -202,14 +247,14 @@ function SettingsPage() { try { const version = await window.electronAPI.app.getVersion() setAppVersion(version) - } catch (e) { + } catch (e: any) { console.error('获取版本号失败:', e) } } // 监听下载进度 useEffect(() => { - const removeListener = window.electronAPI.app.onDownloadProgress?.((progress: number) => { + const removeListener = window.electronAPI.app.onDownloadProgress?.((progress: any) => { setDownloadProgress(progress) }) return () => removeListener?.() @@ -229,17 +274,19 @@ function SettingsPage() { }, [whisperModelDir]) const handleCheckUpdate = async () => { + if (isCheckingUpdate) return setIsCheckingUpdate(true) setUpdateInfo(null) try { const result = await window.electronAPI.app.checkForUpdates() if (result.hasUpdate) { setUpdateInfo(result) + setShowUpdateDialog(true) showMessage(`发现新版:${result.version}`, true) } else { showMessage('当前已是最新版', true) } - } catch (e) { + } catch (e: any) { showMessage(`检查更新失败: ${e}`, false) } finally { setIsCheckingUpdate(false) @@ -247,17 +294,21 @@ function SettingsPage() { } const handleUpdateNow = async () => { + setShowUpdateDialog(false) + setIsDownloading(true) - setDownloadProgress(0) + setDownloadProgress({ percent: 0 }) try { showMessage('正在下载更新...', true) await window.electronAPI.app.downloadAndInstall() - } catch (e) { + } catch (e: any) { showMessage(`更新失败: ${e}`, false) setIsDownloading(false) } } + + const showMessage = (text: string, success: boolean) => { setMessage({ text, success }) setTimeout(() => setMessage(null), 3000) @@ -345,7 +396,7 @@ function SettingsPage() { if (!result.success && result.error) { showMessage(result.error, false) } - } catch (e) { + } catch (e: any) { showMessage(`切换账号后重新连接失败: ${e}`, false) setDbConnected(false) } @@ -382,7 +433,7 @@ function SettingsPage() { } else { showMessage(result.error || '未能自动检测到数据库目录', false) } - } catch (e) { + } catch (e: any) { showMessage(`自动检测失败: ${e}`, false) } finally { setIsDetectingPath(false) @@ -396,7 +447,7 @@ function SettingsPage() { setDbPath(result.filePaths[0]) showMessage('已选择数据库目录', true) } - } catch (e) { + } catch (e: any) { showMessage('选择目录失败', false) } } @@ -424,7 +475,7 @@ function SettingsPage() { } else { if (!silent) showMessage('未检测到账号目录,请检查路径', false) } - } catch (e) { + } catch (e: any) { if (!silent) showMessage(`扫描失败: ${e}`, false) } } @@ -440,7 +491,7 @@ function SettingsPage() { setCachePath(result.filePaths[0]) showMessage('已选择缓存目录', true) } - } catch (e) { + } catch (e: any) { showMessage('选择目录失败', false) } } @@ -456,7 +507,7 @@ function SettingsPage() { await configService.setWhisperModelDir(dir) showMessage('已选择 Whisper 模型目录', true) } - } catch (e) { + } catch (e: any) { showMessage('选择目录失败', false) } } @@ -480,7 +531,7 @@ function SettingsPage() { } else { showMessage(result.error || '模型下载失败', false) } - } catch (e) { + } catch (e: any) { showMessage(`模型下载失败: ${e}`, false) } finally { setIsWhisperDownloading(false) @@ -512,7 +563,7 @@ function SettingsPage() { showMessage(result.error || '自动获取密钥失败', false) } } - } catch (e) { + } catch (e: any) { showMessage(`自动获取密钥失败: ${e}`, false) } finally { setIsFetchingDbKey(false) @@ -524,6 +575,19 @@ function SettingsPage() { handleAutoGetDbKey() } + // Helper to sync current keys to wxid config + const syncCurrentKeys = async () => { + const keys = buildKeysFromState() + await syncKeysToConfig(keys) + if (wxid) { + await configService.setWxidConfig(wxid, { + decryptKey: keys.decryptKey, + imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0, + imageAesKey: keys.imageAesKey + }) + } + } + const handleAutoGetImageKey = async () => { if (isFetchingImageKey) return if (!dbPath) { @@ -542,10 +606,27 @@ function SettingsPage() { setImageAesKey(result.aesKey) setImageKeyStatus('已获取图片密钥') showMessage('已自动获取图片密钥', true) + + // Auto-save after fetching keys + // We need to use the values directly because state updates are async + const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0 + const newAesKey = result.aesKey + + await configService.setImageXorKey(newXorKey) + await configService.setImageAesKey(newAesKey) + + if (wxid) { + await configService.setWxidConfig(wxid, { + decryptKey: decryptKey, // use current state as it hasn't changed here + imageXorKey: newXorKey, + imageAesKey: newAesKey + }) + } + } else { showMessage(result.error || '自动获取图片密钥失败', false) } - } catch (e) { + } catch (e: any) { showMessage(`自动获取图片密钥失败: ${e}`, false) } finally { setIsFetchingImageKey(false) @@ -568,49 +649,15 @@ function SettingsPage() { } else { showMessage(result.error || '连接测试失败', false) } - } catch (e) { + } catch (e: any) { showMessage(`连接测试失败: ${e}`, false) } finally { setIsTesting(false) } } - const handleSaveConfig = async () => { - if (!decryptKey) { showMessage('请输入解密密钥', false); return } - if (decryptKey.length !== 64) { showMessage('密钥长度必须为64个字符', false); return } - if (!dbPath) { showMessage('请选择数据库目录', false); return } - if (!wxid) { showMessage('请输入 wxid', false); return } + // Removed manual save config function - setIsLoadingState(true) - setLoading(true, '正在保存配置...') - - try { - await configService.setDecryptKey(decryptKey) - await configService.setDbPath(dbPath) - await configService.setMyWxid(wxid) - await configService.setCachePath(cachePath) - 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) - await configService.setOnboardingDone(true) - - // 保存按钮只负责持久化配置,不做连接测试/重连,避免影响聊天页的活动连接 - showMessage('配置保存成功', true) - } catch (e) { - showMessage(`保存配置失败: ${e}`, false) - } finally { - setIsLoadingState(false) - setLoading(false) - } - } const handleClearConfig = async () => { const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置?') @@ -636,7 +683,7 @@ function SettingsPage() { setIsWhisperDownloading(false) setDbConnected(false) await window.electronAPI.window.openOnboardingWindow() - } catch (e) { + } catch (e: any) { showMessage(`清除配置失败: ${e}`, false) } finally { setIsLoadingState(false) @@ -648,7 +695,7 @@ function SettingsPage() { try { const logPath = await window.electronAPI.log.getPath() await window.electronAPI.shell.openPath(logPath) - } catch (e) { + } catch (e: any) { showMessage(`打开日志失败: ${e}`, false) } } @@ -662,7 +709,7 @@ function SettingsPage() { } await navigator.clipboard.writeText(result.content || '') showMessage('日志已复制到剪贴板', true) - } catch (e) { + } catch (e: any) { showMessage(`复制日志失败: ${e}`, false) } } @@ -678,7 +725,7 @@ function SettingsPage() { } else { showMessage(`清除分析缓存失败: ${result.error || '未知错误'}`, false) } - } catch (e) { + } catch (e: any) { showMessage(`清除分析缓存失败: ${e}`, false) } finally { setIsClearingAnalyticsCache(false) @@ -695,7 +742,7 @@ function SettingsPage() { } else { showMessage(`清除图片缓存失败: ${result.error || '未知错误'}`, false) } - } catch (e) { + } catch (e: any) { showMessage(`清除图片缓存失败: ${e}`, false) } finally { setIsClearingImageCache(false) @@ -713,7 +760,7 @@ function SettingsPage() { } else { showMessage(`清除所有缓存失败: ${result.error || '未知错误'}`, false) } - } catch (e) { + } catch (e: any) { showMessage(`清除所有缓存失败: ${e}`, false) } finally { setIsClearingAllCache(false) @@ -753,7 +800,18 @@ function SettingsPage() { <label>解密密钥</label> <span className="form-hint">64位十六进制密钥</span> <div className="input-with-toggle"> - <input type={showDecryptKey ? 'text' : 'password'} placeholder="例如: a1b2c3d4e5f6..." value={decryptKey} onChange={(e) => setDecryptKey(e.target.value)} /> + <input + type={showDecryptKey ? 'text' : 'password'} + placeholder="例如: a1b2c3d4e5f6..." + value={decryptKey} + onChange={(e) => setDecryptKey(e.target.value)} + onBlur={async () => { + if (decryptKey && decryptKey.length === 64) { + await syncCurrentKeys() + // showMessage('解密密钥已保存', true) + } + }} + /> <button type="button" className="toggle-visibility" onClick={() => setShowDecryptKey(!showDecryptKey)}> {showDecryptKey ? <EyeOff size={14} /> : <Eye size={14} />} </button> @@ -776,8 +834,18 @@ function SettingsPage() { <div className="form-group"> <label>数据库根目录</label> <span className="form-hint">xwechat_files 目录</span> - <span className="form-hint" style={{ color: '#ff6b6b' }}>⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</span> - <input type="text" placeholder="例如: C:\Users\xxx\Documents\xwechat_files" value={dbPath} onChange={(e) => setDbPath(e.target.value)} /> + <span className="form-hint" style={{ color: '#ff6b6b' }}> 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</span> + <input + type="text" + placeholder="例如: C:\Users\xxx\Documents\xwechat_files" + value={dbPath} + onChange={(e) => setDbPath(e.target.value)} + onBlur={async () => { + if (dbPath) { + await configService.setDbPath(dbPath) + } + }} + /> <div className="btn-row"> <button className="btn btn-primary" onClick={handleAutoDetectPath} disabled={isDetectingPath}> <FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'} @@ -795,6 +863,12 @@ function SettingsPage() { placeholder="例如: wxid_xxxxxx" value={wxid} onChange={(e) => setWxid(e.target.value)} + onBlur={async () => { + if (wxid) { + await configService.setMyWxid(wxid) + await syncCurrentKeys() // Sync keys to the new wxid entry + } + }} /> </div> <button className="btn btn-secondary btn-sm" onClick={() => handleScanWxid()}><Search size={14} /> 扫描 wxid</button> @@ -803,13 +877,25 @@ function SettingsPage() { <div className="form-group"> <label>图片 XOR 密钥 <span className="optional">(可选)</span></label> <span className="form-hint">用于解密图片缓存</span> - <input type="text" placeholder="例如: 0xA4" value={imageXorKey} onChange={(e) => setImageXorKey(e.target.value)} /> + <input + type="text" + placeholder="例如: 0xA4" + value={imageXorKey} + onChange={(e) => setImageXorKey(e.target.value)} + onBlur={syncCurrentKeys} + /> </div> <div className="form-group"> <label>图片 AES 密钥 <span className="optional">(可选)</span></label> <span className="form-hint">16 位密钥</span> - <input type="text" placeholder="16 位 AES 密钥" value={imageAesKey} onChange={(e) => setImageAesKey(e.target.value)} /> + <input + type="text" + placeholder="16 位 AES 密钥" + value={imageAesKey} + onChange={(e) => setImageAesKey(e.target.value)} + onBlur={syncCurrentKeys} + /> <button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}> <Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'} </button> @@ -989,171 +1075,171 @@ function SettingsPage() { const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue) return ( - <div className="tab-content"> - <div className="form-group"> - <label>默认导出格式</label> - <span className="form-hint">导出页面默认选中的格式</span> - <div className="select-field" ref={exportFormatDropdownRef}> - <button - type="button" - className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`} - onClick={() => { - setShowExportFormatSelect(!showExportFormatSelect) - setShowExportDateRangeSelect(false) - setShowExportExcelColumnsSelect(false) - }} - > - <span className="select-value">{exportFormatLabel}</span> - <ChevronDown size={16} /> - </button> - {showExportFormatSelect && ( - <div className="select-dropdown"> - {exportFormatOptions.map((option) => ( - <button - key={option.value} - type="button" - className={`select-option ${exportDefaultFormat === option.value ? 'active' : ''}`} - onClick={async () => { - setExportDefaultFormat(option.value) - await configService.setExportDefaultFormat(option.value) - showMessage('已更新导出格式默认值', true) - setShowExportFormatSelect(false) - }} - > - <span className="option-label">{option.label}</span> - {option.desc && <span className="option-desc">{option.desc}</span>} - </button> - ))} - </div> - )} - </div> - </div> - - <div className="form-group"> - <label>默认导出时间范围</label> - <span className="form-hint">控制导出页面的默认时间选择</span> - <div className="select-field" ref={exportDateRangeDropdownRef}> - <button - type="button" - className={`select-trigger ${showExportDateRangeSelect ? 'open' : ''}`} - onClick={() => { - setShowExportDateRangeSelect(!showExportDateRangeSelect) - setShowExportFormatSelect(false) - setShowExportExcelColumnsSelect(false) - }} - > - <span className="select-value">{exportDateRangeLabel}</span> - <ChevronDown size={16} /> - </button> - {showExportDateRangeSelect && ( - <div className="select-dropdown"> - {exportDateRangeOptions.map((option) => ( - <button - key={option.value} - type="button" - className={`select-option ${exportDefaultDateRange === option.value ? 'active' : ''}`} - onClick={async () => { - setExportDefaultDateRange(option.value) - await configService.setExportDefaultDateRange(option.value) - showMessage('已更新默认导出时间范围', true) - setShowExportDateRangeSelect(false) - }} - > - <span className="option-label">{option.label}</span> - </button> - ))} - </div> - )} - </div> - </div> - - <div className="form-group"> - <label>默认导出媒体文件</label> - <span className="form-hint">控制图片/语音/表情的默认导出开关</span> - <div className="log-toggle-line"> - <span className="log-status">{exportDefaultMedia ? '已开启' : '已关闭'}</span> - <label className="switch" htmlFor="export-default-media"> - <input - id="export-default-media" - className="switch-input" - type="checkbox" - checked={exportDefaultMedia} - onChange={async (e) => { - const enabled = e.target.checked - setExportDefaultMedia(enabled) - await configService.setExportDefaultMedia(enabled) - showMessage(enabled ? '已开启默认媒体导出' : '已关闭默认媒体导出', true) + <div className="tab-content"> + <div className="form-group"> + <label>默认导出格式</label> + <span className="form-hint">导出页面默认选中的格式</span> + <div className="select-field" ref={exportFormatDropdownRef}> + <button + type="button" + className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`} + onClick={() => { + setShowExportFormatSelect(!showExportFormatSelect) + setShowExportDateRangeSelect(false) + setShowExportExcelColumnsSelect(false) }} - /> - <span className="switch-slider" /> - </label> + > + <span className="select-value">{exportFormatLabel}</span> + <ChevronDown size={16} /> + </button> + {showExportFormatSelect && ( + <div className="select-dropdown"> + {exportFormatOptions.map((option) => ( + <button + key={option.value} + type="button" + className={`select-option ${exportDefaultFormat === option.value ? 'active' : ''}`} + onClick={async () => { + setExportDefaultFormat(option.value) + await configService.setExportDefaultFormat(option.value) + showMessage('已更新导出格式默认值', true) + setShowExportFormatSelect(false) + }} + > + <span className="option-label">{option.label}</span> + {option.desc && <span className="option-desc">{option.desc}</span>} + </button> + ))} + </div> + )} + </div> </div> - </div> - <div className="form-group"> - <label>默认语音转文字</label> - <span className="form-hint">导出时默认将语音转写为文字</span> - <div className="log-toggle-line"> - <span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span> - <label className="switch" htmlFor="export-default-voice-as-text"> - <input - id="export-default-voice-as-text" - className="switch-input" - type="checkbox" - checked={exportDefaultVoiceAsText} - onChange={async (e) => { - const enabled = e.target.checked - setExportDefaultVoiceAsText(enabled) - await configService.setExportDefaultVoiceAsText(enabled) - showMessage(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true) + <div className="form-group"> + <label>默认导出时间范围</label> + <span className="form-hint">控制导出页面的默认时间选择</span> + <div className="select-field" ref={exportDateRangeDropdownRef}> + <button + type="button" + className={`select-trigger ${showExportDateRangeSelect ? 'open' : ''}`} + onClick={() => { + setShowExportDateRangeSelect(!showExportDateRangeSelect) + setShowExportFormatSelect(false) + setShowExportExcelColumnsSelect(false) }} - /> - <span className="switch-slider" /> - </label> + > + <span className="select-value">{exportDateRangeLabel}</span> + <ChevronDown size={16} /> + </button> + {showExportDateRangeSelect && ( + <div className="select-dropdown"> + {exportDateRangeOptions.map((option) => ( + <button + key={option.value} + type="button" + className={`select-option ${exportDefaultDateRange === option.value ? 'active' : ''}`} + onClick={async () => { + setExportDefaultDateRange(option.value) + await configService.setExportDefaultDateRange(option.value) + showMessage('已更新默认导出时间范围', true) + setShowExportDateRangeSelect(false) + }} + > + <span className="option-label">{option.label}</span> + </button> + ))} + </div> + )} + </div> </div> - </div> - <div className="form-group"> - <label>Excel 列显示</label> - <span className="form-hint">控制 Excel 导出的列字段</span> - <div className="select-field" ref={exportExcelColumnsDropdownRef}> - <button - type="button" - className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`} - onClick={() => { - setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect) - setShowExportFormatSelect(false) - setShowExportDateRangeSelect(false) - }} - > - <span className="select-value">{exportExcelColumnsLabel}</span> - <ChevronDown size={16} /> - </button> - {showExportExcelColumnsSelect && ( - <div className="select-dropdown"> - {exportExcelColumnOptions.map((option) => ( - <button - key={option.value} - type="button" - className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`} - onClick={async () => { - const compact = option.value === 'compact' - setExportDefaultExcelCompactColumns(compact) - await configService.setExportDefaultExcelCompactColumns(compact) - showMessage(compact ? '已启用精简列' : '已启用完整列', true) - setShowExportExcelColumnsSelect(false) - }} - > - <span className="option-label">{option.label}</span> - {option.desc && <span className="option-desc">{option.desc}</span>} - </button> - ))} - </div> - )} + <div className="form-group"> + <label>默认导出媒体文件</label> + <span className="form-hint">控制图片/语音/表情的默认导出开关</span> + <div className="log-toggle-line"> + <span className="log-status">{exportDefaultMedia ? '已开启' : '已关闭'}</span> + <label className="switch" htmlFor="export-default-media"> + <input + id="export-default-media" + className="switch-input" + type="checkbox" + checked={exportDefaultMedia} + onChange={async (e) => { + const enabled = e.target.checked + setExportDefaultMedia(enabled) + await configService.setExportDefaultMedia(enabled) + showMessage(enabled ? '已开启默认媒体导出' : '已关闭默认媒体导出', true) + }} + /> + <span className="switch-slider" /> + </label> + </div> </div> - </div> - </div> + <div className="form-group"> + <label>默认语音转文字</label> + <span className="form-hint">导出时默认将语音转写为文字</span> + <div className="log-toggle-line"> + <span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span> + <label className="switch" htmlFor="export-default-voice-as-text"> + <input + id="export-default-voice-as-text" + className="switch-input" + type="checkbox" + checked={exportDefaultVoiceAsText} + onChange={async (e) => { + const enabled = e.target.checked + setExportDefaultVoiceAsText(enabled) + await configService.setExportDefaultVoiceAsText(enabled) + showMessage(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true) + }} + /> + <span className="switch-slider" /> + </label> + </div> + </div> + + <div className="form-group"> + <label>Excel 列显示</label> + <span className="form-hint">控制 Excel 导出的列字段</span> + <div className="select-field" ref={exportExcelColumnsDropdownRef}> + <button + type="button" + className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`} + onClick={() => { + setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect) + setShowExportFormatSelect(false) + setShowExportDateRangeSelect(false) + }} + > + <span className="select-value">{exportExcelColumnsLabel}</span> + <ChevronDown size={16} /> + </button> + {showExportExcelColumnsSelect && ( + <div className="select-dropdown"> + {exportExcelColumnOptions.map((option) => ( + <button + key={option.value} + type="button" + className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`} + onClick={async () => { + const compact = option.value === 'compact' + setExportDefaultExcelCompactColumns(compact) + await configService.setExportDefaultExcelCompactColumns(compact) + showMessage(compact ? '已启用精简列' : '已启用完整列', true) + setShowExportExcelColumnsSelect(false) + }} + > + <span className="option-label">{option.label}</span> + {option.desc && <span className="option-desc">{option.desc}</span>} + </button> + ))} + </div> + )} + </div> + </div> + + </div> ) } const renderCacheTab = () => ( @@ -1162,7 +1248,15 @@ function SettingsPage() { <div className="form-group"> <label>缓存目录 <span className="optional">(可选)</span></label> <span className="form-hint">留空使用默认目录</span> - <input type="text" placeholder="留空使用默认目录" value={cachePath} onChange={(e) => setCachePath(e.target.value)} /> + <input + type="text" + placeholder="留空使用默认目录" + value={cachePath} + onChange={(e) => setCachePath(e.target.value)} + onBlur={async () => { + await configService.setCachePath(cachePath) + }} + /> <div className="btn-row"> <button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> 浏览选择</button> <button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> 恢复默认</button> @@ -1189,6 +1283,137 @@ function SettingsPage() { </div> ) + const handleSetupHello = async () => { + setIsSettingHello(true) + try { + const challenge = new Uint8Array(32) + window.crypto.getRandomValues(challenge) + + const credential = await navigator.credentials.create({ + publicKey: { + challenge, + rp: { name: 'WeFlow', id: 'localhost' }, + user: { id: new Uint8Array([1]), name: 'user', displayName: 'User' }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }], + authenticatorSelection: { userVerification: 'required' }, + timeout: 60000 + } + }) + + if (credential) { + setAuthUseHello(true) + await configService.setAuthUseHello(true) + showMessage('Windows Hello 设置成功', true) + } + } catch (e: any) { + if (e.name !== 'NotAllowedError') { + showMessage(`Windows Hello 设置失败: ${e.message}`, false) + } + } finally { + setIsSettingHello(false) + } + } + + const handleUpdatePassword = async () => { + if (!newPassword || newPassword !== confirmPassword) { + showMessage('两次密码不一致', false) + return + } + + // 简单的保存逻辑,实际上应该先验证旧密码,但为了简化流程,这里直接允许覆盖 + // 因为能进入设置页面说明已经解锁了 + try { + const hash = await sha256(newPassword) + await configService.setAuthPassword(hash) + await configService.setAuthEnabled(true) + setAuthEnabled(true) + setNewPassword('') + setConfirmPassword('') + showMessage('密码已更新', true) + } catch (e: any) { + showMessage('密码更新失败', false) + } + } + + const renderSecurityTab = () => ( + <div className="tab-content"> + <div className="form-group"> + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> + <div> + <label>启用应用锁</label> + <span className="form-hint">每次启动应用时需要验证密码</span> + </div> + <label className="switch"> + <input + type="checkbox" + checked={authEnabled} + onChange={async (e) => { + const enabled = e.target.checked + setAuthEnabled(enabled) + await configService.setAuthEnabled(enabled) + }} + /> + <span className="switch-slider" /> + </label> + </div> + </div> + + <div className="divider" /> + + <div className="form-group"> + <label>重置密码</label> + <span className="form-hint">设置新的启动密码</span> + + <div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 10 }}> + <input + type="password" + className="field-input" + placeholder="新密码" + value={newPassword} + onChange={e => setNewPassword(e.target.value)} + /> + <div style={{ display: 'flex', gap: 10 }}> + <input + type="password" + className="field-input" + placeholder="确认新密码" + value={confirmPassword} + onChange={e => setConfirmPassword(e.target.value)} + style={{ flex: 1 }} + /> + <button className="btn btn-primary" onClick={handleUpdatePassword} disabled={!newPassword}>更新</button> + </div> + </div> + </div> + + <div className="divider" /> + + <div className="form-group"> + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> + <div> + <label>Windows Hello</label> + <span className="form-hint">使用面容、指纹快速解锁</span> + {!helloAvailable && <div className="form-hint warning" style={{ color: '#ff4d4f' }}> 当前设备不支持 Windows Hello</div>} + </div> + + <div> + {authUseHello ? ( + <button className="btn btn-secondary btn-sm" onClick={() => setAuthUseHello(false)}>关闭</button> + ) : ( + <button + className="btn btn-secondary btn-sm" + onClick={handleSetupHello} + disabled={!helloAvailable || isSettingHello} + > + {isSettingHello ? '设置中...' : '开启与设置'} + </button> + )} + </div> + </div> + </div> + </div> + ) + const renderAboutTab = () => ( <div className="tab-content about-tab"> <div className="about-card"> @@ -1204,23 +1429,26 @@ function SettingsPage() { <> <p className="update-hint">新版 v{updateInfo.version} 可用</p> {isDownloading ? ( - <div className="download-progress"> + <div className="update-progress"> <div className="progress-bar"> - <div className="progress-fill" style={{ width: `${downloadProgress}%` }} /> + <div className="progress-inner" style={{ width: `${(downloadProgress?.percent || 0)}%` }} /> </div> - <span>{downloadProgress.toFixed(0)}%</span> + <span>{(downloadProgress?.percent || 0).toFixed(0)}%</span> </div> ) : ( - <button className="btn btn-primary" onClick={handleUpdateNow}> + <button className="btn btn-primary" onClick={() => setShowUpdateDialog(true)}> <Download size={16} /> 立即更新 </button> )} </> ) : ( - <button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}> - <RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} /> - {isCheckingUpdate ? '检查中...' : '检查更新'} - </button> + <div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}> + <button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}> + <RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} /> + {isCheckingUpdate ? '检查中...' : '检查更新'} + </button> + + </div> )} </div> </div> @@ -1276,9 +1504,6 @@ function SettingsPage() { <button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}> <Plug size={16} /> {isTesting ? '测试中...' : '测试连接'} </button> - <button className="btn btn-primary" onClick={handleSaveConfig} disabled={isLoading}> - <Save size={16} /> {isLoading ? '保存中...' : '保存配置'} - </button> </div> </div> @@ -1297,8 +1522,10 @@ function SettingsPage() { {activeTab === 'whisper' && renderWhisperTab()} {activeTab === 'export' && renderExportTab()} {activeTab === 'cache' && renderCacheTab()} + {activeTab === 'security' && renderSecurityTab()} {activeTab === 'about' && renderAboutTab()} </div> + </div> ) } diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 4994949..eb9188f 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -10,70 +10,47 @@ } .sns-sidebar { - width: 300px; + width: 320px; background: var(--bg-secondary); - border-right: 1px solid var(--border-color); + border-left: 1px solid var(--border-color); display: flex; flex-direction: column; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); flex-shrink: 0; z-index: 10; + box-shadow: -4px 0 16px rgba(0, 0, 0, 0.05); &.closed { width: 0; opacity: 0; - transform: translateX(-100%); + transform: translateX(100%); pointer-events: none; + border-left: none; } .sidebar-header { - padding: 18px 20px; + padding: 0 24px; + height: 64px; + box-sizing: border-box; display: flex; align-items: center; - justify-content: space-between; + /* justify-content: space-between; -- No longer needed as it's just h3 */ border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); - .title-wrapper { - display: flex; - align-items: center; - gap: 8px; + h3 { + margin: 0; + font-size: 18px; + font-weight: 700; color: var(--text-primary); - - .title-icon { - color: var(--accent-color); - } - - h3 { - margin: 0; - font-size: 15px; - font-weight: 600; - letter-spacing: 0.5px; - } - } - - .toggle-btn { - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - color: var(--text-secondary); - cursor: pointer; - padding: 5px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 6px; - transition: all 0.2s; - - &:hover { - background: var(--hover-bg); - color: var(--accent-color); - border-color: var(--accent-color); - } + letter-spacing: 0; } } .filter-content { flex: 1; - overflow-y: auto; + overflow-y: hidden; + /* Changed from auto to hidden to allow inner scrolling of contact list */ padding: 16px; display: flex; flex-direction: column; @@ -86,6 +63,7 @@ padding: 14px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02); transition: transform 0.2s, box-shadow 0.2s; + flex-shrink: 0; &:hover { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.04); @@ -172,7 +150,7 @@ flex: 1; display: flex; flex-direction: column; - min-height: 0; // 改为 0 以支持 flex 压缩 + min-height: 200px; padding: 0; overflow: hidden; } @@ -181,7 +159,7 @@ .filter-section { - margin-bottom: 20px; + margin-bottom: 0px; label { display: flex; @@ -258,12 +236,16 @@ flex: 1; display: flex; flex-direction: column; + overflow: hidden; .section-header { padding: 16px 16px 1px 16px; + margin-bottom: 12px; + /* Increased spacing */ display: flex; justify-content: space-between; align-items: center; + flex-shrink: 0; .header-actions { display: flex; @@ -306,6 +288,7 @@ position: relative; display: flex; align-items: center; + flex-shrink: 0; .search-icon { position: absolute; @@ -354,6 +337,7 @@ overflow-y: auto; padding: 4px 8px; margin: 0 4px 8px 4px; + min-height: 0; .contact-item { display: flex; @@ -524,6 +508,12 @@ } } + .header-right { + display: flex; + align-items: center; + gap: 12px; + } + .icon-btn { background: none; border: none; @@ -553,6 +543,7 @@ } } + .sns-content-wrapper { flex: 1; display: flex; @@ -741,21 +732,23 @@ .live-badge { position: absolute; - top: 6px; - right: 6px; - background: rgba(0, 0, 0, 0.6); + top: 8px; + left: 8px; + right: 8px; + left: auto; + background: rgba(255, 255, 255, 0.9); + background: rgba(0, 0, 0, 0.3); backdrop-filter: blur(4px); color: white; - font-size: 10px; - font-weight: 700; - padding: 2px 6px; - border-radius: 4px; + padding: 4px; + border-radius: 50%; display: flex; align-items: center; - gap: 2px; + justify-content: center; pointer-events: none; z-index: 2; transition: opacity 0.2s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .download-btn-overlay { diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 2024bdd..8029dd1 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,8 +1,9 @@ -import { useEffect, useState, useRef, useCallback, useMemo } from 'react' -import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download } from 'lucide-react' +import { useEffect, useState, useRef, useCallback, useMemo } from 'react' +import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight } from 'lucide-react' import { Avatar } from '../components/Avatar' import { ImagePreview } from '../components/ImagePreview' import JumpToDateDialog from '../components/JumpToDateDialog' +import { LivePhotoIcon } from '../components/LivePhotoIcon' import './SnsPage.scss' interface SnsPost { @@ -65,8 +66,7 @@ const MediaItem = ({ media, onPreview }: { media: any, onPreview: () => void }) /> {isLive && ( <div className="live-badge"> - <Zap size={10} fill="currentColor" /> - <span>LIVE</span> + <LivePhotoIcon size={16} className="live-icon" /> </div> )} <button className="download-btn-overlay" onClick={handleDownload} title="下载原图"> @@ -384,131 +384,19 @@ export default function SnsPage() { return ( <div className="sns-page"> <div className="sns-container"> - {/* 侧边栏:过滤与搜索 */} - <aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}> - <div className="sidebar-header"> - <div className="title-wrapper"> - <Filter size={18} className="title-icon" /> - <h3>筛选条件</h3> - </div> - <button className="toggle-btn" onClick={() => setIsSidebarOpen(false)}> - <X size={18} /> - </button> - </div> - - <div className="filter-content custom-scrollbar"> - {/* 1. 搜索分组 (放到最顶上) */} - <div className="filter-card"> - <div className="filter-section"> - <label><Search size={14} /> 关键词搜索</label> - <div className="search-input-wrapper"> - <Search size={14} className="input-icon" /> - <input - type="text" - placeholder="搜索动态内容..." - value={searchKeyword} - onChange={e => setSearchKeyword(e.target.value)} - /> - {searchKeyword && ( - <button className="clear-input" onClick={() => setSearchKeyword('')}> - <X size={14} /> - </button> - )} - </div> - </div> - </div> - - {/* 2. 日期跳转 (放搜索下面) */} - <div className="filter-card jump-date-card"> - <div className="filter-section"> - <label><Calendar size={14} /> 时间跳转</label> - <button className={`jump-date-btn ${jumpTargetDate ? 'active' : ''}`} onClick={() => setShowJumpDialog(true)}> - <span className="text"> - {jumpTargetDate ? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }) : '选择跳转日期...'} - </span> - <Calendar size={14} className="icon" /> - </button> - {jumpTargetDate && ( - <button className="clear-jump-date-inline" onClick={() => setJumpTargetDate(undefined)}> - 返回最新动态 - </button> - )} - </div> - </div> - - - {/* 3. 联系人筛选 (放最下面,高度自适应) */} - <div className="filter-card contact-card"> - <div className="contact-filter-section"> - <div className="section-header"> - <label><User size={14} /> 联系人</label> - <div className="header-actions"> - {selectedUsernames.length > 0 && ( - <button className="clear-selection-btn" onClick={() => setSelectedUsernames([])}>清除</button> - )} - {selectedUsernames.length > 0 && ( - <span className="selected-count">{selectedUsernames.length}</span> - )} - </div> - </div> - <div className="contact-search"> - <Search size={12} className="search-icon" /> - <input - type="text" - placeholder="搜索好友..." - value={contactSearch} - onChange={e => setContactSearch(e.target.value)} - /> - {contactSearch && ( - <X size={12} className="clear-search-icon" onClick={() => setContactSearch('')} /> - )} - </div> - <div className="contact-list custom-scrollbar"> - {filteredContacts.map(contact => ( - <div - key={contact.username} - className={`contact-item ${selectedUsernames.includes(contact.username) ? 'active' : ''}`} - onClick={() => toggleUserSelection(contact.username)} - > - <div className="avatar-wrapper"> - <Avatar src={contact.avatarUrl} name={contact.displayName} size={32} shape="rounded" /> - {selectedUsernames.includes(contact.username) && ( - <div className="active-badge"></div> - )} - </div> - <span className="contact-name">{contact.displayName}</span> - <div className="check-box"> - {selectedUsernames.includes(contact.username) && <div className="inner-check"></div>} - </div> - </div> - ))} - {filteredContacts.length === 0 && ( - <div className="empty-contacts">无可显示联系人</div> - )} - </div> - </div> - </div> - </div> - - <div className="sidebar-footer"> - <button className="clear-btn" onClick={clearFilters}> - <RefreshCw size={14} /> - 重置所有筛选 - </button> - </div> - </aside> - <main className="sns-main"> <div className="sns-header"> <div className="header-left"> - {!isSidebarOpen && ( - <button className="icon-btn sidebar-trigger" onClick={() => setIsSidebarOpen(true)}> - <Filter size={20} /> - </button> - )} <h2>社交动态</h2> </div> <div className="header-right"> + <button + className={`icon-btn sidebar-trigger ${isSidebarOpen ? 'active' : ''}`} + onClick={() => setIsSidebarOpen(!isSidebarOpen)} + title={isSidebarOpen ? "收起筛选" : "打开筛选"} + > + <Filter size={18} /> + </button> <button onClick={() => { if (jumpTargetDate) setJumpTargetDate(undefined); @@ -516,6 +404,7 @@ export default function SnsPage() { }} disabled={loading || loadingNewer} className="icon-btn refresh-btn" + title="刷新" > <RefreshCw size={18} className={(loading || loadingNewer) ? 'spinning' : ''} /> </button> @@ -640,6 +529,115 @@ export default function SnsPage() { </div> </div> </main> + + {/* 侧边栏:过滤与搜索 (moved to right) */} + <aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}> + <div className="sidebar-header"> + <h3>筛选条件</h3> + + </div> + + <div className="filter-content custom-scrollbar"> + {/* 1. 搜索分组 (放到最顶上) */} + <div className="filter-card"> + <div className="filter-section"> + <label><Search size={14} /> 关键词搜索</label> + <div className="search-input-wrapper"> + <Search size={14} className="input-icon" /> + <input + type="text" + placeholder="搜索动态内容..." + value={searchKeyword} + onChange={e => setSearchKeyword(e.target.value)} + /> + {searchKeyword && ( + <button className="clear-input" onClick={() => setSearchKeyword('')}> + <X size={14} /> + </button> + )} + </div> + </div> + </div> + + {/* 2. 日期跳转 (放搜索下面) */} + <div className="filter-card jump-date-card"> + <div className="filter-section"> + <label><Calendar size={14} /> 时间跳转</label> + <button className={`jump-date-btn ${jumpTargetDate ? 'active' : ''}`} onClick={() => setShowJumpDialog(true)}> + <span className="text"> + {jumpTargetDate ? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }) : '选择跳转日期...'} + </span> + <Calendar size={14} className="icon" /> + </button> + {jumpTargetDate && ( + <button className="clear-jump-date-inline" onClick={() => setJumpTargetDate(undefined)}> + 返回最新动态 + </button> + )} + </div> + </div> + + + {/* 3. 联系人筛选 (放最下面,高度自适应) */} + <div className="filter-card contact-card"> + <div className="contact-filter-section"> + <div className="section-header"> + <label><User size={14} /> 联系人</label> + <div className="header-actions"> + {selectedUsernames.length > 0 && ( + <button className="clear-selection-btn" onClick={() => setSelectedUsernames([])}>清除</button> + )} + {selectedUsernames.length > 0 && ( + <span className="selected-count">{selectedUsernames.length}</span> + )} + </div> + </div> + <div className="contact-search"> + <Search size={12} className="search-icon" /> + <input + type="text" + placeholder="搜索好友..." + value={contactSearch} + onChange={e => setContactSearch(e.target.value)} + /> + {contactSearch && ( + <X size={12} className="clear-search-icon" onClick={() => setContactSearch('')} /> + )} + </div> + <div className="contact-list custom-scrollbar"> + {filteredContacts.map(contact => ( + <div + key={contact.username} + className={`contact-item ${selectedUsernames.includes(contact.username) ? 'active' : ''}`} + onClick={() => toggleUserSelection(contact.username)} + > + <div className="avatar-wrapper"> + <Avatar src={contact.avatarUrl} name={contact.displayName} size={32} shape="rounded" /> + {selectedUsernames.includes(contact.username) && ( + <div className="active-badge"></div> + )} + </div> + <span className="contact-name">{contact.displayName}</span> + <div className="check-box"> + {selectedUsernames.includes(contact.username) && <div className="inner-check"></div>} + </div> + </div> + ))} + {filteredContacts.length === 0 && ( + <div className="empty-contacts">无可显示联系人</div> + )} + </div> + </div> + </div> + </div> + + <div className="sidebar-footer"> + <button className="clear-btn" onClick={clearFilters}> + <RefreshCw size={14} /> + 重置所有筛选 + </button> + </div> + </aside> </div> {previewImage && ( <ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} /> diff --git a/src/pages/WelcomePage.scss b/src/pages/WelcomePage.scss index 6114fbf..c115f75 100644 --- a/src/pages/WelcomePage.scss +++ b/src/pages/WelcomePage.scss @@ -112,7 +112,6 @@ -webkit-app-region: drag; [data-mode="dark"] & { - background: #18181b; border-right-color: rgba(255, 255, 255, 0.08); } } @@ -152,7 +151,7 @@ margin-top: 2px; [data-mode="dark"] .welcome-sidebar & { - color: rgba(255, 255, 255, 0.45); + color: rgba(255, 255, 255, 0.6); // 稍微调亮一点 } } @@ -188,7 +187,7 @@ border-radius: 12px; [data-mode="dark"] .welcome-sidebar & { - opacity: 0.7; + opacity: 0.75; // 整体调亮一点,原来是0.7 } &.active, @@ -236,8 +235,8 @@ 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); + border-color: rgba(255, 255, 255, 0.2); // 稍微调亮边框 + background: rgba(255, 255, 255, 0.05); } .nav-item.active & { @@ -281,7 +280,7 @@ color: #1a1a1a; [data-mode="dark"] .welcome-sidebar & { - color: #ffffff; + color: rgba(255, 255, 255, 0.9); // 提高非活动标题亮度 } .nav-item.active & { @@ -299,7 +298,8 @@ } .nav-item.active & { - color: rgba(255, 255, 255, 0.85); + color: #ffffff; // 活动描述使用纯白 + font-weight: 500; } } @@ -315,7 +315,7 @@ border-top: 1px dashed var(--border-color); [data-mode="dark"] .welcome-sidebar & { - color: rgba(255, 255, 255, 0.5); + color: rgba(255, 255, 255, 0.65); // 提高底部文字亮度 border-top-color: rgba(255, 255, 255, 0.1); } diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index a2967fd..4955ef9 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -15,7 +15,8 @@ const steps = [ { id: 'db', title: '数据库目录', desc: '定位 xwechat_files 目录' }, { id: 'cache', title: '缓存目录', desc: '设置本地缓存存储位置(可选)' }, { id: 'key', title: '解密密钥', desc: '获取密钥与自动识别账号' }, - { id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' } + { id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' }, + { id: 'security', title: '安全防护', desc: '保护你的数据' } ] interface WelcomePageProps { @@ -46,6 +47,64 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { const [imageKeyStatus, setImageKeyStatus] = useState('') const [isManualStartPrompt, setIsManualStartPrompt] = useState(false) + // 安全相关 state + const [enableAuth, setEnableAuth] = useState(false) + const [authPassword, setAuthPassword] = useState('') + const [authConfirmPassword, setAuthConfirmPassword] = useState('') + const [enableHello, setEnableHello] = useState(false) + const [helloAvailable, setHelloAvailable] = useState(false) + const [isSettingHello, setIsSettingHello] = useState(false) + + // 检查 Hello 可用性 + useEffect(() => { + if (window.PublicKeyCredential) { + void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable) + } + }, []) + + async function sha256(message: string) { + const msgBuffer = new TextEncoder().encode(message) + const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + return hashHex + } + + const handleSetupHello = async () => { + setIsSettingHello(true) + try { + // 注册凭证 (WebAuthn) + const challenge = new Uint8Array(32) + window.crypto.getRandomValues(challenge) + + const credential = await navigator.credentials.create({ + publicKey: { + challenge, + rp: { name: 'WeFlow', id: 'localhost' }, + user: { + id: new Uint8Array([1]), + name: 'user', + displayName: 'User' + }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }], + authenticatorSelection: { userVerification: 'required' }, + timeout: 60000 + } + }) + + if (credential) { + setEnableHello(true) + // 成功提示? + } + } catch (e: any) { + if (e.name !== 'NotAllowedError') { + setError('Windows Hello 设置失败: ' + e.message) + } + } finally { + setIsSettingHello(false) + } + } + useEffect(() => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { setDbKeyStatus(payload.message) @@ -227,6 +286,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { if (currentStep.id === 'cache') return true if (currentStep.id === 'key') return decryptKey.length === 64 && Boolean(wxid) if (currentStep.id === 'image') return true + if (currentStep.id === 'security') { + if (enableAuth) { + return authPassword.length > 0 && authPassword === authConfirmPassword + } + return true + } return false } @@ -277,6 +342,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { imageXorKey: typeof parsedXorKey === 'number' && !Number.isNaN(parsedXorKey) ? parsedXorKey : 0, imageAesKey }) + + // 保存安全配置 + if (enableAuth && authPassword) { + const hash = await sha256(authPassword) + await configService.setAuthEnabled(true) + await configService.setAuthPassword(hash) + await configService.setAuthUseHello(enableHello) + } + await configService.setOnboardingDone(true) setDbConnected(true, dbPath) @@ -450,7 +524,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { <div className="field-hint">请选择微信-设置-存储位置对应的目录</div> <div className="field-hint warning"> - ⚠️ 目录路径不可包含中文,如有中文请先在微信中迁移至全英文目录 + 目录路径不可包含中文,如有中文请先在微信中迁移至全英文目录 </div> </div> )} @@ -525,6 +599,74 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { </div> )} + {currentStep.id === 'security' && ( + <div className="form-group"> + <div className="security-toggle-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> + <div className="toggle-info"> + <label className="field-label" style={{ marginBottom: 0 }}>启用应用锁</label> + <div className="field-hint">每次启动应用时需要验证密码</div> + </div> + <label className="switch"> + <input type="checkbox" checked={enableAuth} onChange={e => setEnableAuth(e.target.checked)} /> + <span className="switch-slider" /> + </label> + </div> + + {enableAuth && ( + <div className="security-settings" style={{ marginTop: 20, padding: 16, backgroundColor: 'var(--bg-secondary)', borderRadius: 8 }}> + <div className="form-group"> + <label className="field-label">应用密码</label> + <input + type="password" + className="field-input" + placeholder="请输入密码" + value={authPassword} + onChange={e => setAuthPassword(e.target.value)} + /> + </div> + <div className="form-group"> + <label className="field-label">确认密码</label> + <input + type="password" + className="field-input" + placeholder="请再次输入密码" + value={authConfirmPassword} + onChange={e => setAuthConfirmPassword(e.target.value)} + /> + {authPassword && authConfirmPassword && authPassword !== authConfirmPassword && ( + <div className="error-text" style={{ color: '#ff4d4f', fontSize: 12, marginTop: 4 }}>两次密码不一致</div> + )} + </div> + + <div className="divider" style={{ margin: '20px 0', borderTop: '1px solid var(--border-color)' }}></div> + + <div className="security-toggle-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> + <div className="toggle-info"> + <label className="field-label" style={{ marginBottom: 0 }}>Windows Hello</label> + <div className="field-hint">使用面容、指纹或 PIN 码快速解锁</div> + </div> + + {enableHello ? ( + <div style={{ color: '#52c41a', display: 'flex', alignItems: 'center', gap: 6 }}> + <CheckCircle2 size={16} /> 已开启 + <button className="btn btn-ghost btn-sm" onClick={() => setEnableHello(false)} style={{ padding: '2px 8px', height: 24, fontSize: 12 }}>关闭</button> + </div> + ) : ( + <button + className="btn btn-secondary btn-sm" + disabled={!helloAvailable || isSettingHello} + onClick={handleSetupHello} + > + {isSettingHello ? '设置中...' : (helloAvailable ? '点击开启' : '不可用')} + </button> + )} + </div> + {!helloAvailable && <div className="field-hint warning"> 当前设备不支持 Windows Hello 或未设置 PIN 码</div>} + </div> + )} + </div> + )} + {currentStep.id === 'image' && ( <div className="form-group"> <div className="grid-2"> @@ -564,8 +706,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {currentStep.id === 'intro' && ( <div className="intro-footer"> - <p>接下来的几个步骤将引导您连接本地微信数据库。</p> - <p>WeFlow 需要访问您的本地数据文件以提供分析与导出功能。</p> + <p>接下来的几个步骤将引导你连接本地微信数据库。</p> + <p>WeFlow 需要访问你的本地数据文件以提供分析与导出功能。</p> </div> )} diff --git a/src/services/config.ts b/src/services/config.ts index 063ff68..e0a20c2 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -29,7 +29,13 @@ export const CONFIG_KEYS = { EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia', EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText', EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', - EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns' + EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns', + EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency', + + // 安全 + AUTH_ENABLED: 'authEnabled', + AUTH_PASSWORD: 'authPassword', + AUTH_USE_HELLO: 'authUseHello' } as const export interface WxidConfig { @@ -352,3 +358,44 @@ export async function getExportDefaultTxtColumns(): Promise<string[] | null> { export async function setExportDefaultTxtColumns(columns: string[]): Promise<void> { await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns) } + +// 获取导出默认并发数 +export async function getExportDefaultConcurrency(): Promise<number | null> { + const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY) + if (typeof value === 'number' && Number.isFinite(value)) return value + return null +} + +// 设置导出默认并发数 +export async function setExportDefaultConcurrency(concurrency: number): Promise<void> { + await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency) +} + +// === 安全相关 === + +export async function getAuthEnabled(): Promise<boolean> { + const value = await config.get(CONFIG_KEYS.AUTH_ENABLED) + return value === true +} + +export async function setAuthEnabled(enabled: boolean): Promise<void> { + await config.set(CONFIG_KEYS.AUTH_ENABLED, enabled) +} + +export async function getAuthPassword(): Promise<string> { + const value = await config.get(CONFIG_KEYS.AUTH_PASSWORD) + return (value as string) || '' +} + +export async function setAuthPassword(passwordHash: string): Promise<void> { + await config.set(CONFIG_KEYS.AUTH_PASSWORD, passwordHash) +} + +export async function getAuthUseHello(): Promise<boolean> { + const value = await config.get(CONFIG_KEYS.AUTH_USE_HELLO) + return value === true +} + +export async function setAuthUseHello(useHello: boolean): Promise<void> { + await config.set(CONFIG_KEYS.AUTH_USE_HELLO, useHello) +} diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index f479f9e..3d33689 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -5,15 +5,34 @@ export interface AppState { isDbConnected: boolean dbPath: string | null myWxid: string | null - + // 加载状态 isLoading: boolean loadingText: string - + + // 更新状态 + updateInfo: { + hasUpdate: boolean + version?: string + releaseNotes?: string + } | null + isDownloading: boolean + downloadProgress: any + showUpdateDialog: boolean + updateError: string | null + // 操作 setDbConnected: (connected: boolean, path?: string) => void setMyWxid: (wxid: string) => void setLoading: (loading: boolean, text?: string) => void + + // 更新操作 + setUpdateInfo: (info: any) => void + setIsDownloading: (isDownloading: boolean) => void + setDownloadProgress: (progress: any) => void + setShowUpdateDialog: (show: boolean) => void + setUpdateError: (error: string | null) => void + reset: () => void } @@ -24,23 +43,41 @@ export const useAppStore = create<AppState>((set) => ({ isLoading: false, loadingText: '', - setDbConnected: (connected, path) => set({ - isDbConnected: connected, - dbPath: path ?? null + // 更新状态初始化 + updateInfo: null, + isDownloading: false, + downloadProgress: { percent: 0 }, + showUpdateDialog: false, + updateError: null, + + setDbConnected: (connected, path) => set({ + isDbConnected: connected, + dbPath: path ?? null }), - + setMyWxid: (wxid) => set({ myWxid: wxid }), - - setLoading: (loading, text) => set({ - isLoading: loading, - loadingText: text ?? '' + + setLoading: (loading, text) => set({ + isLoading: loading, + loadingText: text ?? '' }), - + + setUpdateInfo: (info) => set({ updateInfo: info, updateError: null }), + setIsDownloading: (isDownloading) => set({ isDownloading: isDownloading }), + setDownloadProgress: (progress) => set({ downloadProgress: progress }), + setShowUpdateDialog: (show) => set({ showUpdateDialog: show }), + setUpdateError: (error) => set({ updateError: error }), + reset: () => set({ isDbConnected: false, dbPath: null, myWxid: null, isLoading: false, - loadingText: '' + loadingText: '', + updateInfo: null, + isDownloading: false, + downloadProgress: { percent: 0 }, + showUpdateDialog: false, + updateError: null }) })) diff --git a/src/styles/main.scss b/src/styles/main.scss index 31cb00b..f7d4a55 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -8,33 +8,33 @@ --primary-light: rgba(139, 115, 85, 0.1); --danger: #dc3545; --warning: #ffc107; - + // 背景 --bg-primary: #F0EEE9; --bg-secondary: rgba(255, 255, 255, 0.7); --bg-tertiary: rgba(0, 0, 0, 0.03); --bg-hover: rgba(0, 0, 0, 0.05); - + // 文字 --text-primary: #3d3d3d; --text-secondary: #666666; --text-tertiary: #999999; - + // 边框 --border-color: rgba(0, 0, 0, 0.08); --border-radius: 9999px; - + // 阴影 --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); - + // 侧边栏 --sidebar-width: 220px; - + // 主题渐变 --bg-gradient: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%); --primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%); - + // 卡片背景 --card-bg: rgba(255, 255, 255, 0.7); } @@ -235,7 +235,8 @@ box-sizing: border-box; } -html, body { +html, +body { height: 100%; font-family: 'HarmonyOS Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; @@ -263,7 +264,7 @@ html, body { ::-webkit-scrollbar-thumb { background: var(--text-tertiary); border-radius: 3px; - + &:hover { background: var(--text-secondary); } @@ -280,20 +281,20 @@ html, body { font-size: 14px; cursor: pointer; transition: all 0.2s; - + &-primary { background: var(--primary); color: white; - + &:hover { background: var(--primary-hover); } } - + &-secondary { background: var(--bg-tertiary); color: var(--text-primary); - + &:hover { background: var(--border-color); } @@ -307,3 +308,60 @@ html, body { box-shadow: var(--shadow-sm); padding: 16px; } + +// 全局 Switch 开关样式 +.switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + flex-shrink: 0; + + input { + opacity: 0; + width: 0; + height: 0; + } + + .switch-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--bg-tertiary); + transition: 0.3s; + border-radius: 24px; + border: 1px solid var(--border-color); + + &::before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 2px; + bottom: 2px; + background-color: var(--text-tertiary); + transition: 0.3s; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + } + } + + input:checked+.switch-slider { + background-color: var(--primary); + border-color: var(--primary); + + &::before { + transform: translateX(20px); + background-color: #ffffff; + } + } + + // 禁用状态 + input:disabled+.switch-slider { + opacity: 0.5; + cursor: not-allowed; + } +} \ No newline at end of file diff --git a/src/types/analytics.ts b/src/types/analytics.ts index fbbd60d..1fd98f4 100644 --- a/src/types/analytics.ts +++ b/src/types/analytics.ts @@ -41,11 +41,12 @@ export const MESSAGE_TYPE_LABELS: Record<number, string> = { 244813135921: '文本', 3: '图片', 34: '语音', + 42: '名片', 43: '视频', 47: '表情', 48: '位置', 49: '链接/文件', - 42: '名片', + 50: '通话', 10000: '系统消息', } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 616228b..8866bd9 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -11,6 +11,7 @@ export interface ElectronAPI { setTitleBarOverlay: (options: { symbolColor: string }) => void openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void> resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void> + openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean> } config: { get: (key: string) => Promise<unknown> @@ -106,6 +107,7 @@ export interface ElectronAPI { getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }> + getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }> } image: { @@ -343,9 +345,25 @@ export interface ElectronAPI { createTime: number contentDesc: string type?: number - media: Array<{ url: string; thumb: string }> + media: Array<{ + url: string + thumb: string + md5?: string + token?: string + key?: string + encIdx?: string + livePhoto?: { + url: string + thumb: string + md5?: string + token?: string + key?: string + encIdx?: string + } + }> likes: Array<string> comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }> + rawXml?: string }> error?: string }> @@ -367,6 +385,7 @@ export interface ExportOptions { txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' + exportConcurrency?: number } export interface ExportProgress { diff --git a/src/types/models.ts b/src/types/models.ts index 45ec73d..2600c69 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -53,8 +53,44 @@ export interface Message { // 引用消息 quotedContent?: string quotedSender?: string + // Type 49 细分字段 + linkTitle?: string // 链接/文件标题 + linkUrl?: string // 链接 URL + linkThumb?: string // 链接缩略图 + fileName?: string // 文件名 + fileSize?: number // 文件大小 + fileExt?: string // 文件扩展名 + xmlType?: string // XML 中的 type 字段 + // 名片消息 + cardUsername?: string // 名片的微信ID + cardNickname?: string // 名片的昵称 + // 聊天记录 + chatRecordTitle?: string // 聊天记录标题 + chatRecordList?: ChatRecordItem[] // 聊天记录列表 } +// 聊天记录项 +export interface ChatRecordItem { + datatype: number // 消息类型 + sourcename: string // 发送者 + sourcetime: string // 时间 + sourceheadurl?: string // 发送者头像 + datadesc?: string // 内容描述 + datatitle?: string // 标题 + fileext?: string // 文件扩展名 + datasize?: number // 文件大小 + messageuuid?: string // 消息UUID + dataurl?: string // 数据URL + datathumburl?: string // 缩略图URL + datacdnurl?: string // CDN URL + aeskey?: string // AES密钥 + md5?: string // MD5 + imgheight?: number // 图片高度 + imgwidth?: number // 图片宽度 + duration?: number // 时长(毫秒) +} + + // 分析数据 export interface AnalyticsData { totalMessages: number