mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
3
.gitignore
vendored
3
.gitignore
vendored
@@ -61,4 +61,5 @@ wcdb/
|
||||
chatlab-format.md
|
||||
*.bak
|
||||
AGENTS.md
|
||||
.claude/
|
||||
.claude/
|
||||
.agents/
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { join, dirname, basename, extname } from 'path'
|
||||
import { join, dirname, basename, extname } from 'path'
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch } from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
@@ -73,9 +73,36 @@ export interface Message {
|
||||
fileSize?: number // 文件大小
|
||||
fileExt?: string // 文件扩展名
|
||||
xmlType?: string // XML 中的 type 字段
|
||||
appMsgKind?: string // 归一化 appmsg 类型
|
||||
appMsgDesc?: string
|
||||
appMsgAppName?: string
|
||||
appMsgSourceName?: string
|
||||
appMsgSourceUsername?: string
|
||||
appMsgThumbUrl?: string
|
||||
appMsgMusicUrl?: string
|
||||
appMsgDataUrl?: string
|
||||
appMsgLocationLabel?: string
|
||||
finderNickname?: string
|
||||
finderUsername?: string
|
||||
finderCoverUrl?: string
|
||||
finderAvatar?: string
|
||||
finderDuration?: number
|
||||
// 位置消息
|
||||
locationLat?: number
|
||||
locationLng?: number
|
||||
locationPoiname?: string
|
||||
locationLabel?: string
|
||||
// 音乐消息
|
||||
musicAlbumUrl?: string
|
||||
musicUrl?: string
|
||||
// 礼物消息
|
||||
giftImageUrl?: string
|
||||
giftWish?: string
|
||||
giftPrice?: string
|
||||
// 名片消息
|
||||
cardUsername?: string // 名片的微信ID
|
||||
cardNickname?: string // 名片的昵称
|
||||
cardAvatarUrl?: string // 名片头像 URL
|
||||
// 转账消息
|
||||
transferPayerUsername?: string // 转账付款人
|
||||
transferReceiverUsername?: string // 转账收款人
|
||||
@@ -733,15 +760,15 @@ class ChatService {
|
||||
}
|
||||
|
||||
const batchSize = Math.max(1, limit || this.messageBatchDefault)
|
||||
|
||||
|
||||
// 使用互斥锁保护游标状态访问
|
||||
while (this.messageCursorMutex) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1))
|
||||
}
|
||||
this.messageCursorMutex = true
|
||||
|
||||
|
||||
let state = this.messageCursors.get(sessionId)
|
||||
|
||||
|
||||
// 只在以下情况重新创建游标:
|
||||
// 1. 没有游标状态
|
||||
// 2. offset 为 0 (重新加载会话)
|
||||
@@ -778,7 +805,7 @@ class ChatService {
|
||||
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
|
||||
this.messageCursors.set(sessionId, state)
|
||||
this.messageCursorMutex = false
|
||||
|
||||
|
||||
// 如果需要跳过消息(offset > 0),逐批获取但不返回
|
||||
// 注意:仅在 offset === 0 时重建游标最安全;
|
||||
// 当 startTime/endTime 变化导致重建时,offset 应由前端重置为 0
|
||||
@@ -879,7 +906,7 @@ class ChatService {
|
||||
// 群聊消息:senderUsername 是群成员,需要检查 _db_path 或上下文
|
||||
// 单聊消息:senderUsername 应该是 sessionId 或自己
|
||||
const isGroupChat = sessionId.includes('@chatroom')
|
||||
|
||||
|
||||
if (isGroupChat) {
|
||||
// 群聊消息暂不验证(因为 senderUsername 是群成员,不是 sessionId)
|
||||
return true
|
||||
@@ -916,7 +943,7 @@ class ChatService {
|
||||
|
||||
state.fetched += rows.length
|
||||
this.messageCursorMutex = false
|
||||
|
||||
|
||||
this.messageCacheService.set(sessionId, filtered)
|
||||
return { success: true, messages: filtered, hasMore }
|
||||
} catch (e) {
|
||||
@@ -1224,9 +1251,33 @@ class ChatService {
|
||||
let fileSize: number | undefined
|
||||
let fileExt: string | undefined
|
||||
let xmlType: string | undefined
|
||||
let appMsgKind: string | undefined
|
||||
let appMsgDesc: string | undefined
|
||||
let appMsgAppName: string | undefined
|
||||
let appMsgSourceName: string | undefined
|
||||
let appMsgSourceUsername: string | undefined
|
||||
let appMsgThumbUrl: string | undefined
|
||||
let appMsgMusicUrl: string | undefined
|
||||
let appMsgDataUrl: string | undefined
|
||||
let appMsgLocationLabel: string | undefined
|
||||
let finderNickname: string | undefined
|
||||
let finderUsername: string | undefined
|
||||
let finderCoverUrl: string | undefined
|
||||
let finderAvatar: string | undefined
|
||||
let finderDuration: number | undefined
|
||||
let locationLat: number | undefined
|
||||
let locationLng: number | undefined
|
||||
let locationPoiname: string | undefined
|
||||
let locationLabel: string | undefined
|
||||
let musicAlbumUrl: string | undefined
|
||||
let musicUrl: string | undefined
|
||||
let giftImageUrl: string | undefined
|
||||
let giftWish: string | undefined
|
||||
let giftPrice: string | undefined
|
||||
// 名片消息
|
||||
let cardUsername: string | undefined
|
||||
let cardNickname: string | undefined
|
||||
let cardAvatarUrl: string | undefined
|
||||
// 转账消息
|
||||
let transferPayerUsername: string | undefined
|
||||
let transferReceiverUsername: string | undefined
|
||||
@@ -1264,6 +1315,15 @@ class ChatService {
|
||||
const cardInfo = this.parseCardInfo(content)
|
||||
cardUsername = cardInfo.username
|
||||
cardNickname = cardInfo.nickname
|
||||
cardAvatarUrl = cardInfo.avatarUrl
|
||||
} else if (localType === 48 && content) {
|
||||
// 位置消息
|
||||
const latStr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude')
|
||||
const lngStr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude')
|
||||
if (latStr) { const v = parseFloat(latStr); if (Number.isFinite(v)) locationLat = v }
|
||||
if (lngStr) { const v = parseFloat(lngStr); if (Number.isFinite(v)) locationLng = v }
|
||||
locationLabel = this.extractXmlAttribute(content, 'location', 'label') || this.extractXmlValue(content, 'label') || undefined
|
||||
locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || this.extractXmlValue(content, 'poiname') || undefined
|
||||
} else if ((localType === 49 || localType === 8589934592049) && content) {
|
||||
// Type 49 消息(链接、文件、小程序、转账等),8589934592049 也是转账类型
|
||||
const type49Info = this.parseType49Message(content)
|
||||
@@ -1284,6 +1344,45 @@ class ChatService {
|
||||
quotedSender = quoteInfo.sender
|
||||
}
|
||||
|
||||
const looksLikeAppMsg = Boolean(content && (content.includes('<appmsg') || content.includes('<appmsg')))
|
||||
if (looksLikeAppMsg) {
|
||||
const type49Info = this.parseType49Message(content)
|
||||
xmlType = xmlType || type49Info.xmlType
|
||||
linkTitle = linkTitle || type49Info.linkTitle
|
||||
linkUrl = linkUrl || type49Info.linkUrl
|
||||
linkThumb = linkThumb || type49Info.linkThumb
|
||||
fileName = fileName || type49Info.fileName
|
||||
fileSize = fileSize ?? type49Info.fileSize
|
||||
fileExt = fileExt || type49Info.fileExt
|
||||
appMsgKind = appMsgKind || type49Info.appMsgKind
|
||||
appMsgDesc = appMsgDesc || type49Info.appMsgDesc
|
||||
appMsgAppName = appMsgAppName || type49Info.appMsgAppName
|
||||
appMsgSourceName = appMsgSourceName || type49Info.appMsgSourceName
|
||||
appMsgSourceUsername = appMsgSourceUsername || type49Info.appMsgSourceUsername
|
||||
appMsgThumbUrl = appMsgThumbUrl || type49Info.appMsgThumbUrl
|
||||
appMsgMusicUrl = appMsgMusicUrl || type49Info.appMsgMusicUrl
|
||||
appMsgDataUrl = appMsgDataUrl || type49Info.appMsgDataUrl
|
||||
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
|
||||
transferReceiverUsername = transferReceiverUsername || type49Info.transferReceiverUsername
|
||||
}
|
||||
|
||||
messages.push({
|
||||
localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
|
||||
serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0),
|
||||
@@ -1312,8 +1411,32 @@ class ChatService {
|
||||
fileSize,
|
||||
fileExt,
|
||||
xmlType,
|
||||
appMsgKind,
|
||||
appMsgDesc,
|
||||
appMsgAppName,
|
||||
appMsgSourceName,
|
||||
appMsgSourceUsername,
|
||||
appMsgThumbUrl,
|
||||
appMsgMusicUrl,
|
||||
appMsgDataUrl,
|
||||
appMsgLocationLabel,
|
||||
finderNickname,
|
||||
finderUsername,
|
||||
finderCoverUrl,
|
||||
finderAvatar,
|
||||
finderDuration,
|
||||
locationLat,
|
||||
locationLng,
|
||||
locationPoiname,
|
||||
locationLabel,
|
||||
musicAlbumUrl,
|
||||
musicUrl,
|
||||
giftImageUrl,
|
||||
giftWish,
|
||||
giftPrice,
|
||||
cardUsername,
|
||||
cardNickname,
|
||||
cardAvatarUrl,
|
||||
transferPayerUsername,
|
||||
transferReceiverUsername,
|
||||
chatRecordTitle,
|
||||
@@ -1350,6 +1473,7 @@ class ChatService {
|
||||
|
||||
// 检查 XML type,用于识别引用消息等
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
const looksLikeAppMsg = content.includes('<appmsg') || content.includes('<appmsg')
|
||||
|
||||
switch (localType) {
|
||||
case 1:
|
||||
@@ -1364,8 +1488,14 @@ class ChatService {
|
||||
return '[视频]'
|
||||
case 47:
|
||||
return '[动画表情]'
|
||||
case 48:
|
||||
return '[位置]'
|
||||
case 48: {
|
||||
const label =
|
||||
this.extractXmlAttribute(content, 'location', 'label') ||
|
||||
this.extractXmlAttribute(content, 'location', 'poiname') ||
|
||||
this.extractXmlValue(content, 'label') ||
|
||||
this.extractXmlValue(content, 'poiname')
|
||||
return label ? `[位置] ${label}` : '[位置]'
|
||||
}
|
||||
case 49:
|
||||
return this.parseType49(content)
|
||||
case 50:
|
||||
@@ -1400,6 +1530,10 @@ class ChatService {
|
||||
return title || '[引用消息]'
|
||||
}
|
||||
|
||||
if (looksLikeAppMsg) {
|
||||
return this.parseType49(content)
|
||||
}
|
||||
|
||||
// 尝试从 XML 提取通用 title
|
||||
const genericTitle = this.extractXmlValue(content, 'title')
|
||||
if (genericTitle && genericTitle.length > 0 && genericTitle.length < 100) {
|
||||
@@ -1416,6 +1550,23 @@ class ChatService {
|
||||
private parseType49(content: string): string {
|
||||
const title = this.extractXmlValue(content, 'title')
|
||||
const type = this.extractXmlValue(content, 'type')
|
||||
const normalized = content.toLowerCase()
|
||||
const locationLabel =
|
||||
this.extractXmlAttribute(content, 'location', 'label') ||
|
||||
this.extractXmlAttribute(content, 'location', 'poiname') ||
|
||||
this.extractXmlValue(content, 'label') ||
|
||||
this.extractXmlValue(content, 'poiname')
|
||||
const isFinder =
|
||||
type === '51' ||
|
||||
normalized.includes('<finder') ||
|
||||
normalized.includes('finderusername') ||
|
||||
normalized.includes('finderobjectid')
|
||||
const isRedPacket = type === '2001' || normalized.includes('hongbao')
|
||||
const isMusic =
|
||||
type === '3' ||
|
||||
normalized.includes('<musicurl>') ||
|
||||
normalized.includes('<playurl>') ||
|
||||
normalized.includes('<dataurl>')
|
||||
|
||||
// 群公告消息(type 87)特殊处理
|
||||
if (type === '87') {
|
||||
@@ -1426,6 +1577,19 @@ class ChatService {
|
||||
return '[群公告]'
|
||||
}
|
||||
|
||||
if (isFinder) {
|
||||
return title ? `[视频号] ${title}` : '[视频号]'
|
||||
}
|
||||
if (isRedPacket) {
|
||||
return title ? `[红包] ${title}` : '[红包]'
|
||||
}
|
||||
if (locationLabel) {
|
||||
return `[位置] ${locationLabel}`
|
||||
}
|
||||
if (isMusic) {
|
||||
return title ? `[音乐] ${title}` : '[音乐]'
|
||||
}
|
||||
|
||||
if (title) {
|
||||
switch (type) {
|
||||
case '5':
|
||||
@@ -1443,6 +1607,8 @@ class ChatService {
|
||||
return title
|
||||
case '2000':
|
||||
return `[转账] ${title}`
|
||||
case '2001':
|
||||
return `[红包] ${title}`
|
||||
default:
|
||||
return title
|
||||
}
|
||||
@@ -1459,6 +1625,13 @@ class ChatService {
|
||||
return '[小程序]'
|
||||
case '2000':
|
||||
return '[转账]'
|
||||
case '2001':
|
||||
return '[红包]'
|
||||
case '3':
|
||||
return '[音乐]'
|
||||
case '5':
|
||||
case '49':
|
||||
return '[链接]'
|
||||
case '87':
|
||||
return '[群公告]'
|
||||
default:
|
||||
@@ -1764,7 +1937,7 @@ class ChatService {
|
||||
* 解析名片消息
|
||||
* 格式: <msg username="wxid_xxx" nickname="昵称" ... />
|
||||
*/
|
||||
private parseCardInfo(content: string): { username?: string; nickname?: string } {
|
||||
private parseCardInfo(content: string): { username?: string; nickname?: string; avatarUrl?: string } {
|
||||
try {
|
||||
if (!content) return {}
|
||||
|
||||
@@ -1774,7 +1947,11 @@ class ChatService {
|
||||
// 提取 nickname
|
||||
const nickname = this.extractXmlAttribute(content, 'msg', 'nickname') || undefined
|
||||
|
||||
return { username, nickname }
|
||||
// 提取头像
|
||||
const avatarUrl = this.extractXmlAttribute(content, 'msg', 'bigheadimgurl') ||
|
||||
this.extractXmlAttribute(content, 'msg', 'smallheadimgurl') || undefined
|
||||
|
||||
return { username, nickname, avatarUrl }
|
||||
} catch (e) {
|
||||
console.error('[ChatService] 名片解析失败:', e)
|
||||
return {}
|
||||
@@ -1790,6 +1967,30 @@ class ChatService {
|
||||
linkTitle?: string
|
||||
linkUrl?: string
|
||||
linkThumb?: string
|
||||
appMsgKind?: string
|
||||
appMsgDesc?: string
|
||||
appMsgAppName?: string
|
||||
appMsgSourceName?: string
|
||||
appMsgSourceUsername?: string
|
||||
appMsgThumbUrl?: string
|
||||
appMsgMusicUrl?: string
|
||||
appMsgDataUrl?: string
|
||||
appMsgLocationLabel?: string
|
||||
finderNickname?: string
|
||||
finderUsername?: string
|
||||
finderCoverUrl?: string
|
||||
finderAvatar?: string
|
||||
finderDuration?: number
|
||||
locationLat?: number
|
||||
locationLng?: number
|
||||
locationPoiname?: string
|
||||
locationLabel?: string
|
||||
musicAlbumUrl?: string
|
||||
musicUrl?: string
|
||||
giftImageUrl?: string
|
||||
giftWish?: string
|
||||
giftPrice?: string
|
||||
cardAvatarUrl?: string
|
||||
fileName?: string
|
||||
fileSize?: number
|
||||
fileExt?: string
|
||||
@@ -1816,6 +2017,122 @@ class ChatService {
|
||||
// 提取通用字段
|
||||
const title = this.extractXmlValue(content, 'title')
|
||||
const url = this.extractXmlValue(content, 'url')
|
||||
const desc = this.extractXmlValue(content, 'des') || this.extractXmlValue(content, 'description')
|
||||
const appName = this.extractXmlValue(content, 'appname')
|
||||
const sourceName = this.extractXmlValue(content, 'sourcename')
|
||||
const sourceUsername = this.extractXmlValue(content, 'sourceusername')
|
||||
const thumbUrl =
|
||||
this.extractXmlValue(content, 'thumburl') ||
|
||||
this.extractXmlValue(content, 'cdnthumburl') ||
|
||||
this.extractXmlValue(content, 'cover') ||
|
||||
this.extractXmlValue(content, 'coverurl') ||
|
||||
this.extractXmlValue(content, 'thumb_url')
|
||||
const musicUrl =
|
||||
this.extractXmlValue(content, 'musicurl') ||
|
||||
this.extractXmlValue(content, 'playurl') ||
|
||||
this.extractXmlValue(content, 'songalbumurl')
|
||||
const dataUrl = this.extractXmlValue(content, 'dataurl') || this.extractXmlValue(content, 'lowurl')
|
||||
const locationLabel =
|
||||
this.extractXmlAttribute(content, 'location', 'label') ||
|
||||
this.extractXmlAttribute(content, 'location', 'poiname') ||
|
||||
this.extractXmlValue(content, 'label') ||
|
||||
this.extractXmlValue(content, 'poiname')
|
||||
const finderUsername =
|
||||
this.extractXmlValue(content, 'finderusername') ||
|
||||
this.extractXmlValue(content, 'finder_username') ||
|
||||
this.extractXmlValue(content, 'finderuser')
|
||||
const finderNickname =
|
||||
this.extractXmlValue(content, 'findernickname') ||
|
||||
this.extractXmlValue(content, 'finder_nickname')
|
||||
const normalized = content.toLowerCase()
|
||||
const isFinder = xmlType === '51'
|
||||
const isRedPacket = xmlType === '2001'
|
||||
const isMusic = xmlType === '3'
|
||||
const isLocation = Boolean(locationLabel)
|
||||
|
||||
result.linkTitle = title || undefined
|
||||
result.linkUrl = url || undefined
|
||||
result.linkThumb = thumbUrl || undefined
|
||||
result.appMsgDesc = desc || undefined
|
||||
result.appMsgAppName = appName || undefined
|
||||
result.appMsgSourceName = sourceName || undefined
|
||||
result.appMsgSourceUsername = sourceUsername || undefined
|
||||
result.appMsgThumbUrl = thumbUrl || undefined
|
||||
result.appMsgMusicUrl = musicUrl || undefined
|
||||
result.appMsgDataUrl = dataUrl || undefined
|
||||
result.appMsgLocationLabel = locationLabel || undefined
|
||||
result.finderUsername = finderUsername || undefined
|
||||
result.finderNickname = finderNickname || undefined
|
||||
|
||||
// 视频号封面/头像/时长
|
||||
if (isFinder) {
|
||||
const finderCover =
|
||||
this.extractXmlValue(content, 'thumbUrl') ||
|
||||
this.extractXmlValue(content, 'coverUrl') ||
|
||||
this.extractXmlValue(content, 'thumburl') ||
|
||||
this.extractXmlValue(content, 'coverurl')
|
||||
if (finderCover) result.finderCoverUrl = finderCover
|
||||
const finderAvatar = this.extractXmlValue(content, 'avatar')
|
||||
if (finderAvatar) result.finderAvatar = finderAvatar
|
||||
const durationStr = this.extractXmlValue(content, 'videoPlayDuration') || this.extractXmlValue(content, 'duration')
|
||||
if (durationStr) {
|
||||
const d = parseInt(durationStr, 10)
|
||||
if (Number.isFinite(d) && d > 0) result.finderDuration = d
|
||||
}
|
||||
}
|
||||
|
||||
// 位置经纬度
|
||||
if (isLocation) {
|
||||
const latAttr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude')
|
||||
const lngAttr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude')
|
||||
if (latAttr) { const v = parseFloat(latAttr); if (Number.isFinite(v)) result.locationLat = v }
|
||||
if (lngAttr) { const v = parseFloat(lngAttr); if (Number.isFinite(v)) result.locationLng = v }
|
||||
result.locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || locationLabel || undefined
|
||||
result.locationLabel = this.extractXmlAttribute(content, 'location', 'label') || undefined
|
||||
}
|
||||
|
||||
// 音乐专辑封面
|
||||
if (isMusic) {
|
||||
const albumUrl = this.extractXmlValue(content, 'songalbumurl')
|
||||
if (albumUrl) result.musicAlbumUrl = albumUrl
|
||||
result.musicUrl = musicUrl || dataUrl || url || undefined
|
||||
}
|
||||
|
||||
// 礼物消息
|
||||
const isGift = xmlType === '115'
|
||||
if (isGift) {
|
||||
result.giftWish = this.extractXmlValue(content, 'wishmessage') || undefined
|
||||
result.giftImageUrl = this.extractXmlValue(content, 'skuimgurl') || undefined
|
||||
result.giftPrice = this.extractXmlValue(content, 'skuprice') || undefined
|
||||
}
|
||||
|
||||
if (isFinder) {
|
||||
result.appMsgKind = 'finder'
|
||||
} else if (isRedPacket) {
|
||||
result.appMsgKind = 'red-packet'
|
||||
} else if (isGift) {
|
||||
result.appMsgKind = 'gift'
|
||||
} else if (isLocation) {
|
||||
result.appMsgKind = 'location'
|
||||
} else if (isMusic) {
|
||||
result.appMsgKind = 'music'
|
||||
} else if (xmlType === '33' || xmlType === '36') {
|
||||
result.appMsgKind = 'miniapp'
|
||||
} else if (xmlType === '6') {
|
||||
result.appMsgKind = 'file'
|
||||
} else if (xmlType === '19') {
|
||||
result.appMsgKind = 'chat-record'
|
||||
} else if (xmlType === '2000') {
|
||||
result.appMsgKind = 'transfer'
|
||||
} else if (xmlType === '87') {
|
||||
result.appMsgKind = 'announcement'
|
||||
} else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) {
|
||||
result.appMsgKind = 'official-link'
|
||||
} else if (url) {
|
||||
result.appMsgKind = 'link'
|
||||
} else {
|
||||
result.appMsgKind = 'card'
|
||||
}
|
||||
|
||||
switch (xmlType) {
|
||||
case '6': {
|
||||
@@ -3884,6 +4201,74 @@ class ChatService {
|
||||
* 获取某会话中有消息的日期列表
|
||||
* 返回 YYYY-MM-DD 格式的日期字符串数组
|
||||
*/
|
||||
/**
|
||||
* 获取某会话的全部图片消息(用于聊天页批量图片解密)
|
||||
*/
|
||||
async getAllImageMessages(
|
||||
sessionId: string
|
||||
): Promise<{ success: boolean; images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]; error?: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
|
||||
let tables = this.sessionTablesCache.get(sessionId)
|
||||
if (!tables) {
|
||||
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||
if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) {
|
||||
return { success: false, error: '未找到会话消息表' }
|
||||
}
|
||||
tables = tableStats.tables
|
||||
.map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path }))
|
||||
.filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }>
|
||||
if (tables.length > 0) {
|
||||
this.sessionTablesCache.set(sessionId, tables)
|
||||
setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl)
|
||||
}
|
||||
}
|
||||
|
||||
let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = []
|
||||
|
||||
for (const { tableName, dbPath } of tables) {
|
||||
try {
|
||||
const sql = `SELECT * FROM ${tableName} WHERE local_type = 3 ORDER BY create_time DESC`
|
||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||
if (result.success && result.rows && result.rows.length > 0) {
|
||||
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
|
||||
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<string>()
|
||||
allImages = allImages.filter(img => {
|
||||
const key = img.imageMd5 || img.imageDatName || ''
|
||||
if (!key || seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
console.log(`[ChatService] 共找到 ${allImages.length} 条图片消息(去重后)`)
|
||||
return { success: true, images: allImages }
|
||||
} catch (e) {
|
||||
console.error('[ChatService] 获取全部图片消息失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
@@ -4017,6 +4402,15 @@ class ChatService {
|
||||
msg.emojiThumbUrl = emojiInfo.thumbUrl
|
||||
msg.emojiEncryptUrl = emojiInfo.encryptUrl
|
||||
msg.emojiAesKey = emojiInfo.aesKey
|
||||
} else if (msg.localType === 42) {
|
||||
const cardInfo = this.parseCardInfo(rawContent)
|
||||
msg.cardUsername = cardInfo.username
|
||||
msg.cardNickname = cardInfo.nickname
|
||||
msg.cardAvatarUrl = cardInfo.avatarUrl
|
||||
}
|
||||
|
||||
if (rawContent && (rawContent.includes('<appmsg') || rawContent.includes('<appmsg'))) {
|
||||
Object.assign(msg, this.parseType49Message(rawContent))
|
||||
}
|
||||
|
||||
return msg
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@ import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||
import LockScreen from './components/LockScreen'
|
||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate()
|
||||
@@ -385,6 +386,7 @@ function App() {
|
||||
|
||||
{/* 全局批量转写进度浮窗 */}
|
||||
<BatchTranscribeGlobal />
|
||||
<BatchImageDecryptGlobal />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
{showAgreement && !agreementLoading && (
|
||||
|
||||
133
src/components/BatchImageDecryptGlobal.tsx
Normal file
133
src/components/BatchImageDecryptGlobal.tsx
Normal file
@@ -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(
|
||||
<div className="batch-progress-toast" style={{ bottom: toastBottom }}>
|
||||
<div className="batch-progress-toast-header">
|
||||
<div className="batch-progress-toast-title">
|
||||
<Loader2 size={14} className="spin" />
|
||||
<span>批量解密图片{sessionName ? `(${sessionName})` : ''}</span>
|
||||
</div>
|
||||
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="batch-progress-toast-body">
|
||||
<div className="progress-info-row">
|
||||
<div className="progress-text">
|
||||
<span>{progress.current} / {progress.total}</span>
|
||||
<span className="progress-percent">
|
||||
{progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
{eta && (
|
||||
<div className="progress-eta">
|
||||
<Clock size={12} />
|
||||
<span>剩余 {eta}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{
|
||||
width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{showResultToast && createPortal(
|
||||
<div className="batch-progress-toast batch-inline-result-toast" style={{ bottom: toastBottom }}>
|
||||
<div className="batch-progress-toast-header">
|
||||
<div className="batch-progress-toast-title">
|
||||
<ImageIcon size={14} />
|
||||
<span>图片批量解密完成</span>
|
||||
</div>
|
||||
<button className="batch-progress-toast-close" onClick={() => setShowResultToast(false)} title="关闭">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="batch-progress-toast-body">
|
||||
<div className="batch-inline-result-summary">
|
||||
<div className="batch-inline-result-item success">
|
||||
<CheckCircle size={14} />
|
||||
<span>成功 {result.success}</span>
|
||||
</div>
|
||||
<div className={`batch-inline-result-item ${result.fail > 0 ? 'fail' : 'muted'}`}>
|
||||
<XCircle size={14} />
|
||||
<span>失败 {result.fail}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1041,12 +1041,13 @@
|
||||
// 链接卡片消息样式
|
||||
.link-message {
|
||||
width: 280px;
|
||||
background: var(--card-bg);
|
||||
background: var(--card-inner-bg);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
@@ -1114,19 +1115,362 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.appmsg-meta-badge {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--primary);
|
||||
background: rgba(127, 127, 127, 0.08);
|
||||
border: 1px solid rgba(127, 127, 127, 0.18);
|
||||
border-radius: 999px;
|
||||
padding: 3px 7px;
|
||||
align-self: flex-start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-desc-block {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.appmsg-url-line {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.appmsg-rich-card {
|
||||
.link-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link-thumb.theme-adaptive,
|
||||
.miniapp-thumb.theme-adaptive {
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
[data-mode="dark"] {
|
||||
|
||||
.link-thumb.theme-adaptive,
|
||||
.miniapp-thumb.theme-adaptive {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 适配发送出去的消息中的链接卡片
|
||||
.message-bubble.sent .link-message {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--sent-card-bg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.link-title {
|
||||
color: var(--text-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.link-desc {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.appmsg-url-line {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// ============= 专属消息卡片 =============
|
||||
|
||||
// 红包卡片
|
||||
.hongbao-message {
|
||||
width: 240px;
|
||||
background: linear-gradient(135deg, #e25b4a 0%, #c94535 100%);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
|
||||
.hongbao-icon {
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
}
|
||||
|
||||
.hongbao-info {
|
||||
flex: 1;
|
||||
color: white;
|
||||
|
||||
.hongbao-greeting {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.hongbao-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 礼物卡片
|
||||
.gift-message {
|
||||
width: 240px;
|
||||
background: linear-gradient(135deg, #f7a8b8 0%, #e88fa0 100%);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
cursor: default;
|
||||
|
||||
.gift-img {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.gift-info {
|
||||
color: white;
|
||||
|
||||
.gift-wish {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.gift-price {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.gift-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 视频号卡片
|
||||
.channel-video-card {
|
||||
width: 200px;
|
||||
background: var(--card-inner-bg);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.channel-video-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
background: #000;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.channel-video-cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.channel-video-duration {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-video-info {
|
||||
padding: 8px 10px;
|
||||
|
||||
.channel-video-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.channel-video-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.channel-video-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 音乐卡片
|
||||
.music-message {
|
||||
width: 240px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.music-cover {
|
||||
width: 80px;
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.music-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
|
||||
.music-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.music-artist {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.music-source {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 位置消息卡片
|
||||
.location-message {
|
||||
width: 240px;
|
||||
background: var(--card-inner-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.location-text {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.location-icon {
|
||||
flex-shrink: 0;
|
||||
color: #e25b4a;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.location-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.location-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.location-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.location-map {
|
||||
position: relative;
|
||||
height: 100px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式下地图瓦片反色
|
||||
[data-mode="dark"] {
|
||||
.location-map img {
|
||||
filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1512,6 +1856,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片类消息:气泡变透明,让卡片自己做视觉容器
|
||||
.message-bubble .bubble-content:has(.link-message),
|
||||
.message-bubble .bubble-content:has(.card-message),
|
||||
.message-bubble .bubble-content:has(.chat-record-message),
|
||||
.message-bubble .bubble-content:has(.official-message),
|
||||
.message-bubble .bubble-content:has(.channel-video-card),
|
||||
.message-bubble .bubble-content:has(.location-message) {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.emoji-image {
|
||||
max-width: 120px;
|
||||
max-height: 120px;
|
||||
@@ -2487,10 +2845,17 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 12px 14px;
|
||||
background: var(--card-inner-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
min-width: 200px;
|
||||
transition: opacity 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
flex-shrink: 0;
|
||||
@@ -2515,6 +2880,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天记录消息 (合并转发)
|
||||
.chat-record-message {
|
||||
background: var(--card-inner-bg) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
transition: opacity 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
// 通话消息
|
||||
.call-message {
|
||||
display: flex;
|
||||
@@ -2752,12 +3129,14 @@
|
||||
|
||||
.card-message,
|
||||
.chat-record-message,
|
||||
.miniapp-message {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
.miniapp-message,
|
||||
.appmsg-rich-card {
|
||||
background: var(--sent-card-bg);
|
||||
|
||||
.card-name,
|
||||
.miniapp-title,
|
||||
.source-name {
|
||||
.source-name,
|
||||
.link-title {
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -2765,7 +3144,9 @@
|
||||
.miniapp-label,
|
||||
.chat-record-item,
|
||||
.chat-record-meta-line,
|
||||
.chat-record-desc {
|
||||
.chat-record-desc,
|
||||
.link-desc,
|
||||
.appmsg-url-line {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
@@ -2778,6 +3159,12 @@
|
||||
.chat-record-more {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.appmsg-meta-badge {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.call-message {
|
||||
@@ -3235,4 +3622,234 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.miniapp-message-rich {
|
||||
.miniapp-thumb {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 名片消息样式
|
||||
.card-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--card-inner-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-wxid {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天记录消息外观
|
||||
.chat-record-message {
|
||||
background: var(--card-inner-bg) !important;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
}
|
||||
|
||||
.chat-record-list {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.6;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
.chat-record-item {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.source-name {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-record-more {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 公众号文章图文消息外观 (大图模式)
|
||||
.official-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card-inner-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.official-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
|
||||
.official-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.official-avatar-placeholder {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.official-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.official-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.official-cover-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 42.5%; // ~2.35:1 aspectRatio standard for WeChat article covers
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
|
||||
.official-cover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.official-title-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 24px 12px 10px;
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.7));
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.official-title-text {
|
||||
padding: 0 12px 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.official-digest {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 12px 12px;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Message[] | null>(null)
|
||||
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([])
|
||||
const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set())
|
||||
const [showBatchDecryptConfirm, setShowBatchDecryptConfirm] = useState(false)
|
||||
const [batchImageMessages, setBatchImageMessages] = useState<BatchImageDecryptCandidate[] | null>(null)
|
||||
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
|
||||
const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(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<string>()
|
||||
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<string, number>()
|
||||
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<number | null>(null)
|
||||
|
||||
const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => {
|
||||
@@ -1996,6 +2121,26 @@ function ChatPage(_props: ChatPageProps) {
|
||||
<Mic size={18} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`icon-btn batch-decrypt-btn${isBatchDecrypting ? ' transcribing' : ''}`}
|
||||
onClick={() => {
|
||||
if (isBatchDecrypting) {
|
||||
setShowBatchDecryptToast(true)
|
||||
} else {
|
||||
handleBatchDecrypt()
|
||||
}
|
||||
}}
|
||||
disabled={!currentSessionId}
|
||||
title={isBatchDecrypting
|
||||
? `批量解密中 (${batchDecryptProgress.current}/${batchDecryptProgress.total}),点击查看进度`
|
||||
: '批量解密图片'}
|
||||
>
|
||||
{isBatchDecrypting ? (
|
||||
<Loader2 size={18} className="spin" />
|
||||
) : (
|
||||
<ImageIcon size={18} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn jump-to-time-btn"
|
||||
onClick={async () => {
|
||||
@@ -2361,6 +2506,66 @@ function ChatPage(_props: ChatPageProps) {
|
||||
document.body
|
||||
)}
|
||||
{/* 消息右键菜单 */}
|
||||
{showBatchDecryptConfirm && createPortal(
|
||||
<div className="batch-modal-overlay" onClick={() => setShowBatchDecryptConfirm(false)}>
|
||||
<div className="batch-modal-content batch-confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="batch-modal-header">
|
||||
<ImageIcon size={20} />
|
||||
<h3>批量解密图片</h3>
|
||||
</div>
|
||||
<div className="batch-modal-body">
|
||||
<p>选择要解密的日期(仅显示有图片的日期),然后开始解密。</p>
|
||||
{batchImageDates.length > 0 && (
|
||||
<div className="batch-dates-list-wrap">
|
||||
<div className="batch-dates-actions">
|
||||
<button type="button" className="batch-dates-btn" onClick={selectAllBatchImageDates}>全选</button>
|
||||
<button type="button" className="batch-dates-btn" onClick={clearAllBatchImageDates}>取消全选</button>
|
||||
</div>
|
||||
<ul className="batch-dates-list">
|
||||
{batchImageDates.map(dateStr => {
|
||||
const count = batchImageCountByDate.get(dateStr) ?? 0
|
||||
const checked = batchImageSelectedDates.has(dateStr)
|
||||
return (
|
||||
<li key={dateStr}>
|
||||
<label className="batch-date-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleBatchImageDate(dateStr)}
|
||||
/>
|
||||
<span className="batch-date-label">{formatBatchDateLabel(dateStr)}</span>
|
||||
<span className="batch-date-count">{count} 张图片</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="batch-info">
|
||||
<div className="info-item">
|
||||
<span className="label">已选:</span>
|
||||
<span className="value">{batchImageSelectedDates.size} 天,共 {batchImageSelectedCount} 张图片</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="batch-warning">
|
||||
<AlertCircle size={16} />
|
||||
<span>批量解密可能需要较长时间,进行中会在右下角显示非阻塞进度浮层。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="batch-modal-footer">
|
||||
<button className="btn-secondary" onClick={() => setShowBatchDecryptConfirm(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn-primary" onClick={confirmBatchDecrypt}>
|
||||
<ImageIcon size={16} />
|
||||
开始解密
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{contextMenu && createPortal(
|
||||
<>
|
||||
<div className="context-menu-overlay" onClick={() => setContextMenu(null)}
|
||||
@@ -2856,7 +3061,7 @@ function MessageBubble({
|
||||
setImageLocalPath(result.localPath)
|
||||
setImageHasUpdate(false)
|
||||
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
||||
return
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2867,7 +3072,7 @@ function MessageBubble({
|
||||
imageDataUrlCache.set(imageCacheKey, dataUrl)
|
||||
setImageLocalPath(dataUrl)
|
||||
setImageHasUpdate(false)
|
||||
return
|
||||
return { success: true, localPath: dataUrl } as any
|
||||
}
|
||||
if (!silent) setImageError(true)
|
||||
} catch {
|
||||
@@ -2875,6 +3080,7 @@ function MessageBubble({
|
||||
} finally {
|
||||
if (!silent) setImageLoading(false)
|
||||
}
|
||||
return { success: false } as any
|
||||
}, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64])
|
||||
|
||||
const triggerForceHd = useCallback(() => {
|
||||
@@ -2905,6 +3111,55 @@ function MessageBubble({
|
||||
void requestImageDecrypt()
|
||||
}, [message.imageDatName, message.imageMd5, message.localId, requestImageDecrypt, session.username])
|
||||
|
||||
const handleOpenImageViewer = useCallback(async () => {
|
||||
if (!imageLocalPath) return
|
||||
|
||||
let finalImagePath = imageLocalPath
|
||||
let finalLiveVideoPath = imageLiveVideoPath || undefined
|
||||
|
||||
// If current cache is a thumbnail, wait for a silent force-HD decrypt before opening viewer.
|
||||
if (imageHasUpdate) {
|
||||
try {
|
||||
const upgraded = await requestImageDecrypt(true, true)
|
||||
if (upgraded?.success && upgraded.localPath) {
|
||||
finalImagePath = upgraded.localPath
|
||||
finalLiveVideoPath = upgraded.liveVideoPath || finalLiveVideoPath
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// One more resolve helps when background/batch decrypt has produced a clearer image or live video
|
||||
// but local component state hasn't caught up yet.
|
||||
if (message.imageMd5 || message.imageDatName) {
|
||||
try {
|
||||
const resolved = await window.electronAPI.image.resolveCache({
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName
|
||||
})
|
||||
if (resolved?.success && resolved.localPath) {
|
||||
finalImagePath = resolved.localPath
|
||||
finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath
|
||||
imageDataUrlCache.set(imageCacheKey, resolved.localPath)
|
||||
setImageLocalPath(resolved.localPath)
|
||||
if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath)
|
||||
setImageHasUpdate(Boolean(resolved.hasUpdate))
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath)
|
||||
}, [
|
||||
imageHasUpdate,
|
||||
imageLiveVideoPath,
|
||||
imageLocalPath,
|
||||
imageCacheKey,
|
||||
message.imageDatName,
|
||||
message.imageMd5,
|
||||
requestImageDecrypt,
|
||||
session.username
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imageClickTimerRef.current) {
|
||||
@@ -3426,10 +3681,7 @@ function MessageBubble({
|
||||
src={imageLocalPath}
|
||||
alt="图片"
|
||||
className="image-message"
|
||||
onClick={() => {
|
||||
if (imageHasUpdate) void requestImageDecrypt(true, true)
|
||||
void window.electronAPI.window.openImageViewerWindow(imageLocalPath!, imageLiveVideoPath || undefined)
|
||||
}}
|
||||
onClick={() => { void handleOpenImageViewer() }}
|
||||
onLoad={() => setImageError(false)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
@@ -3692,16 +3944,24 @@ function MessageBubble({
|
||||
// 名片消息
|
||||
if (isCard) {
|
||||
const cardName = message.cardNickname || message.cardUsername || '未知联系人'
|
||||
const cardAvatar = message.cardAvatarUrl
|
||||
return (
|
||||
<div className="card-message">
|
||||
<div className="card-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
{cardAvatar ? (
|
||||
<img src={cardAvatar} alt="" style={{ width: '40px', height: '40px', objectFit: 'cover', borderRadius: '8px' }} referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-info">
|
||||
<div className="card-name">{cardName}</div>
|
||||
{message.cardUsername && message.cardUsername !== message.cardNickname && (
|
||||
<div className="card-wxid">微信号: {message.cardUsername}</div>
|
||||
)}
|
||||
<div className="card-label">个人名片</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3720,7 +3980,329 @@ function MessageBubble({
|
||||
)
|
||||
}
|
||||
|
||||
// 位置消息
|
||||
if (message.localType === 48) {
|
||||
const raw = message.rawContent || ''
|
||||
const poiname = raw.match(/poiname="([^"]*)"/)?.[1] || message.locationPoiname || '位置'
|
||||
const label = raw.match(/label="([^"]*)"/)?.[1] || message.locationLabel || ''
|
||||
const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || String(message.locationLat || 0))
|
||||
const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || String(message.locationLng || 0))
|
||||
const zoom = 15
|
||||
const tileX = Math.floor((lng + 180) / 360 * Math.pow(2, zoom))
|
||||
const latRad = lat * Math.PI / 180
|
||||
const tileY = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * Math.pow(2, zoom))
|
||||
const mapTileUrl = (lat && lng)
|
||||
? `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${tileX}&y=${tileY}&z=${zoom}`
|
||||
: ''
|
||||
return (
|
||||
<div className="location-message" onClick={() => window.electronAPI.shell.openExternal(`https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(poiname || label)}`)}>
|
||||
<div className="location-text">
|
||||
<div className="location-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="location-info">
|
||||
{poiname && <div className="location-name">{poiname}</div>}
|
||||
{label && <div className="location-label">{label}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{mapTileUrl && (
|
||||
<div className="location-map">
|
||||
<img src={mapTileUrl} alt="地图" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 链接消息 (AppMessage)
|
||||
const appMsgRichPreview = (() => {
|
||||
const rawXml = message.rawContent || ''
|
||||
if (!rawXml || (!rawXml.includes('<appmsg') && !rawXml.includes('<appmsg'))) return null
|
||||
|
||||
let doc: Document | null = null
|
||||
const getDoc = () => {
|
||||
if (doc) return doc
|
||||
try {
|
||||
const start = rawXml.indexOf('<msg>')
|
||||
const xml = start >= 0 ? rawXml.slice(start) : rawXml
|
||||
doc = new DOMParser().parseFromString(xml, 'text/xml')
|
||||
} catch {
|
||||
doc = null
|
||||
}
|
||||
return doc
|
||||
}
|
||||
const q = (selector: string) => getDoc()?.querySelector(selector)?.textContent?.trim() || ''
|
||||
|
||||
const xmlType = message.xmlType || q('appmsg > type') || q('type')
|
||||
const title = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || 'Card'
|
||||
const desc = message.appMsgDesc || q('des')
|
||||
const url = message.linkUrl || q('url')
|
||||
const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl')
|
||||
const musicUrl = message.appMsgMusicUrl || message.appMsgDataUrl || q('musicurl') || q('playurl') || q('dataurl') || q('lowurl')
|
||||
const sourceName = message.appMsgSourceName || q('sourcename')
|
||||
const sourceDisplayName = q('sourcedisplayname') || ''
|
||||
const appName = message.appMsgAppName || q('appname')
|
||||
const sourceUsername = message.appMsgSourceUsername || q('sourceusername')
|
||||
const finderName =
|
||||
message.finderNickname ||
|
||||
message.finderUsername ||
|
||||
q('findernickname') ||
|
||||
q('finder_nickname') ||
|
||||
q('finderusername') ||
|
||||
q('finder_username')
|
||||
|
||||
const lower = rawXml.toLowerCase()
|
||||
|
||||
const kind = message.appMsgKind || (
|
||||
(xmlType === '2001' || lower.includes('hongbao')) ? 'red-packet'
|
||||
: (xmlType === '115' ? 'gift'
|
||||
: ((xmlType === '33' || xmlType === '36') ? 'miniapp'
|
||||
: (((xmlType === '5' || xmlType === '49') && (sourceUsername.startsWith('gh_') || !!sourceName || appName.includes('公众号'))) ? 'official-link'
|
||||
: (xmlType === '51' ? 'finder'
|
||||
: (xmlType === '3' ? 'music'
|
||||
: ((xmlType === '5' || xmlType === '49') ? 'link' // Fallback for standard links
|
||||
: (!!musicUrl ? 'music' : '')))))))
|
||||
)
|
||||
|
||||
if (!kind) return null
|
||||
|
||||
// 对视频号提取真实标题,避免出现 "当前版本不支持该内容"
|
||||
let displayTitle = title
|
||||
if (kind === 'finder' && (!displayTitle || displayTitle.includes('不支持'))) {
|
||||
try {
|
||||
const d = new DOMParser().parseFromString(rawXml, 'text/xml')
|
||||
displayTitle = d.querySelector('finderFeed desc')?.textContent?.trim() || desc || ''
|
||||
} catch {
|
||||
displayTitle = desc || ''
|
||||
}
|
||||
}
|
||||
|
||||
const openExternal = (e: React.MouseEvent, nextUrl?: string) => {
|
||||
if (!nextUrl) return
|
||||
e.stopPropagation()
|
||||
if (window.electronAPI?.shell?.openExternal) {
|
||||
window.electronAPI.shell.openExternal(nextUrl)
|
||||
} else {
|
||||
window.open(nextUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const metaLabel =
|
||||
kind === 'red-packet' ? '红包'
|
||||
: kind === 'finder' ? (finderName || '视频号')
|
||||
: kind === 'location' ? '位置'
|
||||
: kind === 'music' ? (sourceName || appName || '音乐')
|
||||
: (sourceName || appName || (sourceUsername.startsWith('gh_') ? '公众号' : ''))
|
||||
|
||||
const renderCard = (cardKind: string, clickableUrl?: string) => (
|
||||
<div
|
||||
className={`link-message appmsg-rich-card ${cardKind}`}
|
||||
onClick={clickableUrl ? (e) => openExternal(e, clickableUrl) : undefined}
|
||||
title={clickableUrl}
|
||||
>
|
||||
<div className="link-header">
|
||||
<div className="link-title" title={title}>{title}</div>
|
||||
{metaLabel ? <div className="appmsg-meta-badge">{metaLabel}</div> : null}
|
||||
</div>
|
||||
<div className="link-body">
|
||||
<div className="link-desc-block">
|
||||
{desc ? <div className="link-desc" title={desc}>{desc}</div> : null}
|
||||
</div>
|
||||
{thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt=""
|
||||
className={`link-thumb${((cardKind === 'miniapp') || /\.svg(?:$|\?)/i.test(thumbUrl)) ? ' theme-adaptive' : ''}`}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className={`link-thumb-placeholder ${cardKind}`}>{cardKind.slice(0, 2).toUpperCase()}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
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 (
|
||||
<div className="hongbao-message">
|
||||
<div className="hongbao-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
||||
<rect x="4" y="6" width="32" height="28" rx="4" fill="white" fillOpacity="0.3" />
|
||||
<rect x="4" y="6" width="32" height="14" rx="4" fill="white" fillOpacity="0.2" />
|
||||
<circle cx="20" cy="20" r="6" fill="white" fillOpacity="0.4" />
|
||||
<text x="20" y="24" textAnchor="middle" fill="white" fontSize="12" fontWeight="bold">¥</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="hongbao-info">
|
||||
<div className="hongbao-greeting">{greeting || '恭喜发财,大吉大利'}</div>
|
||||
<div className="hongbao-label">微信红包</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="gift-message">
|
||||
{giftImg && <img className="gift-img" src={giftImg} alt="" referrerPolicy="no-referrer" />}
|
||||
<div className="gift-info">
|
||||
<div className="gift-wish">{giftWish}</div>
|
||||
{giftPriceYuan && <div className="gift-price">¥{giftPriceYuan}</div>}
|
||||
<div className="gift-label">微信礼物</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="channel-video-card" onClick={url ? (e) => openExternal(e, url) : undefined}>
|
||||
<div className="channel-video-cover">
|
||||
{coverUrl ? (
|
||||
<img src={coverUrl} alt="" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="channel-video-cover-placeholder">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{fmtDuration && <span className="channel-video-duration">{fmtDuration}</span>}
|
||||
</div>
|
||||
<div className="channel-video-info">
|
||||
<div className="channel-video-title">{displayTitle || '视频号视频'}</div>
|
||||
<div className="channel-video-author">
|
||||
{authorAvatar && <img className="channel-video-avatar" src={authorAvatar} alt="" referrerPolicy="no-referrer" />}
|
||||
<span>{authorName || '视频号'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
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 (
|
||||
<div className="music-message" onClick={playUrl ? (e) => openExternal(e, playUrl) : undefined}>
|
||||
<div className="music-cover">
|
||||
{albumUrl ? (
|
||||
<img src={albumUrl} alt="" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="music-info">
|
||||
<div className="music-title">{songTitle}</div>
|
||||
{artist && <div className="music-artist">{artist}</div>}
|
||||
{appLabel && <div className="music-source">{appLabel}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'official-link') {
|
||||
const authorAvatar = q('publisher > headimg') || q('brand_info > headimgurl') || q('appmsg > avatar') || q('headimgurl') || message.cardAvatarUrl
|
||||
const authorName = sourceDisplayName || q('publisher > nickname') || sourceName || appName || '公众号'
|
||||
const coverPic = q('mmreader > category > item > cover') || thumbUrl
|
||||
const digest = q('mmreader > category > item > digest') || desc
|
||||
const articleTitle = q('mmreader > category > item > title') || title
|
||||
|
||||
return (
|
||||
<div className="official-message" onClick={url ? (e) => openExternal(e, url) : undefined}>
|
||||
<div className="official-header">
|
||||
{authorAvatar ? (
|
||||
<img src={authorAvatar} alt="" className="official-avatar" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="official-avatar-placeholder">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<span className="official-name">{authorName}</span>
|
||||
</div>
|
||||
<div className="official-body">
|
||||
{coverPic ? (
|
||||
<div className="official-cover-wrapper">
|
||||
<img src={coverPic} alt="" className="official-cover" referrerPolicy="no-referrer" />
|
||||
<div className="official-title-overlay">{articleTitle}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="official-title-text">{articleTitle}</div>
|
||||
)}
|
||||
{digest && <div className="official-digest">{digest}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'link') return renderCard('link', url || undefined)
|
||||
if (kind === 'card') return renderCard('card', url || undefined)
|
||||
if (kind === 'miniapp') {
|
||||
return (
|
||||
<div className="miniapp-message miniapp-message-rich">
|
||||
<div className="miniapp-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="miniapp-info">
|
||||
<div className="miniapp-title">{title}</div>
|
||||
<div className="miniapp-label">{metaLabel || '小程序'}</div>
|
||||
</div>
|
||||
{thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt=""
|
||||
className={`miniapp-thumb${/\.svg(?:$|\?)/i.test(thumbUrl) ? ' theme-adaptive' : ''}`}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()
|
||||
|
||||
if (appMsgRichPreview) {
|
||||
return appMsgRichPreview
|
||||
}
|
||||
|
||||
const isAppMsg = message.rawContent?.includes('<appmsg') || (message.parsedContent && message.parsedContent.includes('<appmsg'))
|
||||
|
||||
if (isAppMsg) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTMLVideoElement>(null)
|
||||
const liveCleanupTimerRef = useRef<number | null>(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<HTMLDivElement>(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() {
|
||||
<div className="title-bar">
|
||||
<div className="window-drag-area"></div>
|
||||
<div className="title-bar-controls">
|
||||
{liveVideoPath && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !showLive
|
||||
setShowLive(next)
|
||||
if (next && videoRef.current) {
|
||||
videoRef.current.currentTime = 0
|
||||
videoRef.current.play()
|
||||
}
|
||||
}}
|
||||
title={showLive ? '显示照片' : '播放实况'}
|
||||
className={showLive ? 'active' : ''}
|
||||
>
|
||||
<LivePhotoIcon size={16} />
|
||||
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
|
||||
</button>
|
||||
{hasLiveVideo && (
|
||||
<>
|
||||
<button
|
||||
onClick={handlePlayLiveVideo}
|
||||
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
|
||||
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
|
||||
disabled={isPlayingLive}
|
||||
>
|
||||
<LivePhotoIcon size={16} />
|
||||
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
|
||||
</button>
|
||||
<div className="divider"></div>
|
||||
</>
|
||||
)}
|
||||
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
|
||||
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
|
||||
@@ -170,32 +240,31 @@ export default function ImageWindow() {
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{liveVideoPath && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={liveVideoPath}
|
||||
width={imgNatural.w || undefined}
|
||||
height={imgNatural.h || undefined}
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`,
|
||||
position: showLive ? 'relative' : 'absolute',
|
||||
opacity: showLive ? 1 : 0,
|
||||
pointerEvents: showLive ? 'auto' : 'none'
|
||||
}}
|
||||
onEnded={() => setShowLive(false)}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={imagePath}
|
||||
alt="Preview"
|
||||
<div
|
||||
className="media-wrapper"
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`,
|
||||
opacity: showLive ? 0 : 1,
|
||||
position: showLive ? 'absolute' : 'relative'
|
||||
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`
|
||||
}}
|
||||
onLoad={handleImageLoad}
|
||||
draggable={false}
|
||||
/>
|
||||
>
|
||||
<img
|
||||
src={imagePath}
|
||||
alt="Preview"
|
||||
onLoad={handleImageLoad}
|
||||
draggable={false}
|
||||
/>
|
||||
{hasLiveVideo && isPlayingLive && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={liveVideoPath || ''}
|
||||
className={`live-video ${isVideoVisible ? 'visible' : ''}`}
|
||||
autoPlay
|
||||
playsInline
|
||||
preload="auto"
|
||||
onPlaying={() => setIsVideoVisible(true)}
|
||||
onEnded={() => stopLivePlayback(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
64
src/stores/batchImageDecryptStore.ts
Normal file
64
src/stores/batchImageDecryptStore.ts
Normal file
@@ -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<BatchImageDecryptState>((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: ''
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 重置样式
|
||||
|
||||
5
src/types/electron.d.ts
vendored
5
src/types/electron.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -64,12 +64,39 @@ export interface Message {
|
||||
fileSize?: number // 文件大小
|
||||
fileExt?: string // 文件扩展名
|
||||
xmlType?: string // XML 中的 type 字段
|
||||
appMsgKind?: string // 归一化 appmsg 类型
|
||||
appMsgDesc?: string
|
||||
appMsgAppName?: string
|
||||
appMsgSourceName?: string
|
||||
appMsgSourceUsername?: string
|
||||
appMsgThumbUrl?: string
|
||||
appMsgMusicUrl?: string
|
||||
appMsgDataUrl?: string
|
||||
appMsgLocationLabel?: string
|
||||
finderNickname?: string
|
||||
finderUsername?: string
|
||||
finderCoverUrl?: string // 视频号封面图
|
||||
finderAvatar?: string // 视频号作者头像
|
||||
finderDuration?: number // 视频号时长(秒)
|
||||
// 位置消息
|
||||
locationLat?: number // 纬度
|
||||
locationLng?: number // 经度
|
||||
locationPoiname?: string // 地点名称
|
||||
locationLabel?: string // 详细地址
|
||||
// 音乐消息
|
||||
musicAlbumUrl?: string // 专辑封面
|
||||
musicUrl?: string // 播放链接
|
||||
// 礼物消息
|
||||
giftImageUrl?: string // 礼物商品图
|
||||
giftWish?: string // 祝福语
|
||||
giftPrice?: string // 价格(分)
|
||||
// 转账消息
|
||||
transferPayerUsername?: string // 转账付款方 wxid
|
||||
transferReceiverUsername?: string // 转账收款方 wxid
|
||||
// 名片消息
|
||||
cardUsername?: string // 名片的微信ID
|
||||
cardNickname?: string // 名片的昵称
|
||||
cardAvatarUrl?: string // 名片头像 URL
|
||||
// 聊天记录
|
||||
chatRecordTitle?: string // 聊天记录标题
|
||||
chatRecordList?: ChatRecordItem[] // 聊天记录列表
|
||||
|
||||
Reference in New Issue
Block a user