mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
1
.gitignore
vendored
1
.gitignore
vendored
@@ -62,3 +62,4 @@ chatlab-format.md
|
|||||||
*.bak
|
*.bak
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
.claude/
|
.claude/
|
||||||
|
.agents/
|
||||||
@@ -914,6 +914,9 @@ function registerIpcHandlers() {
|
|||||||
ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => {
|
ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => {
|
||||||
return chatService.getAllVoiceMessages(sessionId)
|
return chatService.getAllVoiceMessages(sessionId)
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('chat:getAllImageMessages', async (_, sessionId: string) => {
|
||||||
|
return chatService.getAllImageMessages(sessionId)
|
||||||
|
})
|
||||||
ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => {
|
ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => {
|
||||||
return chatService.getMessageDates(sessionId)
|
return chatService.getMessageDates(sessionId)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
||||||
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
||||||
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
||||||
|
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
||||||
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
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),
|
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 { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch } from 'fs'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
@@ -73,9 +73,36 @@ export interface Message {
|
|||||||
fileSize?: number // 文件大小
|
fileSize?: number // 文件大小
|
||||||
fileExt?: string // 文件扩展名
|
fileExt?: string // 文件扩展名
|
||||||
xmlType?: string // XML 中的 type 字段
|
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
|
cardUsername?: string // 名片的微信ID
|
||||||
cardNickname?: string // 名片的昵称
|
cardNickname?: string // 名片的昵称
|
||||||
|
cardAvatarUrl?: string // 名片头像 URL
|
||||||
// 转账消息
|
// 转账消息
|
||||||
transferPayerUsername?: string // 转账付款人
|
transferPayerUsername?: string // 转账付款人
|
||||||
transferReceiverUsername?: string // 转账收款人
|
transferReceiverUsername?: string // 转账收款人
|
||||||
@@ -1224,9 +1251,33 @@ class ChatService {
|
|||||||
let fileSize: number | undefined
|
let fileSize: number | undefined
|
||||||
let fileExt: string | undefined
|
let fileExt: string | undefined
|
||||||
let xmlType: 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 cardUsername: string | undefined
|
||||||
let cardNickname: string | undefined
|
let cardNickname: string | undefined
|
||||||
|
let cardAvatarUrl: string | undefined
|
||||||
// 转账消息
|
// 转账消息
|
||||||
let transferPayerUsername: string | undefined
|
let transferPayerUsername: string | undefined
|
||||||
let transferReceiverUsername: string | undefined
|
let transferReceiverUsername: string | undefined
|
||||||
@@ -1264,6 +1315,15 @@ class ChatService {
|
|||||||
const cardInfo = this.parseCardInfo(content)
|
const cardInfo = this.parseCardInfo(content)
|
||||||
cardUsername = cardInfo.username
|
cardUsername = cardInfo.username
|
||||||
cardNickname = cardInfo.nickname
|
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) {
|
} else if ((localType === 49 || localType === 8589934592049) && content) {
|
||||||
// Type 49 消息(链接、文件、小程序、转账等),8589934592049 也是转账类型
|
// Type 49 消息(链接、文件、小程序、转账等),8589934592049 也是转账类型
|
||||||
const type49Info = this.parseType49Message(content)
|
const type49Info = this.parseType49Message(content)
|
||||||
@@ -1284,6 +1344,45 @@ class ChatService {
|
|||||||
quotedSender = quoteInfo.sender
|
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({
|
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),
|
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),
|
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,
|
fileSize,
|
||||||
fileExt,
|
fileExt,
|
||||||
xmlType,
|
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,
|
cardUsername,
|
||||||
cardNickname,
|
cardNickname,
|
||||||
|
cardAvatarUrl,
|
||||||
transferPayerUsername,
|
transferPayerUsername,
|
||||||
transferReceiverUsername,
|
transferReceiverUsername,
|
||||||
chatRecordTitle,
|
chatRecordTitle,
|
||||||
@@ -1350,6 +1473,7 @@ class ChatService {
|
|||||||
|
|
||||||
// 检查 XML type,用于识别引用消息等
|
// 检查 XML type,用于识别引用消息等
|
||||||
const xmlType = this.extractXmlValue(content, 'type')
|
const xmlType = this.extractXmlValue(content, 'type')
|
||||||
|
const looksLikeAppMsg = content.includes('<appmsg') || content.includes('<appmsg')
|
||||||
|
|
||||||
switch (localType) {
|
switch (localType) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -1364,8 +1488,14 @@ class ChatService {
|
|||||||
return '[视频]'
|
return '[视频]'
|
||||||
case 47:
|
case 47:
|
||||||
return '[动画表情]'
|
return '[动画表情]'
|
||||||
case 48:
|
case 48: {
|
||||||
return '[位置]'
|
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:
|
case 49:
|
||||||
return this.parseType49(content)
|
return this.parseType49(content)
|
||||||
case 50:
|
case 50:
|
||||||
@@ -1400,6 +1530,10 @@ class ChatService {
|
|||||||
return title || '[引用消息]'
|
return title || '[引用消息]'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (looksLikeAppMsg) {
|
||||||
|
return this.parseType49(content)
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试从 XML 提取通用 title
|
// 尝试从 XML 提取通用 title
|
||||||
const genericTitle = this.extractXmlValue(content, 'title')
|
const genericTitle = this.extractXmlValue(content, 'title')
|
||||||
if (genericTitle && genericTitle.length > 0 && genericTitle.length < 100) {
|
if (genericTitle && genericTitle.length > 0 && genericTitle.length < 100) {
|
||||||
@@ -1416,6 +1550,23 @@ class ChatService {
|
|||||||
private parseType49(content: string): string {
|
private parseType49(content: string): string {
|
||||||
const title = this.extractXmlValue(content, 'title')
|
const title = this.extractXmlValue(content, 'title')
|
||||||
const type = this.extractXmlValue(content, 'type')
|
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)特殊处理
|
// 群公告消息(type 87)特殊处理
|
||||||
if (type === '87') {
|
if (type === '87') {
|
||||||
@@ -1426,6 +1577,19 @@ class ChatService {
|
|||||||
return '[群公告]'
|
return '[群公告]'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFinder) {
|
||||||
|
return title ? `[视频号] ${title}` : '[视频号]'
|
||||||
|
}
|
||||||
|
if (isRedPacket) {
|
||||||
|
return title ? `[红包] ${title}` : '[红包]'
|
||||||
|
}
|
||||||
|
if (locationLabel) {
|
||||||
|
return `[位置] ${locationLabel}`
|
||||||
|
}
|
||||||
|
if (isMusic) {
|
||||||
|
return title ? `[音乐] ${title}` : '[音乐]'
|
||||||
|
}
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case '5':
|
case '5':
|
||||||
@@ -1443,6 +1607,8 @@ class ChatService {
|
|||||||
return title
|
return title
|
||||||
case '2000':
|
case '2000':
|
||||||
return `[转账] ${title}`
|
return `[转账] ${title}`
|
||||||
|
case '2001':
|
||||||
|
return `[红包] ${title}`
|
||||||
default:
|
default:
|
||||||
return title
|
return title
|
||||||
}
|
}
|
||||||
@@ -1459,6 +1625,13 @@ class ChatService {
|
|||||||
return '[小程序]'
|
return '[小程序]'
|
||||||
case '2000':
|
case '2000':
|
||||||
return '[转账]'
|
return '[转账]'
|
||||||
|
case '2001':
|
||||||
|
return '[红包]'
|
||||||
|
case '3':
|
||||||
|
return '[音乐]'
|
||||||
|
case '5':
|
||||||
|
case '49':
|
||||||
|
return '[链接]'
|
||||||
case '87':
|
case '87':
|
||||||
return '[群公告]'
|
return '[群公告]'
|
||||||
default:
|
default:
|
||||||
@@ -1764,7 +1937,7 @@ class ChatService {
|
|||||||
* 解析名片消息
|
* 解析名片消息
|
||||||
* 格式: <msg username="wxid_xxx" nickname="昵称" ... />
|
* 格式: <msg username="wxid_xxx" nickname="昵称" ... />
|
||||||
*/
|
*/
|
||||||
private parseCardInfo(content: string): { username?: string; nickname?: string } {
|
private parseCardInfo(content: string): { username?: string; nickname?: string; avatarUrl?: string } {
|
||||||
try {
|
try {
|
||||||
if (!content) return {}
|
if (!content) return {}
|
||||||
|
|
||||||
@@ -1774,7 +1947,11 @@ class ChatService {
|
|||||||
// 提取 nickname
|
// 提取 nickname
|
||||||
const nickname = this.extractXmlAttribute(content, 'msg', 'nickname') || undefined
|
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) {
|
} catch (e) {
|
||||||
console.error('[ChatService] 名片解析失败:', e)
|
console.error('[ChatService] 名片解析失败:', e)
|
||||||
return {}
|
return {}
|
||||||
@@ -1790,6 +1967,30 @@ class ChatService {
|
|||||||
linkTitle?: string
|
linkTitle?: string
|
||||||
linkUrl?: string
|
linkUrl?: string
|
||||||
linkThumb?: 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
|
fileName?: string
|
||||||
fileSize?: number
|
fileSize?: number
|
||||||
fileExt?: string
|
fileExt?: string
|
||||||
@@ -1816,6 +2017,122 @@ class ChatService {
|
|||||||
// 提取通用字段
|
// 提取通用字段
|
||||||
const title = this.extractXmlValue(content, 'title')
|
const title = this.extractXmlValue(content, 'title')
|
||||||
const url = this.extractXmlValue(content, 'url')
|
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) {
|
switch (xmlType) {
|
||||||
case '6': {
|
case '6': {
|
||||||
@@ -3884,6 +4201,74 @@ class ChatService {
|
|||||||
* 获取某会话中有消息的日期列表
|
* 获取某会话中有消息的日期列表
|
||||||
* 返回 YYYY-MM-DD 格式的日期字符串数组
|
* 返回 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 }> {
|
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const connectResult = await this.ensureConnected()
|
const connectResult = await this.ensureConnected()
|
||||||
@@ -4017,6 +4402,15 @@ class ChatService {
|
|||||||
msg.emojiThumbUrl = emojiInfo.thumbUrl
|
msg.emojiThumbUrl = emojiInfo.thumbUrl
|
||||||
msg.emojiEncryptUrl = emojiInfo.encryptUrl
|
msg.emojiEncryptUrl = emojiInfo.encryptUrl
|
||||||
msg.emojiAesKey = emojiInfo.aesKey
|
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
|
return msg
|
||||||
|
|||||||
@@ -155,6 +155,17 @@ export class ImageDecryptService {
|
|||||||
return { success: false, error: '缺少图片标识' }
|
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) {
|
if (!payload.force) {
|
||||||
const cached = this.resolvedCache.get(cacheKey)
|
const cached = this.resolvedCache.get(cacheKey)
|
||||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||||
@@ -346,23 +357,37 @@ export class ImageDecryptService {
|
|||||||
* 获取解密后的缓存目录(用于查找 hardlink.db)
|
* 获取解密后的缓存目录(用于查找 hardlink.db)
|
||||||
*/
|
*/
|
||||||
private getDecryptedCacheDir(wxid: string): string | null {
|
private getDecryptedCacheDir(wxid: string): string | null {
|
||||||
const cachePath = this.configService.get('cachePath')
|
|
||||||
if (!cachePath) return null
|
|
||||||
|
|
||||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
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
|
for (const base of baseCandidates) {
|
||||||
if (existsSync(join(cacheAccountDir, 'hardlink.db'))) {
|
const accountCandidates = Array.from(new Set([
|
||||||
return cacheAccountDir
|
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
|
||||||
}
|
}
|
||||||
if (existsSync(join(cachePath, 'hardlink.db'))) {
|
const hardlinkSubdir = join(accountDir, 'db_storage', 'hardlink')
|
||||||
return cachePath
|
if (existsSync(join(hardlinkSubdir, 'hardlink.db'))) {
|
||||||
|
return hardlinkSubdir
|
||||||
}
|
}
|
||||||
const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink')
|
|
||||||
if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) {
|
|
||||||
return cacheHardlinkDir
|
|
||||||
}
|
}
|
||||||
|
if (existsSync(join(base, 'hardlink.db'))) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +396,8 @@ export class ImageDecryptService {
|
|||||||
existsSync(join(dirPath, 'hardlink.db')) ||
|
existsSync(join(dirPath, 'hardlink.db')) ||
|
||||||
existsSync(join(dirPath, 'db_storage')) ||
|
existsSync(join(dirPath, 'db_storage')) ||
|
||||||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
|
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)
|
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||||
return 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(不进行全局搜索)
|
// 没找到高清图,返回 null(不进行全局搜索)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -454,9 +486,16 @@ export class ImageDecryptService {
|
|||||||
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
||||||
const hdPath = this.findHdVariantInSameDir(fallbackPath)
|
const hdPath = this.findHdVariantInSameDir(fallbackPath)
|
||||||
if (hdPath) {
|
if (hdPath) {
|
||||||
|
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||||
return 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
|
return null
|
||||||
}
|
}
|
||||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||||
@@ -479,15 +518,17 @@ export class ImageDecryptService {
|
|||||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||||
return hdPath
|
return hdPath
|
||||||
}
|
}
|
||||||
|
const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || '', false)
|
||||||
|
if (hdInDir) {
|
||||||
|
this.cacheDatPath(accountDir, imageDatName, hdInDir)
|
||||||
|
return hdInDir
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
|
// force 模式下也继续尝试缓存目录/文件系统搜索,避免 hardlink.db 缺行时只能拿到缩略图
|
||||||
if (!allowThumbnail) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!imageDatName) return null
|
if (!imageDatName) return null
|
||||||
if (!skipResolvedCache) {
|
if (!skipResolvedCache) {
|
||||||
@@ -497,6 +538,8 @@ export class ImageDecryptService {
|
|||||||
// 缓存的是缩略图,尝试找高清图
|
// 缓存的是缩略图,尝试找高清图
|
||||||
const hdPath = this.findHdVariantInSameDir(cached)
|
const hdPath = this.findHdVariantInSameDir(cached)
|
||||||
if (hdPath) return hdPath
|
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 {
|
try {
|
||||||
// 1. 打开游标 (使用 Ascending=1 从指定时间往后查)
|
// 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) {
|
if (!openRes.success || !openRes.cursor) {
|
||||||
return { success: false, error: openRes.error }
|
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==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -2910,7 +2909,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -3057,7 +3055,6 @@
|
|||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
@@ -3997,7 +3994,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -5107,7 +5103,6 @@
|
|||||||
"integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==",
|
"integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "25.1.8",
|
"app-builder-lib": "25.1.8",
|
||||||
"builder-util": "25.1.7",
|
"builder-util": "25.1.7",
|
||||||
@@ -5295,7 +5290,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
|
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
|
||||||
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.3.0",
|
"tslib": "2.3.0",
|
||||||
"zrender": "5.6.1"
|
"zrender": "5.6.1"
|
||||||
@@ -5382,6 +5376,7 @@
|
|||||||
"integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==",
|
"integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "25.1.8",
|
"app-builder-lib": "25.1.8",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
@@ -5395,6 +5390,7 @@
|
|||||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
"jsonfile": "^6.0.1",
|
"jsonfile": "^6.0.1",
|
||||||
@@ -5410,6 +5406,7 @@
|
|||||||
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"universalify": "^2.0.0"
|
"universalify": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -5423,6 +5420,7 @@
|
|||||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
@@ -9152,7 +9150,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -9162,7 +9159,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -9597,7 +9593,6 @@
|
|||||||
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
|
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"immutable": "^5.0.2",
|
"immutable": "^5.0.2",
|
||||||
@@ -9828,9 +9823,6 @@
|
|||||||
"sherpa-onnx-win-x64": "^1.12.23"
|
"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": {
|
"node_modules/sherpa-onnx-win-ia32": {
|
||||||
"version": "1.12.23",
|
"version": "1.12.23",
|
||||||
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",
|
"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==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -10890,7 +10881,6 @@
|
|||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"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",
|
"resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
|
||||||
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/fdir": {
|
"node_modules/vite/node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
@@ -11007,7 +10996,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
|||||||
import LockScreen from './components/LockScreen'
|
import LockScreen from './components/LockScreen'
|
||||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||||
|
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -385,6 +386,7 @@ function App() {
|
|||||||
|
|
||||||
{/* 全局批量转写进度浮窗 */}
|
{/* 全局批量转写进度浮窗 */}
|
||||||
<BatchTranscribeGlobal />
|
<BatchTranscribeGlobal />
|
||||||
|
<BatchImageDecryptGlobal />
|
||||||
|
|
||||||
{/* 用户协议弹窗 */}
|
{/* 用户协议弹窗 */}
|
||||||
{showAgreement && !agreementLoading && (
|
{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 {
|
.link-message {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
background: var(--card-bg);
|
background: var(--card-inner-bg);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--bg-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 {
|
.message-bubble.sent .link-message {
|
||||||
background: var(--card-bg);
|
background: var(--sent-card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.link-title {
|
.link-title {
|
||||||
color: var(--text-primary);
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-desc {
|
.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);
|
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 {
|
.emoji-image {
|
||||||
max-width: 120px;
|
max-width: 120px;
|
||||||
max-height: 120px;
|
max-height: 120px;
|
||||||
@@ -2487,10 +2845,17 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px 14px;
|
||||||
background: var(--bg-tertiary);
|
background: var(--card-inner-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
.card-icon {
|
.card-icon {
|
||||||
flex-shrink: 0;
|
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 {
|
.call-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2752,12 +3129,14 @@
|
|||||||
|
|
||||||
.card-message,
|
.card-message,
|
||||||
.chat-record-message,
|
.chat-record-message,
|
||||||
.miniapp-message {
|
.miniapp-message,
|
||||||
background: rgba(255, 255, 255, 0.15);
|
.appmsg-rich-card {
|
||||||
|
background: var(--sent-card-bg);
|
||||||
|
|
||||||
.card-name,
|
.card-name,
|
||||||
.miniapp-title,
|
.miniapp-title,
|
||||||
.source-name {
|
.source-name,
|
||||||
|
.link-title {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2765,7 +3144,9 @@
|
|||||||
.miniapp-label,
|
.miniapp-label,
|
||||||
.chat-record-item,
|
.chat-record-item,
|
||||||
.chat-record-meta-line,
|
.chat-record-meta-line,
|
||||||
.chat-record-desc {
|
.chat-record-desc,
|
||||||
|
.link-desc,
|
||||||
|
.appmsg-url-line {
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2778,6 +3159,12 @@
|
|||||||
.chat-record-more {
|
.chat-record-more {
|
||||||
color: rgba(255, 255, 255, 0.9);
|
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 {
|
.call-message {
|
||||||
@@ -3236,3 +3623,233 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 { createPortal } from 'react-dom'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||||
|
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
|
||||||
import type { ChatSession, Message } from '../types/models'
|
import type { ChatSession, Message } from '../types/models'
|
||||||
import { getEmojiPath } from 'wechat-emojis'
|
import { getEmojiPath } from 'wechat-emojis'
|
||||||
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||||
@@ -27,6 +28,12 @@ interface XmlField {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchImageDecryptCandidate {
|
||||||
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
createTime?: number
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试解析 XML 为可编辑字段
|
// 尝试解析 XML 为可编辑字段
|
||||||
function parseXmlToFields(xml: string): XmlField[] {
|
function parseXmlToFields(xml: string): XmlField[] {
|
||||||
const fields: XmlField[] = []
|
const fields: XmlField[] = []
|
||||||
@@ -301,11 +308,16 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
|
|
||||||
// 批量语音转文字相关状态(进度/结果 由全局 store 管理)
|
// 批量语音转文字相关状态(进度/结果 由全局 store 管理)
|
||||||
const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore()
|
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 [showBatchConfirm, setShowBatchConfirm] = useState(false)
|
||||||
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
|
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
|
||||||
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
|
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
|
||||||
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([])
|
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([])
|
||||||
const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set())
|
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)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
@@ -1434,6 +1446,37 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
setShowBatchConfirm(true)
|
setShowBatchConfirm(true)
|
||||||
}, [sessions, currentSessionId, isBatchTranscribing])
|
}, [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(() => {
|
const handleExportCurrentSession = useCallback(() => {
|
||||||
if (!currentSessionId) return
|
if (!currentSessionId) return
|
||||||
navigate('/export', {
|
navigate('/export', {
|
||||||
@@ -1557,6 +1600,88 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates])
|
const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates])
|
||||||
const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), [])
|
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 lastSelectedIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => {
|
const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => {
|
||||||
@@ -1996,6 +2121,26 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
<Mic size={18} />
|
<Mic size={18} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</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
|
<button
|
||||||
className="icon-btn jump-to-time-btn"
|
className="icon-btn jump-to-time-btn"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -2361,6 +2506,66 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
document.body
|
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(
|
{contextMenu && createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="context-menu-overlay" onClick={() => setContextMenu(null)}
|
<div className="context-menu-overlay" onClick={() => setContextMenu(null)}
|
||||||
@@ -2856,7 +3061,7 @@ function MessageBubble({
|
|||||||
setImageLocalPath(result.localPath)
|
setImageLocalPath(result.localPath)
|
||||||
setImageHasUpdate(false)
|
setImageHasUpdate(false)
|
||||||
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
||||||
return
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2867,7 +3072,7 @@ function MessageBubble({
|
|||||||
imageDataUrlCache.set(imageCacheKey, dataUrl)
|
imageDataUrlCache.set(imageCacheKey, dataUrl)
|
||||||
setImageLocalPath(dataUrl)
|
setImageLocalPath(dataUrl)
|
||||||
setImageHasUpdate(false)
|
setImageHasUpdate(false)
|
||||||
return
|
return { success: true, localPath: dataUrl } as any
|
||||||
}
|
}
|
||||||
if (!silent) setImageError(true)
|
if (!silent) setImageError(true)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2875,6 +3080,7 @@ function MessageBubble({
|
|||||||
} finally {
|
} finally {
|
||||||
if (!silent) setImageLoading(false)
|
if (!silent) setImageLoading(false)
|
||||||
}
|
}
|
||||||
|
return { success: false } as any
|
||||||
}, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64])
|
}, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64])
|
||||||
|
|
||||||
const triggerForceHd = useCallback(() => {
|
const triggerForceHd = useCallback(() => {
|
||||||
@@ -2905,6 +3111,55 @@ function MessageBubble({
|
|||||||
void requestImageDecrypt()
|
void requestImageDecrypt()
|
||||||
}, [message.imageDatName, message.imageMd5, message.localId, requestImageDecrypt, session.username])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (imageClickTimerRef.current) {
|
if (imageClickTimerRef.current) {
|
||||||
@@ -3426,10 +3681,7 @@ function MessageBubble({
|
|||||||
src={imageLocalPath}
|
src={imageLocalPath}
|
||||||
alt="图片"
|
alt="图片"
|
||||||
className="image-message"
|
className="image-message"
|
||||||
onClick={() => {
|
onClick={() => { void handleOpenImageViewer() }}
|
||||||
if (imageHasUpdate) void requestImageDecrypt(true, true)
|
|
||||||
void window.electronAPI.window.openImageViewerWindow(imageLocalPath!, imageLiveVideoPath || undefined)
|
|
||||||
}}
|
|
||||||
onLoad={() => setImageError(false)}
|
onLoad={() => setImageError(false)}
|
||||||
onError={() => setImageError(true)}
|
onError={() => setImageError(true)}
|
||||||
/>
|
/>
|
||||||
@@ -3692,16 +3944,24 @@ function MessageBubble({
|
|||||||
// 名片消息
|
// 名片消息
|
||||||
if (isCard) {
|
if (isCard) {
|
||||||
const cardName = message.cardNickname || message.cardUsername || '未知联系人'
|
const cardName = message.cardNickname || message.cardUsername || '未知联系人'
|
||||||
|
const cardAvatar = message.cardAvatarUrl
|
||||||
return (
|
return (
|
||||||
<div className="card-message">
|
<div className="card-message">
|
||||||
<div className="card-icon">
|
<div className="card-icon">
|
||||||
|
{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">
|
<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" />
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
<circle cx="12" cy="7" r="4" />
|
<circle cx="12" cy="7" r="4" />
|
||||||
</svg>
|
</svg>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="card-info">
|
<div className="card-info">
|
||||||
<div className="card-name">{cardName}</div>
|
<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 className="card-label">个人名片</div>
|
||||||
</div>
|
</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)
|
// 链接消息 (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'))
|
const isAppMsg = message.rawContent?.includes('<appmsg') || (message.parsedContent && message.parsedContent.includes('<appmsg'))
|
||||||
|
|
||||||
if (isAppMsg) {
|
if (isAppMsg) {
|
||||||
|
|||||||
@@ -46,6 +46,18 @@
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
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 {
|
.scale-text {
|
||||||
@@ -78,14 +90,40 @@
|
|||||||
cursor: grabbing;
|
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-width: none;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
will-change: transform;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
pointer-events: auto;
|
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 { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
|
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
|
||||||
@@ -9,13 +8,17 @@ export default function ImageWindow() {
|
|||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const imagePath = searchParams.get('imagePath')
|
const imagePath = searchParams.get('imagePath')
|
||||||
const liveVideoPath = searchParams.get('liveVideoPath')
|
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 videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const liveCleanupTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const [scale, setScale] = useState(1)
|
const [scale, setScale] = useState(1)
|
||||||
const [rotation, setRotation] = useState(0)
|
const [rotation, setRotation] = useState(0)
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||||
const [initialScale, setInitialScale] = useState(1)
|
const [initialScale, setInitialScale] = useState(1)
|
||||||
const [imgNatural, setImgNatural] = useState({ w: 0, h: 0 })
|
|
||||||
const viewportRef = useRef<HTMLDivElement>(null)
|
const viewportRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// 使用 ref 存储拖动状态,避免闭包问题
|
// 使用 ref 存储拖动状态,避免闭包问题
|
||||||
@@ -27,6 +30,44 @@ export default function ImageWindow() {
|
|||||||
startPosY: 0
|
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 handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10))
|
||||||
const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1))
|
const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1))
|
||||||
const handleRotate = () => setRotation(prev => (prev + 90) % 360)
|
const handleRotate = () => setRotation(prev => (prev + 90) % 360)
|
||||||
@@ -44,7 +85,6 @@ export default function ImageWindow() {
|
|||||||
const img = e.currentTarget
|
const img = e.currentTarget
|
||||||
const naturalWidth = img.naturalWidth
|
const naturalWidth = img.naturalWidth
|
||||||
const naturalHeight = img.naturalHeight
|
const naturalHeight = img.naturalHeight
|
||||||
setImgNatural({ w: naturalWidth, h: naturalHeight })
|
|
||||||
|
|
||||||
if (viewportRef.current) {
|
if (viewportRef.current) {
|
||||||
const viewportWidth = viewportRef.current.clientWidth * 0.9
|
const viewportWidth = viewportRef.current.clientWidth * 0.9
|
||||||
@@ -57,6 +97,29 @@ 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(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
@@ -112,15 +175,25 @@ export default function ImageWindow() {
|
|||||||
// 快捷键支持
|
// 快捷键支持
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
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 === '=' || e.key === '+') handleZoomIn()
|
||||||
if (e.key === '-') handleZoomOut()
|
if (e.key === '-') handleZoomOut()
|
||||||
if (e.key === 'r' || e.key === 'R') handleRotate()
|
if (e.key === 'r' || e.key === 'R') handleRotate()
|
||||||
if (e.key === '0') handleReset()
|
if (e.key === '0') handleReset()
|
||||||
|
if (e.key === ' ' && hasLiveVideo) {
|
||||||
|
e.preventDefault()
|
||||||
|
handlePlayLiveVideo()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [handleReset])
|
}, [handleReset, hasLiveVideo, handlePlayLiveVideo, isPlayingLive, stopLivePlayback])
|
||||||
|
|
||||||
if (!imagePath) {
|
if (!imagePath) {
|
||||||
return (
|
return (
|
||||||
@@ -137,22 +210,19 @@ export default function ImageWindow() {
|
|||||||
<div className="title-bar">
|
<div className="title-bar">
|
||||||
<div className="window-drag-area"></div>
|
<div className="window-drag-area"></div>
|
||||||
<div className="title-bar-controls">
|
<div className="title-bar-controls">
|
||||||
{liveVideoPath && (
|
{hasLiveVideo && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={handlePlayLiveVideo}
|
||||||
const next = !showLive
|
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
|
||||||
setShowLive(next)
|
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
|
||||||
if (next && videoRef.current) {
|
disabled={isPlayingLive}
|
||||||
videoRef.current.currentTime = 0
|
|
||||||
videoRef.current.play()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={showLive ? '显示照片' : '播放实况'}
|
|
||||||
className={showLive ? 'active' : ''}
|
|
||||||
>
|
>
|
||||||
<LivePhotoIcon size={16} />
|
<LivePhotoIcon size={16} />
|
||||||
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
|
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div className="divider"></div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
|
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
|
||||||
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
|
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
|
||||||
@@ -170,32 +240,31 @@ export default function ImageWindow() {
|
|||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
>
|
>
|
||||||
{liveVideoPath && (
|
<div
|
||||||
<video
|
className="media-wrapper"
|
||||||
ref={videoRef}
|
|
||||||
src={liveVideoPath}
|
|
||||||
width={imgNatural.w || undefined}
|
|
||||||
height={imgNatural.h || undefined}
|
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`,
|
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
|
<img
|
||||||
src={imagePath}
|
src={imagePath}
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
style={{
|
|
||||||
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`,
|
|
||||||
opacity: showLive ? 0 : 1,
|
|
||||||
position: showLive ? 'absolute' : 'relative'
|
|
||||||
}}
|
|
||||||
onLoad={handleImageLoad}
|
onLoad={handleImageLoad}
|
||||||
draggable={false}
|
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>
|
||||||
</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 {
|
.batch-result-modal {
|
||||||
width: 420px;
|
width: 420px;
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
|
|
||||||
// 卡片背景
|
// 卡片背景
|
||||||
--card-bg: rgba(255, 255, 255, 0.7);
|
--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%);
|
--bg-gradient: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%);
|
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%);
|
||||||
--card-bg: rgba(255, 255, 255, 0.7);
|
--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%);
|
--bg-gradient: linear-gradient(135deg, #E8EEF0 0%, #D8E4E8 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #5A7A86 100%);
|
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #5A7A86 100%);
|
||||||
--card-bg: rgba(255, 255, 255, 0.7);
|
--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%);
|
--bg-gradient: linear-gradient(135deg, #E8F0E4 0%, #D8E8D0 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #8AAA6C 100%);
|
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #8AAA6C 100%);
|
||||||
--card-bg: rgba(255, 255, 255, 0.7);
|
--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%);
|
--bg-gradient: linear-gradient(135deg, #F0E8E8 0%, #E8D8D8 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #A05058 100%);
|
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #A05058 100%);
|
||||||
--card-bg: rgba(255, 255, 255, 0.7);
|
--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%);
|
--bg-gradient: linear-gradient(135deg, #E4F0F0 0%, #D4E8E8 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #6A9A9A 100%);
|
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #6A9A9A 100%);
|
||||||
--card-bg: rgba(255, 255, 255, 0.7);
|
--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%);
|
--bg-gradient: linear-gradient(135deg, #1a1816 0%, #252220 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #C9A86C 100%);
|
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #C9A86C 100%);
|
||||||
--card-bg: rgba(40, 36, 32, 0.9);
|
--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%);
|
--bg-gradient: linear-gradient(135deg, #141a1c 0%, #1e282c 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #6A9AAA 100%);
|
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #6A9AAA 100%);
|
||||||
--card-bg: rgba(30, 40, 44, 0.9);
|
--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%);
|
--bg-gradient: linear-gradient(135deg, #161a14 0%, #222a1e 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #9ABA7C 100%);
|
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #9ABA7C 100%);
|
||||||
--card-bg: rgba(34, 42, 30, 0.9);
|
--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%);
|
--bg-gradient: linear-gradient(135deg, #1a1416 0%, #2a2022 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #C06068 100%);
|
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #C06068 100%);
|
||||||
--card-bg: rgba(42, 32, 34, 0.9);
|
--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%);
|
--bg-gradient: linear-gradient(135deg, #121a1a 0%, #1c2a2a 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #7ABAAA 100%);
|
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #7ABAAA 100%);
|
||||||
--card-bg: rgba(28, 42, 42, 0.9);
|
--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 }>
|
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 }>
|
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 }>
|
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 }>
|
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 }>
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||||
|
|||||||
@@ -64,12 +64,39 @@ export interface Message {
|
|||||||
fileSize?: number // 文件大小
|
fileSize?: number // 文件大小
|
||||||
fileExt?: string // 文件扩展名
|
fileExt?: string // 文件扩展名
|
||||||
xmlType?: string // XML 中的 type 字段
|
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
|
transferPayerUsername?: string // 转账付款方 wxid
|
||||||
transferReceiverUsername?: string // 转账收款方 wxid
|
transferReceiverUsername?: string // 转账收款方 wxid
|
||||||
// 名片消息
|
// 名片消息
|
||||||
cardUsername?: string // 名片的微信ID
|
cardUsername?: string // 名片的微信ID
|
||||||
cardNickname?: string // 名片的昵称
|
cardNickname?: string // 名片的昵称
|
||||||
|
cardAvatarUrl?: string // 名片头像 URL
|
||||||
// 聊天记录
|
// 聊天记录
|
||||||
chatRecordTitle?: string // 聊天记录标题
|
chatRecordTitle?: string // 聊天记录标题
|
||||||
chatRecordList?: ChatRecordItem[] // 聊天记录列表
|
chatRecordList?: ChatRecordItem[] // 聊天记录列表
|
||||||
|
|||||||
Reference in New Issue
Block a user