mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-05 15:08:14 +00:00
修复 #597;实现 #556;修复 #623与 #543;修复卡片图片问题
This commit is contained in:
@@ -68,6 +68,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
| 功能模块 | 说明 |
|
| 功能模块 | 说明 |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
||||||
|
| **消息防撤回** | 防止其他人发送的消息被撤回 |
|
||||||
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
||||||
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
||||||
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
||||||
|
|||||||
@@ -1867,6 +1867,18 @@ 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)
|
||||||
})
|
})
|
||||||
@@ -2299,10 +2311,47 @@ 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 onProgress = (progress: ExportProgress) => {
|
const PROGRESS_FORWARD_INTERVAL_MS = 180
|
||||||
if (!event.sender.isDestroyed()) {
|
let pendingProgress: ExportProgress | null = null
|
||||||
event.sender.send('export:progress', progress)
|
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) => {
|
||||||
|
queueProgress(progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
const runMainFallback = async (reason: string) => {
|
const runMainFallback = async (reason: string) => {
|
||||||
@@ -2381,6 +2430,12 @@ 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,6 +190,12 @@ 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'
|
||||||
@@ -558,6 +558,51 @@ 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取会话列表(优化:先返回基础数据,不等待联系人信息加载)
|
* 获取会话列表(优化:先返回基础数据,不等待联系人信息加载)
|
||||||
*/
|
*/
|
||||||
@@ -1773,18 +1818,9 @@ 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(
|
const dbPath = String(row._db_path || row.db_path || '').trim()
|
||||||
this.getRowField(row, ['_db_path', 'db_path', 'dbPath', 'database_path', 'databasePath', 'source_db_path'])
|
const explicitDbName = String(row.db_name || '').trim()
|
||||||
|| ''
|
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,
|
||||||
@@ -3201,7 +3237,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', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
|
const localType = this.getRowInt(row, ['local_type'], 1)
|
||||||
if (localType === 50) {
|
if (localType === 50) {
|
||||||
counters.callMessages += 1
|
counters.callMessages += 1
|
||||||
continue
|
continue
|
||||||
@@ -3216,8 +3252,8 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
if (localType !== 49) continue
|
if (localType !== 49) continue
|
||||||
|
|
||||||
const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content'])
|
const rawMessageContent = row.message_content
|
||||||
const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content'])
|
const rawCompressContent = row.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
|
||||||
@@ -3270,7 +3306,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', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
|
const localType = this.getRowInt(row, ['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
|
||||||
@@ -3279,8 +3315,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 = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content'])
|
const rawMessageContent = row.message_content
|
||||||
const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content'])
|
const rawCompressContent = row.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
|
||||||
@@ -3289,7 +3325,7 @@ class ChatService {
|
|||||||
|
|
||||||
const createTime = this.getRowInt(
|
const createTime = this.getRowInt(
|
||||||
row,
|
row,
|
||||||
['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'],
|
['create_time'],
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
if (createTime > 0) {
|
if (createTime > 0) {
|
||||||
@@ -3302,7 +3338,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionId.endsWith('@chatroom')) {
|
if (sessionId.endsWith('@chatroom')) {
|
||||||
const sender = String(this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || '').trim()
|
const sender = String(row.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])
|
||||||
@@ -3310,7 +3346,7 @@ class ChatService {
|
|||||||
stats.groupMyMessages = (stats.groupMyMessages || 0) + 1
|
stats.groupMyMessages = (stats.groupMyMessages || 0) + 1
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const isSend = this.coerceRowNumber(this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']))
|
const isSend = this.coerceRowNumber(row.computed_is_send ?? row.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
|
||||||
}
|
}
|
||||||
@@ -3744,32 +3780,18 @@ 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 = this.getRowField(row, [
|
const rawMessageContent = row.message_content
|
||||||
'message_content',
|
const rawCompressContent = row.compress_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', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
|
const localType = this.getRowInt(row, ['local_type'], 1)
|
||||||
const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
const isSendRaw = row.computed_is_send ?? row.is_send
|
||||||
const parsedRawIsSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
|
const parsedRawIsSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
|
||||||
const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
const senderUsername = row.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', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
|
const createTime = this.getRowInt(row, ['create_time'], 0)
|
||||||
|
|
||||||
if (senderUsername && !myWxid) {
|
if (senderUsername && !myWxid) {
|
||||||
// [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送
|
// [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送
|
||||||
@@ -3954,10 +3976,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', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
|
const localId = this.getRowInt(row, ['local_id'], 0)
|
||||||
const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id']))
|
const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id)
|
||||||
const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0)
|
const serverId = this.getRowInt(row, ['server_id'], 0)
|
||||||
const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
|
const sortSeq = this.getRowInt(row, ['sort_seq'], createTime)
|
||||||
|
|
||||||
messages.push({
|
messages.push({
|
||||||
messageKey: this.buildMessageKey({
|
messageKey: this.buildMessageKey({
|
||||||
@@ -4404,18 +4426,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private parseImageDatNameFromRow(row: Record<string, any>): string | undefined {
|
private parseImageDatNameFromRow(row: Record<string, any>): string | undefined {
|
||||||
const packed = this.getRowField(row, [
|
const packed = row.packed_info_data
|
||||||
'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[] = []
|
||||||
@@ -5303,14 +5314,14 @@ class ChatService {
|
|||||||
row: Record<string, any>,
|
row: Record<string, any>,
|
||||||
rawContent: string
|
rawContent: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const directSender = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
const directSender = row.sender_username
|
||||||
|| this.extractSenderUsernameFromContent(rawContent)
|
|| this.extractSenderUsernameFromContent(rawContent)
|
||||||
if (directSender) {
|
if (directSender) {
|
||||||
return directSender
|
return directSender
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbPath = this.getRowField(row, ['db_path', 'dbPath', '_db_path'])
|
const dbPath = row._db_path
|
||||||
const realSenderId = this.getRowField(row, ['real_sender_id', 'realSenderId'])
|
const realSenderId = row.real_sender_id
|
||||||
if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') {
|
if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -5359,7 +5370,7 @@ class ChatService {
|
|||||||
50: '[通话]',
|
50: '[通话]',
|
||||||
10000: '[系统消息]',
|
10000: '[系统消息]',
|
||||||
244813135921: '[引用消息]',
|
244813135921: '[引用消息]',
|
||||||
266287972401: '[拍一拍]',
|
266287972401: '拍一拍',
|
||||||
81604378673: '[聊天记录]',
|
81604378673: '[聊天记录]',
|
||||||
154618822705: '[小程序]',
|
154618822705: '[小程序]',
|
||||||
8594229559345: '[红包]',
|
8594229559345: '[红包]',
|
||||||
@@ -5468,7 +5479,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)
|
||||||
@@ -5478,14 +5489,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, 乱码)
|
||||||
@@ -5499,10 +5510,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 '拍一拍'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -7520,11 +7531,7 @@ 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(
|
const resolvedSessionId = String(sessionId || row._session_id || '').trim()
|
||||||
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 &&
|
||||||
@@ -7559,32 +7566,18 @@ 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(
|
||||||
this.getRowField(row, [
|
row.message_content,
|
||||||
'message_content',
|
row.compress_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', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
|
const localId = this.getRowInt(row, ['local_id'], 0)
|
||||||
const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id']))
|
const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id)
|
||||||
const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0)
|
const serverId = this.getRowInt(row, ['server_id'], 0)
|
||||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
|
const localType = this.getRowInt(row, ['local_type'], 0)
|
||||||
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
|
const createTime = this.getRowInt(row, ['create_time'], 0)
|
||||||
const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
|
const sortSeq = this.getRowInt(row, ['sort_seq'], createTime)
|
||||||
const rawIsSend = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
const rawIsSend = row.computed_is_send ?? row.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 = {
|
||||||
@@ -7612,8 +7605,8 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.localId === 0 || msg.createTime === 0) {
|
if (msg.localId === 0 || msg.createTime === 0) {
|
||||||
const rawLocalId = this.getRowField(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'])
|
const rawLocalId = row.local_id
|
||||||
const rawCreateTime = this.getRowField(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'])
|
const rawCreateTime = row.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,6 +61,7 @@ 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 加密的字段(普通模式)
|
||||||
@@ -133,7 +134,8 @@ 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 = {
|
||||||
|
|||||||
@@ -430,6 +430,8 @@ 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)
|
||||||
@@ -454,9 +456,9 @@ class ExportService {
|
|||||||
const shouldEmit = force ||
|
const shouldEmit = force ||
|
||||||
phase !== lastPhase ||
|
phase !== lastPhase ||
|
||||||
sessionId !== lastSessionId ||
|
sessionId !== lastSessionId ||
|
||||||
collectedDelta >= 200 ||
|
collectedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD ||
|
||||||
exportedDelta >= 200 ||
|
exportedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD ||
|
||||||
(now - lastSentAt >= 120)
|
(now - lastSentAt >= MIN_PROGRESS_EMIT_INTERVAL_MS)
|
||||||
|
|
||||||
if (shouldEmit && pending) {
|
if (shouldEmit && pending) {
|
||||||
commit(pending)
|
commit(pending)
|
||||||
@@ -3537,20 +3539,11 @@ 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] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'}`)
|
console.log(`[Export] 缩略图也获取失败,所有方式均失败 → 将显示 [图片] 占位符`)
|
||||||
// 最后尝试:直接从 imageStore 获取缓存的缩略图 data URL
|
if (missingRunCacheKey) {
|
||||||
const { imageStore } = await import('../main')
|
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
|
||||||
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) {
|
|
||||||
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3559,7 +3552,7 @@ class ExportService {
|
|||||||
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 = result.localPath
|
let sourcePath: string = result.localPath!
|
||||||
if (sourcePath.startsWith('data:')) {
|
if (sourcePath.startsWith('data:')) {
|
||||||
// 是 data URL,需要保存为文件
|
// 是 data URL,需要保存为文件
|
||||||
const base64Data = sourcePath.split(',')[1]
|
const base64Data = sourcePath.split(',')[1]
|
||||||
@@ -8389,22 +8382,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))
|
? Math.max(0, Math.floor(metric?.totalMessages ?? 0))
|
||||||
: 0
|
: 0
|
||||||
const voiceCount = Number.isFinite(metric?.voiceMessages)
|
const voiceCount = Number.isFinite(metric?.voiceMessages)
|
||||||
? Math.max(0, Math.floor(metric!.voiceMessages))
|
? Math.max(0, Math.floor(metric?.voiceMessages ?? 0))
|
||||||
: 0
|
: 0
|
||||||
const imageCount = Number.isFinite(metric?.imageMessages)
|
const imageCount = Number.isFinite(metric?.imageMessages)
|
||||||
? Math.max(0, Math.floor(metric!.imageMessages))
|
? Math.max(0, Math.floor(metric?.imageMessages ?? 0))
|
||||||
: 0
|
: 0
|
||||||
const videoCount = Number.isFinite(metric?.videoMessages)
|
const videoCount = Number.isFinite(metric?.videoMessages)
|
||||||
? Math.max(0, Math.floor(metric!.videoMessages))
|
? Math.max(0, Math.floor(metric?.videoMessages ?? 0))
|
||||||
: 0
|
: 0
|
||||||
const emojiCount = Number.isFinite(metric?.emojiMessages)
|
const emojiCount = Number.isFinite(metric?.emojiMessages)
|
||||||
? Math.max(0, Math.floor(metric!.emojiMessages))
|
? Math.max(0, Math.floor(metric?.emojiMessages ?? 0))
|
||||||
: 0
|
: 0
|
||||||
const lastTimestamp = Number.isFinite(metric?.lastTimestamp)
|
const lastTimestamp = Number.isFinite(metric?.lastTimestamp)
|
||||||
? Math.max(0, Math.floor(metric!.lastTimestamp))
|
? Math.max(0, Math.floor(metric?.lastTimestamp ?? 0))
|
||||||
: 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,6 +92,9 @@ 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
|
||||||
@@ -163,7 +166,7 @@ export class WcdbCore {
|
|||||||
pipePath = this.koffi.decode(namePtr[0], 'char', -1)
|
pipePath = this.koffi.decode(namePtr[0], 'char', -1)
|
||||||
this.wcdbFreeString(namePtr[0])
|
this.wcdbFreeString(namePtr[0])
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
this.connectMonitorPipe(pipePath)
|
this.connectMonitorPipe(pipePath)
|
||||||
return true
|
return true
|
||||||
@@ -181,7 +184,7 @@ export class WcdbCore {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!this.monitorCallback) return
|
if (!this.monitorCallback) return
|
||||||
|
|
||||||
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {})
|
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => { })
|
||||||
|
|
||||||
let buffer = ''
|
let buffer = ''
|
||||||
this.monitorPipeClient.on('data', (data: Buffer) => {
|
this.monitorPipeClient.on('data', (data: Buffer) => {
|
||||||
@@ -273,7 +276,7 @@ export class WcdbCore {
|
|||||||
const isArm64 = process.arch === 'arm64'
|
const isArm64 = process.arch === 'arm64'
|
||||||
const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll'
|
const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll'
|
||||||
const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '')
|
const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '')
|
||||||
|
|
||||||
const envDllPath = process.env.WCDB_DLL_PATH
|
const envDllPath = process.env.WCDB_DLL_PATH
|
||||||
if (envDllPath && envDllPath.length > 0) {
|
if (envDllPath && envDllPath.length > 0) {
|
||||||
return envDllPath
|
return envDllPath
|
||||||
@@ -313,7 +316,7 @@ export class WcdbCore {
|
|||||||
'-2302': 'WCDB 初始化异常,请重试',
|
'-2302': 'WCDB 初始化异常,请重试',
|
||||||
'-2303': 'WCDB 未能成功初始化',
|
'-2303': 'WCDB 未能成功初始化',
|
||||||
}
|
}
|
||||||
const msg = messages[String(code) as keyof typeof messages]
|
const msg = messages[String(code) as unknown as keyof typeof messages]
|
||||||
return msg ? `${msg} (错误码: ${code})` : `操作失败,错误码: ${code}`
|
return msg ? `${msg} (错误码: ${code})` : `操作失败,错误码: ${code}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,7 +646,7 @@ export class WcdbCore {
|
|||||||
const dllDir = dirname(dllPath)
|
const dllDir = dirname(dllPath)
|
||||||
const isMac = process.platform === 'darwin'
|
const isMac = process.platform === 'darwin'
|
||||||
const isLinux = process.platform === 'linux'
|
const isLinux = process.platform === 'linux'
|
||||||
|
|
||||||
// 预加载依赖库
|
// 预加载依赖库
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
const wcdbCorePath = join(dllDir, 'libWCDB.dylib')
|
const wcdbCorePath = join(dllDir, 'libWCDB.dylib')
|
||||||
@@ -1077,6 +1080,27 @@ 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)')
|
||||||
@@ -1337,12 +1361,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|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*-?\d{16,}/.test(raw)
|
const needsInt64Normalize = /"server_id"\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|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*)(-?\d{16,})/g,
|
/("server_id"\s*:\s*)(-?\d{16,})/g,
|
||||||
'$1"$2"'
|
'$1"$2"'
|
||||||
)
|
)
|
||||||
return JSON.parse(normalized)
|
return JSON.parse(normalized)
|
||||||
@@ -1655,6 +1679,9 @@ 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] }
|
||||||
@@ -1685,6 +1712,9 @@ 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) {
|
||||||
@@ -1704,6 +1734,9 @@ 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])
|
||||||
@@ -2661,7 +2694,9 @@ export class WcdbCore {
|
|||||||
)
|
)
|
||||||
const hint = result === -3
|
const hint = result === -3
|
||||||
? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试`
|
? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试`
|
||||||
: `创建游标失败: ${result},请查看日志`
|
: result === -7
|
||||||
|
? 'message schema mismatch:当前账号消息表结构与程序要求不一致'
|
||||||
|
: `创建游标失败: ${result},请查看日志`
|
||||||
return { success: false, error: hint }
|
return { success: false, error: hint }
|
||||||
}
|
}
|
||||||
return { success: true, cursor: outCursor[0] }
|
return { success: true, cursor: outCursor[0] }
|
||||||
@@ -2719,6 +2754,9 @@ 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] }
|
||||||
@@ -2790,14 +2828,14 @@ export class WcdbCore {
|
|||||||
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
|
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
|
||||||
const fallbackFlag = /fallback|diag|diagnostic/i.test(String(sql || ''))
|
const fallbackFlag = /fallback|diag|diagnostic/i.test(String(sql || ''))
|
||||||
this.writeLog(`[audit:execQuery] kind=${kind} path=${path || ''} sql_len=${String(sql || '').length} fallback=${fallbackFlag ? 1 : 0}`)
|
this.writeLog(`[audit:execQuery] kind=${kind} path=${path || ''} sql_len=${String(sql || '').length} fallback=${fallbackFlag ? 1 : 0}`)
|
||||||
|
|
||||||
// 如果提供了参数,使用参数化查询(需要 C++ 层支持)
|
// 如果提供了参数,使用参数化查询(需要 C++ 层支持)
|
||||||
// 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现
|
// 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现
|
||||||
// TODO: 需要更新 C++ 层的 wcdb_exec_query 以支持参数绑定
|
// TODO: 需要更新 C++ 层的 wcdb_exec_query 以支持参数绑定
|
||||||
if (params && params.length > 0) {
|
if (params && params.length > 0) {
|
||||||
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)')
|
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)')
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedKind = String(kind || '').toLowerCase()
|
const normalizedKind = String(kind || '').toLowerCase()
|
||||||
const isContactQuery = normalizedKind === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
|
const isContactQuery = normalizedKind === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
|
||||||
let effectivePath = path || ''
|
let effectivePath = path || ''
|
||||||
@@ -3481,6 +3519,122 @@ 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,6 +561,24 @@ 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,6 +230,15 @@ 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
|
||||||
|
|||||||
Binary file not shown.
@@ -2127,6 +2127,24 @@
|
|||||||
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, Link, 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, 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,6 +64,9 @@ 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
|
||||||
@@ -210,6 +213,12 @@ 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
|
||||||
@@ -1179,7 +1188,12 @@ 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) => {
|
||||||
@@ -1400,6 +1414,18 @@ 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')
|
||||||
}, [])
|
}, [])
|
||||||
@@ -3246,6 +3272,29 @@ 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,
|
||||||
@@ -3255,6 +3304,10 @@ 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
|
||||||
@@ -3288,10 +3341,6 @@ 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
|
||||||
|
|
||||||
@@ -3340,12 +3389,11 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
.map(m => m.senderUsername as string)
|
.map(m => m.senderUsername as string)
|
||||||
)]
|
)]
|
||||||
if (unknownSenders.length > 0) {
|
if (unknownSenders.length > 0) {
|
||||||
warmupGroupSenderProfiles(unknownSenders, options.deferGroupSenderWarmup === true)
|
scheduleGroupSenderWarmup(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) {
|
||||||
@@ -3365,6 +3413,19 @@ 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)
|
||||||
|
|
||||||
// 加载更多也同样处理发送者信息预取
|
// 加载更多也同样处理发送者信息预取
|
||||||
@@ -3375,24 +3436,20 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
.map(m => m.senderUsername as string)
|
.map(m => m.senderUsername as string)
|
||||||
)]
|
)]
|
||||||
if (unknownSenders.length > 0) {
|
if (unknownSenders.length > 0) {
|
||||||
warmupGroupSenderProfiles(unknownSenders, false)
|
scheduleGroupSenderWarmup(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)
|
||||||
messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' })
|
)
|
||||||
return
|
if (latestMessages.length > 0) {
|
||||||
}
|
messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' })
|
||||||
}
|
|
||||||
if (resultMessages.length > 0) {
|
|
||||||
messageVirtuosoRef.current.scrollToIndex({ index: resultMessages.length, align: 'start', behavior: 'auto' })
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3432,6 +3489,11 @@ 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) {
|
||||||
@@ -3462,9 +3524,11 @@ 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])
|
||||||
|
|
||||||
@@ -4380,36 +4444,6 @@ 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) {
|
||||||
@@ -4428,27 +4462,18 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
if (pendingUsernames.size >= 24) break
|
if (pendingUsernames.size >= 24) break
|
||||||
}
|
}
|
||||||
if (pendingUsernames.size > 0) {
|
if (pendingUsernames.size > 0) {
|
||||||
warmupGroupSenderProfiles([...pendingUsernames], false)
|
scheduleGroupSenderWarmup([...pendingUsernames], false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
messages.length,
|
messages.length,
|
||||||
isLoadingMore,
|
|
||||||
isLoadingMessages,
|
|
||||||
hasMoreMessages,
|
|
||||||
hasMoreLater,
|
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
currentOffset,
|
|
||||||
jumpStartTime,
|
|
||||||
jumpEndTime,
|
|
||||||
isGroupChatSession,
|
isGroupChatSession,
|
||||||
standaloneSessionWindow,
|
standaloneSessionWindow,
|
||||||
normalizedInitialSessionId,
|
normalizedInitialSessionId,
|
||||||
normalizedStandaloneInitialContactType,
|
normalizedStandaloneInitialContactType,
|
||||||
warmupGroupSenderProfiles,
|
scheduleGroupSenderWarmup
|
||||||
loadMessages,
|
|
||||||
loadLaterMessages
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => {
|
const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => {
|
||||||
@@ -4462,9 +4487,8 @@ 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 || nearBottomByRange || nearBottomByDistance
|
const effectiveAtBottom = atBottom || nearBottomByDistance
|
||||||
isMessageListAtBottomRef.current = effectiveAtBottom
|
isMessageListAtBottomRef.current = effectiveAtBottom
|
||||||
|
|
||||||
if (!effectiveAtBottom) {
|
if (!effectiveAtBottom) {
|
||||||
@@ -4492,19 +4516,48 @@ 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>) => {
|
||||||
if (event.deltaY <= 18) return
|
markMessageListScrolling()
|
||||||
if (!currentSessionId || isLoadingMore || isLoadingMessages || !hasMoreLater) return
|
if (!currentSessionId || isLoadingMore || isLoadingMessages) 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) {
|
||||||
@@ -4659,6 +4712,11 @@ 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()
|
||||||
@@ -4699,8 +4757,12 @@ 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
|
||||||
const wasNearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(previousCount - 2, 0)
|
if (suppressAutoScrollOnNextMessageGrowthRef.current || prependingHistoryRef.current) {
|
||||||
if (!isMessageListAtBottomRef.current && !wasNearBottomByRange) return
|
suppressAutoScrollOnNextMessageGrowthRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isMessageListAtBottomRef.current) return
|
||||||
|
if (suppressAutoLoadLaterRef.current) return
|
||||||
suppressScrollToBottomButton(220)
|
suppressScrollToBottomButton(220)
|
||||||
isMessageListAtBottomRef.current = true
|
isMessageListAtBottomRef.current = true
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -6603,6 +6665,7 @@ 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 ? (
|
||||||
@@ -6616,8 +6679,12 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
className="message-virtuoso"
|
className="message-virtuoso"
|
||||||
customScrollParent={messageListScrollParent ?? undefined}
|
customScrollParent={messageListScrollParent ?? undefined}
|
||||||
data={messages}
|
data={messages}
|
||||||
overscan={360}
|
overscan={220}
|
||||||
followOutput={(atBottom) => (atBottom || isMessageListAtBottomRef.current ? 'auto' : false)}
|
followOutput={(atBottom) => (
|
||||||
|
prependingHistoryRef.current
|
||||||
|
? false
|
||||||
|
: (atBottom && isMessageListAtBottomRef.current ? 'auto' : false)
|
||||||
|
)}
|
||||||
atBottomThreshold={80}
|
atBottomThreshold={80}
|
||||||
atBottomStateChange={handleMessageAtBottomStateChange}
|
atBottomStateChange={handleMessageAtBottomStateChange}
|
||||||
atTopStateChange={handleMessageAtTopStateChange}
|
atTopStateChange={handleMessageAtTopStateChange}
|
||||||
@@ -7659,6 +7726,8 @@ 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)
|
||||||
@@ -7704,6 +7773,11 @@ 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(() => {
|
||||||
@@ -7847,6 +7921,14 @@ 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])
|
||||||
@@ -7855,6 +7937,12 @@ 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])
|
||||||
@@ -8008,6 +8096,7 @@ 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)
|
||||||
@@ -8023,6 +8112,7 @@ 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)
|
||||||
@@ -8036,7 +8126,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])
|
}, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
|
||||||
|
|
||||||
const triggerForceHd = useCallback(() => {
|
const triggerForceHd = useCallback(() => {
|
||||||
if (!message.imageMd5 && !message.imageDatName) return
|
if (!message.imageMd5 && !message.imageDatName) return
|
||||||
@@ -8099,6 +8189,7 @@ 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)
|
||||||
@@ -8113,6 +8204,7 @@ function MessageBubble({
|
|||||||
imageLocalPath,
|
imageLocalPath,
|
||||||
imageCacheKey,
|
imageCacheKey,
|
||||||
captureImageResizeBaseline,
|
captureImageResizeBaseline,
|
||||||
|
lockImageStageHeight,
|
||||||
message.imageDatName,
|
message.imageDatName,
|
||||||
message.imageMd5,
|
message.imageMd5,
|
||||||
requestImageDecrypt,
|
requestImageDecrypt,
|
||||||
@@ -8127,6 +8219,16 @@ 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
|
||||||
@@ -8143,6 +8245,7 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -8153,7 +8256,7 @@ function MessageBubble({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline])
|
}, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isImage) return
|
if (!isImage) return
|
||||||
@@ -8187,6 +8290,7 @@ 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)
|
||||||
@@ -8195,7 +8299,7 @@ function MessageBubble({
|
|||||||
return () => {
|
return () => {
|
||||||
unsubscribe?.()
|
unsubscribe?.()
|
||||||
}
|
}
|
||||||
}, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline])
|
}, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline, lockImageStageHeight])
|
||||||
|
|
||||||
// 图片进入视野前自动解密(懒加载)
|
// 图片进入视野前自动解密(懒加载)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -8578,6 +8682,19 @@ 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')
|
||||||
@@ -8711,6 +8828,17 @@ 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' : ''}`}
|
||||||
@@ -8739,7 +8867,7 @@ function MessageBubble({
|
|||||||
{isSelected && <Check size={14} strokeWidth={3} />}
|
{isSelected && <Check size={14} strokeWidth={3} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="bubble-content">{message.parsedContent}</div>
|
<div className="bubble-content">{systemContentNode}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -8748,7 +8876,11 @@ function MessageBubble({
|
|||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
return (
|
return (
|
||||||
<div ref={imageContainerRef}>
|
<div
|
||||||
|
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" />
|
||||||
@@ -8770,15 +8902,19 @@ function MessageBubble({
|
|||||||
<img
|
<img
|
||||||
src={imageLocalPath}
|
src={imageLocalPath}
|
||||||
alt="图片"
|
alt="图片"
|
||||||
className="image-message"
|
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}
|
||||||
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 && (
|
||||||
@@ -9104,6 +9240,12 @@ 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 || ''
|
||||||
@@ -9147,7 +9289,8 @@ 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 thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl')
|
const fallbackThumbUrl = appMsgThumbRawCandidate
|
||||||
|
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') || ''
|
||||||
@@ -9221,9 +9364,7 @@ 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>
|
||||||
)
|
)
|
||||||
@@ -9663,9 +9804,6 @@ 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ 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
|
||||||
@@ -311,9 +312,7 @@ 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: Object.fromEntries(
|
sessions: { ...(performance?.sessions || {}) }
|
||||||
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 => {
|
||||||
@@ -333,6 +332,18 @@ 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]
|
||||||
@@ -368,7 +379,9 @@ 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)
|
||||||
for (const session of Object.values(performance.sessions)) {
|
const nextSessions: Record<string, TaskSessionPerformance> = {}
|
||||||
|
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)
|
||||||
@@ -378,7 +391,13 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4697,7 +4716,7 @@ function ExportPage() {
|
|||||||
queuedProgressTimer = window.setTimeout(() => {
|
queuedProgressTimer = window.setTimeout(() => {
|
||||||
queuedProgressTimer = null
|
queuedProgressTimer = null
|
||||||
flushQueuedProgress()
|
flushQueuedProgress()
|
||||||
}, 100)
|
}, 180)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (next.payload.scope === 'sns') {
|
if (next.payload.scope === 'sns') {
|
||||||
|
|||||||
@@ -2934,3 +2934,488 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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,11 +15,12 @@ import {
|
|||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import './SettingsPage.scss'
|
import './SettingsPage.scss'
|
||||||
|
|
||||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics'
|
type SettingsTab = 'appearance' | 'notification' | 'antiRevoke' | '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 },
|
||||||
@@ -70,6 +71,8 @@ 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)
|
||||||
@@ -200,6 +203,13 @@ 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
|
||||||
|
|
||||||
@@ -586,6 +596,248 @@ 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
|
||||||
@@ -1319,11 +1571,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const renderNotificationTab = () => {
|
const renderNotificationTab = () => {
|
||||||
const { sessions } = useChatStore.getState()
|
|
||||||
|
|
||||||
// 获取已过滤会话的信息
|
// 获取已过滤会话的信息
|
||||||
const getSessionInfo = (username: string) => {
|
const getSessionInfo = (username: string) => {
|
||||||
const session = sessions.find(s => s.username === username)
|
const session = chatSessions.find(s => s.username === username)
|
||||||
return {
|
return {
|
||||||
displayName: session?.displayName || username,
|
displayName: session?.displayName || username,
|
||||||
avatarUrl: session?.avatarUrl || ''
|
avatarUrl: session?.avatarUrl || ''
|
||||||
@@ -1348,7 +1598,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 过滤掉已在列表中的会话,并根据搜索关键字过滤
|
// 过滤掉已在列表中的会话,并根据搜索关键字过滤
|
||||||
const availableSessions = sessions.filter(s => {
|
const availableSessions = chatSessions.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()
|
||||||
@@ -1564,6 +1814,199 @@ 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">
|
||||||
@@ -2687,6 +3130,7 @@ 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()}
|
||||||
|
|||||||
@@ -1,6 +1,46 @@
|
|||||||
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
|
||||||
@@ -69,59 +109,37 @@ 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) => {
|
||||||
currentSessionId: sessionId,
|
const nextMessages = options?.preserveMessages ? state.messages : []
|
||||||
messages: options?.preserveMessages ? state.messages : [],
|
rebuildMessageAliasIndex(nextMessages)
|
||||||
hasMoreMessages: true,
|
return {
|
||||||
hasMoreLater: false
|
currentSessionId: sessionId,
|
||||||
})),
|
messages: nextMessages,
|
||||||
|
hasMoreMessages: true,
|
||||||
|
hasMoreLater: false
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
||||||
|
|
||||||
setMessages: (messages) => set({ messages }),
|
setMessages: (messages) => set(() => {
|
||||||
|
rebuildMessageAliasIndex(messages || [])
|
||||||
|
return { messages }
|
||||||
|
}),
|
||||||
|
|
||||||
appendMessages: (newMessages, prepend = false) => set((state) => {
|
appendMessages: (newMessages, prepend = false) => set((state) => {
|
||||||
const buildPrimaryKey = (m: Message): string => {
|
|
||||||
if (m.messageKey) return String(m.messageKey)
|
|
||||||
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 currentMessages = state.messages || []
|
||||||
const existingAliases = new Set<string>()
|
if (messageAliasIndex.size === 0 && currentMessages.length > 0) {
|
||||||
currentMessages.forEach((msg) => {
|
rebuildMessageAliasIndex(currentMessages)
|
||||||
buildAliasKeys(msg).forEach((key) => existingAliases.add(key))
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const filtered: Message[] = []
|
const filtered: Message[] = []
|
||||||
newMessages.forEach((msg) => {
|
newMessages.forEach((msg) => {
|
||||||
const aliasKeys = buildAliasKeys(msg)
|
const aliasKeys = buildMessageAliasKeys(msg)
|
||||||
const exists = aliasKeys.some((key) => existingAliases.has(key))
|
const exists = aliasKeys.some((key) => messageAliasIndex.has(key))
|
||||||
if (exists) return
|
if (exists) return
|
||||||
filtered.push(msg)
|
filtered.push(msg)
|
||||||
aliasKeys.forEach((key) => existingAliases.add(key))
|
aliasKeys.forEach((key) => messageAliasIndex.add(key))
|
||||||
})
|
})
|
||||||
|
|
||||||
if (filtered.length === 0) return state
|
if (filtered.length === 0) return state
|
||||||
@@ -150,20 +168,23 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
|
|
||||||
setSearchKeyword: (keyword) => set({ searchKeyword: keyword }),
|
setSearchKeyword: (keyword) => set({ searchKeyword: keyword }),
|
||||||
|
|
||||||
reset: () => set({
|
reset: () => set(() => {
|
||||||
isConnected: false,
|
messageAliasIndex.clear()
|
||||||
isConnecting: false,
|
return {
|
||||||
connectionError: null,
|
isConnected: false,
|
||||||
sessions: [],
|
isConnecting: false,
|
||||||
filteredSessions: [],
|
connectionError: null,
|
||||||
currentSessionId: null,
|
sessions: [],
|
||||||
isLoadingSessions: false,
|
filteredSessions: [],
|
||||||
messages: [],
|
currentSessionId: null,
|
||||||
isLoadingMessages: false,
|
isLoadingSessions: false,
|
||||||
isLoadingMore: false,
|
messages: [],
|
||||||
hasMoreMessages: true,
|
isLoadingMessages: false,
|
||||||
hasMoreLater: false,
|
isLoadingMore: false,
|
||||||
contacts: new Map(),
|
hasMoreMessages: true,
|
||||||
searchKeyword: ''
|
hasMoreLater: false,
|
||||||
|
contacts: new Map(),
|
||||||
|
searchKeyword: ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|||||||
15
src/types/electron.d.ts
vendored
15
src/types/electron.d.ts
vendored
@@ -226,6 +226,21 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user