From fbcf7d2fc30583be07e968bd440996792dc8fbf7 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 25 Feb 2026 13:54:06 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E5=AE=9E=E5=86=B5=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E6=9B=B4=E5=8A=A0=E4=B8=9D=E6=BB=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ImageWindow.scss | 42 ++++++++- src/pages/ImageWindow.tsx | 173 ++++++++++++++++++++++++++----------- 2 files changed, 161 insertions(+), 54 deletions(-) diff --git a/src/pages/ImageWindow.scss b/src/pages/ImageWindow.scss index 3004bbf..c1d842d 100644 --- a/src/pages/ImageWindow.scss +++ b/src/pages/ImageWindow.scss @@ -46,6 +46,18 @@ background: var(--bg-tertiary); color: var(--text-primary); } + + &:disabled { + cursor: default; + opacity: 1; + } + + &.live-play-btn { + &.active { + background: rgba(var(--primary-rgb, 76, 132, 255), 0.16); + color: var(--primary, #4c84ff); + } + } } .scale-text { @@ -78,14 +90,40 @@ cursor: grabbing; } - img, video { + .media-wrapper { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + will-change: transform; + } + + img, + video { + display: block; max-width: none; max-height: none; object-fit: contain; - will-change: transform; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); pointer-events: auto; } + + .live-video { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: fill; + pointer-events: none; + opacity: 0; + will-change: opacity; + transition: opacity 0.3s ease-in-out; + } + + .live-video.visible { + opacity: 1; + } } } diff --git a/src/pages/ImageWindow.tsx b/src/pages/ImageWindow.tsx index 9e5b4eb..e6b2e5d 100644 --- a/src/pages/ImageWindow.tsx +++ b/src/pages/ImageWindow.tsx @@ -1,4 +1,3 @@ - import { useState, useEffect, useRef, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react' @@ -9,15 +8,19 @@ export default function ImageWindow() { const [searchParams] = useSearchParams() const imagePath = searchParams.get('imagePath') const liveVideoPath = searchParams.get('liveVideoPath') - const [showLive, setShowLive] = useState(false) + const hasLiveVideo = !!liveVideoPath + + const [isPlayingLive, setIsPlayingLive] = useState(false) + const [isVideoVisible, setIsVideoVisible] = useState(false) const videoRef = useRef(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 && ( -
) From 83c07b27f914f77b8eec81e5f4df9a7ac2e8cb4c Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 25 Feb 2026 14:23:22 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E8=A7=A3=E5=AF=86=20=E5=9B=BE=E7=89=87=E8=A7=A3=E5=AF=86?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/chatService.ts | 279 ++++++++++++++++++++- src/App.tsx | 2 + src/components/BatchImageDecryptGlobal.tsx | 133 ++++++++++ src/pages/ChatPage.tsx | 205 +++++++++++++++ src/stores/batchImageDecryptStore.ts | 64 +++++ src/styles/batchTranscribe.scss | 46 +++- 6 files changed, 725 insertions(+), 4 deletions(-) create mode 100644 src/components/BatchImageDecryptGlobal.tsx create mode 100644 src/stores/batchImageDecryptStore.ts 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 +} From 1a07c3970f8f7228c47d46c853cbff97cf47a7df Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 25 Feb 2026 14:54:08 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E7=AE=80=E5=8D=95=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E8=A7=A3=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/weflow-overview-sync/SKILL.md | 32 ++++++++ electron/main.ts | 3 + electron/preload.ts | 1 + electron/services/imageDecryptService.ts | 81 +++++++++++++++----- electron/services/wcdbCore.ts | 2 +- package-lock.json | 22 ++---- src/pages/ChatPage.scss | 77 ++++++++++++++++++- src/pages/ChatPage.tsx | 59 ++++++++++++-- src/types/electron.d.ts | 5 ++ src/types/models.ts | 11 +++ 10 files changed, 246 insertions(+), 47 deletions(-) create mode 100644 .agents/skills/weflow-overview-sync/SKILL.md diff --git a/.agents/skills/weflow-overview-sync/SKILL.md b/.agents/skills/weflow-overview-sync/SKILL.md new file mode 100644 index 0000000..c8d8b11 --- /dev/null +++ b/.agents/skills/weflow-overview-sync/SKILL.md @@ -0,0 +1,32 @@ +--- +name: weflow-overview-sync +description: Keep the WeFlow architecture overview document synchronized with code and interface changes. Use when editing WeFlow source files, Electron services, IPC contracts, DB access logic, export and analytics flows, or related docs that affect architecture, fields, or data paths. +--- + +# WeFlow Overview Sync + +## Workflow + +1. Read the architecture overview markdown at repo root before any WeFlow edit. +2. Identify touched files and impacted concepts (module, interface, data flow, field definition, export behavior). +3. Update the overview document in the same task when affected items are already documented. +4. Add a new subsection in the overview document when the requested change is not documented yet. +5. Preserve the existing formatting style of the overview document before finalizing: +- Keep heading hierarchy and numbering style consistent. +- Keep concise wording and use `-` list markers. +- Wrap file paths, APIs, and field names in backticks. +- Place new content in the logically matching section. +6. Re-check the overview document for format consistency and architecture accuracy before replying. + +## Update Rules + +- Update existing sections when they already cover the changed files or interfaces. +- Add missing coverage when new modules, IPC methods, SQL fields, or service flows appear. +- Avoid broad rewrites; apply focused edits that keep the document stable and scannable. +- Reflect any renamed path, API, or field immediately to prevent architecture drift. + +## Collaboration and UI Rules + +- If unrelated additions from other collaborators appear in files you edit, leave them as-is and focus only on the current task scope. +- For dropdown menu UI design, inspect and follow existing in-app dropdown patterns; do not use native browser dropdown styles. +- Do not use native styles for frontend UI design; implement consistent custom-styled components aligned with the product's existing visual system. diff --git a/electron/main.ts b/electron/main.ts index f43d707..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) }) diff --git a/electron/preload.ts b/electron/preload.ts index e1ad7a8..4cf585b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -154,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/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/pages/ChatPage.scss b/src/pages/ChatPage.scss index 8a41b65..7964e3b 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1114,6 +1114,53 @@ } } } + + .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); + } } // 适配发送出去的消息中的链接卡片 @@ -2752,12 +2799,14 @@ .card-message, .chat-record-message, - .miniapp-message { + .miniapp-message, + .appmsg-rich-card { background: rgba(255, 255, 255, 0.15); .card-name, .miniapp-title, - .source-name { + .source-name, + .link-title { color: white; } @@ -2765,7 +2814,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 +2829,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 +3292,16 @@ } } } -} \ No newline at end of file +} + +.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; + } +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index df72d96..939aad1 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -3061,7 +3061,7 @@ function MessageBubble({ setImageLocalPath(result.localPath) setImageHasUpdate(false) if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath) - return + return result } } @@ -3072,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 { @@ -3080,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(() => { @@ -3110,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) { @@ -3631,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)} /> diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index d56cf81..b00f3c0 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -126,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..7d93279 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -64,6 +64,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 // 转账消息 transferPayerUsername?: string // 转账付款方 wxid transferReceiverUsername?: string // 转账收款方 wxid From bc0671440c7282ec9e3ef69da01a220c65f934ef Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 25 Feb 2026 17:07:47 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/chatService.ts | 153 ++++++++- src/pages/ChatPage.scss | 524 ++++++++++++++++++++++++++++++- src/pages/ChatPage.tsx | 328 ++++++++++++++++++- src/types/models.ts | 16 + 4 files changed, 999 insertions(+), 22 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 5d90f93..eda6e7a 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -84,9 +84,25 @@ export interface Message { 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 // 转账收款人 @@ -744,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 (重新加载会话) @@ -789,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 @@ -890,7 +906,7 @@ class ChatService { // 群聊消息:senderUsername 是群成员,需要检查 _db_path 或上下文 // 单聊消息:senderUsername 应该是 sessionId 或自己 const isGroupChat = sessionId.includes('@chatroom') - + if (isGroupChat) { // 群聊消息暂不验证(因为 senderUsername 是群成员,不是 sessionId) return true @@ -927,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) { @@ -1246,9 +1262,22 @@ class ChatService { 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 @@ -1286,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) @@ -1327,6 +1365,18 @@ class ChatService { appMsgLocationLabel = appMsgLocationLabel || type49Info.appMsgLocationLabel finderNickname = finderNickname || type49Info.finderNickname finderUsername = finderUsername || type49Info.finderUsername + finderCoverUrl = finderCoverUrl || type49Info.finderCoverUrl + finderAvatar = finderAvatar || type49Info.finderAvatar + finderDuration = finderDuration ?? type49Info.finderDuration + locationLat = locationLat ?? type49Info.locationLat + locationLng = locationLng ?? type49Info.locationLng + locationPoiname = locationPoiname || type49Info.locationPoiname + locationLabel = locationLabel || type49Info.locationLabel + musicAlbumUrl = musicAlbumUrl || type49Info.musicAlbumUrl + musicUrl = musicUrl || type49Info.musicUrl + giftImageUrl = giftImageUrl || type49Info.giftImageUrl + giftWish = giftWish || type49Info.giftWish + giftPrice = giftPrice || type49Info.giftPrice chatRecordTitle = chatRecordTitle || type49Info.chatRecordTitle chatRecordList = chatRecordList || type49Info.chatRecordList transferPayerUsername = transferPayerUsername || type49Info.transferPayerUsername @@ -1372,8 +1422,21 @@ class ChatService { appMsgLocationLabel, finderNickname, finderUsername, + finderCoverUrl, + finderAvatar, + finderDuration, + locationLat, + locationLng, + locationPoiname, + locationLabel, + musicAlbumUrl, + musicUrl, + giftImageUrl, + giftWish, + giftPrice, cardUsername, cardNickname, + cardAvatarUrl, transferPayerUsername, transferReceiverUsername, chatRecordTitle, @@ -1874,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 {} @@ -1884,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 {} @@ -1911,6 +1978,19 @@ class ChatService { 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 @@ -1965,14 +2045,10 @@ class ChatService { this.extractXmlValue(content, 'findernickname') || this.extractXmlValue(content, 'finder_nickname') const normalized = content.toLowerCase() - const isFinder = - xmlType === '51' || - normalized.includes(' 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) { @@ -4286,6 +4406,7 @@ class ChatService { const cardInfo = this.parseCardInfo(rawContent) msg.cardUsername = cardInfo.username msg.cardNickname = cardInfo.nickname + msg.cardAvatarUrl = cardInfo.avatarUrl } if (rawContent && (rawContent.includes('
- - - - + {cardAvatar ? ( + + ) : ( + + + + + )}
{cardName}
+ {message.cardUsername && message.cardUsername !== message.cardNickname && ( +
微信号: {message.cardUsername}
+ )}
个人名片
@@ -3972,7 +3980,319 @@ 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 mapTileUrl = (lat && lng) + ? `https://restapi.amap.com/v3/staticmap?location=${lng},${lat}&zoom=15&size=280*100&markers=mid,,A:${lng},${lat}&key=e1dedc6bfbb8413ab2185e7a0e21f0a1` + : '' + 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 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' && title.includes('不支持')) { + 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') || message.cardAvatarUrl + const authorName = 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(' Date: Wed, 25 Feb 2026 17:26:45 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.scss | 15 ++++++++++----- src/pages/ChatPage.tsx | 20 +++++++++++++++----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 99fd618..d44b3fd 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1047,6 +1047,7 @@ 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); @@ -3107,7 +3108,7 @@ .chat-record-message, .miniapp-message, .appmsg-rich-card { - background: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.25); .card-name, .miniapp-title, @@ -3618,11 +3619,12 @@ align-items: center; gap: 12px; padding: 12px; - background: var(--bg-primary); // 添加底色 + background: var(--card-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); @@ -3672,12 +3674,13 @@ // 聊天记录消息外观 .chat-record-message { - background: var(--bg-primary); // 添加底色 + background: var(--card-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); + background: var(--bg-hover) !important; } .chat-record-list { @@ -3710,13 +3713,15 @@ .official-message { display: flex; flex-direction: column; - background: var(--bg-primary); + background: var(--card-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); diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index b5c41fe..b8192c9 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -3987,8 +3987,12 @@ function MessageBubble({ 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://restapi.amap.com/v3/staticmap?location=${lng},${lat}&zoom=15&size=280*100&markers=mid,,A:${lng},${lat}&key=e1dedc6bfbb8413ab2185e7a0e21f0a1` + ? `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)}`)}> @@ -4039,6 +4043,7 @@ function MessageBubble({ 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 = @@ -4066,8 +4071,13 @@ function MessageBubble({ // 对视频号提取真实标题,避免出现 "当前版本不支持该内容" let displayTitle = title - if (kind === 'finder' && title.includes('不支持')) { - displayTitle = desc || '' + 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) => { @@ -4224,8 +4234,8 @@ function MessageBubble({ } if (kind === 'official-link') { - const authorAvatar = q('publisher > headimg') || q('brand_info > headimgurl') || q('appmsg > avatar') || message.cardAvatarUrl - const authorName = q('publisher > nickname') || sourceName || appName || '公众号' + 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 From 9585a0295981ab8e11e8c3a11ffc805d6198b316 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 25 Feb 2026 17:59:42 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=8F=E6=98=8E?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.scss | 49 ++++++++++++++++++++++++++++++----------- src/styles/main.scss | 22 ++++++++++++++++++ 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index d44b3fd..abd6368 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1041,7 +1041,7 @@ // 链接卡片消息样式 .link-message { width: 280px; - background: var(--card-bg); + background: var(--card-inner-bg); border-radius: 8px; overflow: hidden; cursor: pointer; @@ -1167,15 +1167,24 @@ // 适配发送出去的消息中的链接卡片 .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: var(--text-secondary); + color: rgba(255, 255, 255, 0.8); + } + + .appmsg-url-line { + color: rgba(255, 255, 255, 0.6); } } @@ -1258,7 +1267,7 @@ // 视频号卡片 .channel-video-card { width: 200px; - background: var(--card-bg); + background: var(--card-inner-bg); border-radius: 8px; overflow: hidden; border: 1px solid var(--border-color); @@ -1403,7 +1412,7 @@ // 位置消息卡片 .location-message { width: 240px; - background: var(--card-bg); + background: var(--card-inner-bg); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; @@ -1847,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; @@ -2823,7 +2846,7 @@ align-items: center; gap: 12px; padding: 12px 14px; - background: var(--card-bg); + background: var(--card-inner-bg); border: 1px solid var(--border-color); border-radius: 8px; min-width: 200px; @@ -2859,7 +2882,7 @@ // 聊天记录消息 (合并转发) .chat-record-message { - background: var(--card-bg) !important; + background: var(--card-inner-bg) !important; border: 1px solid var(--border-color) !important; transition: opacity 0.2s ease; cursor: pointer; @@ -3108,7 +3131,7 @@ .chat-record-message, .miniapp-message, .appmsg-rich-card { - background: rgba(255, 255, 255, 0.25); + background: var(--sent-card-bg); .card-name, .miniapp-title, @@ -3619,7 +3642,7 @@ align-items: center; gap: 12px; padding: 12px; - background: var(--card-bg); + background: var(--card-inner-bg); border: 1px solid var(--border-color); border-radius: 8px; cursor: pointer; @@ -3674,7 +3697,7 @@ // 聊天记录消息外观 .chat-record-message { - background: var(--card-bg) !important; + 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); @@ -3713,7 +3736,7 @@ .official-message { display: flex; flex-direction: column; - background: var(--card-bg); + background: var(--card-inner-bg); border: 1px solid var(--border-color); border-radius: 8px; overflow: hidden; 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); } // 重置样式 From 49d951e96afea9cb74b825ed6c03e503b1aef3ae Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 25 Feb 2026 18:01:27 +0800 Subject: [PATCH 7/7] 1 --- .agents/skills/weflow-overview-sync/SKILL.md | 32 -------------------- .gitignore | 3 +- 2 files changed, 2 insertions(+), 33 deletions(-) delete mode 100644 .agents/skills/weflow-overview-sync/SKILL.md diff --git a/.agents/skills/weflow-overview-sync/SKILL.md b/.agents/skills/weflow-overview-sync/SKILL.md deleted file mode 100644 index c8d8b11..0000000 --- a/.agents/skills/weflow-overview-sync/SKILL.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: weflow-overview-sync -description: Keep the WeFlow architecture overview document synchronized with code and interface changes. Use when editing WeFlow source files, Electron services, IPC contracts, DB access logic, export and analytics flows, or related docs that affect architecture, fields, or data paths. ---- - -# WeFlow Overview Sync - -## Workflow - -1. Read the architecture overview markdown at repo root before any WeFlow edit. -2. Identify touched files and impacted concepts (module, interface, data flow, field definition, export behavior). -3. Update the overview document in the same task when affected items are already documented. -4. Add a new subsection in the overview document when the requested change is not documented yet. -5. Preserve the existing formatting style of the overview document before finalizing: -- Keep heading hierarchy and numbering style consistent. -- Keep concise wording and use `-` list markers. -- Wrap file paths, APIs, and field names in backticks. -- Place new content in the logically matching section. -6. Re-check the overview document for format consistency and architecture accuracy before replying. - -## Update Rules - -- Update existing sections when they already cover the changed files or interfaces. -- Add missing coverage when new modules, IPC methods, SQL fields, or service flows appear. -- Avoid broad rewrites; apply focused edits that keep the document stable and scannable. -- Reflect any renamed path, API, or field immediately to prevent architecture drift. - -## Collaboration and UI Rules - -- If unrelated additions from other collaborators appear in files you edit, leave them as-is and focus only on the current task scope. -- For dropdown menu UI design, inspect and follow existing in-app dropdown patterns; do not use native browser dropdown styles. -- Do not use native styles for frontend UI design; implement consistent custom-styled components aligned with the product's existing visual system. 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