diff --git a/.gitignore b/.gitignore index d1425df..ce31e26 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,5 @@ wcdb/ chatlab-format.md *.bak AGENTS.md -.claude/ \ No newline at end of file +.claude/ +.agents/ \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index d29bf64..2e635b6 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -914,6 +914,9 @@ function registerIpcHandlers() { ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => { return chatService.getAllVoiceMessages(sessionId) }) + ipcMain.handle('chat:getAllImageMessages', async (_, sessionId: string) => { + return chatService.getAllImageMessages(sessionId) + }) ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => { return chatService.getMessageDates(sessionId) }) @@ -1031,7 +1034,65 @@ function registerIpcHandlers() { ? mainWindow : (BrowserWindow.fromWebContents(event.sender) || undefined) - return windowsHelloService.verify(message, targetWin) + const result = await windowsHelloService.verify(message, targetWin) + + // Hello 验证成功后,自动用 authHelloSecret 中的密码解锁密钥 + if (result && configService) { + const secret = configService.getHelloSecret() + if (secret && configService.isLockMode()) { + configService.unlock(secret) + } + } + + return result + }) + + // 验证应用锁状态(检测 lock: 前缀,防篡改) + ipcMain.handle('auth:verifyEnabled', async () => { + return configService?.verifyAuthEnabled() ?? false + }) + + // 密码解锁(验证 + 解密密钥到内存) + ipcMain.handle('auth:unlock', async (_event, password: string) => { + if (!configService) return { success: false, error: '配置服务未初始化' } + return configService.unlock(password) + }) + + // 开启应用锁 + ipcMain.handle('auth:enableLock', async (_event, password: string) => { + if (!configService) return { success: false, error: '配置服务未初始化' } + return configService.enableLock(password) + }) + + // 关闭应用锁 + ipcMain.handle('auth:disableLock', async (_event, password: string) => { + if (!configService) return { success: false, error: '配置服务未初始化' } + return configService.disableLock(password) + }) + + // 修改密码 + ipcMain.handle('auth:changePassword', async (_event, oldPassword: string, newPassword: string) => { + if (!configService) return { success: false, error: '配置服务未初始化' } + return configService.changePassword(oldPassword, newPassword) + }) + + // 设置 Hello Secret + ipcMain.handle('auth:setHelloSecret', async (_event, password: string) => { + if (!configService) return { success: false } + configService.setHelloSecret(password) + return { success: true } + }) + + // 清除 Hello Secret + ipcMain.handle('auth:clearHelloSecret', async () => { + if (!configService) return { success: false } + configService.clearHelloSecret() + return { success: true } + }) + + // 检查是否处于 lock: 模式 + ipcMain.handle('auth:isLockMode', async () => { + return configService?.isLockMode() ?? false }) // 导出相关 diff --git a/electron/preload.ts b/electron/preload.ts index 674ee21..4cf585b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -24,7 +24,15 @@ contextBridge.exposeInMainWorld('electronAPI', { // 认证 auth: { - hello: (message?: string) => ipcRenderer.invoke('auth:hello', message) + hello: (message?: string) => ipcRenderer.invoke('auth:hello', message), + verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'), + unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password), + enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password), + disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password), + changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword), + setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password), + clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'), + isLockMode: () => ipcRenderer.invoke('auth:isLockMode') }, @@ -146,6 +154,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId), + getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId), getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 45be6d9..eda6e7a 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -1,4 +1,4 @@ -import { join, dirname, basename, extname } from 'path' +import { join, dirname, basename, extname } from 'path' import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch } from 'fs' import * as path from 'path' import * as fs from 'fs' @@ -73,9 +73,36 @@ export interface Message { fileSize?: number // 文件大小 fileExt?: string // 文件扩展名 xmlType?: string // XML 中的 type 字段 + appMsgKind?: string // 归一化 appmsg 类型 + appMsgDesc?: string + appMsgAppName?: string + appMsgSourceName?: string + appMsgSourceUsername?: string + appMsgThumbUrl?: string + appMsgMusicUrl?: string + appMsgDataUrl?: string + appMsgLocationLabel?: string + finderNickname?: string + finderUsername?: string + finderCoverUrl?: string + finderAvatar?: string + finderDuration?: number + // 位置消息 + locationLat?: number + locationLng?: number + locationPoiname?: string + locationLabel?: string + // 音乐消息 + musicAlbumUrl?: string + musicUrl?: string + // 礼物消息 + giftImageUrl?: string + giftWish?: string + giftPrice?: string // 名片消息 cardUsername?: string // 名片的微信ID cardNickname?: string // 名片的昵称 + cardAvatarUrl?: string // 名片头像 URL // 转账消息 transferPayerUsername?: string // 转账付款人 transferReceiverUsername?: string // 转账收款人 @@ -733,15 +760,15 @@ class ChatService { } const batchSize = Math.max(1, limit || this.messageBatchDefault) - + // 使用互斥锁保护游标状态访问 while (this.messageCursorMutex) { await new Promise(resolve => setTimeout(resolve, 1)) } this.messageCursorMutex = true - + let state = this.messageCursors.get(sessionId) - + // 只在以下情况重新创建游标: // 1. 没有游标状态 // 2. offset 为 0 (重新加载会话) @@ -778,7 +805,7 @@ class ChatService { state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending } this.messageCursors.set(sessionId, state) this.messageCursorMutex = false - + // 如果需要跳过消息(offset > 0),逐批获取但不返回 // 注意:仅在 offset === 0 时重建游标最安全; // 当 startTime/endTime 变化导致重建时,offset 应由前端重置为 0 @@ -879,7 +906,7 @@ class ChatService { // 群聊消息:senderUsername 是群成员,需要检查 _db_path 或上下文 // 单聊消息:senderUsername 应该是 sessionId 或自己 const isGroupChat = sessionId.includes('@chatroom') - + if (isGroupChat) { // 群聊消息暂不验证(因为 senderUsername 是群成员,不是 sessionId) return true @@ -916,7 +943,7 @@ class ChatService { state.fetched += rows.length this.messageCursorMutex = false - + this.messageCacheService.set(sessionId, filtered) return { success: true, messages: filtered, hasMore } } catch (e) { @@ -1224,9 +1251,33 @@ class ChatService { let fileSize: number | undefined let fileExt: string | undefined let xmlType: string | undefined + let appMsgKind: string | undefined + let appMsgDesc: string | undefined + let appMsgAppName: string | undefined + let appMsgSourceName: string | undefined + let appMsgSourceUsername: string | undefined + let appMsgThumbUrl: string | undefined + let appMsgMusicUrl: string | undefined + let appMsgDataUrl: string | undefined + let appMsgLocationLabel: string | undefined + let finderNickname: string | undefined + let finderUsername: string | undefined + let finderCoverUrl: string | undefined + let finderAvatar: string | undefined + let finderDuration: number | undefined + let locationLat: number | undefined + let locationLng: number | undefined + let locationPoiname: string | undefined + let locationLabel: string | undefined + let musicAlbumUrl: string | undefined + let musicUrl: string | undefined + let giftImageUrl: string | undefined + let giftWish: string | undefined + let giftPrice: string | undefined // 名片消息 let cardUsername: string | undefined let cardNickname: string | undefined + let cardAvatarUrl: string | undefined // 转账消息 let transferPayerUsername: string | undefined let transferReceiverUsername: string | undefined @@ -1264,6 +1315,15 @@ class ChatService { const cardInfo = this.parseCardInfo(content) cardUsername = cardInfo.username cardNickname = cardInfo.nickname + cardAvatarUrl = cardInfo.avatarUrl + } else if (localType === 48 && content) { + // 位置消息 + const latStr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude') + const lngStr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude') + if (latStr) { const v = parseFloat(latStr); if (Number.isFinite(v)) locationLat = v } + if (lngStr) { const v = parseFloat(lngStr); if (Number.isFinite(v)) locationLng = v } + locationLabel = this.extractXmlAttribute(content, 'location', 'label') || this.extractXmlValue(content, 'label') || undefined + locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || this.extractXmlValue(content, 'poiname') || undefined } else if ((localType === 49 || localType === 8589934592049) && content) { // Type 49 消息(链接、文件、小程序、转账等),8589934592049 也是转账类型 const type49Info = this.parseType49Message(content) @@ -1284,6 +1344,45 @@ class ChatService { quotedSender = quoteInfo.sender } + const looksLikeAppMsg = Boolean(content && (content.includes(' 0 && genericTitle.length < 100) { @@ -1416,6 +1550,23 @@ class ChatService { private parseType49(content: string): string { const title = this.extractXmlValue(content, 'title') const type = this.extractXmlValue(content, 'type') + const normalized = content.toLowerCase() + const locationLabel = + this.extractXmlAttribute(content, 'location', 'label') || + this.extractXmlAttribute(content, 'location', 'poiname') || + this.extractXmlValue(content, 'label') || + this.extractXmlValue(content, 'poiname') + const isFinder = + type === '51' || + normalized.includes('') || + normalized.includes('') || + normalized.includes('') // 群公告消息(type 87)特殊处理 if (type === '87') { @@ -1426,6 +1577,19 @@ class ChatService { return '[群公告]' } + if (isFinder) { + return title ? `[视频号] ${title}` : '[视频号]' + } + if (isRedPacket) { + return title ? `[红包] ${title}` : '[红包]' + } + if (locationLabel) { + return `[位置] ${locationLabel}` + } + if (isMusic) { + return title ? `[音乐] ${title}` : '[音乐]' + } + if (title) { switch (type) { case '5': @@ -1443,6 +1607,8 @@ class ChatService { return title case '2000': return `[转账] ${title}` + case '2001': + return `[红包] ${title}` default: return title } @@ -1459,6 +1625,13 @@ class ChatService { return '[小程序]' case '2000': return '[转账]' + case '2001': + return '[红包]' + case '3': + return '[音乐]' + case '5': + case '49': + return '[链接]' case '87': return '[群公告]' default: @@ -1764,7 +1937,7 @@ class ChatService { * 解析名片消息 * 格式: */ - private parseCardInfo(content: string): { username?: string; nickname?: string } { + private parseCardInfo(content: string): { username?: string; nickname?: string; avatarUrl?: string } { try { if (!content) return {} @@ -1774,7 +1947,11 @@ class ChatService { // 提取 nickname const nickname = this.extractXmlAttribute(content, 'msg', 'nickname') || undefined - return { username, nickname } + // 提取头像 + const avatarUrl = this.extractXmlAttribute(content, 'msg', 'bigheadimgurl') || + this.extractXmlAttribute(content, 'msg', 'smallheadimgurl') || undefined + + return { username, nickname, avatarUrl } } catch (e) { console.error('[ChatService] 名片解析失败:', e) return {} @@ -1790,6 +1967,30 @@ class ChatService { linkTitle?: string linkUrl?: string linkThumb?: string + appMsgKind?: string + appMsgDesc?: string + appMsgAppName?: string + appMsgSourceName?: string + appMsgSourceUsername?: string + appMsgThumbUrl?: string + appMsgMusicUrl?: string + appMsgDataUrl?: string + appMsgLocationLabel?: string + finderNickname?: string + finderUsername?: string + finderCoverUrl?: string + finderAvatar?: string + finderDuration?: number + locationLat?: number + locationLng?: number + locationPoiname?: string + locationLabel?: string + musicAlbumUrl?: string + musicUrl?: string + giftImageUrl?: string + giftWish?: string + giftPrice?: string + cardAvatarUrl?: string fileName?: string fileSize?: number fileExt?: string @@ -1816,6 +2017,122 @@ class ChatService { // 提取通用字段 const title = this.extractXmlValue(content, 'title') const url = this.extractXmlValue(content, 'url') + const desc = this.extractXmlValue(content, 'des') || this.extractXmlValue(content, 'description') + const appName = this.extractXmlValue(content, 'appname') + const sourceName = this.extractXmlValue(content, 'sourcename') + const sourceUsername = this.extractXmlValue(content, 'sourceusername') + const thumbUrl = + this.extractXmlValue(content, 'thumburl') || + this.extractXmlValue(content, 'cdnthumburl') || + this.extractXmlValue(content, 'cover') || + this.extractXmlValue(content, 'coverurl') || + this.extractXmlValue(content, 'thumb_url') + const musicUrl = + this.extractXmlValue(content, 'musicurl') || + this.extractXmlValue(content, 'playurl') || + this.extractXmlValue(content, 'songalbumurl') + const dataUrl = this.extractXmlValue(content, 'dataurl') || this.extractXmlValue(content, 'lowurl') + const locationLabel = + this.extractXmlAttribute(content, 'location', 'label') || + this.extractXmlAttribute(content, 'location', 'poiname') || + this.extractXmlValue(content, 'label') || + this.extractXmlValue(content, 'poiname') + const finderUsername = + this.extractXmlValue(content, 'finderusername') || + this.extractXmlValue(content, 'finder_username') || + this.extractXmlValue(content, 'finderuser') + const finderNickname = + this.extractXmlValue(content, 'findernickname') || + this.extractXmlValue(content, 'finder_nickname') + const normalized = content.toLowerCase() + const isFinder = xmlType === '51' + const isRedPacket = xmlType === '2001' + const isMusic = xmlType === '3' + const isLocation = Boolean(locationLabel) + + result.linkTitle = title || undefined + result.linkUrl = url || undefined + result.linkThumb = thumbUrl || undefined + result.appMsgDesc = desc || undefined + result.appMsgAppName = appName || undefined + result.appMsgSourceName = sourceName || undefined + result.appMsgSourceUsername = sourceUsername || undefined + result.appMsgThumbUrl = thumbUrl || undefined + result.appMsgMusicUrl = musicUrl || undefined + result.appMsgDataUrl = dataUrl || undefined + result.appMsgLocationLabel = locationLabel || undefined + result.finderUsername = finderUsername || undefined + result.finderNickname = finderNickname || undefined + + // 视频号封面/头像/时长 + if (isFinder) { + const finderCover = + this.extractXmlValue(content, 'thumbUrl') || + this.extractXmlValue(content, 'coverUrl') || + this.extractXmlValue(content, 'thumburl') || + this.extractXmlValue(content, 'coverurl') + if (finderCover) result.finderCoverUrl = finderCover + const finderAvatar = this.extractXmlValue(content, 'avatar') + if (finderAvatar) result.finderAvatar = finderAvatar + const durationStr = this.extractXmlValue(content, 'videoPlayDuration') || this.extractXmlValue(content, 'duration') + if (durationStr) { + const d = parseInt(durationStr, 10) + if (Number.isFinite(d) && d > 0) result.finderDuration = d + } + } + + // 位置经纬度 + if (isLocation) { + const latAttr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude') + const lngAttr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude') + if (latAttr) { const v = parseFloat(latAttr); if (Number.isFinite(v)) result.locationLat = v } + if (lngAttr) { const v = parseFloat(lngAttr); if (Number.isFinite(v)) result.locationLng = v } + result.locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || locationLabel || undefined + result.locationLabel = this.extractXmlAttribute(content, 'location', 'label') || undefined + } + + // 音乐专辑封面 + if (isMusic) { + const albumUrl = this.extractXmlValue(content, 'songalbumurl') + if (albumUrl) result.musicAlbumUrl = albumUrl + result.musicUrl = musicUrl || dataUrl || url || undefined + } + + // 礼物消息 + const isGift = xmlType === '115' + if (isGift) { + result.giftWish = this.extractXmlValue(content, 'wishmessage') || undefined + result.giftImageUrl = this.extractXmlValue(content, 'skuimgurl') || undefined + result.giftPrice = this.extractXmlValue(content, 'skuprice') || undefined + } + + if (isFinder) { + result.appMsgKind = 'finder' + } else if (isRedPacket) { + result.appMsgKind = 'red-packet' + } else if (isGift) { + result.appMsgKind = 'gift' + } else if (isLocation) { + result.appMsgKind = 'location' + } else if (isMusic) { + result.appMsgKind = 'music' + } else if (xmlType === '33' || xmlType === '36') { + result.appMsgKind = 'miniapp' + } else if (xmlType === '6') { + result.appMsgKind = 'file' + } else if (xmlType === '19') { + result.appMsgKind = 'chat-record' + } else if (xmlType === '2000') { + result.appMsgKind = 'transfer' + } else if (xmlType === '87') { + result.appMsgKind = 'announcement' + } else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) { + result.appMsgKind = 'official-link' + } else if (url) { + result.appMsgKind = 'link' + } else { + result.appMsgKind = 'card' + } switch (xmlType) { case '6': { @@ -3884,6 +4201,74 @@ class ChatService { * 获取某会话中有消息的日期列表 * 返回 YYYY-MM-DD 格式的日期字符串数组 */ + /** + * 获取某会话的全部图片消息(用于聊天页批量图片解密) + */ + async getAllImageMessages( + sessionId: string + ): Promise<{ success: boolean; images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + 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) + } + } + + let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = [] + + for (const { tableName, dbPath } of tables) { + try { + const sql = `SELECT * FROM ${tableName} WHERE local_type = 3 ORDER BY create_time DESC` + const result = await wcdbService.execQuery('message', dbPath, sql) + if (result.success && result.rows && result.rows.length > 0) { + const mapped = this.mapRowsToMessages(result.rows as Record[]) + const images = mapped + .filter(msg => msg.localType === 3) + .map(msg => ({ + imageMd5: msg.imageMd5 || undefined, + imageDatName: msg.imageDatName || undefined, + createTime: msg.createTime || undefined + })) + .filter(img => Boolean(img.imageMd5 || img.imageDatName)) + allImages.push(...images) + } + } catch (e) { + console.error(`[ChatService] 查询图片消息失败 (${dbPath}):`, e) + } + } + + allImages.sort((a, b) => (b.createTime || 0) - (a.createTime || 0)) + + const seen = new Set() + allImages = allImages.filter(img => { + const key = img.imageMd5 || img.imageDatName || '' + if (!key || seen.has(key)) return false + seen.add(key) + return true + }) + + console.log(`[ChatService] 共找到 ${allImages.length} 条图片消息(去重后)`) + return { success: true, images: allImages } + } catch (e) { + console.error('[ChatService] 获取全部图片消息失败:', e) + return { success: false, error: String(e) } + } + } + async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> { try { const connectResult = await this.ensureConnected() @@ -4017,6 +4402,15 @@ class ChatService { msg.emojiThumbUrl = emojiInfo.thumbUrl msg.emojiEncryptUrl = emojiInfo.encryptUrl msg.emojiAesKey = emojiInfo.aesKey + } else if (msg.localType === 42) { + const cardInfo = this.parseCardInfo(rawContent) + msg.cardUsername = cardInfo.username + msg.cardNickname = cardInfo.nickname + msg.cardAvatarUrl = cardInfo.avatarUrl + } + + if (rawContent && (rawContent.includes(' = new Set(['decryptKey', 'imageAesKey', 'authPassword']) +const ENCRYPTED_BOOL_KEYS: Set = new Set(['authEnabled', 'authUseHello']) +const ENCRYPTED_NUMBER_KEYS: Set = new Set(['imageXorKey']) + +// 需要与密码绑定的敏感密钥字段(锁定模式时用 lock: 加密) +const LOCKABLE_STRING_KEYS: Set = new Set(['decryptKey', 'imageAesKey']) +const LOCKABLE_NUMBER_KEYS: Set = new Set(['imageXorKey']) + export class ConfigService { private static instance: ConfigService private store!: Store + // 锁定模式运行时状态 + private unlockedKeys: Map = new Map() + private unlockPassword: string | null = null + static getInstance(): ConfigService { if (!ConfigService.instance) { ConfigService.instance = new ConfigService() @@ -75,7 +93,6 @@ export class ConfigService { imageAesKey: '', wxidConfigs: {}, cachePath: '', - lastOpenedDb: '', lastSession: '', theme: 'system', @@ -90,11 +107,10 @@ export class ConfigService { transcribeLanguages: ['zh'], exportDefaultConcurrency: 2, analyticsExcludedUsernames: [], - authEnabled: false, authPassword: '', authUseHello: false, - + authHelloSecret: '', ignoredUpdateVersion: '', notificationEnabled: true, notificationPosition: 'top-right', @@ -103,29 +119,535 @@ export class ConfigService { wordCloudExcludeWords: [] } }) + this.migrateAuthFields() } + // === 状态查询 === + + isLockMode(): boolean { + const raw: any = this.store.get('decryptKey') + return typeof raw === 'string' && raw.startsWith(LOCK_PREFIX) + } + + isUnlocked(): boolean { + return !this.isLockMode() || this.unlockedKeys.size > 0 + } + + // === get / set === + get(key: K): ConfigSchema[K] { - return this.store.get(key) + const raw = this.store.get(key) + + if (ENCRYPTED_BOOL_KEYS.has(key)) { + const str = typeof raw === 'string' ? raw : '' + if (!str || !str.startsWith(SAFE_PREFIX)) return raw + return (this.safeDecrypt(str) === 'true') as ConfigSchema[K] + } + + if (ENCRYPTED_NUMBER_KEYS.has(key)) { + const str = typeof raw === 'string' ? raw : '' + if (!str) return raw + if (str.startsWith(LOCK_PREFIX)) { + const cached = this.unlockedKeys.get(key as string) + return (cached !== undefined ? cached : 0) as ConfigSchema[K] + } + if (!str.startsWith(SAFE_PREFIX)) return raw + const num = Number(this.safeDecrypt(str)) + return (Number.isFinite(num) ? num : 0) as ConfigSchema[K] + } + + if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') { + if (key === 'authPassword') return this.safeDecrypt(raw) as ConfigSchema[K] + if (raw.startsWith(LOCK_PREFIX)) { + const cached = this.unlockedKeys.get(key as string) + return (cached !== undefined ? cached : '') as ConfigSchema[K] + } + return this.safeDecrypt(raw) as ConfigSchema[K] + } + + if (key === 'wxidConfigs' && raw && typeof raw === 'object') { + return this.decryptWxidConfigs(raw as any) as ConfigSchema[K] + } + + return raw } set(key: K, value: ConfigSchema[K]): void { - this.store.set(key, value) + let toStore = value + const inLockMode = this.isLockMode() && this.unlockPassword + + if (ENCRYPTED_BOOL_KEYS.has(key)) { + toStore = this.safeEncrypt(String(value)) as ConfigSchema[K] + } else if (ENCRYPTED_NUMBER_KEYS.has(key)) { + if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) { + toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K] + this.unlockedKeys.set(key as string, value) + } else { + toStore = this.safeEncrypt(String(value)) as ConfigSchema[K] + } + } else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') { + if (key === 'authPassword') { + toStore = this.safeEncrypt(value) as ConfigSchema[K] + } else if (inLockMode && LOCKABLE_STRING_KEYS.has(key)) { + toStore = this.lockEncrypt(value, this.unlockPassword!) as ConfigSchema[K] + this.unlockedKeys.set(key as string, value) + } else { + toStore = this.safeEncrypt(value) as ConfigSchema[K] + } + } else if (key === 'wxidConfigs' && value && typeof value === 'object') { + if (inLockMode) { + toStore = this.lockEncryptWxidConfigs(value as any) as ConfigSchema[K] + } else { + toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K] + } + } + + this.store.set(key, toStore) } + // === 加密/解密工具 === + + private safeEncrypt(plaintext: string): string { + if (!plaintext) return '' + if (plaintext.startsWith(SAFE_PREFIX)) return plaintext + if (!safeStorage.isEncryptionAvailable()) return plaintext + const encrypted = safeStorage.encryptString(plaintext) + return SAFE_PREFIX + encrypted.toString('base64') + } + + private safeDecrypt(stored: string): string { + if (!stored) return '' + if (!stored.startsWith(SAFE_PREFIX)) return stored + if (!safeStorage.isEncryptionAvailable()) return '' + try { + const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64') + return safeStorage.decryptString(buf) + } catch { + return '' + } + } + + private lockEncrypt(plaintext: string, password: string): string { + if (!plaintext) return '' + const salt = crypto.randomBytes(16) + const iv = crypto.randomBytes(12) + const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256') + const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv) + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + const authTag = cipher.getAuthTag() + const combined = Buffer.concat([salt, iv, authTag, encrypted]) + return LOCK_PREFIX + combined.toString('base64') + } + + private lockDecrypt(stored: string, password: string): string | null { + if (!stored || !stored.startsWith(LOCK_PREFIX)) return null + try { + const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64') + const salt = combined.subarray(0, 16) + const iv = combined.subarray(16, 28) + const authTag = combined.subarray(28, 44) + const ciphertext = combined.subarray(44) + const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256') + const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv) + decipher.setAuthTag(authTag) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return decrypted.toString('utf8') + } catch { + return null + } + } + + // 通过尝试解密 lock: 字段来验证密码是否正确(当 authPassword 被删除时使用) + private verifyPasswordByDecrypt(password: string): boolean { + // 依次尝试解密任意一个 lock: 字段,GCM authTag 会验证密码正确性 + const lockFields = ['decryptKey', 'imageAesKey', 'imageXorKey'] as const + for (const key of lockFields) { + const raw: any = this.store.get(key as any) + if (typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) { + const result = this.lockDecrypt(raw, password) + // lockDecrypt 返回 null 表示解密失败(密码错误),非 null 表示成功 + return result !== null + } + } + return false + } + + // === wxidConfigs 加密/解密 === + + private encryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] { + const result: ConfigSchema['wxidConfigs'] = {} + for (const [wxid, cfg] of Object.entries(configs)) { + result[wxid] = { ...cfg } + if (cfg.decryptKey) result[wxid].decryptKey = this.safeEncrypt(cfg.decryptKey) + if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeEncrypt(cfg.imageAesKey) + if (cfg.imageXorKey !== undefined) { + (result[wxid] as any).imageXorKey = this.safeEncrypt(String(cfg.imageXorKey)) + } + } + return result + } + + private decryptLockedWxidConfigs(password: string): void { + const wxidConfigs = this.store.get('wxidConfigs') + if (!wxidConfigs || typeof wxidConfigs !== 'object') return + for (const [wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) { + if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && cfg.decryptKey.startsWith(LOCK_PREFIX)) { + const d = this.lockDecrypt(cfg.decryptKey, password) + if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, d) + } + if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && cfg.imageAesKey.startsWith(LOCK_PREFIX)) { + const d = this.lockDecrypt(cfg.imageAesKey, password) + if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, d) + } + if (cfg.imageXorKey && typeof cfg.imageXorKey === 'string' && cfg.imageXorKey.startsWith(LOCK_PREFIX)) { + const d = this.lockDecrypt(cfg.imageXorKey, password) + if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, Number(d)) + } + } + } + + private decryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] { + const result: ConfigSchema['wxidConfigs'] = {} + for (const [wxid, cfg] of Object.entries(configs) as [string, any][]) { + result[wxid] = { ...cfg, updatedAt: cfg.updatedAt } + // decryptKey + if (typeof cfg.decryptKey === 'string') { + if (cfg.decryptKey.startsWith(LOCK_PREFIX)) { + result[wxid].decryptKey = this.unlockedKeys.get(`wxid:${wxid}:decryptKey`) ?? '' + } else { + result[wxid].decryptKey = this.safeDecrypt(cfg.decryptKey) + } + } + // imageAesKey + if (typeof cfg.imageAesKey === 'string') { + if (cfg.imageAesKey.startsWith(LOCK_PREFIX)) { + result[wxid].imageAesKey = this.unlockedKeys.get(`wxid:${wxid}:imageAesKey`) ?? '' + } else { + result[wxid].imageAesKey = this.safeDecrypt(cfg.imageAesKey) + } + } + // imageXorKey + if (typeof cfg.imageXorKey === 'string') { + if (cfg.imageXorKey.startsWith(LOCK_PREFIX)) { + result[wxid].imageXorKey = this.unlockedKeys.get(`wxid:${wxid}:imageXorKey`) ?? 0 + } else if (cfg.imageXorKey.startsWith(SAFE_PREFIX)) { + const num = Number(this.safeDecrypt(cfg.imageXorKey)) + result[wxid].imageXorKey = Number.isFinite(num) ? num : 0 + } + } + } + return result + } + private lockEncryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] { + const result: ConfigSchema['wxidConfigs'] = {} + for (const [wxid, cfg] of Object.entries(configs)) { + result[wxid] = { ...cfg } + if (cfg.decryptKey) result[wxid].decryptKey = this.lockEncrypt(cfg.decryptKey, this.unlockPassword!) as any + if (cfg.imageAesKey) result[wxid].imageAesKey = this.lockEncrypt(cfg.imageAesKey, this.unlockPassword!) as any + if (cfg.imageXorKey !== undefined) { + (result[wxid] as any).imageXorKey = this.lockEncrypt(String(cfg.imageXorKey), this.unlockPassword!) + } + } + return result + } + + // === 业务方法 === + + enableLock(password: string): { success: boolean; error?: string } { + try { + // 先读取当前所有明文密钥 + const decryptKey = this.get('decryptKey') + const imageAesKey = this.get('imageAesKey') + const imageXorKey = this.get('imageXorKey') + const wxidConfigs = this.get('wxidConfigs') + + // 存储密码 hash(safeStorage 加密) + const passwordHash = crypto.createHash('sha256').update(password).digest('hex') + this.store.set('authPassword', this.safeEncrypt(passwordHash) as any) + this.store.set('authEnabled', this.safeEncrypt('true') as any) + + // 设置运行时状态 + this.unlockPassword = password + this.unlockedKeys.set('decryptKey', decryptKey) + this.unlockedKeys.set('imageAesKey', imageAesKey) + this.unlockedKeys.set('imageXorKey', imageXorKey) + + // 用密码派生密钥重新加密所有敏感字段 + if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), password) as any) + if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), password) as any) + if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), password) as any) + + // 处理 wxidConfigs 中的嵌套密钥 + if (wxidConfigs && Object.keys(wxidConfigs).length > 0) { + const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs) + this.store.set('wxidConfigs', lockedConfigs) + for (const [wxid, cfg] of Object.entries(wxidConfigs)) { + if (cfg.decryptKey) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, cfg.decryptKey) + if (cfg.imageAesKey) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, cfg.imageAesKey) + if (cfg.imageXorKey !== undefined) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, cfg.imageXorKey) + } + } + + return { success: true } + } catch (e: any) { + return { success: false, error: e.message } + } + } + + unlock(password: string): { success: boolean; error?: string } { + try { + // 验证密码 + const storedHash = this.safeDecrypt(this.store.get('authPassword') as any) + const inputHash = crypto.createHash('sha256').update(password).digest('hex') + + if (storedHash && storedHash !== inputHash) { + // authPassword 存在但密码不匹配 + return { success: false, error: '密码错误' } + } + + if (!storedHash) { + // authPassword 被删除/损坏,尝试用密码直接解密 lock: 字段来验证 + const verified = this.verifyPasswordByDecrypt(password) + if (!verified) { + return { success: false, error: '密码错误' } + } + // 密码正确,自愈 authPassword + const newHash = crypto.createHash('sha256').update(password).digest('hex') + this.store.set('authPassword', this.safeEncrypt(newHash) as any) + this.store.set('authEnabled', this.safeEncrypt('true') as any) + } + + // 解密所有 lock: 字段到内存缓存 + const rawDecryptKey: any = this.store.get('decryptKey') + if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) { + const d = this.lockDecrypt(rawDecryptKey, password) + if (d !== null) this.unlockedKeys.set('decryptKey', d) + } + + const rawImageAesKey: any = this.store.get('imageAesKey') + if (typeof rawImageAesKey === 'string' && rawImageAesKey.startsWith(LOCK_PREFIX)) { + const d = this.lockDecrypt(rawImageAesKey, password) + if (d !== null) this.unlockedKeys.set('imageAesKey', d) + } + + const rawImageXorKey: any = this.store.get('imageXorKey') + if (typeof rawImageXorKey === 'string' && rawImageXorKey.startsWith(LOCK_PREFIX)) { + const d = this.lockDecrypt(rawImageXorKey, password) + if (d !== null) this.unlockedKeys.set('imageXorKey', Number(d)) + } + + // 解密 wxidConfigs 嵌套密钥 + this.decryptLockedWxidConfigs(password) + + // 保留密码供 set() 使用 + this.unlockPassword = password + return { success: true } + } catch (e: any) { + return { success: false, error: e.message } + } + } + + disableLock(password: string): { success: boolean; error?: string } { + try { + // 验证密码 + const storedHash = this.safeDecrypt(this.store.get('authPassword') as any) + const inputHash = crypto.createHash('sha256').update(password).digest('hex') + if (storedHash !== inputHash) { + return { success: false, error: '密码错误' } + } + + // 先解密所有 lock: 字段 + if (this.unlockedKeys.size === 0) { + this.unlock(password) + } + + // 将所有密钥转回 safe: 格式 + const decryptKey = this.unlockedKeys.get('decryptKey') + const imageAesKey = this.unlockedKeys.get('imageAesKey') + const imageXorKey = this.unlockedKeys.get('imageXorKey') + + if (decryptKey) this.store.set('decryptKey', this.safeEncrypt(String(decryptKey)) as any) + if (imageAesKey) this.store.set('imageAesKey', this.safeEncrypt(String(imageAesKey)) as any) + if (imageXorKey !== undefined) this.store.set('imageXorKey', this.safeEncrypt(String(imageXorKey)) as any) + + // 转换 wxidConfigs + const wxidConfigs = this.get('wxidConfigs') + if (wxidConfigs && Object.keys(wxidConfigs).length > 0) { + const safeConfigs = this.encryptWxidConfigs(wxidConfigs) + this.store.set('wxidConfigs', safeConfigs) + } + + // 清除 auth 字段 + this.store.set('authEnabled', false as any) + this.store.set('authPassword', '' as any) + this.store.set('authUseHello', false as any) + this.store.set('authHelloSecret', '' as any) + + // 清除运行时状态 + this.unlockedKeys.clear() + this.unlockPassword = null + + return { success: true } + } catch (e: any) { + return { success: false, error: e.message } + } + } + + changePassword(oldPassword: string, newPassword: string): { success: boolean; error?: string } { + try { + // 验证旧密码 + const storedHash = this.safeDecrypt(this.store.get('authPassword') as any) + const oldHash = crypto.createHash('sha256').update(oldPassword).digest('hex') + if (storedHash !== oldHash) { + return { success: false, error: '旧密码错误' } + } + + // 确保已解锁 + if (this.unlockedKeys.size === 0) { + this.unlock(oldPassword) + } + + // 用新密码重新加密所有密钥 + const decryptKey = this.unlockedKeys.get('decryptKey') + const imageAesKey = this.unlockedKeys.get('imageAesKey') + const imageXorKey = this.unlockedKeys.get('imageXorKey') + + if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), newPassword) as any) + if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), newPassword) as any) + if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), newPassword) as any) + + // 重新加密 wxidConfigs + const wxidConfigs = this.get('wxidConfigs') + if (wxidConfigs && Object.keys(wxidConfigs).length > 0) { + this.unlockPassword = newPassword + const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs) + this.store.set('wxidConfigs', lockedConfigs) + } + + // 更新密码 hash + const newHash = crypto.createHash('sha256').update(newPassword).digest('hex') + this.store.set('authPassword', this.safeEncrypt(newHash) as any) + + // 更新 Hello secret(如果启用了 Hello) + const useHello = this.get('authUseHello') + if (useHello) { + this.store.set('authHelloSecret', this.safeEncrypt(newPassword) as any) + } + + this.unlockPassword = newPassword + return { success: true } + } catch (e: any) { + return { success: false, error: e.message } + } + } + + // === Hello 相关 === + + setHelloSecret(password: string): void { + this.store.set('authHelloSecret', this.safeEncrypt(password) as any) + this.store.set('authUseHello', this.safeEncrypt('true') as any) + } + + getHelloSecret(): string { + const raw: any = this.store.get('authHelloSecret') + if (!raw || typeof raw !== 'string') return '' + return this.safeDecrypt(raw) + } + + clearHelloSecret(): void { + this.store.set('authHelloSecret', '' as any) + this.store.set('authUseHello', this.safeEncrypt('false') as any) + } + + // === 迁移 === + + private migrateAuthFields(): void { + // 将旧版明文 auth 字段迁移为 safeStorage 加密格式 + // 如果已经是 safe: 或 lock: 前缀则跳过 + const rawEnabled: any = this.store.get('authEnabled') + if (typeof rawEnabled === 'boolean') { + this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any) + } + + const rawUseHello: any = this.store.get('authUseHello') + if (typeof rawUseHello === 'boolean') { + this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any) + } + + const rawPassword: any = this.store.get('authPassword') + if (typeof rawPassword === 'string' && rawPassword && !rawPassword.startsWith(SAFE_PREFIX)) { + this.store.set('authPassword', this.safeEncrypt(rawPassword) as any) + } + + // 迁移敏感密钥字段(明文 → safe:) + for (const key of LOCKABLE_STRING_KEYS) { + const raw: any = this.store.get(key as any) + if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX) && !raw.startsWith(LOCK_PREFIX)) { + this.store.set(key as any, this.safeEncrypt(raw) as any) + } + } + + // imageXorKey: 数字 → safe: + const rawXor: any = this.store.get('imageXorKey') + if (typeof rawXor === 'number' && rawXor !== 0) { + this.store.set('imageXorKey', this.safeEncrypt(String(rawXor)) as any) + } + + // wxidConfigs 中的嵌套密钥 + const wxidConfigs: any = this.store.get('wxidConfigs') + if (wxidConfigs && typeof wxidConfigs === 'object') { + let changed = false + for (const [_wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) { + if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && !cfg.decryptKey.startsWith(SAFE_PREFIX) && !cfg.decryptKey.startsWith(LOCK_PREFIX)) { + cfg.decryptKey = this.safeEncrypt(cfg.decryptKey) + changed = true + } + if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && !cfg.imageAesKey.startsWith(SAFE_PREFIX) && !cfg.imageAesKey.startsWith(LOCK_PREFIX)) { + cfg.imageAesKey = this.safeEncrypt(cfg.imageAesKey) + changed = true + } + if (typeof cfg.imageXorKey === 'number' && cfg.imageXorKey !== 0) { + cfg.imageXorKey = this.safeEncrypt(String(cfg.imageXorKey)) + changed = true + } + } + if (changed) { + this.store.set('wxidConfigs', wxidConfigs) + } + } + } + + // === 验证 === + + verifyAuthEnabled(): boolean { + // 先检查 authEnabled 字段 + const rawEnabled: any = this.store.get('authEnabled') + if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) { + if (this.safeDecrypt(rawEnabled) === 'true') return true + } + + // 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁 + const rawDecryptKey: any = this.store.get('decryptKey') + if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) { + return true + } + + return false + } + + // === 工具方法 === + getCacheBasePath(): string { - const configured = this.get('cachePath') - if (configured && configured.trim().length > 0) { - return configured - } - return join(app.getPath('documents'), 'WeFlow') + return join(app.getPath('userData'), 'cache') } - getAll(): ConfigSchema { + getAll(): Partial { return this.store.store } clear(): void { this.store.clear() + this.unlockedKeys.clear() + this.unlockPassword = null } -} +} \ No newline at end of file diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index b3c8b05..7a8c043 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -155,6 +155,17 @@ export class ImageDecryptService { return { success: false, error: '缺少图片标识' } } + if (payload.force) { + const hdCached = this.findCachedOutput(cacheKey, true, payload.sessionId) + if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached) && !this.isThumbnailPath(hdCached)) { + const dataUrl = this.fileToDataUrl(hdCached) + const localPath = dataUrl || this.filePathToUrl(hdCached) + const liveVideoPath = this.checkLiveVideoCache(hdCached) + this.emitCacheResolved(payload, cacheKey, localPath) + return { success: true, localPath, isThumb: false, liveVideoPath } + } + } + if (!payload.force) { const cached = this.resolvedCache.get(cacheKey) if (cached && existsSync(cached) && this.isImageFile(cached)) { @@ -346,23 +357,37 @@ export class ImageDecryptService { * 获取解密后的缓存目录(用于查找 hardlink.db) */ private getDecryptedCacheDir(wxid: string): string | null { - const cachePath = this.configService.get('cachePath') - if (!cachePath) return null - const cleanedWxid = this.cleanAccountDirName(wxid) - const cacheAccountDir = join(cachePath, cleanedWxid) + const configured = this.configService.get('cachePath') + const documentsPath = app.getPath('documents') + const baseCandidates = Array.from(new Set([ + configured || '', + join(documentsPath, 'WeFlow'), + join(documentsPath, 'WeFlowData'), + this.configService.getCacheBasePath() + ].filter(Boolean))) - // 检查缓存目录下是否有 hardlink.db - if (existsSync(join(cacheAccountDir, 'hardlink.db'))) { - return cacheAccountDir - } - if (existsSync(join(cachePath, 'hardlink.db'))) { - return cachePath - } - const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink') - if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) { - return cacheHardlinkDir + for (const base of baseCandidates) { + const accountCandidates = Array.from(new Set([ + join(base, wxid), + join(base, cleanedWxid), + join(base, 'databases', wxid), + join(base, 'databases', cleanedWxid) + ])) + for (const accountDir of accountCandidates) { + if (existsSync(join(accountDir, 'hardlink.db'))) { + return accountDir + } + const hardlinkSubdir = join(accountDir, 'db_storage', 'hardlink') + if (existsSync(join(hardlinkSubdir, 'hardlink.db'))) { + return hardlinkSubdir + } + } + if (existsSync(join(base, 'hardlink.db'))) { + return base + } } + return null } @@ -371,7 +396,8 @@ export class ImageDecryptService { existsSync(join(dirPath, 'hardlink.db')) || existsSync(join(dirPath, 'db_storage')) || existsSync(join(dirPath, 'FileStorage', 'Image')) || - existsSync(join(dirPath, 'FileStorage', 'Image2')) + existsSync(join(dirPath, 'FileStorage', 'Image2')) || + existsSync(join(dirPath, 'msg', 'attach')) ) } @@ -437,6 +463,12 @@ export class ImageDecryptService { if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } + const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || imageMd5 || '', false) + if (hdInDir) { + this.cacheDatPath(accountDir, imageMd5, hdInDir) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdInDir) + return hdInDir + } // 没找到高清图,返回 null(不进行全局搜索) return null } @@ -454,9 +486,16 @@ export class ImageDecryptService { // 找到缩略图但要求高清图,尝试同目录查找高清图变体 const hdPath = this.findHdVariantInSameDir(fallbackPath) if (hdPath) { + this.cacheDatPath(accountDir, imageMd5, hdPath) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } + const hdInDir = await this.searchDatFileInDir(dirname(fallbackPath), imageDatName || imageMd5 || '', false) + if (hdInDir) { + this.cacheDatPath(accountDir, imageMd5, hdInDir) + this.cacheDatPath(accountDir, imageDatName, hdInDir) + return hdInDir + } return null } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) @@ -479,15 +518,17 @@ export class ImageDecryptService { this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } + const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || '', false) + if (hdInDir) { + this.cacheDatPath(accountDir, imageDatName, hdInDir) + return hdInDir + } return null } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) } - // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) - if (!allowThumbnail) { - return null - } + // force 模式下也继续尝试缓存目录/文件系统搜索,避免 hardlink.db 缺行时只能拿到缩略图 if (!imageDatName) return null if (!skipResolvedCache) { @@ -497,6 +538,8 @@ export class ImageDecryptService { // 缓存的是缩略图,尝试找高清图 const hdPath = this.findHdVariantInSameDir(cached) if (hdPath) return hdPath + const hdInDir = await this.searchDatFileInDir(dirname(cached), imageDatName, false) + if (hdInDir) return hdInDir } } diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 08fcf8c..89b5039 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -1024,7 +1024,7 @@ export class WcdbCore { } try { // 1. 打开游标 (使用 Ascending=1 从指定时间往后查) - const openRes = await this.openMessageCursorLite(sessionId, limit, true, minTime, 0) + const openRes = await this.openMessageCursor(sessionId, limit, true, minTime, 0) if (!openRes.success || !openRes.cursor) { return { success: false, error: openRes.error } } diff --git a/package-lock.json b/package-lock.json index 4c688ff..92d10ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2910,7 +2909,6 @@ "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3057,7 +3055,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3997,7 +3994,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5107,7 +5103,6 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -5295,7 +5290,6 @@ "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "2.3.0", "zrender": "5.6.1" @@ -5382,6 +5376,7 @@ "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "archiver": "^5.3.1", @@ -5395,6 +5390,7 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5410,6 +5406,7 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5423,6 +5420,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -9152,7 +9150,6 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9162,7 +9159,6 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9597,7 +9593,6 @@ "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -9828,9 +9823,6 @@ "sherpa-onnx-win-x64": "^1.12.23" } }, - "node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": { - "optional": true - }, "node_modules/sherpa-onnx-win-ia32": { "version": "1.12.23", "resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz", @@ -10442,7 +10434,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10890,7 +10881,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10980,8 +10970,7 @@ "resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -11007,7 +10996,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/src/App.tsx b/src/App.tsx index 1473e18..c999a80 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import UpdateProgressCapsule from './components/UpdateProgressCapsule' import LockScreen from './components/LockScreen' import { GlobalSessionMonitor } from './components/GlobalSessionMonitor' import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal' +import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal' function App() { const navigate = useNavigate() @@ -312,7 +313,7 @@ function App() { const checkLock = async () => { // 并行获取配置,减少等待 const [enabled, useHello] = await Promise.all([ - configService.getAuthEnabled(), + window.electronAPI.auth.verifyEnabled(), configService.getAuthUseHello() ]) @@ -385,6 +386,7 @@ function App() { {/* 全局批量转写进度浮窗 */} + {/* 用户协议弹窗 */} {showAgreement && !agreementLoading && ( diff --git a/src/components/BatchImageDecryptGlobal.tsx b/src/components/BatchImageDecryptGlobal.tsx new file mode 100644 index 0000000..e819d14 --- /dev/null +++ b/src/components/BatchImageDecryptGlobal.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import { Loader2, X, Image as ImageIcon, Clock, CheckCircle, XCircle } from 'lucide-react' +import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore' +import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' +import '../styles/batchTranscribe.scss' + +export const BatchImageDecryptGlobal: React.FC = () => { + const { + isBatchDecrypting, + progress, + showToast, + showResultToast, + result, + sessionName, + startTime, + setShowToast, + setShowResultToast + } = useBatchImageDecryptStore() + + const voiceToastOccupied = useBatchTranscribeStore( + state => state.isBatchTranscribing && state.showToast + ) + + const [eta, setEta] = useState('') + + useEffect(() => { + if (!isBatchDecrypting || !startTime || progress.current === 0) { + setEta('') + return + } + + const timer = setInterval(() => { + const elapsed = Date.now() - startTime + if (elapsed <= 0) return + const rate = progress.current / elapsed + const remain = progress.total - progress.current + if (remain <= 0 || rate <= 0) { + setEta('') + return + } + const seconds = Math.ceil((remain / rate) / 1000) + if (seconds < 60) { + setEta(`${seconds}秒`) + } else { + const m = Math.floor(seconds / 60) + const s = seconds % 60 + setEta(`${m}分${s}秒`) + } + }, 1000) + + return () => clearInterval(timer) + }, [isBatchDecrypting, progress.current, progress.total, startTime]) + + useEffect(() => { + if (!showResultToast) return + const timer = window.setTimeout(() => setShowResultToast(false), 6000) + return () => window.clearTimeout(timer) + }, [showResultToast, setShowResultToast]) + + const toastBottom = useMemo(() => (voiceToastOccupied ? 148 : 24), [voiceToastOccupied]) + + return ( + <> + {showToast && isBatchDecrypting && createPortal( +
+
+
+ + 批量解密图片{sessionName ? `(${sessionName})` : ''} +
+ +
+
+
+
+ {progress.current} / {progress.total} + + {progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0}% + +
+ {eta && ( +
+ + 剩余 {eta} +
+ )} +
+
+
0 ? (progress.current / progress.total) * 100 : 0}%` + }} + /> +
+
+
, + document.body + )} + + {showResultToast && createPortal( +
+
+
+ + 图片批量解密完成 +
+ +
+
+
+
+ + 成功 {result.success} +
+
0 ? 'fail' : 'muted'}`}> + + 失败 {result.fail} +
+
+
+
, + document.body + )} + + ) +} + diff --git a/src/components/LockScreen.tsx b/src/components/LockScreen.tsx index 94d5701..5f9945f 100644 --- a/src/components/LockScreen.tsx +++ b/src/components/LockScreen.tsx @@ -1,5 +1,4 @@ import { useState, useEffect, useRef } from 'react' -import * as configService from '../services/config' import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react' import './LockScreen.scss' @@ -9,14 +8,6 @@ interface LockScreenProps { 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('') @@ -49,19 +40,9 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS const quickStartHello = async () => { try { - // 如果父组件已经告诉我们要用 Hello,直接开始,不等待 IPC - let shouldUseHello = useHello - - // 为了稳健,如果 prop 没传(虽然现在都传了),再 check 一次 config - if (!shouldUseHello) { - shouldUseHello = await configService.getAuthUseHello() - } - - if (shouldUseHello) { - // 标记为可用,显示按钮 + if (useHello) { setHelloAvailable(true) setShowHello(true) - // 立即执行验证 (0延迟) verifyHello() } } catch (e) { @@ -96,25 +77,19 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS e?.preventDefault() if (!password || isUnlocked) return - // 如果正在进行 Hello 验证,它会自动失败或被取代,UI上不用特意取消 - // 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可 - - // 不再检查 isVerifying,因为我们允许打断 Hello setIsVerifying(true) setError('') try { - const storedHash = await configService.getAuthPassword() - const inputHash = await sha256(password) + // 发送原始密码到主进程,由主进程验证并解密密钥 + const result = await window.electronAPI.auth.unlock(password) - if (inputHash === storedHash) { + if (result.success) { handleUnlock() } else { - setError('密码错误') + setError(result.error || '密码错误') setPassword('') setIsVerifying(false) - // 如果密码错误,是否重新触发 Hello? - // 用户可能想重试密码,暂时不自动触发 } } catch (e) { setError('验证失败') diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 014e0d9..0085b6d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react' import { useAppStore } from '../stores/appStore' -import * as configService from '../services/config' + import './Sidebar.scss' function Sidebar() { @@ -12,7 +12,7 @@ function Sidebar() { const setLocked = useAppStore(state => state.setLocked) useEffect(() => { - configService.getAuthEnabled().then(setAuthEnabled) + window.electronAPI.auth.verifyEnabled().then(setAuthEnabled) }, []) const isActive = (path: string) => { diff --git a/src/components/Sns/SnsMediaGrid.tsx b/src/components/Sns/SnsMediaGrid.tsx index a2b832f..6200223 100644 --- a/src/components/Sns/SnsMediaGrid.tsx +++ b/src/components/Sns/SnsMediaGrid.tsx @@ -21,6 +21,7 @@ interface SnsMedia { interface SnsMediaGridProps { mediaList: SnsMedia[] + postType?: number onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void onMediaDeleted?: () => void } @@ -80,7 +81,7 @@ const extractVideoFrame = async (videoPath: string): Promise => { }) } -const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => { +const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsMedia; postType?: number; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => { const [error, setError] = useState(false) const [deleted, setDeleted] = useState(false) const [loading, setLoading] = useState(true) @@ -96,6 +97,8 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr const isVideo = isSnsVideoUrl(media.url) const isLive = !!media.livePhoto const targetUrl = media.thumb || media.url + // type 7 的朋友圈媒体不需要解密,直接使用原始 URL + const skipDecrypt = postType === 7 // 视频重试:失败时重试最多2次,耗尽才标记删除 const videoRetryOrDelete = () => { @@ -119,7 +122,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr // For images, we proxy to get the local path/base64 const result = await window.electronAPI.sns.proxyImage({ url: targetUrl, - key: media.key + key: skipDecrypt ? undefined : media.key }) if (cancelled) return @@ -134,7 +137,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr if (isLive && media.livePhoto?.url) { window.electronAPI.sns.proxyImage({ url: media.livePhoto.url, - key: media.livePhoto.key || media.key + key: skipDecrypt ? undefined : (media.livePhoto.key || media.key) }).then((res: any) => { if (!cancelled && res.success && res.videoPath) { setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`) @@ -150,7 +153,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr // Usually we need to call proxyImage with the video URL to decrypt it to cache const result = await window.electronAPI.sns.proxyImage({ url: media.url, - key: media.key + key: skipDecrypt ? undefined : media.key }) if (cancelled) return @@ -201,7 +204,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr try { const res = await window.electronAPI.sns.proxyImage({ url: media.url, - key: media.key + key: skipDecrypt ? undefined : media.key }) if (res.success && res.videoPath) { const local = `file://${res.videoPath.replace(/\\/g, '/')}` @@ -229,7 +232,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr try { const result = await window.electronAPI.sns.proxyImage({ url: media.url, - key: media.key + key: skipDecrypt ? undefined : media.key }) if (result.success) { @@ -334,7 +337,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr ) } -export const SnsMediaGrid: React.FC = ({ mediaList, onPreview, onMediaDeleted }) => { +export const SnsMediaGrid: React.FC = ({ mediaList, postType, onPreview, onMediaDeleted }) => { if (!mediaList || mediaList.length === 0) return null const count = mediaList.length @@ -350,7 +353,7 @@ export const SnsMediaGrid: React.FC = ({ mediaList, onPreview return (
{mediaList.map((media, idx) => ( - + ))}
) diff --git a/src/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx index 6cef3b5..bf65dca 100644 --- a/src/components/Sns/SnsPostItem.tsx +++ b/src/components/Sns/SnsPostItem.tsx @@ -264,7 +264,7 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb {showMediaGrid && (
- setMediaDeleted(true) : undefined} /> + setMediaDeleted(true) : undefined} />
)} diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 8a41b65..abd6368 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1041,12 +1041,13 @@ // 链接卡片消息样式 .link-message { width: 280px; - background: var(--card-bg); + background: var(--card-inner-bg); border-radius: 8px; overflow: hidden; cursor: pointer; transition: all 0.2s ease; border: 1px solid var(--border-color); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); &:hover { background: var(--bg-hover); @@ -1114,19 +1115,362 @@ } } } + + .appmsg-meta-badge { + font-size: 11px; + line-height: 1; + color: var(--primary); + background: rgba(127, 127, 127, 0.08); + border: 1px solid rgba(127, 127, 127, 0.18); + border-radius: 999px; + padding: 3px 7px; + align-self: flex-start; + white-space: nowrap; + } + + .link-desc-block { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + + .appmsg-url-line { + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &.appmsg-rich-card { + .link-header { + flex-direction: column; + align-items: flex-start; + } + } +} + +.link-thumb.theme-adaptive, +.miniapp-thumb.theme-adaptive { + transition: filter 0.2s ease; +} + +[data-mode="dark"] { + + .link-thumb.theme-adaptive, + .miniapp-thumb.theme-adaptive { + filter: invert(1) hue-rotate(180deg); + } } // 适配发送出去的消息中的链接卡片 .message-bubble.sent .link-message { - background: var(--card-bg); - border: 1px solid var(--border-color); + background: var(--sent-card-bg); + border: 1px solid rgba(255, 255, 255, 0.15); + + &:hover { + background: var(--primary-hover); + border-color: rgba(255, 255, 255, 0.25); + } .link-title { - color: var(--text-primary); + color: white; } .link-desc { + color: rgba(255, 255, 255, 0.8); + } + + .appmsg-url-line { + color: rgba(255, 255, 255, 0.6); + } +} + +// ============= 专属消息卡片 ============= + +// 红包卡片 +.hongbao-message { + width: 240px; + background: linear-gradient(135deg, #e25b4a 0%, #c94535 100%); + border-radius: 12px; + padding: 14px 16px; + display: flex; + gap: 12px; + align-items: center; + cursor: default; + + .hongbao-icon { + flex-shrink: 0; + + svg { + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1)); + } + } + + .hongbao-info { + flex: 1; + color: white; + + .hongbao-greeting { + font-size: 15px; + font-weight: 500; + margin-bottom: 6px; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + } + + .hongbao-label { + font-size: 12px; + opacity: 0.8; + } + } +} + +// 礼物卡片 +.gift-message { + width: 240px; + background: linear-gradient(135deg, #f7a8b8 0%, #e88fa0 100%); + border-radius: 12px; + padding: 14px 16px; + cursor: default; + + .gift-img { + width: 100%; + border-radius: 8px; + margin-bottom: 10px; + object-fit: cover; + } + + .gift-info { + color: white; + + .gift-wish { + font-size: 15px; + font-weight: 500; + margin-bottom: 4px; + } + + .gift-price { + font-size: 13px; + font-weight: 600; + margin-bottom: 4px; + } + + .gift-label { + font-size: 12px; + opacity: 0.7; + } + } +} + +// 视频号卡片 +.channel-video-card { + width: 200px; + background: var(--card-inner-bg); + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border-color); + position: relative; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: var(--primary); + } + + .channel-video-cover { + position: relative; + width: 100%; + height: 160px; + background: #000; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .channel-video-cover-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: #666; + } + + .channel-video-duration { + position: absolute; + bottom: 6px; + right: 6px; + background: rgba(0, 0, 0, 0.6); + color: white; + font-size: 11px; + padding: 1px 5px; + border-radius: 3px; + } + } + + .channel-video-info { + padding: 8px 10px; + + .channel-video-title { + font-size: 13px; + color: var(--text-primary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin-bottom: 4px; + } + + .channel-video-author { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--text-secondary); + + .channel-video-avatar { + width: 16px; + height: 16px; + border-radius: 50%; + } + } + } +} + +// 音乐卡片 +.music-message { + width: 240px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + display: flex; + overflow: hidden; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + opacity: 0.85; + border-color: var(--primary); + } + + .music-cover { + width: 80px; + align-self: stretch; + flex-shrink: 0; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; color: var(--text-secondary); + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } + + .music-info { + flex: 1; + min-width: 0; + overflow: hidden; + padding: 10px; + + .music-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .music-artist { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .music-source { + font-size: 11px; + color: var(--text-tertiary); + margin-top: 2px; + } + } +} + +// 位置消息卡片 +.location-message { + width: 240px; + background: var(--card-inner-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: var(--primary); + } + + .location-text { + padding: 12px; + display: flex; + gap: 8px; + } + + .location-icon { + flex-shrink: 0; + color: #e25b4a; + margin-top: 2px; + } + + .location-info { + flex: 1; + min-width: 0; + + .location-name { + font-size: 14px; + font-weight: 500; + margin-bottom: 2px; + color: var(--text-primary); + } + + .location-label { + font-size: 11px; + color: var(--text-tertiary); + line-height: 1.4; + } + } + + .location-map { + position: relative; + height: 100px; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } +} + +// 暗色模式下地图瓦片反色 +[data-mode="dark"] { + .location-map img { + filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9); } } @@ -1512,6 +1856,20 @@ } } +// 卡片类消息:气泡变透明,让卡片自己做视觉容器 +.message-bubble .bubble-content:has(.link-message), +.message-bubble .bubble-content:has(.card-message), +.message-bubble .bubble-content:has(.chat-record-message), +.message-bubble .bubble-content:has(.official-message), +.message-bubble .bubble-content:has(.channel-video-card), +.message-bubble .bubble-content:has(.location-message) { + background: transparent !important; + padding: 0 !important; + border: none !important; + box-shadow: none !important; + backdrop-filter: none !important; +} + .emoji-image { max-width: 120px; max-height: 120px; @@ -2487,10 +2845,17 @@ display: flex; align-items: center; gap: 12px; - padding: 12px; - background: var(--bg-tertiary); + padding: 12px 14px; + background: var(--card-inner-bg); + border: 1px solid var(--border-color); border-radius: 8px; min-width: 200px; + transition: opacity 0.2s ease; + cursor: pointer; + + &:hover { + opacity: 0.85; + } .card-icon { flex-shrink: 0; @@ -2515,6 +2880,18 @@ } } +// 聊天记录消息 (合并转发) +.chat-record-message { + background: var(--card-inner-bg) !important; + border: 1px solid var(--border-color) !important; + transition: opacity 0.2s ease; + cursor: pointer; + + &:hover { + opacity: 0.85; + } +} + // 通话消息 .call-message { display: flex; @@ -2752,12 +3129,14 @@ .card-message, .chat-record-message, - .miniapp-message { - background: rgba(255, 255, 255, 0.15); + .miniapp-message, + .appmsg-rich-card { + background: var(--sent-card-bg); .card-name, .miniapp-title, - .source-name { + .source-name, + .link-title { color: white; } @@ -2765,7 +3144,9 @@ .miniapp-label, .chat-record-item, .chat-record-meta-line, - .chat-record-desc { + .chat-record-desc, + .link-desc, + .appmsg-url-line { color: rgba(255, 255, 255, 0.8); } @@ -2778,6 +3159,12 @@ .chat-record-more { color: rgba(255, 255, 255, 0.9); } + + .appmsg-meta-badge { + color: rgba(255, 255, 255, 0.92); + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); + } } .call-message { @@ -3235,4 +3622,234 @@ } } } +} + +.miniapp-message-rich { + .miniapp-thumb { + width: 42px; + height: 42px; + border-radius: 8px; + object-fit: cover; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + flex-shrink: 0; + } +} + +// 名片消息样式 +.card-message { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: var(--card-inner-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + + &:hover { + background: var(--bg-hover); + } + + .card-icon { + width: 40px; + height: 40px; + border-radius: 8px; + overflow: hidden; + background: var(--bg-secondary); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + } + + .card-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + .card-name { + font-size: 15px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .card-wxid { + font-size: 12px; + color: var(--text-tertiary); + } + + .card-label { + font-size: 11px; + color: var(--text-tertiary); + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid var(--border-color); + } +} + +// 聊天记录消息外观 +.chat-record-message { + background: var(--card-inner-bg) !important; + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + + &:hover { + background: var(--bg-hover) !important; + } + + .chat-record-list { + font-size: 13px; + color: var(--text-tertiary); + line-height: 1.6; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-color); + + .chat-record-item { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .source-name { + color: var(--text-secondary); + } + } + } + + .chat-record-more { + font-size: 12px; + color: var(--text-tertiary); + margin-top: 4px; + } +} + +// 公众号文章图文消息外观 (大图模式) +.official-message { + display: flex; + flex-direction: column; + background: var(--card-inner-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: all 0.2s ease; + min-width: 240px; + max-width: 320px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + + &:hover { + background: var(--bg-hover); + } + + .official-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + + .official-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; + } + + .official-avatar-placeholder { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--bg-secondary); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + + svg { + width: 14px; + height: 14px; + } + } + + .official-name { + font-size: 13px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .official-body { + display: flex; + flex-direction: column; + + .official-cover-wrapper { + position: relative; + width: 100%; + padding-top: 42.5%; // ~2.35:1 aspectRatio standard for WeChat article covers + background: var(--bg-secondary); + overflow: hidden; + + .official-cover { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + + .official-title-overlay { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 24px 12px 10px; + background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.7)); + color: #fff; + font-size: 15px; + font-weight: 500; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + } + + .official-title-text { + padding: 0 12px 10px; + font-size: 15px; + 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; + } + + .official-digest { + font-size: 13px; + color: var(--text-tertiary); + padding: 0 12px 12px; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + } } \ No newline at end of file diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 89422bd..b8192c9 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' +import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore' import type { ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' @@ -27,6 +28,12 @@ interface XmlField { path: string; } +interface BatchImageDecryptCandidate { + imageMd5?: string + imageDatName?: string + createTime?: number +} + // 尝试解析 XML 为可编辑字段 function parseXmlToFields(xml: string): XmlField[] { const fields: XmlField[] = [] @@ -301,11 +308,16 @@ function ChatPage(_props: ChatPageProps) { // 批量语音转文字相关状态(进度/结果 由全局 store 管理) const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore() + const { isBatchDecrypting, progress: batchDecryptProgress, startDecrypt, updateProgress: updateDecryptProgress, finishDecrypt, setShowToast: setShowBatchDecryptToast } = useBatchImageDecryptStore() const [showBatchConfirm, setShowBatchConfirm] = useState(false) const [batchVoiceCount, setBatchVoiceCount] = useState(0) const [batchVoiceMessages, setBatchVoiceMessages] = useState(null) const [batchVoiceDates, setBatchVoiceDates] = useState([]) const [batchSelectedDates, setBatchSelectedDates] = useState>(new Set()) + const [showBatchDecryptConfirm, setShowBatchDecryptConfirm] = useState(false) + const [batchImageMessages, setBatchImageMessages] = useState(null) + const [batchImageDates, setBatchImageDates] = useState([]) + const [batchImageSelectedDates, setBatchImageSelectedDates] = useState>(new Set()) // 批量删除相关状态 const [isDeleting, setIsDeleting] = useState(false) @@ -1434,6 +1446,37 @@ function ChatPage(_props: ChatPageProps) { setShowBatchConfirm(true) }, [sessions, currentSessionId, isBatchTranscribing]) + const handleBatchDecrypt = useCallback(async () => { + if (!currentSessionId || isBatchDecrypting) return + const session = sessions.find(s => s.username === currentSessionId) + if (!session) { + alert('未找到当前会话') + return + } + + const result = await window.electronAPI.chat.getAllImageMessages(currentSessionId) + if (!result.success || !result.images) { + alert(`获取图片消息失败: ${result.error || '未知错误'}`) + return + } + + if (result.images.length === 0) { + alert('当前会话没有图片消息') + return + } + + const dateSet = new Set() + result.images.forEach((img: BatchImageDecryptCandidate) => { + if (img.createTime) dateSet.add(new Date(img.createTime * 1000).toISOString().slice(0, 10)) + }) + const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a)) + + setBatchImageMessages(result.images) + setBatchImageDates(sortedDates) + setBatchImageSelectedDates(new Set(sortedDates)) + setShowBatchDecryptConfirm(true) + }, [currentSessionId, isBatchDecrypting, sessions]) + const handleExportCurrentSession = useCallback(() => { if (!currentSessionId) return navigate('/export', { @@ -1557,6 +1600,88 @@ function ChatPage(_props: ChatPageProps) { const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates]) const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), []) + const confirmBatchDecrypt = useCallback(async () => { + if (!currentSessionId) return + + const selected = batchImageSelectedDates + if (selected.size === 0) { + alert('请至少选择一个日期') + return + } + + const images = (batchImageMessages || []).filter(img => + img.createTime && selected.has(new Date(img.createTime * 1000).toISOString().slice(0, 10)) + ) + if (images.length === 0) { + alert('所选日期下没有图片消息') + return + } + + const session = sessions.find(s => s.username === currentSessionId) + if (!session) return + + setShowBatchDecryptConfirm(false) + setBatchImageMessages(null) + setBatchImageDates([]) + setBatchImageSelectedDates(new Set()) + + startDecrypt(images.length, session.displayName || session.username) + + let successCount = 0 + let failCount = 0 + for (let i = 0; i < images.length; i++) { + const img = images[i] + try { + const r = await window.electronAPI.image.decrypt({ + sessionId: session.username, + imageMd5: img.imageMd5, + imageDatName: img.imageDatName, + force: false + }) + if (r?.success) successCount++ + else failCount++ + } catch { + failCount++ + } + + updateDecryptProgress(i + 1, images.length) + if (i % 5 === 0) { + await new Promise(resolve => setTimeout(resolve, 0)) + } + } + + finishDecrypt(successCount, failCount) + }, [batchImageMessages, batchImageSelectedDates, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress]) + + const batchImageCountByDate = useMemo(() => { + const map = new Map() + if (!batchImageMessages) return map + batchImageMessages.forEach(img => { + if (!img.createTime) return + const d = new Date(img.createTime * 1000).toISOString().slice(0, 10) + map.set(d, (map.get(d) ?? 0) + 1) + }) + return map + }, [batchImageMessages]) + + const batchImageSelectedCount = useMemo(() => { + if (!batchImageMessages) return 0 + return batchImageMessages.filter(img => + img.createTime && batchImageSelectedDates.has(new Date(img.createTime * 1000).toISOString().slice(0, 10)) + ).length + }, [batchImageMessages, batchImageSelectedDates]) + + const toggleBatchImageDate = useCallback((date: string) => { + setBatchImageSelectedDates(prev => { + const next = new Set(prev) + if (next.has(date)) next.delete(date) + else next.add(date) + return next + }) + }, []) + const selectAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set(batchImageDates)), [batchImageDates]) + const clearAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set()), []) + const lastSelectedIdRef = useRef(null) const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => { @@ -1996,6 +2121,26 @@ function ChatPage(_props: ChatPageProps) { )} + + +
+
    + {batchImageDates.map(dateStr => { + const count = batchImageCountByDate.get(dateStr) ?? 0 + const checked = batchImageSelectedDates.has(dateStr) + return ( +
  • + +
  • + ) + })} +
+ + )} +
+
+ 已选: + {batchImageSelectedDates.size} 天,共 {batchImageSelectedCount} 张图片 +
+
+
+ + 批量解密可能需要较长时间,进行中会在右下角显示非阻塞进度浮层。 +
+ +
+ + +
+ + , + document.body + )} {contextMenu && createPortal( <>
setContextMenu(null)} @@ -2856,7 +3061,7 @@ function MessageBubble({ setImageLocalPath(result.localPath) setImageHasUpdate(false) if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath) - return + return result } } @@ -2867,7 +3072,7 @@ function MessageBubble({ imageDataUrlCache.set(imageCacheKey, dataUrl) setImageLocalPath(dataUrl) setImageHasUpdate(false) - return + return { success: true, localPath: dataUrl } as any } if (!silent) setImageError(true) } catch { @@ -2875,6 +3080,7 @@ function MessageBubble({ } finally { if (!silent) setImageLoading(false) } + return { success: false } as any }, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64]) const triggerForceHd = useCallback(() => { @@ -2905,6 +3111,55 @@ function MessageBubble({ void requestImageDecrypt() }, [message.imageDatName, message.imageMd5, message.localId, requestImageDecrypt, session.username]) + const handleOpenImageViewer = useCallback(async () => { + if (!imageLocalPath) return + + let finalImagePath = imageLocalPath + let finalLiveVideoPath = imageLiveVideoPath || undefined + + // If current cache is a thumbnail, wait for a silent force-HD decrypt before opening viewer. + if (imageHasUpdate) { + try { + const upgraded = await requestImageDecrypt(true, true) + if (upgraded?.success && upgraded.localPath) { + finalImagePath = upgraded.localPath + finalLiveVideoPath = upgraded.liveVideoPath || finalLiveVideoPath + } + } catch { } + } + + // One more resolve helps when background/batch decrypt has produced a clearer image or live video + // but local component state hasn't caught up yet. + if (message.imageMd5 || message.imageDatName) { + try { + const resolved = await window.electronAPI.image.resolveCache({ + sessionId: session.username, + imageMd5: message.imageMd5 || undefined, + imageDatName: message.imageDatName + }) + if (resolved?.success && resolved.localPath) { + finalImagePath = resolved.localPath + finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath + imageDataUrlCache.set(imageCacheKey, resolved.localPath) + setImageLocalPath(resolved.localPath) + if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath) + setImageHasUpdate(Boolean(resolved.hasUpdate)) + } + } catch { } + } + + void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath) + }, [ + imageHasUpdate, + imageLiveVideoPath, + imageLocalPath, + imageCacheKey, + message.imageDatName, + message.imageMd5, + requestImageDecrypt, + session.username + ]) + useEffect(() => { return () => { if (imageClickTimerRef.current) { @@ -3426,10 +3681,7 @@ function MessageBubble({ src={imageLocalPath} alt="图片" className="image-message" - onClick={() => { - if (imageHasUpdate) void requestImageDecrypt(true, true) - void window.electronAPI.window.openImageViewerWindow(imageLocalPath!, imageLiveVideoPath || undefined) - }} + onClick={() => { void handleOpenImageViewer() }} onLoad={() => setImageError(false)} onError={() => setImageError(true)} /> @@ -3692,16 +3944,24 @@ function MessageBubble({ // 名片消息 if (isCard) { const cardName = message.cardNickname || message.cardUsername || '未知联系人' + const cardAvatar = message.cardAvatarUrl return (
- - - - + {cardAvatar ? ( + + ) : ( + + + + + )}
{cardName}
+ {message.cardUsername && message.cardUsername !== message.cardNickname && ( +
微信号: {message.cardUsername}
+ )}
个人名片
@@ -3720,7 +3980,329 @@ function MessageBubble({ ) } + // 位置消息 + if (message.localType === 48) { + const raw = message.rawContent || '' + const poiname = raw.match(/poiname="([^"]*)"/)?.[1] || message.locationPoiname || '位置' + const label = raw.match(/label="([^"]*)"/)?.[1] || message.locationLabel || '' + const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || String(message.locationLat || 0)) + const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || String(message.locationLng || 0)) + const zoom = 15 + const tileX = Math.floor((lng + 180) / 360 * Math.pow(2, zoom)) + const latRad = lat * Math.PI / 180 + const tileY = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * Math.pow(2, zoom)) + const mapTileUrl = (lat && lng) + ? `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${tileX}&y=${tileY}&z=${zoom}` + : '' + return ( +
window.electronAPI.shell.openExternal(`https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(poiname || label)}`)}> +
+
+ + + + +
+
+ {poiname &&
{poiname}
} + {label &&
{label}
} +
+
+ {mapTileUrl && ( +
+ 地图 +
+ )} +
+ ) + } + // 链接消息 (AppMessage) + const appMsgRichPreview = (() => { + const rawXml = message.rawContent || '' + if (!rawXml || (!rawXml.includes(' { + if (doc) return doc + try { + const start = rawXml.indexOf('') + const xml = start >= 0 ? rawXml.slice(start) : rawXml + doc = new DOMParser().parseFromString(xml, 'text/xml') + } catch { + doc = null + } + return doc + } + const q = (selector: string) => getDoc()?.querySelector(selector)?.textContent?.trim() || '' + + const xmlType = message.xmlType || q('appmsg > type') || q('type') + const title = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || 'Card' + const desc = message.appMsgDesc || q('des') + const url = message.linkUrl || q('url') + const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl') + const musicUrl = message.appMsgMusicUrl || message.appMsgDataUrl || q('musicurl') || q('playurl') || q('dataurl') || q('lowurl') + const sourceName = message.appMsgSourceName || q('sourcename') + const sourceDisplayName = q('sourcedisplayname') || '' + const appName = message.appMsgAppName || q('appname') + const sourceUsername = message.appMsgSourceUsername || q('sourceusername') + const finderName = + message.finderNickname || + message.finderUsername || + q('findernickname') || + q('finder_nickname') || + q('finderusername') || + q('finder_username') + + const lower = rawXml.toLowerCase() + + const kind = message.appMsgKind || ( + (xmlType === '2001' || lower.includes('hongbao')) ? 'red-packet' + : (xmlType === '115' ? 'gift' + : ((xmlType === '33' || xmlType === '36') ? 'miniapp' + : (((xmlType === '5' || xmlType === '49') && (sourceUsername.startsWith('gh_') || !!sourceName || appName.includes('公众号'))) ? 'official-link' + : (xmlType === '51' ? 'finder' + : (xmlType === '3' ? 'music' + : ((xmlType === '5' || xmlType === '49') ? 'link' // Fallback for standard links + : (!!musicUrl ? 'music' : ''))))))) + ) + + if (!kind) return null + + // 对视频号提取真实标题,避免出现 "当前版本不支持该内容" + let displayTitle = title + if (kind === 'finder' && (!displayTitle || displayTitle.includes('不支持'))) { + try { + const d = new DOMParser().parseFromString(rawXml, 'text/xml') + displayTitle = d.querySelector('finderFeed desc')?.textContent?.trim() || desc || '' + } catch { + displayTitle = desc || '' + } + } + + const openExternal = (e: React.MouseEvent, nextUrl?: string) => { + if (!nextUrl) return + e.stopPropagation() + if (window.electronAPI?.shell?.openExternal) { + window.electronAPI.shell.openExternal(nextUrl) + } else { + window.open(nextUrl, '_blank') + } + } + + const metaLabel = + kind === 'red-packet' ? '红包' + : kind === 'finder' ? (finderName || '视频号') + : kind === 'location' ? '位置' + : kind === 'music' ? (sourceName || appName || '音乐') + : (sourceName || appName || (sourceUsername.startsWith('gh_') ? '公众号' : '')) + + const renderCard = (cardKind: string, clickableUrl?: string) => ( +
openExternal(e, clickableUrl) : undefined} + title={clickableUrl} + > +
+
{title}
+ {metaLabel ?
{metaLabel}
: null} +
+
+
+ {desc ?
{desc}
: null} +
+ {thumbUrl ? ( + + ) : ( +
{cardKind.slice(0, 2).toUpperCase()}
+ )} +
+
+ ) + + if (kind === 'red-packet') { + // 专属红包卡片 + const greeting = (() => { + try { + const d = getDoc() + if (!d) return '' + return d.querySelector('receivertitle')?.textContent?.trim() || + d.querySelector('sendertitle')?.textContent?.trim() || '' + } catch { return '' } + })() + return ( +
+
+ + + + + ¥ + +
+
+
{greeting || '恭喜发财,大吉大利'}
+
微信红包
+
+
+ ) + } + + if (kind === 'gift') { + // 礼物卡片 + const giftImg = message.giftImageUrl || thumbUrl + const giftWish = message.giftWish || title || '送你一份心意' + const giftPriceRaw = message.giftPrice + const giftPriceYuan = giftPriceRaw ? (parseInt(giftPriceRaw) / 100).toFixed(2) : '' + return ( +
+ {giftImg && } +
+
{giftWish}
+ {giftPriceYuan &&
¥{giftPriceYuan}
} +
微信礼物
+
+
+ ) + } + + if (kind === 'finder') { + // 视频号专属卡片 + const coverUrl = message.finderCoverUrl || thumbUrl + const duration = message.finderDuration + const authorName = finderName || '' + const authorAvatar = message.finderAvatar + const fmtDuration = duration ? `${Math.floor(duration / 60)}:${String(duration % 60).padStart(2, '0')}` : '' + return ( +
openExternal(e, url) : undefined}> +
+ {coverUrl ? ( + + ) : ( +
+ + + +
+ )} + {fmtDuration && {fmtDuration}} +
+
+
{displayTitle || '视频号视频'}
+
+ {authorAvatar && } + {authorName || '视频号'} +
+
+
+ ) + } + + + + if (kind === 'music') { + // 音乐专属卡片 + const albumUrl = message.musicAlbumUrl || thumbUrl + const playUrl = message.musicUrl || musicUrl || url + const songTitle = title || '未知歌曲' + const artist = desc || '' + const appLabel = sourceName || appName || '' + return ( +
openExternal(e, playUrl) : undefined}> +
+ {albumUrl ? ( + + ) : ( + + + + )} +
+
+
{songTitle}
+ {artist &&
{artist}
} + {appLabel &&
{appLabel}
} +
+
+ ) + } + + if (kind === 'official-link') { + const authorAvatar = q('publisher > headimg') || q('brand_info > headimgurl') || q('appmsg > avatar') || q('headimgurl') || message.cardAvatarUrl + const authorName = sourceDisplayName || q('publisher > nickname') || sourceName || appName || '公众号' + const coverPic = q('mmreader > category > item > cover') || thumbUrl + const digest = q('mmreader > category > item > digest') || desc + const articleTitle = q('mmreader > category > item > title') || title + + return ( +
openExternal(e, url) : undefined}> +
+ {authorAvatar ? ( + + ) : ( +
+ + + + +
+ )} + {authorName} +
+
+ {coverPic ? ( +
+ +
{articleTitle}
+
+ ) : ( +
{articleTitle}
+ )} + {digest &&
{digest}
} +
+
+ ) + } + + if (kind === 'link') return renderCard('link', url || undefined) + if (kind === 'card') return renderCard('card', url || undefined) + if (kind === 'miniapp') { + return ( +
+
+ + + +
+
+
{title}
+
{metaLabel || '小程序'}
+
+ {thumbUrl ? ( + + ) : null} +
+ ) + } + return null + })() + + if (appMsgRichPreview) { + return appMsgRichPreview + } + const isAppMsg = message.rawContent?.includes('(null) + const liveCleanupTimerRef = useRef(null) + const [scale, setScale] = useState(1) const [rotation, setRotation] = useState(0) const [position, setPosition] = useState({ x: 0, y: 0 }) const [initialScale, setInitialScale] = useState(1) - const [imgNatural, setImgNatural] = useState({ w: 0, h: 0 }) const viewportRef = useRef(null) - + // 使用 ref 存储拖动状态,避免闭包问题 const dragStateRef = useRef({ isDragging: false, @@ -27,11 +30,49 @@ export default function ImageWindow() { startPosY: 0 }) + const clearLiveCleanupTimer = useCallback(() => { + if (liveCleanupTimerRef.current !== null) { + window.clearTimeout(liveCleanupTimerRef.current) + liveCleanupTimerRef.current = null + } + }, []) + + const stopLivePlayback = useCallback((immediate = false) => { + clearLiveCleanupTimer() + setIsVideoVisible(false) + + if (immediate) { + if (videoRef.current) { + videoRef.current.pause() + videoRef.current.currentTime = 0 + } + setIsPlayingLive(false) + return + } + + liveCleanupTimerRef.current = window.setTimeout(() => { + if (videoRef.current) { + videoRef.current.pause() + videoRef.current.currentTime = 0 + } + setIsPlayingLive(false) + liveCleanupTimerRef.current = null + }, 300) + }, [clearLiveCleanupTimer]) + + const handlePlayLiveVideo = useCallback(() => { + if (!liveVideoPath || isPlayingLive) return + + clearLiveCleanupTimer() + setIsPlayingLive(true) + setIsVideoVisible(false) + }, [clearLiveCleanupTimer, liveVideoPath, isPlayingLive]) + const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10)) const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1)) const handleRotate = () => setRotation(prev => (prev + 90) % 360) const handleRotateCcw = () => setRotation(prev => (prev - 90 + 360) % 360) - + // 重置视图 const handleReset = useCallback(() => { setScale(1) @@ -44,8 +85,7 @@ export default function ImageWindow() { const img = e.currentTarget const naturalWidth = img.naturalWidth const naturalHeight = img.naturalHeight - setImgNatural({ w: naturalWidth, h: naturalHeight }) - + if (viewportRef.current) { const viewportWidth = viewportRef.current.clientWidth * 0.9 const viewportHeight = viewportRef.current.clientHeight * 0.9 @@ -57,14 +97,37 @@ export default function ImageWindow() { } }, []) + // 视频挂载后再播放,避免点击瞬间 ref 尚未就绪导致丢播 + useEffect(() => { + if (!isPlayingLive || !videoRef.current) return + + const timer = window.setTimeout(() => { + const video = videoRef.current + if (!video || !isPlayingLive || !video.paused) return + + video.currentTime = 0 + void video.play().catch(() => { + stopLivePlayback(true) + }) + }, 0) + + return () => window.clearTimeout(timer) + }, [isPlayingLive, stopLivePlayback]) + + useEffect(() => { + return () => { + clearLiveCleanupTimer() + } + }, [clearLiveCleanupTimer]) + // 使用原生事件监听器处理拖动 useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!dragStateRef.current.isDragging) return - + const dx = e.clientX - dragStateRef.current.startX const dy = e.clientY - dragStateRef.current.startY - + setPosition({ x: dragStateRef.current.startPosX + dx, y: dragStateRef.current.startPosY + dy @@ -88,7 +151,7 @@ export default function ImageWindow() { const handleMouseDown = (e: React.MouseEvent) => { if (e.button !== 0) return e.preventDefault() - + dragStateRef.current = { isDragging: true, startX: e.clientX, @@ -112,15 +175,25 @@ export default function ImageWindow() { // 快捷键支持 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') window.electronAPI.window.close() + if (e.key === 'Escape') { + if (isPlayingLive) { + stopLivePlayback(true) + return + } + window.electronAPI.window.close() + } if (e.key === '=' || e.key === '+') handleZoomIn() if (e.key === '-') handleZoomOut() if (e.key === 'r' || e.key === 'R') handleRotate() if (e.key === '0') handleReset() + if (e.key === ' ' && hasLiveVideo) { + e.preventDefault() + handlePlayLiveVideo() + } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [handleReset]) + }, [handleReset, hasLiveVideo, handlePlayLiveVideo, isPlayingLive, stopLivePlayback]) if (!imagePath) { return ( @@ -137,22 +210,19 @@ export default function ImageWindow() {
- {liveVideoPath && ( - + {hasLiveVideo && ( + <> + +
+ )} {Math.round(displayScale * 100)}% @@ -170,32 +240,31 @@ export default function ImageWindow() { onDoubleClick={handleDoubleClick} onMouseDown={handleMouseDown} > - {liveVideoPath && ( -
) diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 83d3c66..48b2f2e 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -146,6 +146,11 @@ function SettingsPage() { const [helloAvailable, setHelloAvailable] = useState(false) const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') + const [oldPassword, setOldPassword] = useState('') + const [helloPassword, setHelloPassword] = useState('') + const [disableLockPassword, setDisableLockPassword] = useState('') + const [showDisableLockInput, setShowDisableLockInput] = useState(false) + const [isLockMode, setIsLockMode] = useState(false) const [isSettingHello, setIsSettingHello] = useState(false) // HTTP API 设置 state @@ -184,14 +189,6 @@ function SettingsPage() { checkApiStatus() }, []) - 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() @@ -279,10 +276,12 @@ function SettingsPage() { const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterList = await configService.getNotificationFilterList() - const savedAuthEnabled = await configService.getAuthEnabled() + const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() const savedAuthUseHello = await configService.getAuthUseHello() + const savedIsLockMode = await window.electronAPI.auth.isLockMode() setAuthEnabled(savedAuthEnabled) setAuthUseHello(savedAuthUseHello) + setIsLockMode(savedIsLockMode) if (savedPath) setDbPath(savedPath) if (savedWxid) setWxid(savedWxid) @@ -1931,6 +1930,10 @@ function SettingsPage() { ) const handleSetupHello = async () => { + if (!helloPassword) { + showMessage('请输入当前密码以开启 Hello', false) + return + } setIsSettingHello(true) try { const challenge = new Uint8Array(32) @@ -1948,8 +1951,10 @@ function SettingsPage() { }) if (credential) { + // 存储密码作为 Hello Secret,以便 Hello 解锁时能派生密钥 + await window.electronAPI.auth.setHelloSecret(helloPassword) setAuthUseHello(true) - await configService.setAuthUseHello(true) + setHelloPassword('') showMessage('Windows Hello 设置成功', true) } } catch (e: any) { @@ -1967,18 +1972,40 @@ function SettingsPage() { return } - // 简单的保存逻辑,实际上应该先验证旧密码,但为了简化流程,这里直接允许覆盖 - // 因为能进入设置页面说明已经解锁了 try { - const hash = await sha256(newPassword) - await configService.setAuthPassword(hash) - await configService.setAuthEnabled(true) - setAuthEnabled(true) - setNewPassword('') - setConfirmPassword('') - showMessage('密码已更新', true) + const lockMode = await window.electronAPI.auth.isLockMode() + + if (authEnabled && lockMode) { + // 已开启应用锁且已是 lock: 模式 → 修改密码 + if (!oldPassword) { + showMessage('请输入旧密码', false) + return + } + const result = await window.electronAPI.auth.changePassword(oldPassword, newPassword) + if (result.success) { + setNewPassword('') + setConfirmPassword('') + setOldPassword('') + showMessage('密码已更新', true) + } else { + showMessage(result.error || '密码更新失败', false) + } + } else { + // 未开启应用锁,或旧版 safe: 模式 → 开启/升级为 lock: 模式 + const result = await window.electronAPI.auth.enableLock(newPassword) + if (result.success) { + setAuthEnabled(true) + setIsLockMode(true) + setNewPassword('') + setConfirmPassword('') + setOldPassword('') + showMessage('应用锁已开启', true) + } else { + showMessage(result.error || '开启失败', false) + } + } } catch (e: any) { - showMessage('密码更新失败', false) + showMessage('操作失败', false) } } @@ -2037,31 +2064,73 @@ function SettingsPage() {
- - 每次启动应用时需要验证密码 + + { + isLockMode ? '已开启' : + authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' : + '未开启 — 请设置密码以开启' + }
- + {authEnabled && !showDisableLockInput && ( + + )}
+ {showDisableLockInput && ( +
+ setDisableLockPassword(e.target.value)} + style={{ flex: 1 }} + /> + + +
+ )}
- - 设置新的启动密码 + + {isLockMode ? '修改应用锁密码(需要旧密码验证)' : '设置密码后将自动开启应用锁'}
+ {isLockMode && ( + setOldPassword(e.target.value)} + /> + )} setConfirmPassword(e.target.value)} style={{ flex: 1 }} /> - +
@@ -2090,23 +2161,39 @@ function SettingsPage() {
使用面容、指纹快速解锁 - {!helloAvailable &&
当前设备不支持 Windows Hello
} + {!authEnabled &&
请先开启应用锁
} + {!helloAvailable && authEnabled &&
当前设备不支持 Windows Hello
}
{authUseHello ? ( - + ) : ( )}
+ {!authUseHello && authEnabled && ( +
+ setHelloPassword(e.target.value)} + /> +
+ )} ) diff --git a/src/stores/batchImageDecryptStore.ts b/src/stores/batchImageDecryptStore.ts new file mode 100644 index 0000000..d074362 --- /dev/null +++ b/src/stores/batchImageDecryptStore.ts @@ -0,0 +1,64 @@ +import { create } from 'zustand' + +export interface BatchImageDecryptState { + isBatchDecrypting: boolean + progress: { current: number; total: number } + showToast: boolean + showResultToast: boolean + result: { success: number; fail: number } + startTime: number + sessionName: string + + startDecrypt: (total: number, sessionName: string) => void + updateProgress: (current: number, total: number) => void + finishDecrypt: (success: number, fail: number) => void + setShowToast: (show: boolean) => void + setShowResultToast: (show: boolean) => void + reset: () => void +} + +export const useBatchImageDecryptStore = create((set) => ({ + isBatchDecrypting: false, + progress: { current: 0, total: 0 }, + showToast: false, + showResultToast: false, + result: { success: 0, fail: 0 }, + startTime: 0, + sessionName: '', + + startDecrypt: (total, sessionName) => set({ + isBatchDecrypting: true, + progress: { current: 0, total }, + showToast: true, + showResultToast: false, + result: { success: 0, fail: 0 }, + startTime: Date.now(), + sessionName + }), + + updateProgress: (current, total) => set({ + progress: { current, total } + }), + + finishDecrypt: (success, fail) => set({ + isBatchDecrypting: false, + showToast: false, + showResultToast: true, + result: { success, fail }, + startTime: 0 + }), + + setShowToast: (show) => set({ showToast: show }), + setShowResultToast: (show) => set({ showResultToast: show }), + + reset: () => set({ + isBatchDecrypting: false, + progress: { current: 0, total: 0 }, + showToast: false, + showResultToast: false, + result: { success: 0, fail: 0 }, + startTime: 0, + sessionName: '' + }) +})) + diff --git a/src/styles/batchTranscribe.scss b/src/styles/batchTranscribe.scss index 5a7256f..b17f561 100644 --- a/src/styles/batchTranscribe.scss +++ b/src/styles/batchTranscribe.scss @@ -167,6 +167,50 @@ } } +.batch-inline-result-toast { + .batch-progress-toast-title { + svg { + color: #22c55e; + } + } + + .batch-inline-result-summary { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .batch-inline-result-item { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 8px; + background: var(--bg-tertiary); + font-size: 12px; + color: var(--text-secondary); + + svg { + flex-shrink: 0; + } + + &.success { + color: #16a34a; + svg { color: #16a34a; } + } + + &.fail { + color: #dc2626; + svg { color: #dc2626; } + } + + &.muted { + color: var(--text-tertiary, #999); + svg { color: var(--text-tertiary, #999); } + } + } +} + // 批量转写结果对话框 .batch-result-modal { width: 420px; @@ -293,4 +337,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/styles/main.scss b/src/styles/main.scss index 88324e6..9f81ffd 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -37,6 +37,8 @@ // 卡片背景 --card-bg: rgba(255, 255, 255, 0.7); + --card-inner-bg: #FAFAF7; + --sent-card-bg: var(--primary); } // ==================== 浅色主题 ==================== @@ -59,6 +61,8 @@ --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); + --card-inner-bg: #FAFAF7; + --sent-card-bg: var(--primary); } // 刚玉蓝主题 @@ -79,6 +83,8 @@ --bg-gradient: linear-gradient(135deg, #E8EEF0 0%, #D8E4E8 100%); --primary-gradient: linear-gradient(135deg, #4A6670 0%, #5A7A86 100%); --card-bg: rgba(255, 255, 255, 0.7); + --card-inner-bg: #F8FAFB; + --sent-card-bg: var(--primary); } // 冰猕猴桃汁绿主题 @@ -99,6 +105,8 @@ --bg-gradient: linear-gradient(135deg, #E8F0E4 0%, #D8E8D0 100%); --primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #8AAA6C 100%); --card-bg: rgba(255, 255, 255, 0.7); + --card-inner-bg: #F8FBF6; + --sent-card-bg: var(--primary); } // 辛辣红主题 @@ -119,6 +127,8 @@ --bg-gradient: linear-gradient(135deg, #F0E8E8 0%, #E8D8D8 100%); --primary-gradient: linear-gradient(135deg, #8B4049 0%, #A05058 100%); --card-bg: rgba(255, 255, 255, 0.7); + --card-inner-bg: #FAF8F8; + --sent-card-bg: var(--primary); } // 明水鸭色主题 @@ -139,6 +149,8 @@ --bg-gradient: linear-gradient(135deg, #E4F0F0 0%, #D4E8E8 100%); --primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #6A9A9A 100%); --card-bg: rgba(255, 255, 255, 0.7); + --card-inner-bg: #F6FBFB; + --sent-card-bg: var(--primary); } // ==================== 深色主题 ==================== @@ -160,6 +172,8 @@ --bg-gradient: linear-gradient(135deg, #1a1816 0%, #252220 100%); --primary-gradient: linear-gradient(135deg, #8B7355 0%, #C9A86C 100%); --card-bg: rgba(40, 36, 32, 0.9); + --card-inner-bg: #27231F; + --sent-card-bg: var(--primary); } // 刚玉蓝 - 深色 @@ -179,6 +193,8 @@ --bg-gradient: linear-gradient(135deg, #141a1c 0%, #1e282c 100%); --primary-gradient: linear-gradient(135deg, #4A6670 0%, #6A9AAA 100%); --card-bg: rgba(30, 40, 44, 0.9); + --card-inner-bg: #1D272A; + --sent-card-bg: var(--primary); } // 冰猕猴桃汁绿 - 深色 @@ -198,6 +214,8 @@ --bg-gradient: linear-gradient(135deg, #161a14 0%, #222a1e 100%); --primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #9ABA7C 100%); --card-bg: rgba(34, 42, 30, 0.9); + --card-inner-bg: #21281D; + --sent-card-bg: var(--primary); } // 辛辣红 - 深色 @@ -217,6 +235,8 @@ --bg-gradient: linear-gradient(135deg, #1a1416 0%, #2a2022 100%); --primary-gradient: linear-gradient(135deg, #8B4049 0%, #C06068 100%); --card-bg: rgba(42, 32, 34, 0.9); + --card-inner-bg: #281F21; + --sent-card-bg: var(--primary); } // 明水鸭色 - 深色 @@ -236,6 +256,8 @@ --bg-gradient: linear-gradient(135deg, #121a1a 0%, #1c2a2a 100%); --primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #7ABAAA 100%); --card-bg: rgba(28, 42, 42, 0.9); + --card-inner-bg: #1B2828; + --sent-card-bg: var(--primary); } // 重置样式 diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index fe6fefa..b00f3c0 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -19,6 +19,17 @@ export interface ElectronAPI { set: (key: string, value: unknown) => Promise clear: () => Promise } + auth: { + hello: (message?: string) => Promise<{ success: boolean; error?: string }> + verifyEnabled: () => Promise + unlock: (password: string) => Promise<{ success: boolean; error?: string }> + enableLock: (password: string) => Promise<{ success: boolean; error?: string }> + disableLock: (password: string) => Promise<{ success: boolean; error?: string }> + changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }> + setHelloSecret: (password: string) => Promise<{ success: boolean }> + clearHelloSecret: () => Promise<{ success: boolean }> + isLockMode: () => Promise + } dialog: { openFile: (options?: Electron.OpenDialogOptions) => Promise openDirectory: (options?: Electron.OpenDialogOptions) => Promise @@ -115,6 +126,11 @@ export interface ElectronAPI { getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }> getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }> + getAllImageMessages: (sessionId: string) => Promise<{ + success: boolean + images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[] + error?: string + }> resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }> getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void diff --git a/src/types/models.ts b/src/types/models.ts index 5b5a3bf..b03e088 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -64,12 +64,39 @@ export interface Message { fileSize?: number // 文件大小 fileExt?: string // 文件扩展名 xmlType?: string // XML 中的 type 字段 + appMsgKind?: string // 归一化 appmsg 类型 + appMsgDesc?: string + appMsgAppName?: string + appMsgSourceName?: string + appMsgSourceUsername?: string + appMsgThumbUrl?: string + appMsgMusicUrl?: string + appMsgDataUrl?: string + appMsgLocationLabel?: string + finderNickname?: string + finderUsername?: string + finderCoverUrl?: string // 视频号封面图 + finderAvatar?: string // 视频号作者头像 + finderDuration?: number // 视频号时长(秒) + // 位置消息 + locationLat?: number // 纬度 + locationLng?: number // 经度 + locationPoiname?: string // 地点名称 + locationLabel?: string // 详细地址 + // 音乐消息 + musicAlbumUrl?: string // 专辑封面 + musicUrl?: string // 播放链接 + // 礼物消息 + giftImageUrl?: string // 礼物商品图 + giftWish?: string // 祝福语 + giftPrice?: string // 价格(分) // 转账消息 transferPayerUsername?: string // 转账付款方 wxid transferReceiverUsername?: string // 转账收款方 wxid // 名片消息 cardUsername?: string // 名片的微信ID cardNickname?: string // 名片的昵称 + cardAvatarUrl?: string // 名片头像 URL // 聊天记录 chatRecordTitle?: string // 聊天记录标题 chatRecordList?: ChatRecordItem[] // 聊天记录列表 diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index b1ee881..5b4176e 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,6 +5,14 @@ interface Window { // ... other methods ... auth: { hello: (message?: string) => Promise<{ success: boolean; error?: string }> + verifyEnabled: () => Promise + unlock: (password: string) => Promise<{ success: boolean; error?: string }> + enableLock: (password: string) => Promise<{ success: boolean; error?: string }> + disableLock: (password: string) => Promise<{ success: boolean; error?: string }> + changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }> + setHelloSecret: (password: string) => Promise<{ success: boolean }> + clearHelloSecret: () => Promise<{ success: boolean }> + isLockMode: () => Promise } // For brevity, using 'any' for other parts or properly importing types if available. // In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts