mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-04 23:15:51 +00:00
Compare commits
1 Commits
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d172114c09 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -72,4 +72,3 @@ pnpm-lock.yaml
|
|||||||
/pnpm-workspace.yaml
|
/pnpm-workspace.yaml
|
||||||
wechat-research-site
|
wechat-research-site
|
||||||
.codex
|
.codex
|
||||||
weflow-web-offical
|
|
||||||
@@ -68,7 +68,6 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
| 功能模块 | 说明 |
|
| 功能模块 | 说明 |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
||||||
| **消息防撤回** | 防止其他人发送的消息被撤回 |
|
|
||||||
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
||||||
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
||||||
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为朋友圈安装删除
|
* 为朋友圈安装删除
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 安装朋友圈删除拦截
|
* 安装朋友圈删除拦截
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
8
package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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.
BIN
resources/wcdb_api_arm64.dll
Normal file
BIN
resources/wcdb_api_arm64.dll
Normal file
Binary file not shown.
10
src/App.tsx
10
src/App.tsx
@@ -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>
|
|
||||||
·
|
|
||||||
<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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: ''
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|||||||
19
src/types/electron.d.ts
vendored
19
src/types/electron.d.ts
vendored
@@ -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[]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user