Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
d172114c09 chore(deps-dev): bump sass from 1.98.0 to 1.99.0
Bumps [sass](https://github.com/sass/dart-sass) from 1.98.0 to 1.99.0.
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.98.0...1.99.0)

---
updated-dependencies:
- dependency-name: sass
  dependency-version: 1.99.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-03 23:16:12 +00:00
28 changed files with 349 additions and 2024 deletions

1
.gitignore vendored
View File

@@ -72,4 +72,3 @@ pnpm-lock.yaml
/pnpm-workspace.yaml /pnpm-workspace.yaml
wechat-research-site wechat-research-site
.codex .codex
weflow-web-offical

View File

@@ -68,7 +68,6 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
| 功能模块 | 说明 | | 功能模块 | 说明 |
|---------|------| |---------|------|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 | | **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
| **消息防撤回** | 防止其他人发送的消息被撤回 |
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 | | **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 | | **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 | | **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |

View File

@@ -136,7 +136,6 @@ const shouldOfferUpdateForTrack = (latestVersion: string, currentVersion: string
} }
let lastAppliedUpdaterChannel: string | null = null let lastAppliedUpdaterChannel: string | null = null
let lastAppliedUpdaterFeedUrl: string | null = null
const resetUpdaterProviderCache = () => { const resetUpdaterProviderCache = () => {
const updater = autoUpdater as any const updater = autoUpdater as any
// electron-updater 会缓存 provider切换 channel 后需清理缓存,避免仍请求旧通道 // electron-updater 会缓存 provider切换 channel 后需清理缓存,避免仍请求旧通道
@@ -147,41 +146,23 @@ const resetUpdaterProviderCache = () => {
} }
} }
const getUpdaterFeedUrlByTrack = (track: 'stable' | 'preview' | 'dev'): string => {
const repoBase = 'https://github.com/hicccc77/WeFlow/releases'
if (track === 'stable') return `${repoBase}/latest/download`
if (track === 'preview') return `${repoBase}/download/nightly-preview`
return `${repoBase}/download/nightly-dev`
}
const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => { const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => {
const track = getEffectiveUpdateTrack() const track = getEffectiveUpdateTrack()
const currentTrack = inferUpdateTrackFromVersion(appVersion) const currentTrack = inferUpdateTrackFromVersion(appVersion)
const baseUpdateChannel = track === 'stable' ? 'latest' : track const baseUpdateChannel = track === 'stable' ? 'latest' : track
const nextFeedUrl = getUpdaterFeedUrlByTrack(track)
const nextUpdaterChannel = const nextUpdaterChannel =
process.platform === 'win32' && process.arch === 'arm64' process.platform === 'win32' && process.arch === 'arm64'
? `${baseUpdateChannel}-arm64` ? `${baseUpdateChannel}-arm64`
: baseUpdateChannel : baseUpdateChannel
if ( if (lastAppliedUpdaterChannel && lastAppliedUpdaterChannel !== nextUpdaterChannel) {
(lastAppliedUpdaterChannel && lastAppliedUpdaterChannel !== nextUpdaterChannel) ||
(lastAppliedUpdaterFeedUrl && lastAppliedUpdaterFeedUrl !== nextFeedUrl)
) {
resetUpdaterProviderCache() resetUpdaterProviderCache()
} }
autoUpdater.allowPrerelease = track !== 'stable' autoUpdater.allowPrerelease = track !== 'stable'
// 只要用户当前选择的目标通道与当前安装版本所属通道不同,就允许跨通道更新(含降级) // 只要用户当前选择的目标通道与当前安装版本所属通道不同,就允许跨通道更新(含降级)
autoUpdater.allowDowngrade = track !== currentTrack autoUpdater.allowDowngrade = track !== currentTrack
// 统一走 generic feed确保 preview/dev 命中各自固定发布页,不受 GitHub provider 的 prerelease 选择影响。
autoUpdater.setFeedURL({
provider: 'generic',
url: nextFeedUrl,
channel: nextUpdaterChannel
})
autoUpdater.channel = nextUpdaterChannel autoUpdater.channel = nextUpdaterChannel
lastAppliedUpdaterChannel = nextUpdaterChannel lastAppliedUpdaterChannel = nextUpdaterChannel
lastAppliedUpdaterFeedUrl = nextFeedUrl console.log(`[Update](${reason}) 当前版本 ${appVersion},当前轨道: ${currentTrack},渠道偏好: ${track},更新通道: ${autoUpdater.channel}allowDowngrade=${autoUpdater.allowDowngrade}`)
console.log(`[Update](${reason}) 当前版本 ${appVersion},当前轨道: ${currentTrack},渠道偏好: ${track},更新通道: ${autoUpdater.channel}feed=${nextFeedUrl}allowDowngrade=${autoUpdater.allowDowngrade}`)
} }
applyAutoUpdateChannel('startup') applyAutoUpdateChannel('startup')
@@ -1867,18 +1848,6 @@ function registerIpcHandlers() {
return chatService.deleteMessage(sessionId, localId, createTime, dbPathHint) return chatService.deleteMessage(sessionId, localId, createTime, dbPathHint)
}) })
ipcMain.handle('chat:checkAntiRevokeTriggers', async (_, sessionIds: string[]) => {
return chatService.checkAntiRevokeTriggers(sessionIds)
})
ipcMain.handle('chat:installAntiRevokeTriggers', async (_, sessionIds: string[]) => {
return chatService.installAntiRevokeTriggers(sessionIds)
})
ipcMain.handle('chat:uninstallAntiRevokeTriggers', async (_, sessionIds: string[]) => {
return chatService.uninstallAntiRevokeTriggers(sessionIds)
})
ipcMain.handle('chat:getContact', async (_, username: string) => { ipcMain.handle('chat:getContact', async (_, username: string) => {
return await chatService.getContact(username) return await chatService.getContact(username)
}) })
@@ -2311,47 +2280,10 @@ function registerIpcHandlers() {
}) })
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => { ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
const PROGRESS_FORWARD_INTERVAL_MS = 180
let pendingProgress: ExportProgress | null = null
let progressTimer: NodeJS.Timeout | null = null
let lastProgressSentAt = 0
const flushProgress = () => {
if (!pendingProgress) return
if (progressTimer) {
clearTimeout(progressTimer)
progressTimer = null
}
if (!event.sender.isDestroyed()) {
event.sender.send('export:progress', pendingProgress)
}
pendingProgress = null
lastProgressSentAt = Date.now()
}
const queueProgress = (progress: ExportProgress) => {
pendingProgress = progress
const force = progress.phase === 'complete'
if (force) {
flushProgress()
return
}
const now = Date.now()
const elapsed = now - lastProgressSentAt
if (elapsed >= PROGRESS_FORWARD_INTERVAL_MS) {
flushProgress()
return
}
if (progressTimer) return
progressTimer = setTimeout(() => {
flushProgress()
}, PROGRESS_FORWARD_INTERVAL_MS - elapsed)
}
const onProgress = (progress: ExportProgress) => { const onProgress = (progress: ExportProgress) => {
queueProgress(progress) if (!event.sender.isDestroyed()) {
event.sender.send('export:progress', progress)
}
} }
const runMainFallback = async (reason: string) => { const runMainFallback = async (reason: string) => {
@@ -2430,12 +2362,6 @@ function registerIpcHandlers() {
return await runWorker() return await runWorker()
} catch (error) { } catch (error) {
return runMainFallback(error instanceof Error ? error.message : String(error)) return runMainFallback(error instanceof Error ? error.message : String(error))
} finally {
flushProgress()
if (progressTimer) {
clearTimeout(progressTimer)
progressTimer = null
}
} }
}) })

View File

@@ -190,12 +190,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent), ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint), ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
checkAntiRevokeTriggers: (sessionIds: string[]) =>
ipcRenderer.invoke('chat:checkAntiRevokeTriggers', sessionIds),
installAntiRevokeTriggers: (sessionIds: string[]) =>
ipcRenderer.invoke('chat:installAntiRevokeTriggers', sessionIds),
uninstallAntiRevokeTriggers: (sessionIds: string[]) =>
ipcRenderer.invoke('chat:uninstallAntiRevokeTriggers', sessionIds),
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername), ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'), getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),

View File

