diff --git a/electron/main.ts b/electron/main.ts index 5900356..58bc29f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,3 +1,4 @@ +import './preload-env' import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron' import { Worker } from 'worker_threads' import { join, dirname } from 'path' @@ -451,7 +452,7 @@ function registerIpcHandlers() { // 监听下载进度 autoUpdater.on('download-progress', (progress) => { - win?.webContents.send('app:downloadProgress', progress.percent) + win?.webContents.send('app:downloadProgress', progress) }) // 下载完成后自动安装 @@ -682,6 +683,14 @@ function registerIpcHandlers() { return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) }) + ipcMain.handle('sns:debugResource', async (_, url: string) => { + return snsService.debugResource(url) + }) + + ipcMain.handle('sns:proxyImage', async (_, url: string) => { + return snsService.proxyImage(url) + }) + // 私聊克隆 diff --git a/electron/preload-env.ts b/electron/preload-env.ts new file mode 100644 index 0000000..70d36d0 --- /dev/null +++ b/electron/preload-env.ts @@ -0,0 +1,39 @@ +import { join, dirname } from 'path' + +/** + * 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL + * 解决系统中存在冲突版本的 DLL 导致的应用崩溃问题 + */ +function enforceLocalDllPriority() { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const sep = process.platform === 'win32' ? ';' : ':' + + let possiblePaths: string[] = [] + + if (isDev) { + // 开发环境 + possiblePaths.push(join(process.cwd(), 'resources')) + } else { + // 生产环境 + possiblePaths.push(dirname(process.execPath)) + if (process.resourcesPath) { + possiblePaths.push(process.resourcesPath) + } + } + + const dllPaths = possiblePaths.join(sep) + + if (process.env.PATH) { + process.env.PATH = dllPaths + sep + process.env.PATH + } else { + process.env.PATH = dllPaths + } + + console.log('[WeFlow] Environment PATH updated to enforce local DLL priority:', dllPaths) +} + +try { + enforceLocalDllPriority() +} catch (e) { + console.error('[WeFlow] Failed to enforce local DLL priority:', e) +} diff --git a/electron/preload.ts b/electron/preload.ts index 6fa3c36..cf0352a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -29,7 +29,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getVersion: () => ipcRenderer.invoke('app:getVersion'), checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), - onDownloadProgress: (callback: (progress: number) => void) => { + onDownloadProgress: (callback: (progress: any) => void) => { ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress)) return () => ipcRenderer.removeAllListeners('app:downloadProgress') }, @@ -214,6 +214,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // 朋友圈 sns: { getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => - ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime) + ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), + debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), + proxyImage: (url: string) => ipcRenderer.invoke('sns:proxyImage', url) } }) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 2c3c143..ccc7a28 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -328,7 +328,7 @@ class ChatService { const cached = this.avatarCache.get(username) // 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取 // 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取 - const isValidAvatar = cached?.avatarUrl && + const isValidAvatar = cached?.avatarUrl && !cached.avatarUrl.includes('base64,ffd8') // 检测错误的 hex 格式 if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) { result[username] = { @@ -970,7 +970,7 @@ class ChatService { const title = this.extractXmlValue(content, 'title') return title || '[引用消息]' case 266287972401: - return '[拍一拍]' + return this.cleanPatMessage(content) case 81604378673: return '[聊天记录]' case 8594229559345: @@ -1659,6 +1659,37 @@ class ChatService { } } + /** + * 清理拍一拍消息 + * 格式示例: 我拍了拍 "梨绒" ງ໐໐໓ ຖiງht620000wxid_... + */ + private cleanPatMessage(content: string): string { + if (!content) return '[拍一拍]' + + // 1. 尝试匹配标准的 "A拍了拍B" 格式 + // 这里的正则比较宽泛,为了兼容不同的语言环境 + const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content) + if (match) { + return `[拍一拍] ${match[1].trim()}` + } + + // 2. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码) + let cleaned = content.replace(/wxid_[a-zA-Z0-9_-]+/g, '') // 移除 wxid + cleaned = cleaned.replace(/[ງ໐໓ຖiht]+/g, ' ') // 移除已知的乱码字符 + cleaned = cleaned.replace(/\d{6,}/g, '') // 移除长数字 + cleaned = cleaned.replace(/\s+/g, ' ').trim() // 清理空格 + + // 移除不可见字符 + cleaned = this.cleanUtf16(cleaned) + + // 如果清理后还有内容,返回 + if (cleaned && cleaned.length > 1 && !cleaned.includes('xml')) { + return `[拍一拍] ${cleaned}` + } + + return '[拍一拍]' + } + /** * 解码消息内容(处理 BLOB 和压缩数据) */ @@ -2323,7 +2354,7 @@ class ChatService { /** * getVoiceData (绕过WCDB的buggy getVoiceData,直接用execQuery读取) */ - async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number): Promise<{ success: boolean; data?: string; error?: string }> { + async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number, senderWxidOpt?: string): Promise<{ success: boolean; data?: string; error?: string }> { const startTime = Date.now() try { const localId = parseInt(msgId, 10) @@ -2332,7 +2363,7 @@ class ChatService { } let msgCreateTime = createTime - let senderWxid: string | null = null + let senderWxid: string | null = senderWxidOpt || null // 如果前端没传 createTime,才需要查询消息(这个很慢) if (!msgCreateTime) { @@ -2403,7 +2434,7 @@ class ChatService { console.log(`[Voice] getVoiceDataFromMediaDb: ${t4 - t3}ms`) if (!silkData) { - return { success: false, error: '未找到语音数据' } + return { success: false, error: '未找到语音数据 (请确保已在微信中播放过该语音)' } } const t5 = Date.now() @@ -2471,11 +2502,20 @@ class ChatService { const t2 = Date.now() console.log(`[Voice] listMediaDbs: ${t2 - t1}ms`) - if (!mediaDbsResult.success || !mediaDbsResult.data || mediaDbsResult.data.length === 0) { + let files = mediaDbsResult.success && mediaDbsResult.data ? (mediaDbsResult.data as string[]) : [] + + // Fallback: 如果 WCDB DLL 没找到,手动查找 + if (files.length === 0) { + console.warn('[Voice] listMediaDbs returned empty, trying manual search') + files = await this.findMediaDbsManually() + } + + if (files.length === 0) { + console.error('[Voice] No media DBs found') return null } - mediaDbFiles = mediaDbsResult.data as string[] + mediaDbFiles = files this.mediaDbsCache = mediaDbFiles // 永久缓存 } @@ -2854,7 +2894,8 @@ class ChatService { sessionId: string, msgId: string, createTime?: number, - onPartial?: (text: string) => void + onPartial?: (text: string) => void, + senderWxid?: string ): Promise<{ success: boolean; transcript?: string; error?: string }> { const startTime = Date.now() console.log(`[Transcribe] 开始转写: sessionId=${sessionId}, msgId=${msgId}, createTime=${createTime}`) @@ -2926,7 +2967,7 @@ class ChatService { console.log(`[Transcribe] WAV缓存未命中,调用 getVoiceData`) const t3 = Date.now() // 调用 getVoiceData 获取并解码 - const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId) + const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId, senderWxid) const t4 = Date.now() console.log(`[Transcribe] getVoiceData: ${t4 - t3}ms, success=${voiceResult.success}`) @@ -3098,7 +3139,7 @@ class ChatService { private resolveAccountDir(dbPath: string, wxid: string): string | null { const normalized = dbPath.replace(/[\\\\/]+$/, '') - + // 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件) // 则向上回溯到账号目录 if (basename(normalized).toLowerCase() === 'db_storage') { @@ -3108,14 +3149,14 @@ class ChatService { if (basename(dir).toLowerCase() === 'db_storage') { return dirname(dir) } - + // 否则,dbPath 应该是数据库根目录(如 xwechat_files) // 账号目录应该是 {dbPath}/{wxid} const accountDirWithWxid = join(normalized, wxid) if (existsSync(accountDirWithWxid)) { return accountDirWithWxid } - + // 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录) return normalized } diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 7e57c05..94abb6f 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -10,6 +10,7 @@ import { wcdbService } from './wcdbService' import { imageDecryptService } from './imageDecryptService' import { chatService } from './chatService' import { videoService } from './videoService' +import { voiceTranscribeService } from './voiceTranscribeService' import { EXPORT_HTML_STYLES } from './exportHtmlStyles' // ChatLab 格式类型定义 @@ -1032,15 +1033,15 @@ class ExportService { /** * 转写语音为文字 */ - private async transcribeVoice(sessionId: string, msgId: string): Promise { + private async transcribeVoice(sessionId: string, msgId: string, createTime: number, senderWxid: string | null): Promise { try { - const transcript = await chatService.getVoiceTranscript(sessionId, msgId) + const transcript = await chatService.getVoiceTranscript(sessionId, msgId, createTime, undefined, senderWxid || undefined) if (transcript.success && transcript.transcript) { return `[语音转文字] ${transcript.transcript}` } - return '[语音消息 - 转文字失败]' + return `[语音消息 - 转文字失败: ${transcript.error || '未知错误'}]` } catch (e) { - return '[语音消息 - 转文字失败]' + return `[语音消息 - 转文字失败: ${String(e)}]` } } @@ -1655,6 +1656,10 @@ class ExportService { phase: 'preparing' }) + if (options.exportVoiceAsText) { + await this.ensureVoiceModel(onProgress) + } + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const allMessages = collected.rows if (isGroup) { @@ -1719,7 +1724,7 @@ class ExportService { // 并行转写语音,限制 4 个并发(转写比较耗资源) const VOICE_CONCURRENCY = 4 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) }) } @@ -1849,6 +1854,10 @@ class ExportService { phase: 'preparing' }) + if (options.exportVoiceAsText) { + await this.ensureVoiceModel(onProgress) + } + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) @@ -1904,7 +1913,7 @@ class ExportService { const VOICE_CONCURRENCY = 4 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) }) } @@ -2088,6 +2097,10 @@ class ExportService { phase: 'preparing' }) + if (options.exportVoiceAsText) { + await this.ensureVoiceModel(onProgress) + } + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) @@ -2202,11 +2215,11 @@ class ExportService { } // 预加载群昵称 (仅群聊且完整列模式) - console.log('🔍 预加载群昵称检查: isGroup=', isGroup, 'useCompactColumns=', useCompactColumns, 'sessionId=', sessionId) + console.log('预加载群昵称检查: isGroup=', isGroup, 'useCompactColumns=', useCompactColumns, 'sessionId=', sessionId) const groupNicknamesMap = (isGroup && !useCompactColumns) ? await this.getGroupNicknamesForRoom(sessionId) : new Map() - console.log('🔍 群昵称Map大小:', groupNicknamesMap.size) + console.log('群昵称Map大小:', groupNicknamesMap.size) // 填充数据 @@ -2267,7 +2280,7 @@ class ExportService { const VOICE_CONCURRENCY = 4 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) }) } @@ -2417,6 +2430,41 @@ class ExportService { } } + /** + * 确保语音转写模型已下载 + */ + private async ensureVoiceModel(onProgress?: (progress: ExportProgress) => void): Promise { + try { + const status = await voiceTranscribeService.getModelStatus() + if (status.success && status.exists) { + return true + } + + onProgress?.({ + current: 0, + total: 100, + currentSession: '正在下载 AI 模型', + phase: 'preparing' + }) + + const downloadResult = await voiceTranscribeService.downloadModel((progress: any) => { + if (progress.percent !== undefined) { + onProgress?.({ + current: progress.percent, + total: 100, + currentSession: `正在下载 AI 模型 (${progress.percent.toFixed(0)}%)`, + phase: 'preparing' + }) + } + }) + + return downloadResult.success + } catch (e) { + console.error('Auto download model failed:', e) + return false + } + } + /** * 导出单个会话为 TXT 格式(默认与 Excel 精简列一致) */ @@ -2442,6 +2490,10 @@ class ExportService { phase: 'preparing' }) + if (options.exportVoiceAsText) { + await this.ensureVoiceModel(onProgress) + } + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) @@ -2495,7 +2547,7 @@ class ExportService { const VOICE_CONCURRENCY = 4 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) }) } @@ -2613,6 +2665,10 @@ class ExportService { phase: 'preparing' }) + if (options.exportVoiceAsText) { + await this.ensureVoiceModel(onProgress) + } + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) if (isGroup) { await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) @@ -2673,7 +2729,7 @@ class ExportService { const VOICE_CONCURRENCY = 4 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) }) } diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index bf674f4..4d5c2cf 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -2,6 +2,25 @@ import { wcdbService } from './wcdbService' import { ConfigService } from './config' import { ContactCacheService } from './contactCacheService' +export interface SnsLivePhoto { + url: string + thumb: string + md5?: string + token?: string + key?: string + encIdx?: string +} + +export interface SnsMedia { + url: string + thumb: string + md5?: string + token?: string + key?: string + encIdx?: string + livePhoto?: SnsLivePhoto +} + export interface SnsPost { id: string username: string @@ -10,11 +29,25 @@ export interface SnsPost { createTime: number contentDesc: string type?: number - media: { url: string; thumb: string }[] + media: SnsMedia[] likes: string[] comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] + rawXml?: string // 原始 XML 数据 } +const fixSnsUrl = (url: string, token?: string) => { + if (!url) return url; + + // 1. 统一使用 https + // 2. 将 /150 (缩略图) 强制改为 /0 (原图) + let fixedUrl = url.replace('http://', 'https://').replace(/\/150($|\?)/, '/0$1'); + + if (!token || fixedUrl.includes('token=')) return fixedUrl; + + const connector = fixedUrl.includes('?') ? '&' : '?'; + return `${fixedUrl}${connector}token=${token}&idx=1`; +}; + class SnsService { private contactCache: ContactCacheService @@ -35,14 +68,50 @@ class SnsService { }) if (result.success && result.timeline) { - const enrichedTimeline = result.timeline.map((post: any) => { + const enrichedTimeline = result.timeline.map((post: any, index: number) => { const contact = this.contactCache.get(post.username) - // 修复媒体 URL,如果是 http 则尝试用 https (虽然 qpic 可能不支持强制 https,但通常支持) - const fixedMedia = post.media.map((m: any) => ({ - url: m.url.replace('http://', 'https://'), - thumb: m.thumb.replace('http://', 'https://') - })) + // 修复媒体 URL + const fixedMedia = post.media.map((m: any, mIdx: number) => { + const base = { + url: fixSnsUrl(m.url, m.token), + thumb: fixSnsUrl(m.thumb, m.token), + md5: m.md5, + token: m.token, + key: m.key, + encIdx: m.encIdx || m.enc_idx, // 兼容不同命名 + livePhoto: m.livePhoto ? { + ...m.livePhoto, + url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token), + thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token), + token: m.livePhoto.token, + key: m.livePhoto.key + } : undefined + } + + // [MOCK] 模拟数据:如果后端没返回 key (说明 DLL 未更新),注入一些 Mock 数据以便前端开发 + if (!base.key) { + base.key = 'mock_key_for_dev' + if (!base.token) { + base.token = 'mock_token_for_dev' + base.url = fixSnsUrl(base.url, base.token) + base.thumb = fixSnsUrl(base.thumb, base.token) + } + base.encIdx = '1' + + // 强制给第一个帖子的第一张图加 LivePhoto 模拟 + if (index === 0 && mIdx === 0 && !base.livePhoto) { + base.livePhoto = { + url: fixSnsUrl('https://tm.sh/d4cb0.mp4', 'mock_live_token'), + thumb: base.thumb, + token: 'mock_live_token', + key: 'mock_live_key' + } + } + } + + return base + }) return { ...post, @@ -59,6 +128,128 @@ class SnsService { console.log('[SnsService] Returning result:', result) return result } + async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> { + return new Promise((resolve) => { + try { + const { app, net } = require('electron') + // Remove mocking 'require' if it causes issues, but here we need 'net' or 'https' + // implementing with 'https' for reliability if 'net' is main-process only special + const https = require('https') + const urlObj = new URL(url) + + const options = { + hostname: urlObj.hostname, + path: urlObj.pathname + urlObj.search, + method: 'GET', + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351", + "Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "zh-CN,zh;q=0.9", + "Referer": "https://servicewechat.com/", + "Connection": "keep-alive", + "Range": "bytes=0-10" // Keep our range check + } + } + + const req = https.request(options, (res: any) => { + resolve({ + success: true, + status: res.statusCode, + headers: { + 'x-enc': res.headers['x-enc'], + 'content-length': res.headers['content-length'], + 'content-type': res.headers['content-type'] + } + }) + req.destroy() // We only need headers + }) + + req.on('error', (e: any) => { + resolve({ success: false, error: e.message }) + }) + + req.end() + } catch (e: any) { + resolve({ success: false, error: e.message }) + } + }) + } + + private imageCache = new Map() + + async proxyImage(url: string): Promise<{ success: boolean; dataUrl?: string; error?: string }> { + // Check cache + if (this.imageCache.has(url)) { + return { success: true, dataUrl: this.imageCache.get(url) } + } + + return new Promise((resolve) => { + try { + const https = require('https') + const zlib = require('zlib') + const urlObj = new URL(url) + + const options = { + hostname: urlObj.hostname, + path: urlObj.pathname + urlObj.search, + method: 'GET', + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351", + "Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "zh-CN,zh;q=0.9", + "Referer": "https://servicewechat.com/", + "Connection": "keep-alive" + } + } + + const req = https.request(options, (res: any) => { + if (res.statusCode !== 200) { + resolve({ success: false, error: `HTTP ${res.statusCode}` }) + return + } + + const chunks: Buffer[] = [] + let stream = res + + // Handle gzip compression + const encoding = res.headers['content-encoding'] + if (encoding === 'gzip') { + stream = res.pipe(zlib.createGunzip()) + } else if (encoding === 'deflate') { + stream = res.pipe(zlib.createInflate()) + } else if (encoding === 'br') { + stream = res.pipe(zlib.createBrotliDecompress()) + } + + stream.on('data', (chunk: Buffer) => chunks.push(chunk)) + stream.on('end', () => { + const buffer = Buffer.concat(chunks) + const contentType = res.headers['content-type'] || 'image/jpeg' + const base64 = buffer.toString('base64') + const dataUrl = `data:${contentType};base64,${base64}` + + // Cache + this.imageCache.set(url, dataUrl) + + resolve({ success: true, dataUrl }) + }) + stream.on('error', (e: any) => { + resolve({ success: false, error: e.message }) + }) + }) + + req.on('error', (e: any) => { + resolve({ success: false, error: e.message }) + }) + + req.end() + } catch (e: any) { + resolve({ success: false, error: e.message }) + } + }) + } } export const snsService = new SnsService() diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 3e6ebd1..9825b3e 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -246,14 +246,15 @@ export class WcdbCore { // InitProtection (Added for security) try { - this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)') - const protectionOk = this.wcdbInitProtection(dllDir) - if (!protectionOk) { - console.error('Core security check failed') + this.wcdbInitProtection = this.lib.func('int32 InitProtection(const char* resourcePath)') + const protectionCode = this.wcdbInitProtection(dllDir) + if (protectionCode !== 0) { + console.error('Core security check failed:', protectionCode) + lastDllInitError = `初始化失败,错误码: ${protectionCode}` return false } } catch (e) { - console.warn('InitProtection symbol not found:', e) + console.warn('InitProtection symbol not found or failed:', e) } // 定义类型 diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index e9e509e..c5c8c35 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/App.tsx b/src/App.tsx index c8c4074..678d594 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,10 +24,25 @@ import * as configService from './services/config' import { Download, X, Shield } from 'lucide-react' import './App.scss' +import UpdateDialog from './components/UpdateDialog' +import UpdateProgressCapsule from './components/UpdateProgressCapsule' + function App() { const navigate = useNavigate() const location = useLocation() - const { setDbConnected } = useAppStore() + const { + setDbConnected, + updateInfo, + setUpdateInfo, + isDownloading, + setIsDownloading, + downloadProgress, + setDownloadProgress, + showUpdateDialog, + setShowUpdateDialog, + setUpdateError + } = useAppStore() + const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const isAgreementWindow = location.pathname === '/agreement-window' const isOnboardingWindow = location.pathname === '/onboarding-window' @@ -39,11 +54,6 @@ function App() { const [agreementChecked, setAgreementChecked] = useState(false) const [agreementLoading, setAgreementLoading] = useState(true) - // 更新提示状态 - const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null) - const [isDownloading, setIsDownloading] = useState(false) - const [downloadProgress, setDownloadProgress] = useState(0) - useEffect(() => { const root = document.documentElement const body = document.body @@ -148,8 +158,12 @@ function App() { // 监听启动时的更新通知 useEffect(() => { - const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info) => { - setUpdateInfo(info) + const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info: any) => { + // 发现新版本时自动打开更新弹窗 + if (info) { + setUpdateInfo({ ...info, hasUpdate: true }) + setShowUpdateDialog(true) + } }) const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => { setDownloadProgress(progress) @@ -158,16 +172,20 @@ function App() { removeUpdateListener?.() removeProgressListener?.() } - }, []) + }, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog]) const handleUpdateNow = async () => { + setShowUpdateDialog(false) setIsDownloading(true) - setDownloadProgress(0) + setDownloadProgress({ percent: 0 }) try { await window.electronAPI.app.downloadAndInstall() - } catch (e) { + } catch (e: any) { console.error('更新失败:', e) setIsDownloading(false) + // Extract clean error message if possible + const errorMsg = e.message || String(e) + setUpdateError(errorMsg.includes('暂时禁用') ? '自动更新已暂时禁用' : errorMsg) } } @@ -250,6 +268,9 @@ function App() {
+ {/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */} + + {/* 用户协议弹窗 */} {showAgreement && !agreementLoading && (
@@ -301,31 +322,15 @@ function App() {
)} - {/* 更新提示条 */} - {updateInfo && ( -
- - 发现新版本 v{updateInfo.version} - - {isDownloading ? ( -
-
-
-
- {downloadProgress.toFixed(0)}% -
- ) : ( - <> - - - - )} -
- )} + {/* 更新提示对话框 */} + setShowUpdateDialog(false)} + onUpdate={handleUpdateNow} + isDownloading={isDownloading} + progress={downloadProgress} + />
diff --git a/src/components/LivePhotoIcon.tsx b/src/components/LivePhotoIcon.tsx new file mode 100644 index 0000000..ce904b9 --- /dev/null +++ b/src/components/LivePhotoIcon.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +interface LivePhotoIconProps { + size?: number | string; + className?: string; + style?: React.CSSProperties; +} + +export const LivePhotoIcon: React.FC = ({ size = 24, className = '', style = {} }) => { + return ( + + + + + + + + + + ); +}; diff --git a/src/components/UpdateDialog.scss b/src/components/UpdateDialog.scss new file mode 100644 index 0000000..452447a --- /dev/null +++ b/src/components/UpdateDialog.scss @@ -0,0 +1,251 @@ +.update-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + animation: fadeIn 0.3s ease-out; + + .update-dialog { + width: 680px; + background: #f5f5f5; + border-radius: 24px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); + overflow: hidden; + position: relative; + animation: slideUp 0.3s ease-out; + display: flex; + flex-direction: column; + + /* Top Section (White/Gradient) */ + .dialog-header { + background: #ffffff; + padding: 40px 20px 30px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + position: relative; + + /* Subtle radial gradient effect in top left as seen in image */ + &::before { + content: ''; + position: absolute; + top: -50px; + left: -50px; + width: 200px; + height: 200px; + background: radial-gradient(circle, rgba(255, 235, 220, 0.4) 0%, rgba(255, 255, 255, 0) 70%); + opacity: 0.8; + pointer-events: none; + } + + .version-tag { + background: #f0eee9; + color: #8c7b6e; + padding: 4px 16px; + border-radius: 12px; + font-size: 13px; + font-weight: 600; + margin-bottom: 24px; + letter-spacing: 0.5px; + } + + h2 { + font-size: 32px; + font-weight: 800; + color: #333333; + margin: 0 0 12px; + letter-spacing: -0.5px; + } + + .subtitle { + font-size: 15px; + color: #999999; + font-weight: 400; + } + } + + /* Content Section (Light Gray) */ + .dialog-content { + background: #f2f2f2; + padding: 24px 40px 40px; + flex: 1; + display: flex; + flex-direction: column; + + .update-notes-container { + display: flex; + align-items: flex-start; + padding: 20px 0; + margin-bottom: 30px; + + .icon-box { + background: #fbfbfb; // Beige-ish white + width: 48px; + height: 48px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 20px; + flex-shrink: 0; + color: #8c7b6e; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03); + + svg { + opacity: 0.8; + } + } + + .text-box { + flex: 1; + + h3 { + font-size: 18px; + font-weight: 700; + color: #333333; + margin: 0 0 8px; + } + + p { + font-size: 14px; + color: #666666; + line-height: 1.6; + margin: 0; + } + + ul { + margin: 8px 0 0 18px; + padding: 0; + + li { + font-size: 14px; + color: #666666; + line-height: 1.6; + } + } + } + } + + .progress-section { + margin-bottom: 30px; + + .progress-info-row { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + font-size: 12px; + color: #888; + font-weight: 500; + } + + .progress-bar-bg { + height: 6px; + background: #e0e0e0; + border-radius: 3px; + overflow: hidden; + + .progress-bar-fill { + height: 100%; + background: #000000; + border-radius: 3px; + transition: width 0.3s ease; + } + } + + .status-text { + text-align: center; + margin-top: 12px; + font-size: 13px; + color: #666; + } + } + + .actions { + display: flex; + justify-content: center; + + .btn-update { + background: #000000; + color: #ffffff; + border: none; + padding: 16px 48px; + border-radius: 20px; // Pill shape + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); + } + + &:active { + transform: translateY(0); + } + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; + } + } + } + } + + .close-btn { + position: absolute; + top: 16px; + right: 16px; + background: rgba(0, 0, 0, 0.05); + border: none; + color: #999; + cursor: pointer; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + z-index: 10; + + &:hover { + background: rgba(0, 0, 0, 0.1); + color: #333; + transform: rotate(90deg); + } + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} \ No newline at end of file diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx new file mode 100644 index 0000000..78f3db5 --- /dev/null +++ b/src/components/UpdateDialog.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useState } from 'react' +import { Quote, X } from 'lucide-react' +import './UpdateDialog.scss' + +interface UpdateInfo { + version?: string + releaseNotes?: string +} + +interface UpdateDialogProps { + open: boolean + updateInfo: UpdateInfo | null + onClose: () => void + onUpdate: () => void + isDownloading: boolean + progress: number | { + percent: number + bytesPerSecond?: number + transferred?: number + total?: number + remaining?: number // seconds + } +} + +const UpdateDialog: React.FC = ({ + open, + updateInfo, + onClose, + onUpdate, + isDownloading, + progress +}) => { + if (!open || !updateInfo) return null + + // Safe normalize progress + const safeProgress = typeof progress === 'number' ? { percent: progress } : (progress || { percent: 0 }) + const percent = safeProgress.percent || 0 + const bytesPerSecond = safeProgress.bytesPerSecond + const total = safeProgress.total + const transferred = safeProgress.transferred + const remaining = safeProgress.remaining + + // Format bytes + const formatBytes = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + const unitIndex = Math.max(0, Math.min(i, sizes.length - 1)) + return parseFloat((bytes / Math.pow(k, unitIndex)).toFixed(1)) + ' ' + sizes[unitIndex] + } + + // Format speed + const formatSpeed = (bytesPerSecond: number) => { + return `${formatBytes(bytesPerSecond)}/s` + } + + // Format time + const formatTime = (seconds: number) => { + if (!Number.isFinite(seconds)) return '计算中...' + if (seconds < 60) return `${Math.ceil(seconds)} 秒` + const minutes = Math.floor(seconds / 60) + const remainingSeconds = Math.ceil(seconds % 60) + return `${minutes} 分 ${remainingSeconds} 秒` + } + + return ( +
+
+ {!isDownloading && ( + + )} + +
+
+ 新版本 {updateInfo.version} +
+

欢迎体验全新的 WeFlow

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

优化

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

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

+ )} +
+
+ + {isDownloading ? ( +
+
+ {bytesPerSecond ? formatSpeed(bytesPerSecond) : '下载中...'} + {total ? `${formatBytes(transferred || 0)} / ${formatBytes(total)}` : `${percent.toFixed(1)}%`} + {remaining !== undefined && 剩余 {formatTime(remaining)}} +
+ +
+
+
+ + {/* Fallback status text if detailed info is missing */} + {(!bytesPerSecond && !total) && ( +
{percent.toFixed(0)}% 已下载
+ )} +
+ ) : ( +
+ +
+ )} +
+
+
+ ) +} + +export default UpdateDialog diff --git a/src/components/UpdateProgressCapsule.scss b/src/components/UpdateProgressCapsule.scss new file mode 100644 index 0000000..b58f6b1 --- /dev/null +++ b/src/components/UpdateProgressCapsule.scss @@ -0,0 +1,192 @@ +.update-progress-capsule { + position: fixed; + top: 38px; // Just below title bar + left: 50%; + transform: translateX(-50%); + z-index: 9998; + cursor: pointer; + animation: capsuleSlideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1); + user-select: none; + + &:hover { + .capsule-content { + background: rgba(255, 255, 255, 0.95); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12); + transform: scale(1.02); + } + } + + .capsule-content { + background: var(--bg-primary); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + padding: 8px 18px; + border-radius: 24px; + border: 1px solid var(--border-color); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12); + display: flex; + align-items: center; + gap: 12px; + height: 40px; + position: relative; + overflow: hidden; + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + + .icon-wrapper { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-primary); + + .download-icon { + animation: capsulePulse 2s infinite ease-in-out; + } + } + + .info-wrapper { + display: flex; + align-items: baseline; + gap: 10px; + z-index: 1; + + .percent-text { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + } + + .speed-text { + font-size: 13px; + color: var(--text-tertiary); + font-weight: 500; + font-variant-numeric: tabular-nums; + } + + .error-text { + font-size: 15px; + color: #ff4d4f; + font-weight: 600; + } + + .available-text { + font-size: 15px; + color: var(--text-primary); + font-weight: 600; + } + } + + .progress-bg { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(0, 0, 0, 0.05); + + .progress-fill { + height: 100%; + background: var(--primary); + transition: width 0.3s ease; + } + } + + .capsule-close { + background: none; + border: none; + padding: 4px; + margin-left: -4px; + margin-right: -8px; + cursor: pointer; + opacity: 0.5; + transition: all 0.2s ease; + display: flex; + align-items: center; + color: var(--text-secondary); + + &:hover { + opacity: 1; + background: var(--bg-tertiary); + border-radius: 50%; + } + } + } + + // State Modifiers + &.state-available { + .capsule-content { + background: var(--primary); + border-color: rgba(255, 255, 255, 0.1); + color: white; + + .icon-wrapper { + color: white; + } + + .info-wrapper { + .available-text { + color: white; + } + } + + .capsule-close { + color: rgba(255, 255, 255, 0.8); + + &:hover { + background: rgba(255, 255, 255, 0.1); + } + } + } + } + + &.state-downloading { + .capsule-content { + background: var(--bg-primary); + } + } + + &.state-error { + .capsule-content { + background: #fff1f0; + border-color: #ffa39e; + + .icon-wrapper { + color: #ff4d4f; + } + + .info-wrapper .error-text { + color: #cf1322; + } + + .capsule-close { + color: #cf1322; + } + } + } +} + +@keyframes capsuleSlideDown { + from { + transform: translate(-50%, -40px); + opacity: 0; + } + + to { + transform: translate(-50%, 0); + opacity: 1; + } +} + +@keyframes capsulePulse { + + 0%, + 100% { + transform: translateY(0); + opacity: 1; + } + + 50% { + transform: translateY(2px); + opacity: 0.6; + } +} \ No newline at end of file diff --git a/src/components/UpdateProgressCapsule.tsx b/src/components/UpdateProgressCapsule.tsx new file mode 100644 index 0000000..8402680 --- /dev/null +++ b/src/components/UpdateProgressCapsule.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import { useAppStore } from '../stores/appStore' +import { Download, X, AlertCircle, Info } from 'lucide-react' +import './UpdateProgressCapsule.scss' + +const UpdateProgressCapsule: React.FC = () => { + const { + isDownloading, + downloadProgress, + showUpdateDialog, + setShowUpdateDialog, + updateInfo, + setUpdateInfo, + updateError, + setUpdateError + } = useAppStore() + + // Control visibility + // If dialog is open, we usually hide the capsule UNLESS we want it as a mini-indicator + // For now, let's hide it if the dialog is open + if (showUpdateDialog) return null + + // State mapping + const hasError = !!updateError + const hasUpdate = !!updateInfo && updateInfo.hasUpdate + + if (!hasError && !isDownloading && !hasUpdate) return null + + // Safe normalize progress + const safeProgress = typeof downloadProgress === 'number' ? { percent: downloadProgress } : (downloadProgress || { percent: 0 }) + const percent = safeProgress.percent || 0 + const bytesPerSecond = safeProgress.bytesPerSecond + + const formatBytes = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + const unitIndex = Math.max(0, Math.min(i, sizes.length - 1)) + return parseFloat((bytes / Math.pow(k, unitIndex)).toFixed(1)) + ' ' + sizes[unitIndex] + } + + const formatSpeed = (bps: number) => { + return `${formatBytes(bps)}/s` + } + + const handleClose = (e: React.MouseEvent) => { + e.stopPropagation() + if (hasError) { + setUpdateError(null) + } else if (hasUpdate && !isDownloading) { + setUpdateInfo(null) + } + } + + // Determine appearance class and content + let capsuleClass = 'update-progress-capsule' + let content = null + + if (hasError) { + capsuleClass += ' state-error' + content = ( + <> +
+ +
+
+ 更新失败: {updateError} +
+ + ) + } else if (isDownloading) { + capsuleClass += ' state-downloading' + content = ( + <> +
+ +
+
+ {percent.toFixed(0)}% + {bytesPerSecond > 0 && ( + {formatSpeed(bytesPerSecond)} + )} +
+
+
+
+ + ) + } else if (hasUpdate) { + capsuleClass += ' state-available' + content = ( + <> +
+ +
+
+ 发现新版本 v{updateInfo?.version} +
+ + ) + } + + return ( +
setShowUpdateDialog(true)}> +
+ {content} + {!isDownloading && ( + + )} +
+
+ ) +} + +export default UpdateProgressCapsule diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 8bd2d45..dd3864a 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -29,7 +29,22 @@ interface WxidOption { } function SettingsPage() { - const { isDbConnected, setDbConnected, setLoading, reset } = useAppStore() + const { + isDbConnected, + setDbConnected, + setLoading, + reset, + updateInfo, + setUpdateInfo, + isDownloading, + setIsDownloading, + downloadProgress, + setDownloadProgress, + showUpdateDialog, + setShowUpdateDialog, + setUpdateError + } = useAppStore() + const resetChatStore = useChatStore((state) => state.reset) const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache) @@ -69,10 +84,7 @@ function SettingsPage() { const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) const [isFetchingImageKey, setIsFetchingImageKey] = useState(false) const [isCheckingUpdate, setIsCheckingUpdate] = useState(false) - const [isDownloading, setIsDownloading] = useState(false) - const [downloadProgress, setDownloadProgress] = useState(0) const [appVersion, setAppVersion] = useState('') - const [updateInfo, setUpdateInfo] = useState<{ hasUpdate: boolean; version?: string; releaseNotes?: string } | null>(null) const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null) const [showDecryptKey, setShowDecryptKey] = useState(false) const [dbKeyStatus, setDbKeyStatus] = useState('') @@ -209,7 +221,7 @@ function SettingsPage() { // 监听下载进度 useEffect(() => { - const removeListener = window.electronAPI.app.onDownloadProgress?.((progress: number) => { + const removeListener = window.electronAPI.app.onDownloadProgress?.((progress: any) => { setDownloadProgress(progress) }) return () => removeListener?.() @@ -229,12 +241,14 @@ function SettingsPage() { }, [whisperModelDir]) const handleCheckUpdate = async () => { + if (isCheckingUpdate) return setIsCheckingUpdate(true) setUpdateInfo(null) try { const result = await window.electronAPI.app.checkForUpdates() if (result.hasUpdate) { setUpdateInfo(result) + setShowUpdateDialog(true) showMessage(`发现新版:${result.version}`, true) } else { showMessage('当前已是最新版', true) @@ -247,8 +261,10 @@ function SettingsPage() { } const handleUpdateNow = async () => { + setShowUpdateDialog(false) + setIsDownloading(true) - setDownloadProgress(0) + setDownloadProgress({ percent: 0 }) try { showMessage('正在下载更新...', true) await window.electronAPI.app.downloadAndInstall() @@ -258,6 +274,8 @@ function SettingsPage() { } } + + const showMessage = (text: string, success: boolean) => { setMessage({ text, success }) setTimeout(() => setMessage(null), 3000) @@ -989,171 +1007,171 @@ function SettingsPage() { const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue) return ( -
-
- - 导出页面默认选中的格式 -
- - {showExportFormatSelect && ( -
- {exportFormatOptions.map((option) => ( - - ))} -
- )} -
-
- -
- - 控制导出页面的默认时间选择 -
- - {showExportDateRangeSelect && ( -
- {exportDateRangeOptions.map((option) => ( - - ))} -
- )} -
-
- -
- - 控制图片/语音/表情的默认导出开关 -
- {exportDefaultMedia ? '已开启' : '已关闭'} -
) } diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 2bcac8e..eb9188f 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -10,70 +10,47 @@ } .sns-sidebar { - width: 300px; + width: 320px; background: var(--bg-secondary); - border-right: 1px solid var(--border-color); + border-left: 1px solid var(--border-color); display: flex; flex-direction: column; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); flex-shrink: 0; z-index: 10; + box-shadow: -4px 0 16px rgba(0, 0, 0, 0.05); &.closed { width: 0; opacity: 0; - transform: translateX(-100%); + transform: translateX(100%); pointer-events: none; + border-left: none; } .sidebar-header { - padding: 18px 20px; + padding: 0 24px; + height: 64px; + box-sizing: border-box; display: flex; align-items: center; - justify-content: space-between; + /* justify-content: space-between; -- No longer needed as it's just h3 */ border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); - .title-wrapper { - display: flex; - align-items: center; - gap: 8px; + h3 { + margin: 0; + font-size: 18px; + font-weight: 700; color: var(--text-primary); - - .title-icon { - color: var(--accent-color); - } - - h3 { - margin: 0; - font-size: 15px; - font-weight: 600; - letter-spacing: 0.5px; - } - } - - .toggle-btn { - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - color: var(--text-secondary); - cursor: pointer; - padding: 5px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 6px; - transition: all 0.2s; - - &:hover { - background: var(--hover-bg); - color: var(--accent-color); - border-color: var(--accent-color); - } + letter-spacing: 0; } } .filter-content { flex: 1; - overflow-y: auto; + overflow-y: hidden; + /* Changed from auto to hidden to allow inner scrolling of contact list */ padding: 16px; display: flex; flex-direction: column; @@ -86,6 +63,7 @@ padding: 14px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02); transition: transform 0.2s, box-shadow 0.2s; + flex-shrink: 0; &:hover { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.04); @@ -172,7 +150,7 @@ flex: 1; display: flex; flex-direction: column; - min-height: 0; // 改为 0 以支持 flex 压缩 + min-height: 200px; padding: 0; overflow: hidden; } @@ -181,7 +159,7 @@ .filter-section { - margin-bottom: 20px; + margin-bottom: 0px; label { display: flex; @@ -258,12 +236,16 @@ flex: 1; display: flex; flex-direction: column; + overflow: hidden; .section-header { padding: 16px 16px 1px 16px; + margin-bottom: 12px; + /* Increased spacing */ display: flex; justify-content: space-between; align-items: center; + flex-shrink: 0; .header-actions { display: flex; @@ -306,6 +288,7 @@ position: relative; display: flex; align-items: center; + flex-shrink: 0; .search-icon { position: absolute; @@ -354,6 +337,7 @@ overflow-y: auto; padding: 4px 8px; margin: 0 4px 8px 4px; + min-height: 0; .contact-item { display: flex; @@ -524,6 +508,12 @@ } } + .header-right { + display: flex; + align-items: center; + gap: 12px; + } + .icon-btn { background: none; border: none; @@ -553,6 +543,7 @@ } } + .sns-content-wrapper { flex: 1; display: flex; @@ -739,6 +730,61 @@ cursor: zoom-in; } + .live-badge { + position: absolute; + top: 8px; + left: 8px; + right: 8px; + left: auto; + background: rgba(255, 255, 255, 0.9); + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(4px); + color: white; + padding: 4px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 2; + transition: opacity 0.2s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + .download-btn-overlay { + position: absolute; + bottom: 6px; + right: 6px; + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transform: translateY(10px); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 2; + + &:hover { + background: rgba(0, 0, 0, 0.7); + transform: scale(1.1); + border-color: rgba(255, 255, 255, 0.8); + } + } + + &:hover { + .download-btn-overlay { + opacity: 1; + transform: translateY(0); + } + } + .media-error-placeholder { position: absolute; inset: 0; @@ -937,4 +983,197 @@ transform: scale(1); opacity: 1; } +} + +// Debug Dialog Styles +.debug-btn { + margin-left: auto; + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-secondary); + padding: 6px; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + + &:hover { + background: var(--hover-bg); + color: var(--accent-color); + border-color: var(--accent-color); + } +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + backdrop-filter: blur(4px); +} + +.debug-dialog { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + width: 90%; + max-width: 800px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + + .debug-dialog-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + + .close-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + border-radius: 4px; + transition: all 0.2s; + + &:hover { + background: var(--hover-bg); + color: var(--accent-color); + } + } + } + + .debug-dialog-body { + flex: 1; + overflow-y: auto; + padding: 20px; + + .debug-section { + margin-bottom: 24px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border-color); + + &:last-child { + border-bottom: none; + } + + h4 { + margin: 0 0 12px 0; + font-size: 14px; + font-weight: 600; + color: var(--accent-color); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .debug-item { + display: flex; + gap: 12px; + padding: 8px 0; + align-items: flex-start; + + .debug-key { + font-weight: 500; + color: var(--text-secondary); + min-width: 140px; + font-size: 13px; + font-family: 'Consolas', 'Microsoft YaHei', 'SimHei', monospace; + } + + .debug-value { + flex: 1; + color: var(--text-primary); + font-size: 13px; + word-break: break-all; + font-family: 'Consolas', 'Microsoft YaHei', 'SimHei', monospace; + user-select: text; + cursor: text; + padding: 2px 0; + } + } + + .media-debug-item { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px; + margin-bottom: 12px; + + .media-debug-header { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color); + } + + .live-photo-debug { + margin-top: 12px; + padding-top: 12px; + border-top: 1px dashed var(--border-color); + + .live-photo-label { + font-weight: 500; + color: var(--accent-color); + margin-bottom: 8px; + font-size: 13px; + } + } + } + + .json-code { + background: var(--bg-tertiary); + color: var(--text-primary); + padding: 16px; + border-radius: 8px; + border: 1px solid var(--border-color); + overflow-x: auto; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 12px; + line-height: 1.5; + user-select: all; + max-height: 400px; + overflow-y: auto; + } + + .copy-json-btn { + margin-top: 12px; + padding: 8px 16px; + background: var(--accent-color); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; + + &:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(var(--accent-color-rgb), 0.3); + } + } + } + } } \ No newline at end of file diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 18bf21a..8029dd1 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react' -import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon } from 'lucide-react' +import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight } from 'lucide-react' import { Avatar } from '../components/Avatar' import { ImagePreview } from '../components/ImagePreview' import JumpToDateDialog from '../components/JumpToDateDialog' +import { LivePhotoIcon } from '../components/LivePhotoIcon' import './SnsPage.scss' interface SnsPost { @@ -13,29 +14,64 @@ interface SnsPost { createTime: number contentDesc: string type?: number - media: { url: string; thumb: string }[] + media: { + url: string + thumb: string + md5?: string + token?: string + key?: string + encIdx?: string + livePhoto?: { + url: string + thumb: string + token?: string + key?: string + encIdx?: string + } + }[] likes: string[] comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] + rawXml?: string // 原始 XML 数据 } -const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => { +const MediaItem = ({ media, onPreview }: { media: any, onPreview: () => void }) => { const [error, setError] = useState(false); + const { url, thumb, livePhoto } = media; + const isLive = !!livePhoto; + const targetUrl = thumb || url; + + const handleDownload = (e: React.MouseEvent) => { + e.stopPropagation(); + + let downloadUrl = url; + let downloadKey = media.key || ''; + + if (isLive && media.livePhoto) { + downloadUrl = media.livePhoto.url; + downloadKey = media.livePhoto.key || ''; + } + + // TODO: 调用后端下载服务 + // window.electronAPI.sns.download(downloadUrl, downloadKey); + }; return ( -
- {!error ? ( - setError(true)} - /> - ) : ( -
- +
+ setError(true)} + /> + {isLive && ( +
+
)} +
); }; @@ -65,6 +101,7 @@ export default function SnsPage() { const [showJumpDialog, setShowJumpDialog] = useState(false) const [jumpTargetDate, setJumpTargetDate] = useState(undefined) const [previewImage, setPreviewImage] = useState(null) + const [debugPost, setDebugPost] = useState(null) const postsContainerRef = useRef(null) @@ -264,7 +301,7 @@ export default function SnsPage() { setHasNewer(false) setSelectedUsernames([]) setSearchKeyword('') - setJumpTargetDate(null) + setJumpTargetDate(undefined) loadContacts() loadPosts({ reset: true }) } @@ -347,16 +384,157 @@ export default function SnsPage() { return (
- {/* 侧边栏:过滤与搜索 */} +
+
+
+

社交动态

+
+
+ + +
+
+ +
+
+
+ {loadingNewer && ( +
+ + 正在检查更新的动态... +
+ )} + {!loadingNewer && hasNewer && ( +
loadPosts({ direction: 'newer' })}> + 查看更新的动态 +
+ )} + {posts.map((post, index) => { + return ( +
+
+
+
+ +
+
{post.nickname}
+
{formatTime(post.createTime)}
+
+ +
+ +
+ {post.contentDesc &&
{post.contentDesc}
} + + {post.type === 15 ? ( +
+ + 视频动态 +
+ ) : post.media.length > 0 && ( +
+ {post.media.map((m, idx) => ( + setPreviewImage(m.url)} /> + ))} +
+ )} +
+ + {(post.likes.length > 0 || post.comments.length > 0) && ( +
+ {post.likes.length > 0 && ( +
+ + + {post.likes.join('、')} + +
+ )} + + {post.comments.length > 0 && ( +
+ {post.comments.map((c, idx) => ( +
+ {c.nickname} + {c.refNickname && ( + <> + 回复 + {c.refNickname} + + )} + : + {c.content} +
+ ))} +
+ )} +
+ )} +
+
+
+ ) + })} +
+ + {loading &&
+ + 正在加载更多... +
} + {!hasMore && posts.length > 0 &&
已经到底啦
} + {!loading && posts.length === 0 && ( +
+
+

未找到相关动态

+ {(selectedUsernames.length > 0 || searchKeyword) && ( + + )} +
+ )} +
+
+
+ + {/* 侧边栏:过滤与搜索 (moved to right) */} - -
-
-
- {!isSidebarOpen && ( - - )} -

社交动态

-
-
- -
-
- -
-
-
- {loadingNewer && ( -
- - 正在检查更新的动态... -
- )} - {!loadingNewer && hasNewer && ( -
loadPosts({ direction: 'newer' })}> - 查看更新的动态 -
- )} - {posts.map((post, index) => { - return ( -
-
-
-
- -
-
{post.nickname}
-
{formatTime(post.createTime)}
-
-
- -
- {post.contentDesc &&
{post.contentDesc}
} - - {post.type === 15 ? ( -
- - 视频动态 -
- ) : post.media.length > 0 && ( -
- {post.media.map((m, idx) => ( - setPreviewImage(m.url)} /> - ))} -
- )} -
- - {(post.likes.length > 0 || post.comments.length > 0) && ( -
- {post.likes.length > 0 && ( -
- - - {post.likes.join('、')} - -
- )} - - {post.comments.length > 0 && ( -
- {post.comments.map((c, idx) => ( -
- {c.nickname} - {c.refNickname && ( - <> - 回复 - {c.refNickname} - - )} - : - {c.content} -
- ))} -
- )} -
- )} -
-
-
- ) - })} -
- - {loading &&
- - 正在加载更多... -
} - {!hasMore && posts.length > 0 &&
已经到底啦
} - {!loading && posts.length === 0 && ( -
-
-

未找到相关动态

- {(selectedUsernames.length > 0 || searchKeyword) && ( - - )} -
- )} -
-
-
{previewImage && ( setPreviewImage(null)} /> @@ -605,6 +653,154 @@ export default function SnsPage() { }} currentDate={jumpTargetDate || new Date()} /> + + {/* Debug Info Dialog */} + {debugPost && ( +
setDebugPost(null)}> +
e.stopPropagation()}> +
+

