diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 45be6d9..5d90f93 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,6 +73,17 @@ 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 // 名片消息 cardUsername?: string // 名片的微信ID cardNickname?: string // 名片的昵称 @@ -1224,6 +1235,17 @@ 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 cardUsername: string | undefined let cardNickname: string | undefined @@ -1284,6 +1306,33 @@ class ChatService { quotedSender = quoteInfo.sender } + const looksLikeAppMsg = Boolean(content && (content.includes(' 0 && genericTitle.length < 100) { @@ -1416,6 +1487,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 +1514,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 +1544,8 @@ class ChatService { return title case '2000': return `[转账] ${title}` + case '2001': + return `[红包] ${title}` default: return title } @@ -1459,6 +1562,13 @@ class ChatService { return '[小程序]' case '2000': return '[转账]' + case '2001': + return '[红包]' + case '3': + return '[音乐]' + case '5': + case '49': + return '[链接]' case '87': return '[群公告]' default: @@ -1790,6 +1900,17 @@ 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 fileName?: string fileSize?: number fileExt?: string @@ -1816,6 +1937,82 @@ 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' || + normalized.includes(' { + 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 +4282,14 @@ 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 + } + + if (rawContent && (rawContent.includes(' + {/* 用户协议弹窗 */} {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/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 89422bd..df72d96 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)} 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 +}