@@ -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, promises as fsPromises } from 'fs' import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs'
import * as path from 'path' import * as path from 'path'
import * as fs from 'fs' import * as fs from 'fs'
@@ -75,7 +75,6 @@ export interface Message {
fileName?: string // 文件名 fileName?: string // 文件名
fileSize?: number // 文件大小 fileSize?: number // 文件大小
fileExt?: string // 文件扩展名 fileExt?: string // 文件扩展名
fileMd5?: string // 文件 MD5
xmlType?: string // XML 中的 type 字段 xmlType?: string // XML 中的 type 字段
appMsgKind?: string // 归一化 appmsg 类型 appMsgKind?: string // 归一化 appmsg 类型
appMsgDesc?: string appMsgDesc?: string
@@ -559,51 +558,6 @@ class ChatService {
} }
} }
async checkAntiRevokeTriggers(sessionIds: string[]): Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>
error?: string
}> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) return { success: false, error: connectResult.error }
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
return await wcdbService.checkMessageAntiRevokeTriggers(normalizedIds)
} catch (e) {
return { success: false, error: String(e) }
}
}
async installAntiRevokeTriggers(sessionIds: string[]): Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>
error?: string
}> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) return { success: false, error: connectResult.error }
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
return await wcdbService.installMessageAntiRevokeTriggers(normalizedIds)
} catch (e) {
return { success: false, error: String(e) }
}
}
async uninstallAntiRevokeTriggers(sessionIds: string[]): Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; error?: string }>
error?: string
}> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) return { success: false, error: connectResult.error }
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
return await wcdbService.uninstallMessageAntiRevokeTriggers(normalizedIds)
} catch (e) {
return { success: false, error: String(e) }
}
}
/** /**
* 获取会话列表(优化:先返回基础数据,不等待联系人信息加载) * 获取会话列表(优化:先返回基础数据,不等待联系人信息加载)
*/ */
@@ -1819,9 +1773,18 @@ class ChatService {
} }
private getMessageSourceInfo(row: Record<string, any>): { dbName?: string; tableName?: string; dbPath?: string } { private getMessageSourceInfo(row: Record<string, any>): { dbName?: string; tableName?: string; dbPath?: string } {
const dbPath = String(row._db_path || row.db_path || '').trim() const dbPath = String(
const explicitDbName = String(row.db_name || '').trim() this.getRowField(row, ['_db_path', 'db_path', 'dbPath', 'database_path', 'databasePath', 'source_db_path'])
const tableName = String(row.table_name || '').trim() || ''
).trim()
const explicitDbName = String(
this.getRowField(row, ['db_name', 'dbName', 'database_name', 'databaseName', 'db', 'database', 'source_db'])
|| ''
).trim()
const tableName = String(
this.getRowField(row, ['table_name', 'tableName', 'table', 'source_table', 'sourceTable'])
|| ''
).trim()
const dbName = explicitDbName || (dbPath ? basename(dbPath, extname(dbPath)) : '') const dbName = explicitDbName || (dbPath ? basename(dbPath, extname(dbPath)) : '')
return { return {
dbName: dbName || undefined, dbName: dbName || undefined,
@@ -3238,7 +3201,7 @@ class ChatService {
if (!batch.success) break if (!batch.success) break
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : [] const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
for (const row of rows) { for (const row of rows) {
const localType = this.getRowInt(row, ['local_type'], 1) const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
if (localType === 50) { if (localType === 50) {
counters.callMessages += 1 counters.callMessages += 1
continue continue
@@ -3253,8 +3216,8 @@ class ChatService {
} }
if (localType !== 49) continue if (localType !== 49) continue
const rawMessageContent = row.message_content const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content'])
const rawCompressContent = row.compress_content const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content'])
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
const xmlType = this.extractType49XmlTypeForStats(content) const xmlType = this.extractType49XmlTypeForStats(content)
if (xmlType === '2000') counters.transferMessages += 1 if (xmlType === '2000') counters.transferMessages += 1
@@ -3307,7 +3270,7 @@ class ChatService {
for (const row of rows) { for (const row of rows) {
stats.totalMessages += 1 stats.totalMessages += 1
const localType = this.getRowInt(row, ['local_type'], 1) const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
if (localType === 34) stats.voiceMessages += 1 if (localType === 34) stats.voiceMessages += 1
if (localType === 3) stats.imageMessages += 1 if (localType === 3) stats.imageMessages += 1
if (localType === 43) stats.videoMessages += 1 if (localType === 43) stats.videoMessages += 1
@@ -3316,8 +3279,8 @@ class ChatService {
if (localType === 8589934592049) stats.transferMessages += 1 if (localType === 8589934592049) stats.transferMessages += 1
if (localType === 8594229559345) stats.redPacketMessages += 1 if (localType === 8594229559345) stats.redPacketMessages += 1
if (localType === 49) { if (localType === 49) {
const rawMessageContent = row.message_content const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content'])
const rawCompressContent = row.compress_content const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content'])
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
const xmlType = this.extractType49XmlTypeForStats(content) const xmlType = this.extractType49XmlTypeForStats(content)
if (xmlType === '2000') stats.transferMessages += 1 if (xmlType === '2000') stats.transferMessages += 1
@@ -3326,7 +3289,7 @@ class ChatService {
const createTime = this.getRowInt( const createTime = this.getRowInt(
row, row,
['create_time'], ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'],
0 0
) )
if (createTime > 0) { if (createTime > 0) {
@@ -3339,7 +3302,7 @@ class ChatService {
} }
if (sessionId.endsWith('@chatroom')) { if (sessionId.endsWith('@chatroom')) {
const sender = String(row.sender_username || '').trim() const sender = String(this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || '').trim()
const senderKeys = this.buildIdentityKeys(sender) const senderKeys = this.buildIdentityKeys(sender)
if (senderKeys.length > 0) { if (senderKeys.length > 0) {
senderIdentities.add(senderKeys[0]) senderIdentities.add(senderKeys[0])
@@ -3347,7 +3310,7 @@ class ChatService {
stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 stats.groupMyMessages = (stats.groupMyMessages || 0) + 1
} }
} else { } else {
const isSend = this.coerceRowNumber(row.computed_is_send ?? row.is_send) const isSend = this.coerceRowNumber(this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']))
if (Number.isFinite(isSend) && isSend === 1) { if (Number.isFinite(isSend) && isSend === 1) {
stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 stats.groupMyMessages = (stats.groupMyMessages || 0) + 1
} }
@@ -3781,18 +3744,32 @@ class ChatService {
const messages: Message[] = [] const messages: Message[] = []
for (const row of rows) { for (const row of rows) {
const sourceInfo = this.getMessageSourceInfo(row) const sourceInfo = this.getMessageSourceInfo(row)
const rawMessageContent = row.message_content const rawMessageContent = this.getRowField(row, [
const rawCompressContent = row.compress_content 'message_content',
'messageContent',
'content',
'msg_content',
'msgContent',
'WCDB_CT_message_content',
'WCDB_CT_messageContent'
]);
const rawCompressContent = this.getRowField(row, [
'compress_content',
'compressContent',
'compressed_content',
'WCDB_CT_compress_content',
'WCDB_CT_compressContent'
]);
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent); const content = this.decodeMessageContent(rawMessageContent, rawCompressContent);
const localType = this.getRowInt(row, ['local_type'], 1) const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
const isSendRaw = row.computed_is_send ?? row.is_send const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
const parsedRawIsSend = isSendRaw === null ? null : parseInt(isSendRaw, 10) const parsedRawIsSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
const senderUsername = row.sender_username const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|| this.extractSenderUsernameFromContent(content) || this.extractSenderUsernameFromContent(content)
|| null || null
const { isSend } = this.resolveMessageIsSend(parsedRawIsSend, senderUsername) const { isSend } = this.resolveMessageIsSend(parsedRawIsSend, senderUsername)
const createTime = this.getRowInt(row, ['create_time'], 0) const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
if (senderUsername && !myWxid) { if (senderUsername && !myWxid) {
// [DEBUG] Issue #34: 未配置 myWxid无法判断是否发送 // [DEBUG] Issue #34: 未配置 myWxid无法判断是否发送
@@ -3819,7 +3796,6 @@ class ChatService {
let fileName: string | undefined let fileName: string | undefined
let fileSize: number | undefined let fileSize: number | undefined
let fileExt: string | undefined let fileExt: string | undefined
let fileMd5: string | undefined
let xmlType: string | undefined let xmlType: string | undefined
let appMsgKind: string | undefined let appMsgKind: string | undefined
let appMsgDesc: string | undefined let appMsgDesc: string | undefined
@@ -3924,7 +3900,6 @@ class ChatService {
fileName = type49Info.fileName fileName = type49Info.fileName
fileSize = type49Info.fileSize fileSize = type49Info.fileSize
fileExt = type49Info.fileExt fileExt = type49Info.fileExt
fileMd5 = type49Info.fileMd5
chatRecordTitle = type49Info.chatRecordTitle chatRecordTitle = type49Info.chatRecordTitle
chatRecordList = type49Info.chatRecordList chatRecordList = type49Info.chatRecordList
transferPayerUsername = type49Info.transferPayerUsername transferPayerUsername = type49Info.transferPayerUsername
@@ -3948,7 +3923,6 @@ class ChatService {
fileName = fileName || type49Info.fileName fileName = fileName || type49Info.fileName
fileSize = fileSize ?? type49Info.fileSize fileSize = fileSize ?? type49Info.fileSize
fileExt = fileExt || type49Info.fileExt fileExt = fileExt || type49Info.fileExt
fileMd5 = fileMd5 || type49Info.fileMd5
appMsgKind = appMsgKind || type49Info.appMsgKind appMsgKind = appMsgKind || type49Info.appMsgKind
appMsgDesc = appMsgDesc || type49Info.appMsgDesc appMsgDesc = appMsgDesc || type49Info.appMsgDesc
appMsgAppName = appMsgAppName || type49Info.appMsgAppName appMsgAppName = appMsgAppName || type49Info.appMsgAppName
@@ -3980,10 +3954,10 @@ class ChatService {
if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
} }
const localId = this.getRowInt(row, ['local_id'], 0) const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id) const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id']))
const serverId = this.getRowInt(row, ['server_id'], 0) const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0)
const sortSeq = this.getRowInt(row, ['sort_seq'], createTime) const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
messages.push({ messages.push({
messageKey: this.buildMessageKey({ messageKey: this.buildMessageKey({
@@ -4022,7 +3996,6 @@ class ChatService {
fileName, fileName,
fileSize, fileSize,
fileExt, fileExt,
fileMd5,
xmlType, xmlType,
appMsgKind, appMsgKind,
appMsgDesc, appMsgDesc,
@@ -4431,7 +4404,18 @@ class ChatService {
} }
private parseImageDatNameFromRow(row: Record<string, any>): string | undefined { private parseImageDatNameFromRow(row: Record<string, any>): string | undefined {
const packed = row.packed_info_data const packed = this.getRowField(row, [
'packed_info_data',
'packed_info',
'packedInfoData',
'packedInfo',
'PackedInfoData',
'PackedInfo',
'WCDB_CT_packed_info_data',
'WCDB_CT_packed_info',
'WCDB_CT_PackedInfoData',
'WCDB_CT_PackedInfo'
])
const buffer = this.decodePackedInfo(packed) const buffer = this.decodePackedInfo(packed)
if (!buffer || buffer.length === 0) return undefined if (!buffer || buffer.length === 0) return undefined
const printable: number[] = [] const printable: number[] = []
@@ -4615,7 +4599,6 @@ class ChatService {
fileName?: string fileName?: string
fileSize?: number fileSize?: number
fileExt?: string fileExt?: string
fileMd5?: string
transferPayerUsername?: string transferPayerUsername?: string
transferReceiverUsername?: string transferReceiverUsername?: string
chatRecordTitle?: string chatRecordTitle?: string
@@ -4812,7 +4795,6 @@ class ChatService {
// 提取文件扩展名 // 提取文件扩展名
const fileExt = this.extractXmlValue(content, 'fileext') const fileExt = this.extractXmlValue(content, 'fileext')
const fileMd5 = this.extractXmlValue(content, 'md5') || this.extractXmlValue(content, 'filemd5')
if (fileExt) { if (fileExt) {
result.fileExt = fileExt result.fileExt = fileExt
} else if (result.fileName) { } else if (result.fileName) {
@@ -4822,9 +4804,6 @@ class ChatService {
result.fileExt = match[1] result.fileExt = match[1]
} }
} }
if (fileMd5) {
result.fileMd5 = fileMd5.toLowerCase()
}
break break
} }
@@ -5324,14 +5303,14 @@ class ChatService {
row: Record<string, any>, row: Record<string, any>,
rawContent: string rawContent: string
): Promise<string | null> { ): Promise<string | null> {
const directSender = row.sender_username const directSender = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|| this.extractSenderUsernameFromContent(rawContent) || this.extractSenderUsernameFromContent(rawContent)
if (directSender) { if (directSender) {
return directSender return directSender
} }
const dbPath = row._db_path const dbPath = this.getRowField(row, ['db_path', 'dbPath', '_db_path'])
const realSenderId = row.real_sender_id const realSenderId = this.getRowField(row, ['real_sender_id', 'realSenderId'])
if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') { if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') {
return null return null
} }
@@ -5380,7 +5359,7 @@ class ChatService {
50: '[通话]', 50: '[通话]',
10000: '[系统消息]', 10000: '[系统消息]',
244813135921: '[引用消息]', 244813135921: '[引用消息]',
266287972401: '拍一拍', 266287972401: '[拍一拍]',
81604378673: '[聊天记录]', 81604378673: '[聊天记录]',
154618822705: '[小程序]', 154618822705: '[小程序]',
8594229559345: '[红包]', 8594229559345: '[红包]',
@@ -5489,7 +5468,7 @@ class ChatService {
* XML: <msg><appmsg...><title>"XX"拍了拍"XX"相信未来!</title>...</msg> * XML: <msg><appmsg...><title>"XX"拍了拍"XX"相信未来!</title>...</msg>
*/ */
private cleanPatMessage(content: string): string { private cleanPatMessage(content: string): string {
if (!content) return '拍一拍' if (!content) return '[拍一拍]'
// 1. 优先从 XML <title> 标签提取内容 // 1. 优先从 XML <title> 标签提取内容
const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content) const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content)
@@ -5499,14 +5478,14 @@ class ChatService {
.replace(/\]\]>/g, '') .replace(/\]\]>/g, '')
.trim() .trim()
if (title) { if (title) {
return title return `[拍一拍] ${title}`
} }
} }
// 2. 尝试匹配标准的 "A拍了拍B" 格式 // 2. 尝试匹配标准的 "A拍了拍B" 格式
const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content) const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content)
if (match) { if (match) {
return match[1].trim() return `[拍一拍] ${match[1].trim()}`
} }
// 3. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码) // 3. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码)
@@ -5520,10 +5499,10 @@ class ChatService {
// 如果清理后还有内容,返回 // 如果清理后还有内容,返回
if (cleaned && cleaned.length > 1 && !cleaned.includes('xml')) { if (cleaned && cleaned.length > 1 && !cleaned.includes('xml')) {
return cleaned return `[拍一拍] ${cleaned}`
} }
return '拍一拍' return '[拍一拍]'
} }
/** /**
@@ -7541,7 +7520,11 @@ class ChatService {
for (const row of result.messages) { for (const row of result.messages) {
let message = await this.parseMessage(row, { source: 'search', sessionId }) let message = await this.parseMessage(row, { source: 'search', sessionId })
const resolvedSessionId = String(sessionId || row._session_id || '').trim() const resolvedSessionId = String(
sessionId ||
this.getRowField(row, ['_session_id', 'session_id', 'sessionId', 'talker', 'username'])
|| ''
).trim()
const needsDetailHydration = isGroupSearch && const needsDetailHydration = isGroupSearch &&
Boolean(sessionId) && Boolean(sessionId) &&
message.localId > 0 && message.localId > 0 &&
@@ -7576,18 +7559,32 @@ class ChatService {
private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise<Message> { private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise<Message> {
const sourceInfo = this.getMessageSourceInfo(row) const sourceInfo = this.getMessageSourceInfo(row)
const rawContent = this.decodeMessageContent( const rawContent = this.decodeMessageContent(
row.message_content, this.getRowField(row, [
row.compress_content 'message_content',
'messageContent',
'content',
'msg_content',
'msgContent',
'WCDB_CT_message_content',
'WCDB_CT_messageContent'
]),
this.getRowField(row, [
'compress_content',
'compressContent',
'compressed_content',
'WCDB_CT_compress_content',
'WCDB_CT_compressContent'
])
) )
// 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的 // 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的
// 实际项目中建议抽取 parseRawMessage(row) 供多处使用 // 实际项目中建议抽取 parseRawMessage(row) 供多处使用
const localId = this.getRowInt(row, ['local_id'], 0) const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id) const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id']))
const serverId = this.getRowInt(row, ['server_id'], 0) const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0)
const localType = this.getRowInt(row, ['local_type'], 0) const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
const createTime = this.getRowInt(row, ['create_time'], 0) const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
const sortSeq = this.getRowInt(row, ['sort_seq'], createTime) const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
const rawIsSend = row.computed_is_send ?? row.is_send const rawIsSend = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
const senderUsername = await this.resolveSenderUsernameForMessageRow(row, rawContent) const senderUsername = await this.resolveSenderUsernameForMessageRow(row, rawContent)
const sendState = this.resolveMessageIsSend(rawIsSend === null ? null : parseInt(rawIsSend, 10), senderUsername) const sendState = this.resolveMessageIsSend(rawIsSend === null ? null : parseInt(rawIsSend, 10), senderUsername)
const msg: Message = { const msg: Message = {
@@ -7615,8 +7612,8 @@ class ChatService {
} }
if (msg.localId === 0 || msg.createTime === 0) { if (msg.localId === 0 || msg.createTime === 0) {
const rawLocalId = row.local_id const rawLocalId = this.getRowField(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'])
const rawCreateTime = row.create_time const rawCreateTime = this.getRowField(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'])
console.warn('[ChatService] parseMessage raw keys', { console.warn('[ChatService] parseMessage raw keys', {
rawLocalId, rawLocalId,
rawLocalIdType: rawLocalId ? typeof rawLocalId : 'null', rawLocalIdType: rawLocalId ? typeof rawLocalId : 'null',

View File

@@ -61,7 +61,6 @@ interface ConfigSchema {
windowCloseBehavior: 'ask' | 'tray' | 'quit' windowCloseBehavior: 'ask' | 'tray' | 'quit'
quoteLayout: 'quote-top' | 'quote-bottom' quoteLayout: 'quote-top' | 'quote-bottom'
wordCloudExcludeWords: string[] wordCloudExcludeWords: string[]
exportWriteLayout: 'A' | 'B' | 'C'
} }
// 需要 safeStorage 加密的字段(普通模式) // 需要 safeStorage 加密的字段(普通模式)
@@ -134,8 +133,7 @@ export class ConfigService {
messagePushEnabled: false, messagePushEnabled: false,
windowCloseBehavior: 'ask', windowCloseBehavior: 'ask',
quoteLayout: 'quote-top', quoteLayout: 'quote-top',
wordCloudExcludeWords: [], wordCloudExcludeWords: []
exportWriteLayout: 'A'
} }
const storeOptions: any = { const storeOptions: any = {

View File

@@ -98,8 +98,6 @@ export interface ExportOptions {
exportVoices?: boolean exportVoices?: boolean
exportVideos?: boolean exportVideos?: boolean
exportEmojis?: boolean exportEmojis?: boolean
exportFiles?: boolean
maxFileSizeMb?: number
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
excelCompactColumns?: boolean excelCompactColumns?: boolean
txtColumns?: string[] txtColumns?: string[]
@@ -123,7 +121,7 @@ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
interface MediaExportItem { interface MediaExportItem {
relativePath: string relativePath: string
kind: 'image' | 'voice' | 'emoji' | 'video' | 'file' kind: 'image' | 'voice' | 'emoji' | 'video'
posterDataUrl?: string posterDataUrl?: string
} }
@@ -138,11 +136,6 @@ interface ExportDisplayProfile {
type MessageCollectMode = 'full' | 'text-fast' | 'media-fast' type MessageCollectMode = 'full' | 'text-fast' | 'media-fast'
type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' type MediaContentType = 'voice' | 'image' | 'video' | 'emoji'
interface FileExportCandidate {
sourcePath: string
matchedBy: 'md5' | 'name'
yearMonth?: string
}
export interface ExportProgress { export interface ExportProgress {
current: number current: number
@@ -437,8 +430,6 @@ class ExportService {
let lastSessionId = '' let lastSessionId = ''
let lastCollected = 0 let lastCollected = 0
let lastExported = 0 let lastExported = 0
const MIN_PROGRESS_EMIT_INTERVAL_MS = 250
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 500
const commit = (progress: ExportProgress) => { const commit = (progress: ExportProgress) => {
onProgress(progress) onProgress(progress)
@@ -463,9 +454,9 @@ class ExportService {
const shouldEmit = force || const shouldEmit = force ||
phase !== lastPhase || phase !== lastPhase ||
sessionId !== lastSessionId || sessionId !== lastSessionId ||
collectedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD || collectedDelta >= 200 ||
exportedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD || exportedDelta >= 200 ||
(now - lastSentAt >= MIN_PROGRESS_EMIT_INTERVAL_MS) (now - lastSentAt >= 120)
if (shouldEmit && pending) { if (shouldEmit && pending) {
commit(pending) commit(pending)
@@ -851,7 +842,7 @@ class ExportService {
private isMediaExportEnabled(options: ExportOptions): boolean { private isMediaExportEnabled(options: ExportOptions): boolean {
return options.exportMedia === true && return options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles) Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
} }
private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean { private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean {
@@ -889,7 +880,7 @@ class ExportService {
if (options.exportImages) selected.add(3) if (options.exportImages) selected.add(3)
if (options.exportVoices) selected.add(34) if (options.exportVoices) selected.add(34)
if (options.exportVideos) selected.add(43) if (options.exportVideos) selected.add(43)
if (options.exportFiles) selected.add(49) if (options.exportEmojis) selected.add(47)
return selected return selected
} }
@@ -3425,8 +3416,6 @@ class ExportService {
exportVoices?: boolean exportVoices?: boolean
exportVideos?: boolean exportVideos?: boolean
exportEmojis?: boolean exportEmojis?: boolean
exportFiles?: boolean
maxFileSizeMb?: number
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
includeVideoPoster?: boolean includeVideoPoster?: boolean
includeVoiceWithTranscript?: boolean includeVoiceWithTranscript?: boolean
@@ -3480,16 +3469,6 @@ class ExportService {
) )
} }
if ((localType === 49 || localType === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') {
return this.exportFileAttachment(
msg,
mediaRootDir,
mediaRelativePrefix,
options.maxFileSizeMb,
options.dirCache
)
}
return null return null
} }
@@ -3558,20 +3537,29 @@ class ExportService {
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
result.localPath = thumbResult.localPath result.localPath = thumbResult.localPath
} else { } else {
console.log(`[Export] 缩略图也获取失败,所有方式均失败 → 将显示 [图片] 占位符`) console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'}`)
// 最后尝试:直接从 imageStore 获取缓存的缩略图 data URL
const { imageStore } = await import('../main')
const cachedThumb = imageStore?.getCachedImage(sessionId, imageMd5, imageDatName)
if (cachedThumb) {
console.log(`[Export] 从 imageStore 获取到缓存缩略图 (localId=${msg.localId})`)
result.localPath = cachedThumb
} else {
console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`)
if (missingRunCacheKey) { if (missingRunCacheKey) {
this.mediaRunMissingImageKeys.add(missingRunCacheKey) this.mediaRunMissingImageKeys.add(missingRunCacheKey)
} }
return null return null
} }
} }
}
// 为每条消息生成稳定且唯一的文件名前缀,避免跨日期/消息发生同名覆盖 // 为每条消息生成稳定且唯一的文件名前缀,避免跨日期/消息发生同名覆盖
const messageId = String(msg.localId || Date.now()) const messageId = String(msg.localId || Date.now())
const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '') const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '')
// 从 data URL 或 file URL 获取实际路径 // 从 data URL 或 file URL 获取实际路径
let sourcePath: string = result.localPath! let sourcePath = result.localPath
if (sourcePath.startsWith('data:')) { if (sourcePath.startsWith('data:')) {
// 是 data URL需要保存为文件 // 是 data URL需要保存为文件
const base64Data = sourcePath.split(',')[1] const base64Data = sourcePath.split(',')[1]
@@ -3951,165 +3939,6 @@ class ExportService {
return tagMatch?.[1]?.toLowerCase() return tagMatch?.[1]?.toLowerCase()
} }
private resolveFileAttachmentRoots(): string[] {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const rawWxid = String(this.configService.get('myWxid') || '').trim()
const cleanedWxid = this.cleanAccountDirName(rawWxid)
if (!dbPath) return []
const normalized = dbPath.replace(/[\\/]+$/, '')
const roots = new Set<string>()
const tryAddRoot = (candidate: string) => {
const fileRoot = path.join(candidate, 'msg', 'file')
if (fs.existsSync(fileRoot)) {
roots.add(fileRoot)
}
}
tryAddRoot(normalized)
if (rawWxid) tryAddRoot(path.join(normalized, rawWxid))
if (cleanedWxid && cleanedWxid !== rawWxid) tryAddRoot(path.join(normalized, cleanedWxid))
const dbStoragePath =
this.resolveDbStoragePathForExport(normalized, cleanedWxid) ||
this.resolveDbStoragePathForExport(normalized, rawWxid)
if (dbStoragePath) {
tryAddRoot(path.dirname(dbStoragePath))
}
return Array.from(roots)
}
private buildPreferredFileYearMonths(createTime?: unknown): string[] {
const raw = Number(createTime)
if (!Number.isFinite(raw) || raw <= 0) return []
const ts = raw > 1e12 ? raw : raw * 1000
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return []
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
return [`${y}-${m}`]
}
private async verifyFileHash(sourcePath: string, expectedMd5?: string): Promise<boolean> {
const normalizedExpected = String(expectedMd5 || '').trim().toLowerCase()
if (!normalizedExpected) return true
if (!/^[a-f0-9]{32}$/i.test(normalizedExpected)) return true
try {
const hash = crypto.createHash('md5')
await new Promise<void>((resolve, reject) => {
const stream = fs.createReadStream(sourcePath)
stream.on('data', chunk => hash.update(chunk))
stream.on('end', () => resolve())
stream.on('error', reject)
})
return hash.digest('hex').toLowerCase() === normalizedExpected
} catch {
return false
}
}
private async resolveFileAttachmentCandidates(msg: any): Promise<FileExportCandidate[]> {
const fileName = String(msg?.fileName || '').trim()
if (!fileName) return []
const roots = this.resolveFileAttachmentRoots()
if (roots.length === 0) return []
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
const preferredMonths = this.buildPreferredFileYearMonths(msg?.createTime)
const candidates: FileExportCandidate[] = []
const seen = new Set<string>()
for (const root of roots) {
let monthDirs: string[] = []
try {
monthDirs = fs.readdirSync(root)
.filter(entry => /^\d{4}-\d{2}$/.test(entry) && fs.existsSync(path.join(root, entry)))
.sort()
} catch {
continue
}
const orderedMonths = Array.from(new Set([
...preferredMonths,
...monthDirs.slice().reverse()
]))
for (const month of orderedMonths) {
const sourcePath = path.join(root, month, fileName)
if (!fs.existsSync(sourcePath)) continue
const resolvedPath = path.resolve(sourcePath)
if (seen.has(resolvedPath)) continue
seen.add(resolvedPath)
if (normalizedMd5) {
const ok = await this.verifyFileHash(resolvedPath, normalizedMd5)
if (ok) {
candidates.unshift({ sourcePath: resolvedPath, matchedBy: 'md5', yearMonth: month })
continue
}
}
candidates.push({ sourcePath: resolvedPath, matchedBy: 'name', yearMonth: month })
}
}
return candidates
}
private async exportFileAttachment(
msg: any,
mediaRootDir: string,
mediaRelativePrefix: string,
maxFileSizeMb?: number,
dirCache?: Set<string>
): Promise<MediaExportItem | null> {
try {
const fileNameRaw = String(msg?.fileName || '').trim()
if (!fileNameRaw) return null
const filesDir = path.join(mediaRootDir, mediaRelativePrefix, 'files')
if (!dirCache?.has(filesDir)) {
await fs.promises.mkdir(filesDir, { recursive: true })
dirCache?.add(filesDir)
}
const candidates = await this.resolveFileAttachmentCandidates(msg)
if (candidates.length === 0) return null
const maxBytes = Number.isFinite(maxFileSizeMb)
? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024))
: 0
const selected = candidates[0]
const stat = await fs.promises.stat(selected.sourcePath)
if (!stat.isFile()) return null
if (maxBytes > 0 && stat.size > maxBytes) return null
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
if (normalizedMd5 && selected.matchedBy !== 'md5') {
const verified = await this.verifyFileHash(selected.sourcePath, normalizedMd5)
if (!verified) return null
}
const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file'
const messageId = String(msg?.localId || Date.now())
const destFileName = `${messageId}_${safeBaseName}`
const destPath = path.join(filesDir, destFileName)
const copied = await this.copyFileOptimized(selected.sourcePath, destPath)
if (!copied.success) return null
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
return {
relativePath: path.posix.join(mediaRelativePrefix, 'files', destFileName),
kind: 'file'
}
} catch {
return null
}
}
private extractLocationMeta(content: string, localType: number): { private extractLocationMeta(content: string, localType: number): {
locationLat?: number locationLat?: number
locationLng?: number locationLng?: number
@@ -4166,7 +3995,7 @@ class ExportService {
mediaRelativePrefix: string mediaRelativePrefix: string
} { } {
const exportMediaEnabled = options.exportMedia === true && const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles) Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
const outputDir = path.dirname(outputPath) const outputDir = path.dirname(outputPath)
const rawWriteLayout = this.configService.get('exportWriteLayout') const rawWriteLayout = this.configService.get('exportWriteLayout')
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
@@ -5103,8 +4932,7 @@ class ExportService {
return (t === 3 && options.exportImages) || // 图片 return (t === 3 && options.exportImages) || // 图片
(t === 47 && options.exportEmojis) || // 表情 (t === 47 && options.exportEmojis) || // 表情
(t === 43 && options.exportVideos) || // 视频 (t === 43 && options.exportVideos) || // 视频
(t === 34 && options.exportVoices) || // 语音文件 (t === 34 && options.exportVoices) // 语音文件
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
}) })
: [] : []
@@ -5145,8 +4973,6 @@ class ExportService {
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportVideos: options.exportVideos, exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
@@ -5615,8 +5441,7 @@ class ExportService {
return (t === 3 && options.exportImages) || return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) || (t === 47 && options.exportEmojis) ||
(t === 43 && options.exportVideos) || (t === 43 && options.exportVideos) ||
(t === 34 && options.exportVoices) || (t === 34 && options.exportVoices)
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
}) })
: [] : []
@@ -5656,8 +5481,6 @@ class ExportService {
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportVideos: options.exportVideos, exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
@@ -6478,8 +6301,7 @@ class ExportService {
return (t === 3 && options.exportImages) || return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) || (t === 47 && options.exportEmojis) ||
(t === 43 && options.exportVideos) || (t === 43 && options.exportVideos) ||
(t === 34 && options.exportVoices) || (t === 34 && options.exportVoices)
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
}) })
: [] : []
@@ -6519,8 +6341,6 @@ class ExportService {
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportVideos: options.exportVideos, exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
@@ -7194,8 +7014,7 @@ class ExportService {
return (t === 3 && options.exportImages) || return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) || (t === 47 && options.exportEmojis) ||
(t === 43 && options.exportVideos) || (t === 43 && options.exportVideos) ||
(t === 34 && options.exportVoices) || (t === 34 && options.exportVoices)
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
}) })
: [] : []
@@ -7235,8 +7054,6 @@ class ExportService {
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportVideos: options.exportVideos, exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
@@ -7574,8 +7391,7 @@ class ExportService {
return (t === 3 && options.exportImages) || return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) || (t === 47 && options.exportEmojis) ||
(t === 43 && options.exportVideos) || (t === 43 && options.exportVideos) ||
(t === 34 && options.exportVoices) || (t === 34 && options.exportVoices)
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
}) })
: [] : []
@@ -7615,8 +7431,6 @@ class ExportService {
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportVideos: options.exportVideos, exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
@@ -8037,8 +7851,6 @@ class ExportService {
exportImages: options.exportImages, exportImages: options.exportImages,
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
includeVoiceWithTranscript: true, includeVoiceWithTranscript: true,
@@ -8577,22 +8389,22 @@ class ExportService {
const metric = aggregatedData?.[sessionId] const metric = aggregatedData?.[sessionId]
const totalCount = Number.isFinite(metric?.totalMessages) const totalCount = Number.isFinite(metric?.totalMessages)
? Math.max(0, Math.floor(metric?.totalMessages ?? 0)) ? Math.max(0, Math.floor(metric!.totalMessages))
: 0 : 0
const voiceCount = Number.isFinite(metric?.voiceMessages) const voiceCount = Number.isFinite(metric?.voiceMessages)
? Math.max(0, Math.floor(metric?.voiceMessages ?? 0)) ? Math.max(0, Math.floor(metric!.voiceMessages))
: 0 : 0
const imageCount = Number.isFinite(metric?.imageMessages) const imageCount = Number.isFinite(metric?.imageMessages)
? Math.max(0, Math.floor(metric?.imageMessages ?? 0)) ? Math.max(0, Math.floor(metric!.imageMessages))
: 0 : 0
const videoCount = Number.isFinite(metric?.videoMessages) const videoCount = Number.isFinite(metric?.videoMessages)
? Math.max(0, Math.floor(metric?.videoMessages ?? 0)) ? Math.max(0, Math.floor(metric!.videoMessages))
: 0 : 0
const emojiCount = Number.isFinite(metric?.emojiMessages) const emojiCount = Number.isFinite(metric?.emojiMessages)
? Math.max(0, Math.floor(metric?.emojiMessages ?? 0)) ? Math.max(0, Math.floor(metric!.emojiMessages))
: 0 : 0
const lastTimestamp = Number.isFinite(metric?.lastTimestamp) const lastTimestamp = Number.isFinite(metric?.lastTimestamp)
? Math.max(0, Math.floor(metric?.lastTimestamp ?? 0)) ? Math.max(0, Math.floor(metric!.lastTimestamp))
: undefined : undefined
const cachedCountRaw = Number(cachedVoiceCountMap[sessionId] || 0) const cachedCountRaw = Number(cachedVoiceCountMap[sessionId] || 0)
const sessionCachedVoiceCount = Math.min( const sessionCachedVoiceCount = Math.min(

View File

@@ -1,4 +1,4 @@
import { join, dirname, basename } from 'path' import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
import { tmpdir } from 'os' import { tmpdir } from 'os'
@@ -92,9 +92,6 @@ export class WcdbCore {
private wcdbResolveImageHardlinkBatch: any = null private wcdbResolveImageHardlinkBatch: any = null
private wcdbResolveVideoHardlinkMd5: any = null private wcdbResolveVideoHardlinkMd5: any = null
private wcdbResolveVideoHardlinkMd5Batch: any = null private wcdbResolveVideoHardlinkMd5Batch: any = null
private wcdbInstallMessageAntiRevokeTrigger: any = null
private wcdbUninstallMessageAntiRevokeTrigger: any = null
private wcdbCheckMessageAntiRevokeTrigger: any = null
private wcdbInstallSnsBlockDeleteTrigger: any = null private wcdbInstallSnsBlockDeleteTrigger: any = null
private wcdbUninstallSnsBlockDeleteTrigger: any = null private wcdbUninstallSnsBlockDeleteTrigger: any = null
private wcdbCheckSnsBlockDeleteTrigger: any = null private wcdbCheckSnsBlockDeleteTrigger: any = null
@@ -316,7 +313,7 @@ export class WcdbCore {
'-2302': 'WCDB 初始化异常,请重试', '-2302': 'WCDB 初始化异常,请重试',
'-2303': 'WCDB 未能成功初始化', '-2303': 'WCDB 未能成功初始化',
} }
const msg = messages[String(code) as unknown as keyof typeof messages] const msg = messages[String(code) as keyof typeof messages]
return msg ? `${msg} (错误码: ${code})` : `操作失败,错误码: ${code}` return msg ? `${msg} (错误码: ${code})` : `操作失败,错误码: ${code}`
} }
@@ -1080,27 +1077,6 @@ export class WcdbCore {
this.wcdbResolveVideoHardlinkMd5Batch = null this.wcdbResolveVideoHardlinkMd5Batch = null
} }
// wcdb_status wcdb_install_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, char** out_error)
try {
this.wcdbInstallMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_install_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ void** outError)')
} catch {
this.wcdbInstallMessageAntiRevokeTrigger = null
}
// wcdb_status wcdb_uninstall_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, char** out_error)
try {
this.wcdbUninstallMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_uninstall_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ void** outError)')
} catch {
this.wcdbUninstallMessageAntiRevokeTrigger = null
}
// wcdb_status wcdb_check_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, int32_t* out_installed)
try {
this.wcdbCheckMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_check_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ int32* outInstalled)')
} catch {
this.wcdbCheckMessageAntiRevokeTrigger = null
}
// wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error) // wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
try { try {
this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)') this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)')
@@ -1361,12 +1337,12 @@ export class WcdbCore {
const raw = String(jsonStr || '') const raw = String(jsonStr || '')
if (!raw) return [] if (!raw) return []
// 热路径优化:仅在检测到 16+ 位整数字段时才进行字符串包裹,避免每批次多轮全量 replace。 // 热路径优化:仅在检测到 16+ 位整数字段时才进行字符串包裹,避免每批次多轮全量 replace。
const needsInt64Normalize = /"server_id"\s*:\s*-?\d{16,}/.test(raw) const needsInt64Normalize = /"(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*-?\d{16,}/.test(raw)
if (!needsInt64Normalize) { if (!needsInt64Normalize) {
return JSON.parse(raw) return JSON.parse(raw)
} }
const normalized = raw.replace( const normalized = raw.replace(
/("server_id"\s*:\s*)(-?\d{16,})/g, /("(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*)(-?\d{16,})/g,
'$1"$2"' '$1"$2"'
) )
return JSON.parse(normalized) return JSON.parse(normalized)
@@ -1679,9 +1655,6 @@ export class WcdbCore {
const outCount = [0] const outCount = [0]
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount) const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
if (result !== 0) { if (result !== 0) {
if (result === -7) {
return { success: false, error: 'message schema mismatch当前账号消息表结构与程序要求不一致' }
}
return { success: false, error: `获取消息总数失败: ${result}` } return { success: false, error: `获取消息总数失败: ${result}` }
} }
return { success: true, count: outCount[0] } return { success: true, count: outCount[0] }
@@ -1712,9 +1685,6 @@ export class WcdbCore {
const sessionId = normalizedSessionIds[i] const sessionId = normalizedSessionIds[i]
const outCount = [0] const outCount = [0]
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount) const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
if (result === -7) {
return { success: false, error: `message schema mismatch会话 ${sessionId} 的消息表结构不匹配` }
}
counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0 counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0
if (i > 0 && i % 160 === 0) { if (i > 0 && i % 160 === 0) {
@@ -1734,9 +1704,6 @@ export class WcdbCore {
const outPtr = [null as any] const outPtr = [null as any]
const result = this.wcdbGetSessionMessageCounts(this.handle, JSON.stringify(sessionIds || []), outPtr) const result = this.wcdbGetSessionMessageCounts(this.handle, JSON.stringify(sessionIds || []), outPtr)
if (result !== 0 || !outPtr[0]) { if (result !== 0 || !outPtr[0]) {
if (result === -7) {
return { success: false, error: 'message schema mismatch当前账号消息表结构与程序要求不一致' }
}
return { success: false, error: `获取会话消息总数失败: ${result}` } return { success: false, error: `获取会话消息总数失败: ${result}` }
} }
const jsonStr = this.decodeJsonPtr(outPtr[0]) const jsonStr = this.decodeJsonPtr(outPtr[0])
@@ -2694,8 +2661,6 @@ export class WcdbCore {
) )
const hint = result === -3 const hint = result === -3
? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试` ? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试`
: result === -7
? 'message schema mismatch当前账号消息表结构与程序要求不一致'
: `创建游标失败: ${result},请查看日志` : `创建游标失败: ${result},请查看日志`
return { success: false, error: hint } return { success: false, error: hint }
} }
@@ -2754,9 +2719,6 @@ export class WcdbCore {
`openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`, `openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
true true
) )
if (result === -7) {
return { success: false, error: 'message schema mismatch当前账号消息表结构与程序要求不一致' }
}
return { success: false, error: `创建游标失败: ${result},请查看日志` } return { success: false, error: `创建游标失败: ${result},请查看日志` }
} }
return { success: true, cursor: outCursor[0] } return { success: true, cursor: outCursor[0] }
@@ -3519,122 +3481,6 @@ export class WcdbCore {
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
} }
async installMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbInstallMessageAntiRevokeTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' }
try {
const outPtr = [null]
const status = this.wcdbInstallMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outPtr)
let msg = ''
if (outPtr[0]) {
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
try { this.wcdbFreeString(outPtr[0]) } catch { }
}
if (status === 1) {
return { success: true, alreadyInstalled: true }
}
if (status !== 0) {
return { success: false, error: msg || `DLL error ${status}` }
}
return { success: true, alreadyInstalled: false }
} catch (e) {
return { success: false, error: String(e) }
}
}
async uninstallMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbUninstallMessageAntiRevokeTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' }
try {
const outPtr = [null]
const status = this.wcdbUninstallMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outPtr)
let msg = ''
if (outPtr[0]) {
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
try { this.wcdbFreeString(outPtr[0]) } catch { }
}
if (status !== 0) {
return { success: false, error: msg || `DLL error ${status}` }
}
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
async checkMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; installed?: boolean; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbCheckMessageAntiRevokeTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' }
try {
const outInstalled = [0]
const status = this.wcdbCheckMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outInstalled)
if (status !== 0) {
return { success: false, error: `DLL error ${status}` }
}
return { success: true, installed: outInstalled[0] === 1 }
} catch (e) {
return { success: false, error: String(e) }
}
}
async checkMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>
error?: string
}> {
if (!Array.isArray(sessionIds) || sessionIds.length === 0) {
return { success: true, rows: [] }
}
const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean)))
const rows: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }> = []
for (const sessionId of uniqueIds) {
const result = await this.checkMessageAntiRevokeTrigger(sessionId)
rows.push({ sessionId, success: result.success, installed: result.installed, error: result.error })
}
return { success: true, rows }
}
async installMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>
error?: string
}> {
if (!Array.isArray(sessionIds) || sessionIds.length === 0) {
return { success: true, rows: [] }
}
const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean)))
const rows: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }> = []
for (const sessionId of uniqueIds) {
const result = await this.installMessageAntiRevokeTrigger(sessionId)
rows.push({ sessionId, success: result.success, alreadyInstalled: result.alreadyInstalled, error: result.error })
}
return { success: true, rows }
}
async uninstallMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; error?: string }>
error?: string
}> {
if (!Array.isArray(sessionIds) || sessionIds.length === 0) {
return { success: true, rows: [] }
}
const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean)))
const rows: Array<{ sessionId: string; success: boolean; error?: string }> = []
for (const sessionId of uniqueIds) {
const result = await this.uninstallMessageAntiRevokeTrigger(sessionId)
rows.push({ sessionId, success: result.success, error: result.error })
}
return { success: true, rows }
}
/** /**
* 为朋友圈安装删除 * 为朋友圈安装删除
*/ */

View File

@@ -561,24 +561,6 @@ export class WcdbService {
return this.callWorker('getSnsExportStats', { myWxid }) return this.callWorker('getSnsExportStats', { myWxid })
} }
async checkMessageAntiRevokeTriggers(
sessionIds: string[]
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>; error?: string }> {
return this.callWorker('checkMessageAntiRevokeTriggers', { sessionIds })
}
async installMessageAntiRevokeTriggers(
sessionIds: string[]
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>; error?: string }> {
return this.callWorker('installMessageAntiRevokeTriggers', { sessionIds })
}
async uninstallMessageAntiRevokeTriggers(
sessionIds: string[]
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; error?: string }>; error?: string }> {
return this.callWorker('uninstallMessageAntiRevokeTriggers', { sessionIds })
}
/** /**
* 安装朋友圈删除拦截 * 安装朋友圈删除拦截
*/ */

View File

@@ -230,15 +230,6 @@ if (parentPort) {
case 'getSnsExportStats': case 'getSnsExportStats':
result = await core.getSnsExportStats(payload.myWxid) result = await core.getSnsExportStats(payload.myWxid)
break break
case 'checkMessageAntiRevokeTriggers':
result = await core.checkMessageAntiRevokeTriggers(payload.sessionIds)
break
case 'installMessageAntiRevokeTriggers':
result = await core.installMessageAntiRevokeTriggers(payload.sessionIds)
break
case 'uninstallMessageAntiRevokeTriggers':
result = await core.uninstallMessageAntiRevokeTriggers(payload.sessionIds)
break
case 'installSnsBlockDeleteTrigger': case 'installSnsBlockDeleteTrigger':
result = await core.installSnsBlockDeleteTrigger() result = await core.installSnsBlockDeleteTrigger()
break break

8
package-lock.json generated
View File

@@ -40,7 +40,7 @@
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"electron": "^41.1.1", "electron": "^41.1.1",
"electron-builder": "^26.8.1", "electron-builder": "^26.8.1",
"sass": "^1.98.0", "sass": "^1.99.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^7.0.0", "vite": "^7.0.0",
@@ -8908,9 +8908,9 @@
} }
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz",
"integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -54,7 +54,7 @@
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"electron": "^41.1.1", "electron": "^41.1.1",
"electron-builder": "^26.8.1", "electron-builder": "^26.8.1",
"sass": "^1.98.0", "sass": "^1.99.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^7.0.0", "vite": "^7.0.0",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -591,13 +591,9 @@ function App() {
<div className="agreement-notice"> <div className="agreement-notice">
<strong></strong> <strong></strong>
<span className="agreement-notice-link"> <span className="agreement-notice-link">
<a href="https://weflow.top" target="_blank" rel="noreferrer">
https://weflow.top
</a>
&nbsp;·&nbsp;
<a href="https://github.com/hicccc77/WeFlow" target="_blank" rel="noreferrer"> <a href="https://github.com/hicccc77/WeFlow" target="_blank" rel="noreferrer">
GitHub https://github.com/hicccc77/WeFlow
</a> </a>
</span> </span>
</div> </div>
@@ -612,7 +608,7 @@ function App() {
<p>使使</p> <p>使使</p>
<h4>4. </h4> <h4>4. </h4>
<p></p> <p></p>
</div> </div>
</div> </div>
<div className="agreement-footer"> <div className="agreement-footer">

View File

@@ -66,8 +66,7 @@ export function ExportDefaultsSettingsForm({
images: true, images: true,
videos: true, videos: true,
voices: true, voices: true,
emojis: true, emojis: true
files: true
}) })
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
@@ -95,8 +94,7 @@ export function ExportDefaultsSettingsForm({
images: true, images: true,
videos: true, videos: true,
voices: true, voices: true,
emojis: true, emojis: true
files: true
}) })
setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
@@ -294,7 +292,7 @@ export function ExportDefaultsSettingsForm({
<div className="form-group media-setting-group"> <div className="form-group media-setting-group">
<div className="form-copy"> <div className="form-copy">
<label></label> <label></label>
<span className="form-hint"></span> <span className="form-hint"></span>
</div> </div>
<div className="form-control"> <div className="form-control">
<div className="media-default-grid"> <div className="media-default-grid">
@@ -354,20 +352,6 @@ export function ExportDefaultsSettingsForm({
/> />
</label> </label>
<label>
<input
type="checkbox"
checked={exportDefaultMedia.files}
onChange={async (e) => {
const next = { ...exportDefaultMedia, files: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`${e.target.checked ? '开启' : '关闭'}默认导出文件`, true)
}}
/>
</label>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2127,24 +2127,6 @@
display: block; display: block;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
transition: opacity 0.18s ease;
}
.image-message.pending {
opacity: 0;
}
.image-message.ready {
opacity: 1;
}
.image-stage {
display: inline-block;
-webkit-app-region: no-drag;
}
.image-stage.locked {
overflow: hidden;
} }
.image-message-wrapper { .image-message-wrapper {

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
@@ -64,9 +64,6 @@ const GLOBAL_MSG_LEGACY_CONCURRENCY = 6
const GLOBAL_MSG_SEARCH_CANCELED_ERROR = '__WEFLOW_GLOBAL_MSG_SEARCH_CANCELED__' const GLOBAL_MSG_SEARCH_CANCELED_ERROR = '__WEFLOW_GLOBAL_MSG_SEARCH_CANCELED__'
const GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE = 0.2 const GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE = 0.2
const GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY = 'weflow.debug.searchShadowCompare' const GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY = 'weflow.debug.searchShadowCompare'
const MESSAGE_LIST_SCROLL_IDLE_MS = 160
const MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS = 160
const MESSAGE_EDGE_TRIGGER_DISTANCE_PX = 96
function isGlobalMsgSearchCanceled(error: unknown): boolean { function isGlobalMsgSearchCanceled(error: unknown): boolean {
return String(error || '') === GLOBAL_MSG_SEARCH_CANCELED_ERROR return String(error || '') === GLOBAL_MSG_SEARCH_CANCELED_ERROR
@@ -213,12 +210,6 @@ function sortMessagesByCreateTimeDesc<T extends Pick<Message, 'createTime' | 'lo
}) })
} }
function isRenderableImageSrc(value?: string | null): boolean {
const src = String(value || '').trim()
if (!src) return false
return /^(https?:\/\/|data:image\/|blob:|file:\/\/|\/)/i.test(src)
}
function normalizeSearchIdentityText(value?: string | null): string | undefined { function normalizeSearchIdentityText(value?: string | null): string | undefined {
const normalized = String(value || '').trim() const normalized = String(value || '').trim()
if (!normalized) return undefined if (!normalized) return undefined
@@ -1188,12 +1179,7 @@ function ChatPage(props: ChatPageProps) {
const visibleMessageRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: 0 }) const visibleMessageRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: 0 })
const topRangeLoadLockRef = useRef(false) const topRangeLoadLockRef = useRef(false)
const bottomRangeLoadLockRef = useRef(false) const bottomRangeLoadLockRef = useRef(false)
const topRangeLoadLastTriggerAtRef = useRef(0)
const suppressAutoLoadLaterRef = useRef(false) const suppressAutoLoadLaterRef = useRef(false)
const suppressAutoScrollOnNextMessageGrowthRef = useRef(false)
const prependingHistoryRef = useRef(false)
const isMessageListScrollingRef = useRef(false)
const messageListScrollTimeoutRef = useRef<number | null>(null)
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
const sidebarRef = useRef<HTMLDivElement>(null) const sidebarRef = useRef<HTMLDivElement>(null)
const handleMessageListScrollParentRef = useCallback((node: HTMLDivElement | null) => { const handleMessageListScrollParentRef = useCallback((node: HTMLDivElement | null) => {
@@ -1414,18 +1400,6 @@ function ChatPage(props: ChatPageProps) {
}, delayMs) }, delayMs)
}, []) }, [])
const markMessageListScrolling = useCallback(() => {
isMessageListScrollingRef.current = true
if (messageListScrollTimeoutRef.current !== null) {
window.clearTimeout(messageListScrollTimeoutRef.current)
messageListScrollTimeoutRef.current = null
}
messageListScrollTimeoutRef.current = window.setTimeout(() => {
isMessageListScrollingRef.current = false
messageListScrollTimeoutRef.current = null
}, MESSAGE_LIST_SCROLL_IDLE_MS)
}, [])
const isGroupChatSession = useCallback((username: string) => { const isGroupChatSession = useCallback((username: string) => {
return username.includes('@chatroom') return username.includes('@chatroom')
}, []) }, [])
@@ -3272,29 +3246,6 @@ function ChatPage(props: ChatPageProps) {
runWarmup() runWarmup()
}, [loadContactInfoBatch]) }, [loadContactInfoBatch])
const scheduleGroupSenderWarmup = useCallback((usernames: string[], defer = false) => {
if (!Array.isArray(usernames) || usernames.length === 0) return
const run = () => warmupGroupSenderProfiles(usernames, false)
if (!defer && !isMessageListScrollingRef.current) {
run()
return
}
const runWhenIdle = () => {
if (isMessageListScrollingRef.current) {
window.setTimeout(runWhenIdle, MESSAGE_LIST_SCROLL_IDLE_MS)
return
}
run()
}
if ('requestIdleCallback' in window) {
window.requestIdleCallback(runWhenIdle, { timeout: 1200 })
} else {
window.setTimeout(runWhenIdle, MESSAGE_LIST_SCROLL_IDLE_MS)
}
}, [warmupGroupSenderProfiles])
// 加载消息 // 加载消息
const loadMessages = async ( const loadMessages = async (
sessionId: string, sessionId: string,
@@ -3304,10 +3255,6 @@ function ChatPage(props: ChatPageProps) {
ascending = false, ascending = false,
options: LoadMessagesOptions = {} options: LoadMessagesOptions = {}
) => { ) => {
const isPrependHistoryLoad = offset > 0 && !ascending
if (isPrependHistoryLoad) {
prependingHistoryRef.current = true
}
const listEl = messageListRef.current const listEl = messageListRef.current
const session = sessionMapRef.current.get(sessionId) const session = sessionMapRef.current.get(sessionId)
const unreadCount = session?.unreadCount ?? 0 const unreadCount = session?.unreadCount ?? 0
@@ -3341,6 +3288,10 @@ function ChatPage(props: ChatPageProps) {
Math.max(visibleRange.startIndex, 0), Math.max(visibleRange.startIndex, 0),
Math.max(messages.length - 1, 0) Math.max(messages.length - 1, 0)
) )
const anchorMessageKeyBeforePrepend = offset > 0 && messages.length > 0
? getMessageKey(messages[visibleStartIndex])
: null
// 记录加载前的第一条消息元素(非虚拟列表回退路径) // 记录加载前的第一条消息元素(非虚拟列表回退路径)
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
@@ -3389,11 +3340,12 @@ function ChatPage(props: ChatPageProps) {
.map(m => m.senderUsername as string) .map(m => m.senderUsername as string)
)] )]
if (unknownSenders.length > 0) { if (unknownSenders.length > 0) {
scheduleGroupSenderWarmup(unknownSenders, options.deferGroupSenderWarmup === true) warmupGroupSenderProfiles(unknownSenders, options.deferGroupSenderWarmup === true)
} }
} }
// 日期跳转时滚动到顶部,否则滚动到底部 // 日期跳转时滚动到顶部,否则滚动到底部
const loadedMessages = result.messages
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (isDateJumpRef.current) { if (isDateJumpRef.current) {
if (messageVirtuosoRef.current && resultMessages.length > 0) { if (messageVirtuosoRef.current && resultMessages.length > 0) {
@@ -3413,19 +3365,6 @@ function ChatPage(props: ChatPageProps) {
} }
}) })
} else { } else {
const existingMessageKeys = messageKeySetRef.current
const incomingSeen = new Set<string>()
let prependedInsertedCount = 0
for (const row of resultMessages) {
const key = getMessageKey(row)
if (incomingSeen.has(key)) continue
incomingSeen.add(key)
if (!existingMessageKeys.has(key)) {
prependedInsertedCount += 1
}
}
suppressAutoScrollOnNextMessageGrowthRef.current = true
appendMessages(resultMessages, true) appendMessages(resultMessages, true)
// 加载更多也同样处理发送者信息预取 // 加载更多也同样处理发送者信息预取
@@ -3436,20 +3375,24 @@ function ChatPage(props: ChatPageProps) {
.map(m => m.senderUsername as string) .map(m => m.senderUsername as string)
)] )]
if (unknownSenders.length > 0) { if (unknownSenders.length > 0) {
scheduleGroupSenderWarmup(unknownSenders, false) warmupGroupSenderProfiles(unknownSenders, false)
} }
} }
// 加载更早消息后保持视口锚点,避免跳屏 // 加载更早消息后保持视口锚点,避免跳屏
const appendedMessages = result.messages
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (messageVirtuosoRef.current) { if (messageVirtuosoRef.current) {
if (anchorMessageKeyBeforePrepend) {
const latestMessages = useChatStore.getState().messages || [] const latestMessages = useChatStore.getState().messages || []
const anchorIndex = Math.min( const anchorIndex = latestMessages.findIndex((msg) => getMessageKey(msg) === anchorMessageKeyBeforePrepend)
Math.max(visibleStartIndex + prependedInsertedCount, 0), if (anchorIndex >= 0) {
Math.max(latestMessages.length - 1, 0)
)
if (latestMessages.length > 0) {
messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' }) messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' })
return
}
}
if (resultMessages.length > 0) {
messageVirtuosoRef.current.scrollToIndex({ index: resultMessages.length, align: 'start', behavior: 'auto' })
} }
return return
} }
@@ -3489,11 +3432,6 @@ function ChatPage(props: ChatPageProps) {
setMessages([]) setMessages([])
} }
} finally { } finally {
if (isPrependHistoryLoad) {
requestAnimationFrame(() => {
prependingHistoryRef.current = false
})
}
setLoadingMessages(false) setLoadingMessages(false)
setLoadingMore(false) setLoadingMore(false)
if (offset === 0 && pendingSessionLoadRef.current === sessionId) { if (offset === 0 && pendingSessionLoadRef.current === sessionId) {
@@ -3524,11 +3462,9 @@ function ChatPage(props: ChatPageProps) {
setCurrentOffset(0) setCurrentOffset(0)
setJumpStartTime(0) setJumpStartTime(0)
setJumpEndTime(end) setJumpEndTime(end)
suppressAutoLoadLaterRef.current = true
setShowJumpPopover(false) setShowJumpPopover(false)
void loadMessages(targetSessionId, 0, 0, end, false, { void loadMessages(targetSessionId, 0, 0, end, false, {
switchRequestSeq: options.switchRequestSeq, switchRequestSeq: options.switchRequestSeq
forceInitialLimit: 120
}) })
}, [currentSessionId, loadMessages]) }, [currentSessionId, loadMessages])
@@ -4444,6 +4380,36 @@ function ChatPage(props: ChatPageProps) {
return return
} }
if (range.endIndex >= Math.max(total - 2, 0)) {
isMessageListAtBottomRef.current = true
setShowScrollToBottom(prev => (prev ? false : prev))
}
if (
range.startIndex <= 2 &&
!topRangeLoadLockRef.current &&
!isLoadingMore &&
!isLoadingMessages &&
hasMoreMessages &&
currentSessionId
) {
topRangeLoadLockRef.current = true
void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
}
if (
range.endIndex >= total - 3 &&
!bottomRangeLoadLockRef.current &&
!suppressAutoLoadLaterRef.current &&
!isLoadingMore &&
!isLoadingMessages &&
hasMoreLater &&
currentSessionId
) {
bottomRangeLoadLockRef.current = true
void loadLaterMessages()
}
if (shouldWarmupVisibleGroupSenders) { if (shouldWarmupVisibleGroupSenders) {
const now = Date.now() const now = Date.now()
if (now - lastVisibleSenderWarmupAtRef.current >= 180) { if (now - lastVisibleSenderWarmupAtRef.current >= 180) {
@@ -4462,18 +4428,27 @@ function ChatPage(props: ChatPageProps) {
if (pendingUsernames.size >= 24) break if (pendingUsernames.size >= 24) break
} }
if (pendingUsernames.size > 0) { if (pendingUsernames.size > 0) {
scheduleGroupSenderWarmup([...pendingUsernames], false) warmupGroupSenderProfiles([...pendingUsernames], false)
} }
} }
} }
}, [ }, [
messages.length, messages.length,
isLoadingMore,
isLoadingMessages,
hasMoreMessages,
hasMoreLater,
currentSessionId, currentSessionId,
currentOffset,
jumpStartTime,
jumpEndTime,
isGroupChatSession, isGroupChatSession,
standaloneSessionWindow, standaloneSessionWindow,
normalizedInitialSessionId, normalizedInitialSessionId,
normalizedStandaloneInitialContactType, normalizedStandaloneInitialContactType,
scheduleGroupSenderWarmup warmupGroupSenderProfiles,
loadMessages,
loadLaterMessages
]) ])
const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => { const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => {
@@ -4487,8 +4462,9 @@ function ChatPage(props: ChatPageProps) {
const distanceFromBottom = listEl const distanceFromBottom = listEl
? (listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight)) ? (listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight))
: Number.POSITIVE_INFINITY : Number.POSITIVE_INFINITY
const nearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(messages.length - 2, 0)
const nearBottomByDistance = distanceFromBottom <= 140 const nearBottomByDistance = distanceFromBottom <= 140
const effectiveAtBottom = atBottom || nearBottomByDistance const effectiveAtBottom = atBottom || nearBottomByRange || nearBottomByDistance
isMessageListAtBottomRef.current = effectiveAtBottom isMessageListAtBottomRef.current = effectiveAtBottom
if (!effectiveAtBottom) { if (!effectiveAtBottom) {
@@ -4516,48 +4492,19 @@ function ChatPage(props: ChatPageProps) {
}, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching]) }, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching])
const handleMessageListWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => { const handleMessageListWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
markMessageListScrolling() if (event.deltaY <= 18) return
if (!currentSessionId || isLoadingMore || isLoadingMessages) return if (!currentSessionId || isLoadingMore || isLoadingMessages || !hasMoreLater) return
const listEl = messageListRef.current const listEl = messageListRef.current
if (!listEl) return if (!listEl) return
const distanceFromTop = listEl.scrollTop
const distanceFromBottom = listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight) const distanceFromBottom = listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight)
if (distanceFromBottom > 96) return
if (event.deltaY <= -18) {
if (!hasMoreMessages) return
if (distanceFromTop > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return
if (topRangeLoadLockRef.current) return
const now = Date.now()
if (now - topRangeLoadLastTriggerAtRef.current < MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS) return
topRangeLoadLastTriggerAtRef.current = now
topRangeLoadLockRef.current = true
isMessageListAtBottomRef.current = false
void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
return
}
if (event.deltaY <= 18) return
if (!hasMoreLater) return
if (distanceFromBottom > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return
if (bottomRangeLoadLockRef.current) return if (bottomRangeLoadLockRef.current) return
// 用户明确向下滚动时允许加载后续消息 // 用户明确向下滚动时允许加载后续消息
suppressAutoLoadLaterRef.current = false suppressAutoLoadLaterRef.current = false
bottomRangeLoadLockRef.current = true bottomRangeLoadLockRef.current = true
void loadLaterMessages() void loadLaterMessages()
}, [ }, [currentSessionId, hasMoreLater, isLoadingMessages, isLoadingMore, loadLaterMessages])
currentSessionId,
hasMoreLater,
hasMoreMessages,
isLoadingMessages,
isLoadingMore,
currentOffset,
jumpStartTime,
jumpEndTime,
markMessageListScrolling,
loadMessages,
loadLaterMessages
])
const handleMessageAtTopStateChange = useCallback((atTop: boolean) => { const handleMessageAtTopStateChange = useCallback((atTop: boolean) => {
if (!atTop) { if (!atTop) {
@@ -4712,11 +4659,6 @@ function ChatPage(props: ChatPageProps) {
if (sessionScrollTimeoutRef.current) { if (sessionScrollTimeoutRef.current) {
clearTimeout(sessionScrollTimeoutRef.current) clearTimeout(sessionScrollTimeoutRef.current)
} }
if (messageListScrollTimeoutRef.current !== null) {
window.clearTimeout(messageListScrollTimeoutRef.current)
messageListScrollTimeoutRef.current = null
}
isMessageListScrollingRef.current = false
contactUpdateQueueRef.current.clear() contactUpdateQueueRef.current.clear()
pendingSessionContactEnrichRef.current.clear() pendingSessionContactEnrichRef.current.clear()
sessionContactEnrichAttemptAtRef.current.clear() sessionContactEnrichAttemptAtRef.current.clear()
@@ -4757,12 +4699,8 @@ function ChatPage(props: ChatPageProps) {
lastObservedMessageCountRef.current = currentCount lastObservedMessageCountRef.current = currentCount
if (currentCount <= previousCount) return if (currentCount <= previousCount) return
if (!currentSessionId || isLoadingMessages || isSessionSwitching) return if (!currentSessionId || isLoadingMessages || isSessionSwitching) return
if (suppressAutoScrollOnNextMessageGrowthRef.current || prependingHistoryRef.current) { const wasNearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(previousCount - 2, 0)
suppressAutoScrollOnNextMessageGrowthRef.current = false if (!isMessageListAtBottomRef.current && !wasNearBottomByRange) return
return
}
if (!isMessageListAtBottomRef.current) return
if (suppressAutoLoadLaterRef.current) return
suppressScrollToBottomButton(220) suppressScrollToBottomButton(220)
isMessageListAtBottomRef.current = true isMessageListAtBottomRef.current = true
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -6665,7 +6603,6 @@ function ChatPage(props: ChatPageProps) {
<div <div
className={`message-list ${hasInitialMessages ? 'loaded' : 'loading'}`} className={`message-list ${hasInitialMessages ? 'loaded' : 'loading'}`}
ref={handleMessageListScrollParentRef} ref={handleMessageListScrollParentRef}
onScroll={markMessageListScrolling}
onWheel={handleMessageListWheel} onWheel={handleMessageListWheel}
> >
{!isLoadingMessages && messages.length === 0 && !hasMoreMessages ? ( {!isLoadingMessages && messages.length === 0 && !hasMoreMessages ? (
@@ -6679,12 +6616,8 @@ function ChatPage(props: ChatPageProps) {
className="message-virtuoso" className="message-virtuoso"
customScrollParent={messageListScrollParent ?? undefined} customScrollParent={messageListScrollParent ?? undefined}
data={messages} data={messages}
overscan={220} overscan={360}
followOutput={(atBottom) => ( followOutput={(atBottom) => (atBottom || isMessageListAtBottomRef.current ? 'auto' : false)}
prependingHistoryRef.current
? false
: (atBottom && isMessageListAtBottomRef.current ? 'auto' : false)
)}
atBottomThreshold={80} atBottomThreshold={80}
atBottomStateChange={handleMessageAtBottomStateChange} atBottomStateChange={handleMessageAtBottomStateChange}
atTopStateChange={handleMessageAtTopStateChange} atTopStateChange={handleMessageAtTopStateChange}
@@ -7726,8 +7659,6 @@ function MessageBubble({
// State variables... // State variables...
const [imageError, setImageError] = useState(false) const [imageError, setImageError] = useState(false)
const [imageLoading, setImageLoading] = useState(false) const [imageLoading, setImageLoading] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false)
const [imageStageLockHeight, setImageStageLockHeight] = useState<number | null>(null)
const [imageHasUpdate, setImageHasUpdate] = useState(false) const [imageHasUpdate, setImageHasUpdate] = useState(false)
const [imageClicked, setImageClicked] = useState(false) const [imageClicked, setImageClicked] = useState(false)
const imageUpdateCheckedRef = useRef<string | null>(null) const imageUpdateCheckedRef = useRef<string | null>(null)
@@ -7773,11 +7704,6 @@ function MessageBubble({
const videoContainerRef = useRef<HTMLElement>(null) const videoContainerRef = useRef<HTMLElement>(null)
const [isVideoVisible, setIsVideoVisible] = useState(false) const [isVideoVisible, setIsVideoVisible] = useState(false)
const [videoMd5, setVideoMd5] = useState<string | null>(null) const [videoMd5, setVideoMd5] = useState<string | null>(null)
const imageStageLockStyle = useMemo<React.CSSProperties | undefined>(() => (
imageStageLockHeight && imageStageLockHeight > 0
? { height: `${Math.round(imageStageLockHeight)}px` }
: undefined
), [imageStageLockHeight])
// 解析视频 MD5 // 解析视频 MD5
useEffect(() => { useEffect(() => {
@@ -7921,14 +7847,6 @@ function MessageBubble({
captureResizeBaseline(imageContainerRef.current, imageResizeBaselineRef) captureResizeBaseline(imageContainerRef.current, imageResizeBaselineRef)
}, [captureResizeBaseline]) }, [captureResizeBaseline])
const lockImageStageHeight = useCallback(() => {
const host = imageContainerRef.current
if (!host) return
const height = host.getBoundingClientRect().height
if (!Number.isFinite(height) || height <= 0) return
setImageStageLockHeight(Math.round(height))
}, [])
const captureEmojiResizeBaseline = useCallback(() => { const captureEmojiResizeBaseline = useCallback(() => {
captureResizeBaseline(emojiContainerRef.current, emojiResizeBaselineRef) captureResizeBaseline(emojiContainerRef.current, emojiResizeBaselineRef)
}, [captureResizeBaseline]) }, [captureResizeBaseline])
@@ -7937,12 +7855,6 @@ function MessageBubble({
stabilizeScrollAfterResize(imageContainerRef.current, imageResizeBaselineRef) stabilizeScrollAfterResize(imageContainerRef.current, imageResizeBaselineRef)
}, [stabilizeScrollAfterResize]) }, [stabilizeScrollAfterResize])
const releaseImageStageLock = useCallback(() => {
window.requestAnimationFrame(() => {
setImageStageLockHeight(null)
})
}, [])
const stabilizeEmojiScrollAfterResize = useCallback(() => { const stabilizeEmojiScrollAfterResize = useCallback(() => {
stabilizeScrollAfterResize(emojiContainerRef.current, emojiResizeBaselineRef) stabilizeScrollAfterResize(emojiContainerRef.current, emojiResizeBaselineRef)
}, [stabilizeScrollAfterResize]) }, [stabilizeScrollAfterResize])
@@ -8096,7 +8008,6 @@ function MessageBubble({
imageDataUrlCache.set(imageCacheKey, result.localPath) imageDataUrlCache.set(imageCacheKey, result.localPath)
if (imageLocalPath !== result.localPath) { if (imageLocalPath !== result.localPath) {
captureImageResizeBaseline() captureImageResizeBaseline()
lockImageStageHeight()
} }
setImageLocalPath(result.localPath) setImageLocalPath(result.localPath)
setImageHasUpdate(false) setImageHasUpdate(false)
@@ -8112,7 +8023,6 @@ function MessageBubble({
imageDataUrlCache.set(imageCacheKey, dataUrl) imageDataUrlCache.set(imageCacheKey, dataUrl)
if (imageLocalPath !== dataUrl) { if (imageLocalPath !== dataUrl) {
captureImageResizeBaseline() captureImageResizeBaseline()
lockImageStageHeight()
} }
setImageLocalPath(dataUrl) setImageLocalPath(dataUrl)
setImageHasUpdate(false) setImageHasUpdate(false)
@@ -8126,7 +8036,7 @@ function MessageBubble({
imageDecryptPendingRef.current = false imageDecryptPendingRef.current = false
} }
return { success: false } return { success: false }
}, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight]) }, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline])
const triggerForceHd = useCallback(() => { const triggerForceHd = useCallback(() => {
if (!message.imageMd5 && !message.imageDatName) return if (!message.imageMd5 && !message.imageDatName) return
@@ -8189,7 +8099,6 @@ function MessageBubble({
imageDataUrlCache.set(imageCacheKey, resolved.localPath) imageDataUrlCache.set(imageCacheKey, resolved.localPath)
if (imageLocalPath !== resolved.localPath) { if (imageLocalPath !== resolved.localPath) {
captureImageResizeBaseline() captureImageResizeBaseline()
lockImageStageHeight()
} }
setImageLocalPath(resolved.localPath) setImageLocalPath(resolved.localPath)
if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath) if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath)
@@ -8204,7 +8113,6 @@ function MessageBubble({
imageLocalPath, imageLocalPath,
imageCacheKey, imageCacheKey,
captureImageResizeBaseline, captureImageResizeBaseline,
lockImageStageHeight,
message.imageDatName, message.imageDatName,
message.imageMd5, message.imageMd5,
requestImageDecrypt, requestImageDecrypt,
@@ -8219,16 +8127,6 @@ function MessageBubble({
} }
}, []) }, [])
useEffect(() => {
setImageLoaded(false)
}, [imageLocalPath])
useEffect(() => {
if (imageLoading) return
if (!imageError && imageLocalPath) return
setImageStageLockHeight(null)
}, [imageError, imageLoading, imageLocalPath])
useEffect(() => { useEffect(() => {
if (!isImage || imageLoading) return if (!isImage || imageLoading) return
if (!message.imageMd5 && !message.imageDatName) return if (!message.imageMd5 && !message.imageDatName) return
@@ -8245,7 +8143,6 @@ function MessageBubble({
imageDataUrlCache.set(imageCacheKey, result.localPath) imageDataUrlCache.set(imageCacheKey, result.localPath)
if (!imageLocalPath || imageLocalPath !== result.localPath) { if (!imageLocalPath || imageLocalPath !== result.localPath) {
captureImageResizeBaseline() captureImageResizeBaseline()
lockImageStageHeight()
setImageLocalPath(result.localPath) setImageLocalPath(result.localPath)
setImageError(false) setImageError(false)
} }
@@ -8256,7 +8153,7 @@ function MessageBubble({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight]) }, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline])
useEffect(() => { useEffect(() => {
if (!isImage) return if (!isImage) return
@@ -8290,7 +8187,6 @@ function MessageBubble({
} }
if (imageLocalPath !== payload.localPath) { if (imageLocalPath !== payload.localPath) {
captureImageResizeBaseline() captureImageResizeBaseline()
lockImageStageHeight()
} }
setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath)) setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath))
setImageError(false) setImageError(false)
@@ -8299,7 +8195,7 @@ function MessageBubble({
return () => { return () => {
unsubscribe?.() unsubscribe?.()
} }
}, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline, lockImageStageHeight]) }, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline])
// 图片进入视野前自动解密(懒加载) // 图片进入视野前自动解密(懒加载)
useEffect(() => { useEffect(() => {
@@ -8682,19 +8578,6 @@ function MessageBubble({
appMsgTextCache.set(selector, value) appMsgTextCache.set(selector, value)
return value return value
}, [appMsgDoc, appMsgTextCache]) }, [appMsgDoc, appMsgTextCache])
const appMsgThumbRawCandidate = useMemo(() => (
message.linkThumb ||
message.appMsgThumbUrl ||
queryAppMsgText('appmsg > thumburl') ||
queryAppMsgText('appmsg > cdnthumburl') ||
queryAppMsgText('appmsg > cover') ||
queryAppMsgText('appmsg > coverurl') ||
queryAppMsgText('thumburl') ||
queryAppMsgText('cdnthumburl') ||
queryAppMsgText('cover') ||
queryAppMsgText('coverurl') ||
''
).trim(), [message.linkThumb, message.appMsgThumbUrl, queryAppMsgText])
const quotedSenderUsername = resolveQuotedSenderUsername( const quotedSenderUsername = resolveQuotedSenderUsername(
queryAppMsgText('refermsg > fromusr'), queryAppMsgText('refermsg > fromusr'),
queryAppMsgText('refermsg > chatusr') queryAppMsgText('refermsg > chatusr')
@@ -8828,17 +8711,6 @@ function MessageBubble({
// Selection mode handling removed from here to allow normal rendering // Selection mode handling removed from here to allow normal rendering
// We will wrap the output instead // We will wrap the output instead
if (isSystem) { if (isSystem) {
const isPatSystemMessage = message.localType === 266287972401
const patTitleRaw = isPatSystemMessage
? (queryAppMsgText('appmsg > title') || queryAppMsgText('title') || message.parsedContent || '')
: ''
const patDisplayText = isPatSystemMessage
? cleanMessageContent(String(patTitleRaw).replace(/^\s*\[拍一拍\]\s*/i, ''))
: ''
const systemContentNode = isPatSystemMessage
? renderTextWithEmoji(patDisplayText || '拍一拍')
: message.parsedContent
return ( return (
<div <div
className={`message-bubble system ${isSelectionMode ? 'selectable' : ''}`} className={`message-bubble system ${isSelectionMode ? 'selectable' : ''}`}
@@ -8867,7 +8739,7 @@ function MessageBubble({
{isSelected && <Check size={14} strokeWidth={3} />} {isSelected && <Check size={14} strokeWidth={3} />}
</div> </div>
)} )}
<div className="bubble-content">{systemContentNode}</div> <div className="bubble-content">{message.parsedContent}</div>
</div> </div>
) )
} }
@@ -8876,11 +8748,7 @@ function MessageBubble({
const renderContent = () => { const renderContent = () => {
if (isImage) { if (isImage) {
return ( return (
<div <div ref={imageContainerRef}>
ref={imageContainerRef}
className={`image-stage ${imageStageLockHeight ? 'locked' : ''}`}
style={imageStageLockStyle}
>
{imageLoading ? ( {imageLoading ? (
<div className="image-loading"> <div className="image-loading">
<Loader2 size={20} className="spin" /> <Loader2 size={20} className="spin" />
@@ -8902,19 +8770,15 @@ function MessageBubble({
<img <img
src={imageLocalPath} src={imageLocalPath}
alt="图片" alt="图片"
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`} className="image-message"
onClick={() => { void handleOpenImageViewer() }} onClick={() => { void handleOpenImageViewer() }}
onLoad={() => { onLoad={() => {
setImageLoaded(true)
setImageError(false) setImageError(false)
stabilizeImageScrollAfterResize() stabilizeImageScrollAfterResize()
releaseImageStageLock()
}} }}
onError={() => { onError={() => {
imageResizeBaselineRef.current = null imageResizeBaselineRef.current = null
setImageLoaded(false)
setImageError(true) setImageError(true)
releaseImageStageLock()
}} }}
/> />
{imageLiveVideoPath && ( {imageLiveVideoPath && (
@@ -9240,12 +9104,6 @@ function MessageBubble({
const xmlType = message.xmlType || q('appmsg > type') || q('type') const xmlType = message.xmlType || q('appmsg > type') || q('type')
// type 62: 拍一拍(按普通文本渲染,支持 [烟花] 这类 emoji 占位符)
if (xmlType === '62') {
const patText = cleanMessageContent((q('title') || cleanedParsedContent || '').replace(/^\s*\[拍一拍\]\s*/i, ''))
return <div className="bubble-content">{renderTextWithEmoji(patText || '拍一拍')}</div>
}
// type 57: 引用回复消息,解析 refermsg 渲染为引用样式 // type 57: 引用回复消息,解析 refermsg 渲染为引用样式
if (xmlType === '57') { if (xmlType === '57') {
const replyText = q('title') || cleanedParsedContent || '' const replyText = q('title') || cleanedParsedContent || ''
@@ -9289,8 +9147,7 @@ function MessageBubble({
const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card' const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card'
const desc = message.appMsgDesc || q('des') const desc = message.appMsgDesc || q('des')
const url = message.linkUrl || q('url') const url = message.linkUrl || q('url')
const fallbackThumbUrl = appMsgThumbRawCandidate const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl')
const thumbUrl = isRenderableImageSrc(fallbackThumbUrl) ? fallbackThumbUrl : ''
const musicUrl = message.appMsgMusicUrl || message.appMsgDataUrl || q('musicurl') || q('playurl') || q('dataurl') || q('lowurl') const musicUrl = message.appMsgMusicUrl || message.appMsgDataUrl || q('musicurl') || q('playurl') || q('dataurl') || q('lowurl')
const sourceName = message.appMsgSourceName || q('sourcename') const sourceName = message.appMsgSourceName || q('sourcename')
const sourceDisplayName = q('sourcedisplayname') || '' const sourceDisplayName = q('sourcedisplayname') || ''
@@ -9364,7 +9221,9 @@ function MessageBubble({
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
) : null} ) : (
<div className={`link-thumb-placeholder ${cardKind}`}>{cardKind.slice(0, 2).toUpperCase()}</div>
)}
</div> </div>
</div> </div>
) )
@@ -9804,6 +9663,9 @@ function MessageBubble({
</div> </div>
<div className="link-body"> <div className="link-body">
<div className="link-desc" title={desc}>{desc}</div> <div className="link-desc" title={desc}>{desc}</div>
<div className="link-thumb-placeholder">
<Link size={24} />
</div>
</div> </div>
</div> </div>
) )

View File

@@ -67,7 +67,7 @@ import './ExportPage.scss'
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
type TaskStatus = 'queued' | 'running' | 'success' | 'error' type TaskStatus = 'queued' | 'running' | 'success' | 'error'
type TaskScope = 'single' | 'multi' | 'content' | 'sns' type TaskScope = 'single' | 'multi' | 'content' | 'sns'
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file' type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji'
type ContentCardType = ContentType | 'sns' type ContentCardType = ContentType | 'sns'
type SnsRankMode = 'likes' | 'comments' type SnsRankMode = 'likes' | 'comments'
@@ -88,8 +88,6 @@ interface ExportOptions {
exportVoices: boolean exportVoices: boolean
exportVideos: boolean exportVideos: boolean
exportEmojis: boolean exportEmojis: boolean
exportFiles: boolean
maxFileSizeMb: number
exportVoiceAsText: boolean exportVoiceAsText: boolean
excelCompactColumns: boolean excelCompactColumns: boolean
txtColumns: string[] txtColumns: string[]
@@ -183,7 +181,6 @@ interface ExportDialogState {
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900
const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10 const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
const SESSION_MEDIA_METRIC_BATCH_SIZE = 8 const SESSION_MEDIA_METRIC_BATCH_SIZE = 8
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
@@ -198,8 +195,7 @@ const contentTypeLabels: Record<ContentType, string> = {
voice: '语音', voice: '语音',
image: '图片', image: '图片',
video: '视频', video: '视频',
emoji: '表情包', emoji: '表情包'
file: '文件'
} }
const backgroundTaskSourceLabels: Record<string, string> = { const backgroundTaskSourceLabels: Record<string, string> = {
@@ -315,7 +311,9 @@ const cloneTaskPerformance = (performance?: TaskPerformance): TaskPerformance =>
write: performance?.stages.write || 0, write: performance?.stages.write || 0,
other: performance?.stages.other || 0 other: performance?.stages.other || 0
}, },
sessions: { ...(performance?.sessions || {}) } sessions: Object.fromEntries(
Object.entries(performance?.sessions || {}).map(([sessionId, session]) => [sessionId, { ...session }])
)
}) })
const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => { const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => {
@@ -335,18 +333,6 @@ const applyProgressToTaskPerformance = (
const sessionId = String(payload.currentSessionId || '').trim() const sessionId = String(payload.currentSessionId || '').trim()
if (!sessionId) return task.performance || createEmptyTaskPerformance() if (!sessionId) return task.performance || createEmptyTaskPerformance()
const currentPerformance = task.performance
const currentSession = currentPerformance?.sessions?.[sessionId]
if (
payload.phase !== 'complete' &&
currentSession &&
currentSession.lastPhase === payload.phase &&
typeof currentSession.lastPhaseStartedAt === 'number' &&
now - currentSession.lastPhaseStartedAt < TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS
) {
return currentPerformance
}
const performance = cloneTaskPerformance(task.performance) const performance = cloneTaskPerformance(task.performance)
const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId) const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId)
const existing = performance.sessions[sessionId] const existing = performance.sessions[sessionId]
@@ -382,9 +368,7 @@ const applyProgressToTaskPerformance = (
const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => { const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => {
if (!isTextBatchTask(task) || !task.performance) return task.performance if (!isTextBatchTask(task) || !task.performance) return task.performance
const performance = cloneTaskPerformance(task.performance) const performance = cloneTaskPerformance(task.performance)
const nextSessions: Record<string, TaskSessionPerformance> = {} for (const session of Object.values(performance.sessions)) {
for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) {
const session: TaskSessionPerformance = { ...sourceSession }
if (session.finishedAt) continue if (session.finishedAt) continue
if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') { if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
const delta = Math.max(0, now - session.lastPhaseStartedAt) const delta = Math.max(0, now - session.lastPhaseStartedAt)
@@ -394,13 +378,7 @@ const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance
session.finishedAt = now session.finishedAt = now
session.lastPhase = undefined session.lastPhase = undefined
session.lastPhaseStartedAt = undefined session.lastPhaseStartedAt = undefined
nextSessions[sessionId] = session
} }
for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) {
if (nextSessions[sessionId]) continue
nextSessions[sessionId] = { ...sourceSession }
}
performance.sessions = nextSessions
return performance return performance
} }
@@ -1620,8 +1598,7 @@ function ExportPage() {
images: true, images: true,
videos: true, videos: true,
voices: true, voices: true,
emojis: true, emojis: true
files: true
}) })
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
@@ -1641,8 +1618,6 @@ function ExportPage() {
exportVoices: true, exportVoices: true,
exportVideos: true, exportVideos: true,
exportEmojis: true, exportEmojis: true,
exportFiles: true,
maxFileSizeMb: 200,
exportVoiceAsText: false, exportVoiceAsText: false,
excelCompactColumns: true, excelCompactColumns: true,
txtColumns: defaultTxtColumns, txtColumns: defaultTxtColumns,
@@ -2306,8 +2281,7 @@ function ExportPage() {
images: true, images: true,
videos: true, videos: true,
voices: true, voices: true,
emojis: true, emojis: true
files: true
}) })
setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
@@ -2336,14 +2310,12 @@ function ExportPage() {
(savedMedia?.images ?? prev.exportImages) || (savedMedia?.images ?? prev.exportImages) ||
(savedMedia?.voices ?? prev.exportVoices) || (savedMedia?.voices ?? prev.exportVoices) ||
(savedMedia?.videos ?? prev.exportVideos) || (savedMedia?.videos ?? prev.exportVideos) ||
(savedMedia?.emojis ?? prev.exportEmojis) || (savedMedia?.emojis ?? prev.exportEmojis)
(savedMedia?.files ?? prev.exportFiles)
), ),
exportImages: savedMedia?.images ?? prev.exportImages, exportImages: savedMedia?.images ?? prev.exportImages,
exportVoices: savedMedia?.voices ?? prev.exportVoices, exportVoices: savedMedia?.voices ?? prev.exportVoices,
exportVideos: savedMedia?.videos ?? prev.exportVideos, exportVideos: savedMedia?.videos ?? prev.exportVideos,
exportEmojis: savedMedia?.emojis ?? prev.exportEmojis, exportEmojis: savedMedia?.emojis ?? prev.exportEmojis,
exportFiles: savedMedia?.files ?? prev.exportFiles,
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
txtColumns, txtColumns,
@@ -4116,15 +4088,12 @@ function ExportPage() {
exportDefaultMedia.images || exportDefaultMedia.images ||
exportDefaultMedia.voices || exportDefaultMedia.voices ||
exportDefaultMedia.videos || exportDefaultMedia.videos ||
exportDefaultMedia.emojis || exportDefaultMedia.emojis
exportDefaultMedia.files
), ),
exportImages: exportDefaultMedia.images, exportImages: exportDefaultMedia.images,
exportVoices: exportDefaultMedia.voices, exportVoices: exportDefaultMedia.voices,
exportVideos: exportDefaultMedia.videos, exportVideos: exportDefaultMedia.videos,
exportEmojis: exportDefaultMedia.emojis, exportEmojis: exportDefaultMedia.emojis,
exportFiles: exportDefaultMedia.files,
maxFileSizeMb: prev.maxFileSizeMb,
exportVoiceAsText: exportDefaultVoiceAsText, exportVoiceAsText: exportDefaultVoiceAsText,
excelCompactColumns: exportDefaultExcelCompactColumns, excelCompactColumns: exportDefaultExcelCompactColumns,
exportConcurrency: exportDefaultConcurrency, exportConcurrency: exportDefaultConcurrency,
@@ -4142,14 +4111,12 @@ function ExportPage() {
next.exportVoices = false next.exportVoices = false
next.exportVideos = false next.exportVideos = false
next.exportEmojis = false next.exportEmojis = false
next.exportFiles = false
} else { } else {
next.exportMedia = true next.exportMedia = true
next.exportImages = payload.contentType === 'image' next.exportImages = payload.contentType === 'image'
next.exportVoices = payload.contentType === 'voice' next.exportVoices = payload.contentType === 'voice'
next.exportVideos = payload.contentType === 'video' next.exportVideos = payload.contentType === 'video'
next.exportEmojis = payload.contentType === 'emoji' next.exportEmojis = payload.contentType === 'emoji'
next.exportFiles = payload.contentType === 'file'
next.exportVoiceAsText = false next.exportVoiceAsText = false
} }
} }
@@ -4368,13 +4335,7 @@ function ExportPage() {
const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => { const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => {
const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared' const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared'
const exportMediaEnabled = Boolean( const exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
options.exportImages ||
options.exportVoices ||
options.exportVideos ||
options.exportEmojis ||
options.exportFiles
)
const base: ElectronExportOptions = { const base: ElectronExportOptions = {
format: options.format, format: options.format,
@@ -4384,8 +4345,6 @@ function ExportPage() {
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportVideos: options.exportVideos, exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
excelCompactColumns: options.excelCompactColumns, excelCompactColumns: options.excelCompactColumns,
txtColumns: options.txtColumns, txtColumns: options.txtColumns,
@@ -4416,8 +4375,7 @@ function ExportPage() {
exportImages: false, exportImages: false,
exportVoices: false, exportVoices: false,
exportVideos: false, exportVideos: false,
exportEmojis: false, exportEmojis: false
exportFiles: false
} }
} }
@@ -4429,7 +4387,6 @@ function ExportPage() {
exportVoices: contentType === 'voice', exportVoices: contentType === 'voice',
exportVideos: contentType === 'video', exportVideos: contentType === 'video',
exportEmojis: contentType === 'emoji', exportEmojis: contentType === 'emoji',
exportFiles: contentType === 'file',
exportVoiceAsText: false exportVoiceAsText: false
} }
} }
@@ -4495,7 +4452,6 @@ function ExportPage() {
if (opts.exportVoices) labels.push('语音') if (opts.exportVoices) labels.push('语音')
if (opts.exportVideos) labels.push('视频') if (opts.exportVideos) labels.push('视频')
if (opts.exportEmojis) labels.push('表情包') if (opts.exportEmojis) labels.push('表情包')
if (opts.exportFiles) labels.push('文件')
} }
return Array.from(new Set(labels)).join('、') return Array.from(new Set(labels)).join('、')
}, []) }, [])
@@ -4551,7 +4507,6 @@ function ExportPage() {
if (opts.exportImages) types.push('image') if (opts.exportImages) types.push('image')
if (opts.exportVideos) types.push('video') if (opts.exportVideos) types.push('video')
if (opts.exportEmojis) types.push('emoji') if (opts.exportEmojis) types.push('emoji')
if (opts.exportFiles) types.push('file')
} }
return types return types
} }
@@ -4742,7 +4697,7 @@ function ExportPage() {
queuedProgressTimer = window.setTimeout(() => { queuedProgressTimer = window.setTimeout(() => {
queuedProgressTimer = null queuedProgressTimer = null
flushQueuedProgress() flushQueuedProgress()
}, 180) }, 100)
}) })
} }
if (next.payload.scope === 'sns') { if (next.payload.scope === 'sns') {
@@ -4982,8 +4937,7 @@ function ExportPage() {
images: options.exportImages, images: options.exportImages,
voices: options.exportVoices, voices: options.exportVoices,
videos: options.exportVideos, videos: options.exportVideos,
emojis: options.exportEmojis, emojis: options.exportEmojis
files: options.exportFiles
}) })
await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText) await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText)
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
@@ -7001,12 +6955,11 @@ function ExportPage() {
setExportDefaultMedia(mediaPatch) setExportDefaultMedia(mediaPatch)
setOptions(prev => ({ setOptions(prev => ({
...prev, ...prev,
exportMedia: Boolean(mediaPatch.images || mediaPatch.voices || mediaPatch.videos || mediaPatch.emojis || mediaPatch.files), exportMedia: Boolean(mediaPatch.images || mediaPatch.voices || mediaPatch.videos || mediaPatch.emojis),
exportImages: mediaPatch.images, exportImages: mediaPatch.images,
exportVoices: mediaPatch.voices, exportVoices: mediaPatch.voices,
exportVideos: mediaPatch.videos, exportVideos: mediaPatch.videos,
exportEmojis: mediaPatch.emojis, exportEmojis: mediaPatch.emojis
exportFiles: mediaPatch.files
})) }))
} }
if (typeof patch.voiceAsText === 'boolean') { if (typeof patch.voiceAsText === 'boolean') {
@@ -8206,36 +8159,15 @@ function ExportPage() {
<label><input type="checkbox" checked={options.exportVoices} onChange={event => setOptions(prev => ({ ...prev, exportVoices: event.target.checked }))} /> </label> <label><input type="checkbox" checked={options.exportVoices} onChange={event => setOptions(prev => ({ ...prev, exportVoices: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportVideos} onChange={event => setOptions(prev => ({ ...prev, exportVideos: event.target.checked }))} /> </label> <label><input type="checkbox" checked={options.exportVideos} onChange={event => setOptions(prev => ({ ...prev, exportVideos: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportEmojis} onChange={event => setOptions(prev => ({ ...prev, exportEmojis: event.target.checked }))} /> </label> <label><input type="checkbox" checked={options.exportEmojis} onChange={event => setOptions(prev => ({ ...prev, exportEmojis: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportFiles} onChange={event => setOptions(prev => ({ ...prev, exportFiles: event.target.checked }))} /> </label>
</> </>
)} )}
</div> </div>
{exportDialog.scope !== 'sns' && options.exportFiles && ( {exportDialog.scope === 'sns' && (
<div className="format-note">使 MD5 </div> <div className="format-note"></div>
)} )}
</div> </div>
)} )}
{shouldShowMediaSection && exportDialog.scope !== 'sns' && options.exportFiles && (
<div className="dialog-section">
<h4></h4>
<div className="format-note">0 </div>
<div className="dialog-input-row">
<input
type="number"
min={0}
step={10}
value={options.maxFileSizeMb}
onChange={event => {
const raw = Number(event.target.value)
setOptions(prev => ({ ...prev, maxFileSizeMb: Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : 0 }))
}}
/>
<span>MB</span>
</div>
</div>
)}
{shouldShowImageDeepSearchToggle && ( {shouldShowImageDeepSearchToggle && (
<div className="dialog-section"> <div className="dialog-section">
<div className="dialog-switch-row"> <div className="dialog-switch-row">

View File

@@ -2934,488 +2934,3 @@
} }
} }
} }
.anti-revoke-tab {
display: flex;
flex-direction: column;
gap: 14px;
.anti-revoke-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 18px;
border-radius: 18px;
border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 94%, var(--primary) 6%) 0%,
color-mix(in srgb, var(--bg-secondary) 96%, var(--bg-primary) 4%) 100%
);
}
.anti-revoke-hero-main {
min-width: 240px;
h3 {
margin: 0;
font-size: 19px;
font-weight: 600;
line-height: 1.3;
color: var(--text-primary);
letter-spacing: 0.3px;
}
p {
margin: 8px 0 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
}
}
.anti-revoke-metrics {
flex: 1;
display: grid;
grid-template-columns: repeat(4, minmax(112px, 1fr));
gap: 10px;
min-width: 460px;
}
.anti-revoke-metric {
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
background: color-mix(in srgb, var(--bg-primary) 93%, var(--bg-secondary) 7%);
.label {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.2;
letter-spacing: 0.2px;
}
.value {
font-size: 30px;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
font-variant-numeric: tabular-nums;
}
&.is-total {
border-color: color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%);
background: color-mix(in srgb, var(--bg-primary) 88%, var(--primary) 12%);
}
&.is-installed {
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
background: color-mix(in srgb, var(--bg-primary) 90%, var(--primary) 10%);
.value {
color: var(--primary);
}
}
&.is-pending {
background: color-mix(in srgb, var(--bg-primary) 95%, var(--bg-secondary) 5%);
.value {
color: color-mix(in srgb, var(--text-primary) 82%, var(--text-secondary));
}
}
&.is-error {
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
background: color-mix(in srgb, var(--danger) 6%, var(--bg-primary));
.value {
color: color-mix(in srgb, var(--danger) 65%, var(--text-primary) 35%);
}
}
}
.anti-revoke-control-card {
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
border-radius: 16px;
padding: 14px;
background: color-mix(in srgb, var(--bg-secondary) 95%, var(--bg-primary) 5%);
}
.anti-revoke-toolbar {
display: flex;
gap: 14px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
flex-wrap: wrap;
}
.anti-revoke-search {
min-width: 280px;
flex: 1;
max-width: 420px;
border-radius: 10px;
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary) 15%);
input {
height: 36px;
font-size: 13px;
}
}
.anti-revoke-toolbar-actions {
display: flex;
align-items: stretch;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
margin-left: auto;
}
.anti-revoke-btn-group {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.anti-revoke-batch-actions {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
justify-content: space-between;
border-top: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
padding-top: 12px;
}
.anti-revoke-selected-count {
display: inline-flex;
align-items: center;
gap: 14px;
font-size: 12px;
color: var(--text-secondary);
margin-left: auto;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
background: color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary) 8%);
span {
position: relative;
line-height: 1.2;
white-space: nowrap;
strong {
color: var(--text-primary);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
&:not(:last-child)::after {
content: '';
position: absolute;
right: -8px;
top: 50%;
width: 4px;
height: 4px;
border-radius: 50%;
background: color-mix(in srgb, var(--text-tertiary) 70%, transparent);
transform: translateY(-50%);
}
}
}
.anti-revoke-toolbar-actions .btn,
.anti-revoke-batch-actions .btn {
border-radius: 10px;
padding-inline: 14px;
border-width: 1px;
min-height: 36px;
justify-content: center;
}
.anti-revoke-summary {
padding: 11px 14px;
border-radius: 12px;
font-size: 13px;
border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
background: color-mix(in srgb, var(--bg-primary) 95%, var(--bg-secondary) 5%);
line-height: 1.5;
font-weight: 500;
&.success {
color: color-mix(in srgb, var(--primary) 72%, var(--text-primary) 28%);
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
background: color-mix(in srgb, var(--primary) 9%, var(--bg-primary));
}
&.error {
color: color-mix(in srgb, var(--danger) 70%, var(--text-primary) 30%);
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
background: color-mix(in srgb, var(--danger) 7%, var(--bg-primary));
}
}
.anti-revoke-list {
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
border-radius: 16px;
background: var(--bg-primary);
max-height: 460px;
overflow-y: auto;
overflow-x: hidden;
}
.anti-revoke-list-header {
position: sticky;
top: 0;
z-index: 2;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: color-mix(in srgb, var(--bg-secondary) 93%, var(--bg-primary) 7%);
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
color: var(--text-tertiary);
font-size: 12px;
letter-spacing: 0.24px;
}
.anti-revoke-empty {
padding: 44px 18px;
font-size: 13px;
color: var(--text-secondary);
text-align: center;
}
.anti-revoke-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 13px 16px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
transition: background-color 0.18s ease, box-shadow 0.18s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background: color-mix(in srgb, var(--bg-secondary) 32%, var(--bg-primary) 68%);
}
&.selected {
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--primary) 70%, transparent);
}
}
.anti-revoke-row-main {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
cursor: pointer;
.anti-revoke-check {
position: relative;
width: 18px;
height: 18px;
flex-shrink: 0;
input[type='checkbox'] {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.check-indicator {
width: 100%;
height: 100%;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%);
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary) 14%);
color: var(--on-primary, #fff);
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.16s ease;
svg {
opacity: 0;
transform: scale(0.75);
transition: opacity 0.16s ease, transform 0.16s ease;
}
}
input[type='checkbox']:checked + .check-indicator {
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
svg {
opacity: 1;
transform: scale(1);
}
}
input[type='checkbox']:focus-visible + .check-indicator {
outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent);
outline-offset: 1px;
}
input[type='checkbox']:disabled {
cursor: not-allowed;
}
input[type='checkbox']:disabled + .check-indicator {
opacity: 0.55;
}
}
}
.anti-revoke-row-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
.name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.2;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
.anti-revoke-row-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
max-width: 45%;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
line-height: 1.3;
font-weight: 500;
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
color: var(--text-secondary);
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%);
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-tertiary);
flex-shrink: 0;
}
&.installed {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary));
.status-dot {
background: var(--primary);
}
}
&.not-installed {
color: var(--text-secondary);
border-color: color-mix(in srgb, var(--border-color) 84%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%);
.status-dot {
background: color-mix(in srgb, var(--text-tertiary) 86%, transparent);
}
}
&.checking {
color: color-mix(in srgb, var(--primary) 70%, var(--text-primary) 30%);
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
background: color-mix(in srgb, var(--primary) 9%, var(--bg-secondary));
.status-dot {
background: var(--primary);
animation: pulse 1.2s ease-in-out infinite;
}
}
&.error {
color: color-mix(in srgb, var(--danger) 72%, var(--text-primary) 28%);
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
background: color-mix(in srgb, var(--danger) 8%, var(--bg-secondary));
.status-dot {
background: var(--danger);
}
}
}
.status-error {
font-size: 12px;
color: color-mix(in srgb, var(--danger) 66%, var(--text-primary) 34%);
max-width: 100%;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
@media (max-width: 980px) {
.anti-revoke-hero {
flex-direction: column;
}
.anti-revoke-metrics {
width: 100%;
min-width: 0;
grid-template-columns: repeat(2, minmax(130px, 1fr));
}
.anti-revoke-batch-actions {
align-items: flex-start;
flex-direction: column;
}
.anti-revoke-selected-count {
margin-left: 0;
width: 100%;
justify-content: flex-start;
overflow-x: auto;
}
.anti-revoke-row {
align-items: flex-start;
flex-direction: column;
}
.anti-revoke-row-status {
width: 100%;
flex-direction: row;
align-items: center;
justify-content: space-between;
max-width: none;
}
}
}

View File

@@ -15,12 +15,11 @@ import {
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import './SettingsPage.scss' import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'notification' | 'antiRevoke' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics' type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette }, { id: 'appearance', label: '外观', icon: Palette },
{ id: 'notification', label: '通知', icon: Bell }, { id: 'notification', label: '通知', icon: Bell },
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
{ id: 'database', label: '数据库连接', icon: Database }, { id: 'database', label: '数据库连接', icon: Database },
{ id: 'models', label: '模型管理', icon: Mic }, { id: 'models', label: '模型管理', icon: Mic },
{ id: 'cache', label: '缓存', icon: HardDrive }, { id: 'cache', label: '缓存', icon: HardDrive },
@@ -71,8 +70,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setShowUpdateDialog, setShowUpdateDialog,
} = useAppStore() } = useAppStore()
const chatSessions = useChatStore((state) => state.sessions)
const setChatSessions = useChatStore((state) => state.setSessions)
const resetChatStore = useChatStore((state) => state.reset) const resetChatStore = useChatStore((state) => state.reset)
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
const [systemDark, setSystemDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches) const [systemDark, setSystemDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches)
@@ -203,13 +200,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [isTogglingApi, setIsTogglingApi] = useState(false) const [isTogglingApi, setIsTogglingApi] = useState(false)
const [showApiWarning, setShowApiWarning] = useState(false) const [showApiWarning, setShowApiWarning] = useState(false)
const [messagePushEnabled, setMessagePushEnabled] = useState(false) const [messagePushEnabled, setMessagePushEnabled] = useState(false)
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
const [isAntiRevokeRefreshing, setIsAntiRevokeRefreshing] = useState(false)
const [isAntiRevokeInstalling, setIsAntiRevokeInstalling] = useState(false)
const [isAntiRevokeUninstalling, setIsAntiRevokeUninstalling] = useState(false)
const [antiRevokeSummary, setAntiRevokeSummary] = useState<{ action: 'refresh' | 'install' | 'uninstall'; success: number; failed: number } | null>(null)
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
@@ -596,248 +586,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}, 200) }, 200)
} }
const normalizeSessionIds = (sessionIds: string[]): string[] =>
Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
const getCurrentAntiRevokeSessionIds = (): string[] =>
normalizeSessionIds(chatSessions.map((session) => session.username))
const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => {
const current = getCurrentAntiRevokeSessionIds()
if (current.length > 0) return current
const sessionsResult = await window.electronAPI.chat.getSessions()
if (!sessionsResult.success || !sessionsResult.sessions) {
throw new Error(sessionsResult.error || '加载会话失败')
}
setChatSessions(sessionsResult.sessions)
return normalizeSessionIds(sessionsResult.sessions.map((session) => session.username))
}
const markAntiRevokeRowsLoading = (sessionIds: string[]) => {
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
for (const sessionId of sessionIds) {
next[sessionId] = {
...(next[sessionId] || {}),
loading: true,
error: undefined
}
}
return next
})
}
const handleRefreshAntiRevokeStatus = async (sessionIds?: string[]) => {
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
setAntiRevokeSummary(null)
setIsAntiRevokeRefreshing(true)
try {
const targetIds = normalizeSessionIds(
sessionIds && sessionIds.length > 0
? sessionIds
: await ensureAntiRevokeSessionsLoaded()
)
if (targetIds.length === 0) {
setAntiRevokeStatusMap({})
showMessage('暂无可检查的会话', true)
return
}
markAntiRevokeRowsLoading(targetIds)
const result = await window.electronAPI.chat.checkAntiRevokeTriggers(targetIds)
if (!result.success || !result.rows) {
const errorText = result.error || '防撤回状态检查失败'
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
for (const sessionId of targetIds) {
next[sessionId] = {
...(next[sessionId] || {}),
loading: false,
error: errorText
}
}
return next
})
showMessage(errorText, false)
return
}
const rowMap = new Map<string, { sessionId: string; success: boolean; installed?: boolean; error?: string }>()
for (const row of result.rows || []) {
const sessionId = String(row.sessionId || '').trim()
if (!sessionId) continue
rowMap.set(sessionId, row)
}
const mergedRows = targetIds.map((sessionId) => (
rowMap.get(sessionId) || { sessionId, success: false, error: '状态查询未返回结果' }
))
const successCount = mergedRows.filter((row) => row.success).length
const failedCount = mergedRows.length - successCount
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
for (const row of mergedRows) {
const sessionId = String(row.sessionId || '').trim()
if (!sessionId) continue
next[sessionId] = {
installed: row.installed === true,
loading: false,
error: row.success ? undefined : (row.error || '状态查询失败')
}
}
return next
})
setAntiRevokeSummary({ action: 'refresh', success: successCount, failed: failedCount })
showMessage(`状态刷新完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
} catch (e: any) {
showMessage(`防撤回状态刷新失败: ${e?.message || String(e)}`, false)
} finally {
setIsAntiRevokeRefreshing(false)
}
}
const handleInstallAntiRevokeTriggers = async () => {
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
const sessionIds = normalizeSessionIds(Array.from(antiRevokeSelectedIds))
if (sessionIds.length === 0) {
showMessage('请先选择至少一个会话', false)
return
}
setAntiRevokeSummary(null)
setIsAntiRevokeInstalling(true)
try {
markAntiRevokeRowsLoading(sessionIds)
const result = await window.electronAPI.chat.installAntiRevokeTriggers(sessionIds)
if (!result.success || !result.rows) {
const errorText = result.error || '批量安装失败'
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
for (const sessionId of sessionIds) {
next[sessionId] = {
...(next[sessionId] || {}),
loading: false,
error: errorText
}
}
return next
})
showMessage(errorText, false)
return
}
const rowMap = new Map<string, { sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>()
for (const row of result.rows || []) {
const sessionId = String(row.sessionId || '').trim()
if (!sessionId) continue
rowMap.set(sessionId, row)
}
const mergedRows = sessionIds.map((sessionId) => (
rowMap.get(sessionId) || { sessionId, success: false, error: '安装未返回结果' }
))
const successCount = mergedRows.filter((row) => row.success).length
const failedCount = mergedRows.length - successCount
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
for (const row of mergedRows) {
const sessionId = String(row.sessionId || '').trim()
if (!sessionId) continue
next[sessionId] = {
installed: row.success ? true : next[sessionId]?.installed,
loading: false,
error: row.success ? undefined : (row.error || '安装失败')
}
}
return next
})
setAntiRevokeSummary({ action: 'install', success: successCount, failed: failedCount })
showMessage(`批量安装完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
} catch (e: any) {
showMessage(`批量安装失败: ${e?.message || String(e)}`, false)
} finally {
setIsAntiRevokeInstalling(false)
}
}
const handleUninstallAntiRevokeTriggers = async () => {
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
const sessionIds = normalizeSessionIds(Array.from(antiRevokeSelectedIds))
if (sessionIds.length === 0) {
showMessage('请先选择至少一个会话', false)
return
}
setAntiRevokeSummary(null)
setIsAntiRevokeUninstalling(true)
try {
markAntiRevokeRowsLoading(sessionIds)
const result = await window.electronAPI.chat.uninstallAntiRevokeTriggers(sessionIds)
if (!result.success || !result.rows) {
const errorText = result.error || '批量卸载失败'
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
for (const sessionId of sessionIds) {
next[sessionId] = {
...(next[sessionId] || {}),
loading: false,
error: errorText
}
}
return next
})
showMessage(errorText, false)
return
}
const rowMap = new Map<string, { sessionId: string; success: boolean; error?: string }>()
for (const row of result.rows || []) {
const sessionId = String(row.sessionId || '').trim()
if (!sessionId) continue
rowMap.set(sessionId, row)
}
const mergedRows = sessionIds.map((sessionId) => (
rowMap.get(sessionId) || { sessionId, success: false, error: '卸载未返回结果' }
))
const successCount = mergedRows.filter((row) => row.success).length
const failedCount = mergedRows.length - successCount
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
for (const row of mergedRows) {
const sessionId = String(row.sessionId || '').trim()
if (!sessionId) continue
next[sessionId] = {
installed: row.success ? false : next[sessionId]?.installed,
loading: false,
error: row.success ? undefined : (row.error || '卸载失败')
}
}
return next
})
setAntiRevokeSummary({ action: 'uninstall', success: successCount, failed: failedCount })
showMessage(`批量卸载完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
} catch (e: any) {
showMessage(`批量卸载失败: ${e?.message || String(e)}`, false)
} finally {
setIsAntiRevokeUninstalling(false)
}
}
useEffect(() => {
if (activeTab !== 'antiRevoke') return
let canceled = false
;(async () => {
try {
const sessionIds = await ensureAntiRevokeSessionsLoaded()
if (canceled) return
await handleRefreshAntiRevokeStatus(sessionIds)
} catch (e: any) {
if (!canceled) {
showMessage(`加载防撤回会话失败: ${e?.message || String(e)}`, false)
}
}
})()
return () => {
canceled = true
}
}, [activeTab])
type WxidKeys = { type WxidKeys = {
decryptKey: string decryptKey: string
imageXorKey: number | null imageXorKey: number | null
@@ -1571,9 +1319,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
) )
const renderNotificationTab = () => { const renderNotificationTab = () => {
const { sessions } = useChatStore.getState()
// 获取已过滤会话的信息 // 获取已过滤会话的信息
const getSessionInfo = (username: string) => { const getSessionInfo = (username: string) => {
const session = chatSessions.find(s => s.username === username) const session = sessions.find(s => s.username === username)
return { return {
displayName: session?.displayName || username, displayName: session?.displayName || username,
avatarUrl: session?.avatarUrl || '' avatarUrl: session?.avatarUrl || ''
@@ -1598,7 +1348,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
} }
// 过滤掉已在列表中的会话,并根据搜索关键字过滤 // 过滤掉已在列表中的会话,并根据搜索关键字过滤
const availableSessions = chatSessions.filter(s => { const availableSessions = sessions.filter(s => {
if (notificationFilterList.includes(s.username)) return false if (notificationFilterList.includes(s.username)) return false
if (filterSearchKeyword) { if (filterSearchKeyword) {
const keyword = filterSearchKeyword.toLowerCase() const keyword = filterSearchKeyword.toLowerCase()
@@ -1814,199 +1564,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
) )
} }
const renderAntiRevokeTab = () => {
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
const keyword = antiRevokeSearchKeyword.trim().toLowerCase()
const filteredSessions = sortedSessions.filter((session) => {
if (!keyword) return true
const displayName = String(session.displayName || '').toLowerCase()
const username = String(session.username || '').toLowerCase()
return displayName.includes(keyword) || username.includes(keyword)
})
const filteredSessionIds = filteredSessions.map((session) => session.username)
const selectedCount = antiRevokeSelectedIds.size
const selectedInFilteredCount = filteredSessionIds.filter((sessionId) => antiRevokeSelectedIds.has(sessionId)).length
const allFilteredSelected = filteredSessionIds.length > 0 && selectedInFilteredCount === filteredSessionIds.length
const busy = isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling
const statusStats = filteredSessions.reduce(
(acc, session) => {
const rowState = antiRevokeStatusMap[session.username]
if (rowState?.error) acc.failed += 1
else if (rowState?.installed === true) acc.installed += 1
else if (rowState?.installed === false) acc.notInstalled += 1
return acc
},
{ installed: 0, notInstalled: 0, failed: 0 }
)
const toggleSelected = (sessionId: string) => {
setAntiRevokeSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(sessionId)) next.delete(sessionId)
else next.add(sessionId)
return next
})
}
const selectAllFiltered = () => {
if (filteredSessionIds.length === 0) return
setAntiRevokeSelectedIds((prev) => {
const next = new Set(prev)
for (const sessionId of filteredSessionIds) {
next.add(sessionId)
}
return next
})
}
const clearSelection = () => {
setAntiRevokeSelectedIds(new Set())
}
return (
<div className="tab-content anti-revoke-tab">
<div className="anti-revoke-hero">
<div className="anti-revoke-hero-main">
<h3></h3>
<p></p>
</div>
<div className="anti-revoke-metrics">
<div className="anti-revoke-metric is-total">
<span className="label"></span>
<span className="value">{filteredSessionIds.length}</span>
</div>
<div className="anti-revoke-metric is-installed">
<span className="label"></span>
<span className="value">{statusStats.installed}</span>
</div>
<div className="anti-revoke-metric is-pending">
<span className="label"></span>
<span className="value">{statusStats.notInstalled}</span>
</div>
<div className="anti-revoke-metric is-error">
<span className="label"></span>
<span className="value">{statusStats.failed}</span>
</div>
</div>
</div>
<div className="anti-revoke-control-card">
<div className="anti-revoke-toolbar">
<div className="filter-search-box anti-revoke-search">
<Search size={14} />
<input
type="text"
placeholder="搜索会话..."
value={antiRevokeSearchKeyword}
onChange={(e) => setAntiRevokeSearchKeyword(e.target.value)}
/>
</div>
<div className="anti-revoke-toolbar-actions">
<div className="anti-revoke-btn-group">
<button className="btn btn-secondary btn-sm" onClick={() => void handleRefreshAntiRevokeStatus()} disabled={busy}>
<RefreshCw size={14} /> {isAntiRevokeRefreshing ? '刷新中...' : '刷新状态'}
</button>
</div>
<div className="anti-revoke-btn-group">
<button className="btn btn-secondary btn-sm" onClick={selectAllFiltered} disabled={busy || filteredSessionIds.length === 0 || allFilteredSelected}>
</button>
<button className="btn btn-secondary btn-sm" onClick={clearSelection} disabled={busy || selectedCount === 0}>
</button>
</div>
</div>
</div>
<div className="anti-revoke-batch-actions">
<div className="anti-revoke-btn-group anti-revoke-batch-btns">
<button className="btn btn-primary btn-sm" onClick={() => void handleInstallAntiRevokeTriggers()} disabled={busy || selectedCount === 0}>
{isAntiRevokeInstalling ? '安装中...' : '批量安装'}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => void handleUninstallAntiRevokeTriggers()} disabled={busy || selectedCount === 0}>
{isAntiRevokeUninstalling ? '卸载中...' : '批量卸载'}
</button>
</div>
<div className="anti-revoke-selected-count">
<span> <strong>{selectedCount}</strong> </span>
<span> <strong>{selectedInFilteredCount}</strong> / {filteredSessionIds.length}</span>
</div>
</div>
</div>
{antiRevokeSummary && (
<div className={`anti-revoke-summary ${antiRevokeSummary.failed > 0 ? 'error' : 'success'}`}>
{antiRevokeSummary.action === 'refresh' ? '刷新' : antiRevokeSummary.action === 'install' ? '安装' : '卸载'}
{antiRevokeSummary.success} {antiRevokeSummary.failed}
</div>
)}
<div className="anti-revoke-list">
{filteredSessions.length === 0 ? (
<div className="anti-revoke-empty">{antiRevokeSearchKeyword ? '没有匹配的会话' : '暂无会话可配置'}</div>
) : (
<>
<div className="anti-revoke-list-header">
<span>{filteredSessions.length}</span>
<span></span>
</div>
{filteredSessions.map((session) => {
const rowState = antiRevokeStatusMap[session.username]
let statusClass = 'unknown'
let statusLabel = '未检查'
if (rowState?.loading) {
statusClass = 'checking'
statusLabel = '检查中'
} else if (rowState?.error) {
statusClass = 'error'
statusLabel = '失败'
} else if (rowState?.installed === true) {
statusClass = 'installed'
statusLabel = '已安装'
} else if (rowState?.installed === false) {
statusClass = 'not-installed'
statusLabel = '未安装'
}
return (
<div key={session.username} className={`anti-revoke-row ${antiRevokeSelectedIds.has(session.username) ? 'selected' : ''}`}>
<label className="anti-revoke-row-main">
<span className="anti-revoke-check">
<input
type="checkbox"
checked={antiRevokeSelectedIds.has(session.username)}
onChange={() => toggleSelected(session.username)}
disabled={busy}
/>
<span className="check-indicator" aria-hidden="true">
<Check size={12} />
</span>
</span>
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
size={30}
/>
<div className="anti-revoke-row-text">
<span className="name">{session.displayName || session.username}</span>
</div>
</label>
<div className="anti-revoke-row-status">
<span className={`status-badge ${statusClass}`}>
<i className="status-dot" aria-hidden="true" />
{statusLabel}
</span>
{rowState?.error && <span className="status-error">{rowState.error}</span>}
</div>
</div>
)
})}
</>
)}
</div>
</div>
)
}
const renderDatabaseTab = () => ( const renderDatabaseTab = () => (
<div className="tab-content"> <div className="tab-content">
<div className="form-group"> <div className="form-group">
@@ -2951,9 +2508,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="about-footer"> <div className="about-footer">
<p className="about-desc"></p> <p className="about-desc"></p>
<div className="about-links"> <div className="about-links">
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://weflow.top') }}></a> <a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://github.com/hicccc77/WeFlow') }}></a>
<span>·</span>
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://github.com/hicccc77/WeFlow') }}>GitHub </a>
<span>·</span> <span>·</span>
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://chatlab.fun') }}>ChatLab</a> <a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://chatlab.fun') }}>ChatLab</a>
<span>·</span> <span>·</span>
@@ -3130,7 +2685,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="settings-body"> <div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()} {activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'notification' && renderNotificationTab()} {activeTab === 'notification' && renderNotificationTab()}
{activeTab === 'antiRevoke' && renderAntiRevokeTab()}
{activeTab === 'database' && renderDatabaseTab()} {activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'models' && renderModelsTab()} {activeTab === 'models' && renderModelsTab()}
{activeTab === 'cache' && renderCacheTab()} {activeTab === 'cache' && renderCacheTab()}

View File

@@ -94,7 +94,6 @@ export interface ExportDefaultMediaConfig {
videos: boolean videos: boolean
voices: boolean voices: boolean
emojis: boolean emojis: boolean
files: boolean
} }
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit' export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
@@ -105,8 +104,7 @@ const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
images: true, images: true,
videos: true, videos: true,
voices: true, voices: true,
emojis: true, emojis: true
files: true
} }
// 获取解密密钥 // 获取解密密钥
@@ -425,8 +423,7 @@ export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig
images: value, images: value,
videos: value, videos: value,
voices: value, voices: value,
emojis: value, emojis: value
files: value
} }
} }
if (value && typeof value === 'object') { if (value && typeof value === 'object') {
@@ -435,8 +432,7 @@ export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig
images: typeof raw.images === 'boolean' ? raw.images : DEFAULT_EXPORT_MEDIA_CONFIG.images, images: typeof raw.images === 'boolean' ? raw.images : DEFAULT_EXPORT_MEDIA_CONFIG.images,
videos: typeof raw.videos === 'boolean' ? raw.videos : DEFAULT_EXPORT_MEDIA_CONFIG.videos, videos: typeof raw.videos === 'boolean' ? raw.videos : DEFAULT_EXPORT_MEDIA_CONFIG.videos,
voices: typeof raw.voices === 'boolean' ? raw.voices : DEFAULT_EXPORT_MEDIA_CONFIG.voices, voices: typeof raw.voices === 'boolean' ? raw.voices : DEFAULT_EXPORT_MEDIA_CONFIG.voices,
emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis, emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis
files: typeof raw.files === 'boolean' ? raw.files : DEFAULT_EXPORT_MEDIA_CONFIG.files
} }
} }
return null return null
@@ -448,8 +444,7 @@ export async function setExportDefaultMedia(media: ExportDefaultMediaConfig): Pr
images: media.images, images: media.images,
videos: media.videos, videos: media.videos,
voices: media.voices, voices: media.voices,
emojis: media.emojis, emojis: media.emojis
files: media.files
}) })
} }

View File

@@ -1,46 +1,6 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { ChatSession, Message, Contact } from '../types/models' import type { ChatSession, Message, Contact } from '../types/models'
const messageAliasIndex = new Set<string>()
function buildPrimaryMessageKey(message: Message): string {
if (message.messageKey) return String(message.messageKey)
return `fallback:${message.serverId || 0}:${message.createTime}:${message.sortSeq || 0}:${message.localId || 0}:${message.senderUsername || ''}:${message.localType || 0}`
}
function buildMessageAliasKeys(message: Message): string[] {
const keys = [buildPrimaryMessageKey(message)]
const localId = Math.max(0, Number(message.localId || 0))
const serverId = Math.max(0, Number(message.serverId || 0))
const createTime = Math.max(0, Number(message.createTime || 0))
const localType = Math.floor(Number(message.localType || 0))
const sender = String(message.senderUsername || '')
const isSend = Number(message.isSend ?? -1)
if (localId > 0) {
keys.push(`lid:${localId}`)
}
if (serverId > 0) {
keys.push(`sid:${serverId}`)
}
if (localType === 3) {
const imageIdentity = String(message.imageMd5 || message.imageDatName || '').trim()
if (imageIdentity) {
keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`)
}
}
return keys
}
function rebuildMessageAliasIndex(messages: Message[]): void {
messageAliasIndex.clear()
for (const message of messages) {
const aliasKeys = buildMessageAliasKeys(message)
aliasKeys.forEach((key) => messageAliasIndex.add(key))
}
}
export interface ChatState { export interface ChatState {
// 连接状态 // 连接状态
isConnected: boolean isConnected: boolean
@@ -109,37 +69,59 @@ export const useChatStore = create<ChatState>((set, get) => ({
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }), setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }), setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
setCurrentSession: (sessionId, options) => set((state) => { setCurrentSession: (sessionId, options) => set((state) => ({
const nextMessages = options?.preserveMessages ? state.messages : []
rebuildMessageAliasIndex(nextMessages)
return {
currentSessionId: sessionId, currentSessionId: sessionId,
messages: nextMessages, messages: options?.preserveMessages ? state.messages : [],
hasMoreMessages: true, hasMoreMessages: true,
hasMoreLater: false hasMoreLater: false
} })),
}),
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }), setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
setMessages: (messages) => set(() => { setMessages: (messages) => set({ messages }),
rebuildMessageAliasIndex(messages || [])
return { messages }
}),
appendMessages: (newMessages, prepend = false) => set((state) => { appendMessages: (newMessages, prepend = false) => set((state) => {
const currentMessages = state.messages || [] const buildPrimaryKey = (m: Message): string => {
if (messageAliasIndex.size === 0 && currentMessages.length > 0) { if (m.messageKey) return String(m.messageKey)
rebuildMessageAliasIndex(currentMessages) return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}`
} }
const buildAliasKeys = (m: Message): string[] => {
const keys = [buildPrimaryKey(m)]
const localId = Math.max(0, Number(m.localId || 0))
const serverId = Math.max(0, Number(m.serverId || 0))
const createTime = Math.max(0, Number(m.createTime || 0))
const localType = Math.floor(Number(m.localType || 0))
const sender = String(m.senderUsername || '')
const isSend = Number(m.isSend ?? -1)
if (localId > 0) {
keys.push(`lid:${localId}`)
}
if (serverId > 0) {
keys.push(`sid:${serverId}`)
}
if (localType === 3) {
const imageIdentity = String(m.imageMd5 || m.imageDatName || '').trim()
if (imageIdentity) {
keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`)
}
}
return keys
}
const currentMessages = state.messages || []
const existingAliases = new Set<string>()
currentMessages.forEach((msg) => {
buildAliasKeys(msg).forEach((key) => existingAliases.add(key))
})
const filtered: Message[] = [] const filtered: Message[] = []
newMessages.forEach((msg) => { newMessages.forEach((msg) => {
const aliasKeys = buildMessageAliasKeys(msg) const aliasKeys = buildAliasKeys(msg)
const exists = aliasKeys.some((key) => messageAliasIndex.has(key)) const exists = aliasKeys.some((key) => existingAliases.has(key))
if (exists) return if (exists) return
filtered.push(msg) filtered.push(msg)
aliasKeys.forEach((key) => messageAliasIndex.add(key)) aliasKeys.forEach((key) => existingAliases.add(key))
}) })
if (filtered.length === 0) return state if (filtered.length === 0) return state
@@ -168,9 +150,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
setSearchKeyword: (keyword) => set({ searchKeyword: keyword }), setSearchKeyword: (keyword) => set({ searchKeyword: keyword }),
reset: () => set(() => { reset: () => set({
messageAliasIndex.clear()
return {
isConnected: false, isConnected: false,
isConnecting: false, isConnecting: false,
connectionError: null, connectionError: null,
@@ -185,6 +165,5 @@ export const useChatStore = create<ChatState>((set, get) => ({
hasMoreLater: false, hasMoreLater: false,
contacts: new Map(), contacts: new Map(),
searchKeyword: '' searchKeyword: ''
}
}) })
})) }))

View File

@@ -226,21 +226,6 @@ export interface ElectronAPI {
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null> getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }> updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }> deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
checkAntiRevokeTriggers: (sessionIds: string[]) => Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>
error?: string
}>
installAntiRevokeTriggers: (sessionIds: string[]) => Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>
error?: string
}>
uninstallAntiRevokeTriggers: (sessionIds: string[]) => Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; error?: string }>
error?: string
}>
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }> resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
getContacts: (options?: { lite?: boolean }) => Promise<{ getContacts: (options?: { lite?: boolean }) => Promise<{
success: boolean success: boolean
@@ -896,7 +881,7 @@ export interface ElectronAPI {
export interface ExportOptions { export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file' contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji'
dateRange?: { start: number; end: number } | null dateRange?: { start: number; end: number } | null
senderUsername?: string senderUsername?: string
fileNameSuffix?: string fileNameSuffix?: string
@@ -906,8 +891,6 @@ export interface ExportOptions {
exportVoices?: boolean exportVoices?: boolean
exportVideos?: boolean exportVideos?: boolean
exportEmojis?: boolean exportEmojis?: boolean
exportFiles?: boolean
maxFileSizeMb?: number
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
excelCompactColumns?: boolean excelCompactColumns?: boolean
txtColumns?: string[] txtColumns?: string[]

View File

@@ -75,7 +75,6 @@ export interface Message {
fileName?: string // 文件名 fileName?: string // 文件名
fileSize?: number // 文件大小 fileSize?: number // 文件大小
fileExt?: string // 文件扩展名 fileExt?: string // 文件扩展名
fileMd5?: string // 文件 MD5
xmlType?: string // XML 中的 type 字段 xmlType?: string // XML 中的 type 字段
appMsgKind?: string // 归一化 appmsg 类型 appMsgKind?: string // 归一化 appmsg 类型
appMsgDesc?: string appMsgDesc?: string