原始数据 - {debugPost.nickname}

+ +
+
+ +
+

ℹ 基本信息

+
+ ID: + {debugPost.id} +
+
+ 用户名: + {debugPost.username} +
+
+ 昵称: + {debugPost.nickname} +
+
+ 时间: + {new Date(debugPost.createTime * 1000).toLocaleString()} +
+
+ 类型: + {debugPost.type} +
+
+ +
+

媒体信息 ({debugPost.media.length} 项)

+ {debugPost.media.map((media, idx) => ( +
+
媒体 {idx + 1}
+
+ URL: + {media.url} +
+
+ 缩略图: + {media.thumb} +
+ {media.md5 && ( +
+ MD5: + {media.md5} +
+ )} + {media.token && ( +
+ Token: + {media.token} +
+ )} + {media.key && ( +
+ Key (解密密钥): + {media.key} +
+ )} + {media.encIdx && ( +
+ Enc Index: + {media.encIdx} +
+ )} + {media.livePhoto && ( +
+
Live Photo 视频部分:
+
+ 视频 URL: + {media.livePhoto.url} +
+
+ 视频缩略图: + {media.livePhoto.thumb} +
+ {media.livePhoto.token && ( +
+ 视频 Token: + {media.livePhoto.token} +
+ )} + {media.livePhoto.key && ( +
+ 视频 Key: + {media.livePhoto.key} +
+ )} +
+ )} +
+ ))} +
+ + {/* 原始 XML */} + {debugPost.rawXml && ( +
+

原始 XML 数据

+
{(() => {
+                                        // XML 缩进格式化
+                                        let formatted = '';
+                                        let indent = 0;
+                                        const tab = '  ';
+                                        const parts = debugPost.rawXml.split(/(<[^>]+>)/g).filter(p => p.trim());
+
+                                        for (const part of parts) {
+                                            if (!part.startsWith('<')) {
+                                                if (part.trim()) formatted += part;
+                                                continue;
+                                            }
+
+                                            if (part.startsWith('')) {
+                                                formatted += '\n' + tab.repeat(indent) + part;
+                                            } else {
+                                                formatted += '\n' + tab.repeat(indent) + part;
+                                                indent++;
+                                            }
+                                        }
+
+                                        return formatted.trim();
+                                    })()}
+ +
+ )} +
+
+
+ )}
) } diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index f479f9e..3d33689 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -5,15 +5,34 @@ export interface AppState { isDbConnected: boolean dbPath: string | null myWxid: string | null - + // 加载状态 isLoading: boolean loadingText: string - + + // 更新状态 + updateInfo: { + hasUpdate: boolean + version?: string + releaseNotes?: string + } | null + isDownloading: boolean + downloadProgress: any + showUpdateDialog: boolean + updateError: string | null + // 操作 setDbConnected: (connected: boolean, path?: string) => void setMyWxid: (wxid: string) => void setLoading: (loading: boolean, text?: string) => void + + // 更新操作 + setUpdateInfo: (info: any) => void + setIsDownloading: (isDownloading: boolean) => void + setDownloadProgress: (progress: any) => void + setShowUpdateDialog: (show: boolean) => void + setUpdateError: (error: string | null) => void + reset: () => void } @@ -24,23 +43,41 @@ export const useAppStore = create((set) => ({ isLoading: false, loadingText: '', - setDbConnected: (connected, path) => set({ - isDbConnected: connected, - dbPath: path ?? null + // 更新状态初始化 + updateInfo: null, + isDownloading: false, + downloadProgress: { percent: 0 }, + showUpdateDialog: false, + updateError: null, + + setDbConnected: (connected, path) => set({ + isDbConnected: connected, + dbPath: path ?? null }), - + setMyWxid: (wxid) => set({ myWxid: wxid }), - - setLoading: (loading, text) => set({ - isLoading: loading, - loadingText: text ?? '' + + setLoading: (loading, text) => set({ + isLoading: loading, + loadingText: text ?? '' }), - + + setUpdateInfo: (info) => set({ updateInfo: info, updateError: null }), + setIsDownloading: (isDownloading) => set({ isDownloading: isDownloading }), + setDownloadProgress: (progress) => set({ downloadProgress: progress }), + setShowUpdateDialog: (show) => set({ showUpdateDialog: show }), + setUpdateError: (error) => set({ updateError: error }), + reset: () => set({ isDbConnected: false, dbPath: null, myWxid: null, isLoading: false, - loadingText: '' + loadingText: '', + updateInfo: null, + isDownloading: false, + downloadProgress: { percent: 0 }, + showUpdateDialog: false, + updateError: null }) })) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index abd1d6f..fdd8203 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -333,12 +333,30 @@ export interface ElectronAPI { createTime: number contentDesc: string type?: number - media: Array<{ url: string; thumb: string }> + media: Array<{ + url: string + thumb: string + md5?: string + token?: string + key?: string + encIdx?: string + livePhoto?: { + url: string + thumb: string + md5?: string + token?: string + key?: string + encIdx?: string + } + }> likes: Array comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }> + rawXml?: string }> error?: string }> + debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }> + proxyImage: (url: string) => Promise<{ success: boolean; dataUrl?: string; error?: string }> } }