mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
图片批量解密 图片解密优化
This commit is contained in:
@@ -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,6 +73,17 @@ 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
|
||||||
// 名片消息
|
// 名片消息
|
||||||
cardUsername?: string // 名片的微信ID
|
cardUsername?: string // 名片的微信ID
|
||||||
cardNickname?: string // 名片的昵称
|
cardNickname?: string // 名片的昵称
|
||||||
@@ -1224,6 +1235,17 @@ 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 cardUsername: string | undefined
|
let cardUsername: string | undefined
|
||||||
let cardNickname: string | undefined
|
let cardNickname: string | undefined
|
||||||
@@ -1284,6 +1306,33 @@ 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
|
||||||
|
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,6 +1361,17 @@ class ChatService {
|
|||||||
fileSize,
|
fileSize,
|
||||||
fileExt,
|
fileExt,
|
||||||
xmlType,
|
xmlType,
|
||||||
|
appMsgKind,
|
||||||
|
appMsgDesc,
|
||||||
|
appMsgAppName,
|
||||||
|
appMsgSourceName,
|
||||||
|
appMsgSourceUsername,
|
||||||
|
appMsgThumbUrl,
|
||||||
|
appMsgMusicUrl,
|
||||||
|
appMsgDataUrl,
|
||||||
|
appMsgLocationLabel,
|
||||||
|
finderNickname,
|
||||||
|
finderUsername,
|
||||||
cardUsername,
|
cardUsername,
|
||||||
cardNickname,
|
cardNickname,
|
||||||
transferPayerUsername,
|
transferPayerUsername,
|
||||||
@@ -1350,6 +1410,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 +1425,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 +1467,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 +1487,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 +1514,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 +1544,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 +1562,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:
|
||||||
@@ -1790,6 +1900,17 @@ 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
|
||||||
fileName?: string
|
fileName?: string
|
||||||
fileSize?: number
|
fileSize?: number
|
||||||
fileExt?: string
|
fileExt?: string
|
||||||
@@ -1816,6 +1937,82 @@ 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' ||
|
||||||
|
normalized.includes('<finder') ||
|
||||||
|
normalized.includes('finderusername') ||
|
||||||
|
normalized.includes('finderobjectid')
|
||||||
|
const isRedPacket = xmlType === '2001' || normalized.includes('hongbao')
|
||||||
|
const isMusic = xmlType === '3' || Boolean(musicUrl || dataUrl)
|
||||||
|
const isLocation = Boolean(locationLabel) || normalized.includes('<location')
|
||||||
|
|
||||||
|
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) {
|
||||||
|
result.appMsgKind = 'finder'
|
||||||
|
} else if (isRedPacket) {
|
||||||
|
result.appMsgKind = 'red-packet'
|
||||||
|
} 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 +4081,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 +4282,14 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawContent && (rawContent.includes('<appmsg') || rawContent.includes('<appmsg'))) {
|
||||||
|
Object.assign(msg, this.parseType49Message(rawContent))
|
||||||
}
|
}
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|||||||
@@ -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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)}
|
||||||
|
|||||||
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;
|
||||||
@@ -293,4 +337,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user