mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-05-01 07:26:48 +00:00
Compare commits
1 Commits
v4.4.0
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe02ff0d84 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -76,4 +76,3 @@ wechat-research-site
|
|||||||
.codex
|
.codex
|
||||||
weflow-web-offical
|
weflow-web-offical
|
||||||
/Wedecrypt
|
/Wedecrypt
|
||||||
/scripts/syncwcdb.py
|
|
||||||
@@ -74,14 +74,14 @@ GET /api/v1/push/messages
|
|||||||
- 需要先在设置页开启 `HTTP API 服务`
|
- 需要先在设置页开启 `HTTP API 服务`
|
||||||
- 同时需要开启 `主动推送`
|
- 同时需要开启 `主动推送`
|
||||||
- 响应类型为 `text/event-stream`
|
- 响应类型为 `text/event-stream`
|
||||||
- 事件名包含 `message.new` 和 `message.revoke`
|
- 新消息事件名固定为 `message.new`
|
||||||
- 建议接收端按 `event + rawid` 去重
|
- 建议接收端按 `messageKey` 去重
|
||||||
|
|
||||||
### 事件字段
|
### 事件字段
|
||||||
|
|
||||||
- `event`
|
- `event`
|
||||||
- `sessionId`
|
- `sessionId`
|
||||||
- `rawid`
|
- `messageKey`
|
||||||
- `avatarUrl`
|
- `avatarUrl`
|
||||||
- `sourceName`
|
- `sourceName`
|
||||||
- `groupName`(仅群聊)
|
- `groupName`(仅群聊)
|
||||||
@@ -98,14 +98,7 @@ curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
event: message.new
|
event: message.new
|
||||||
data: {"event":"message.new","sessionId":"xxx@chatroom","sessionType":"group","rawid":"1234567890123456789","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]","timestamp":1760000123}
|
data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123456:1760000123:1760000123000:321:wxid_member:1","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]","timestamp":1760000123}
|
||||||
```
|
|
||||||
|
|
||||||
撤回事件示例:
|
|
||||||
|
|
||||||
```text
|
|
||||||
event: message.revoke
|
|
||||||
data: {"event":"message.revoke","sessionId":"wxid_xxx","sessionType":"other","rawid":"1234567890123456789","avatarUrl":"https://example.com/avatar.jpg","sourceName":"张三","content":"对方撤回了一条消息(rawid:1234567890123456789) 内容为“你好”","timestamp":1760000180}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
# macOS 微信密钥自动获取失败排障指南
|
|
||||||
|
|
||||||
如果你在 macOS 系统下,遇到了 WeFlow 自动获取微信数据库密钥失败的问题,这篇指南或许可以帮到你。
|
|
||||||
|
|
||||||
### 请立刻停止连续重试
|
|
||||||
|
|
||||||
当你看到下面这些报错时,请务必暂停操作,不要再去反复点击获取:
|
|
||||||
|
|
||||||
- SCAN_FAILED,通常伴随 No suitable module found 或 Sink pattern not found
|
|
||||||
- HOOK_FAILED 或 Native Hook Failed
|
|
||||||
- patch_breakpoint_failed
|
|
||||||
- thread_get_state_failed
|
|
||||||
|
|
||||||
现在的 macOS 系统和微信防护机制非常敏锐。连续的重试动作不仅无法解决问题,反而容易被判定为异常行为,进而触发微信的安全模式或系统级的内存保护。
|
|
||||||
|
|
||||||
### 可能的尝试流程
|
|
||||||
|
|
||||||
根据大量社区用户的反馈,如果你已经遇到了获取失败的情况,按照下面的步骤顺序操作,通常都能顺利解决问题:
|
|
||||||
|
|
||||||
1. **降级微信版本**。找一个经过大家验证、兼容性更好的老版本,目前最推荐先退回到 4.1.7.57 或者 4.1.8.100。
|
|
||||||
2. **彻底退出微信**。请使用快捷键 Command + Q 或在活动监视器中结束进程,而不仅仅是关闭窗口。
|
|
||||||
3. **重启你的 Mac**。这一步极其关键,必须是真正的重新启动。注销或睡眠唤醒无法清除系统底层的拦截状态。
|
|
||||||
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是可以正常交互的状态。
|
|
||||||
5. **回到 WeFlow**。仅仅尝试一次“自动获取密钥”。
|
|
||||||
6. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
|
|
||||||
|
|
||||||
### 常见报错与应对方法
|
|
||||||
|
|
||||||
为了方便排查,这里列出了几类最常见的报错及其背后的原因和对策:
|
|
||||||
|
|
||||||
**SCAN_FAILED: No suitable module found**
|
|
||||||
这意味着微信的内存布局并不标准,或者目标模块没有被命中。你可以先确保微信完整启动并保持在前台。如果还是不行,请直接执行上面提到的“降级、重启电脑、获取、再升级”的完整流程。
|
|
||||||
|
|
||||||
**SCAN_FAILED: Sink pattern not found**
|
|
||||||
这说明 WeFlow 还没有适配你当前正在使用的微信版本特征。最快的解决办法是直接降级到微信 4.1.7 或 4.1.8.100 版本再试。
|
|
||||||
|
|
||||||
**patch_breakpoint_failed 或 thread_get_state_failed**
|
|
||||||
这类错误大多是因为调试断点注入或线程状态读取被 macOS 系统的安全机制拦截了。此时继续尝试毫无意义,彻底退出微信并重启电脑再试。
|
|
||||||
|
|
||||||
**task_for_pid:5**
|
|
||||||
这是进程附加权限被系统拒绝的提示。请确保你使用的是打包好的 WeFlow.app,同时检查系统的签名与调试权限是否已经正确配置。
|
|
||||||
|
|
||||||
### 关于推荐版本的补充说明
|
|
||||||
|
|
||||||
截至 2026 年 4 月,综合社区的反馈来看,微信 4.1.7 和 4.1.8.100 版本在密钥获取流程中的表现最为稳定,成功率最高。
|
|
||||||
|
|
||||||
这并不意味着其他新版本绝对无法获取,只是作为当前的排障参考。未来 WeFlow 也会在后续的更新中逐步适配新版微信的特征,建议大家多留意项目的 Release 动态。
|
|
||||||
|
|
||||||
### 最后的几点建议
|
|
||||||
|
|
||||||
首次失败后,首要任务是排查原因,切忌盲目地连续点击自动获取。如果你在看到这篇文档前已经失败了好几次,最好的做法是直接清零重来:彻底退出微信,重启电脑,然后再进行下一次尝试。
|
|
||||||
|
|
||||||
最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。
|
|
||||||
173
electron/main.ts
173
electron/main.ts
@@ -375,34 +375,7 @@ let isAppQuitting = false
|
|||||||
let shutdownPromise: Promise<void> | null = null
|
let shutdownPromise: Promise<void> | null = null
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
let isClosePromptVisible = false
|
let isClosePromptVisible = false
|
||||||
|
const chatHistoryPayloadStore = new Map<string, { sessionId: string; title?: string; recordList: any[] }>()
|
||||||
interface ChatHistoryPayloadEntry {
|
|
||||||
sessionId: string
|
|
||||||
title?: string
|
|
||||||
recordList: any[]
|
|
||||||
createdAt: number
|
|
||||||
lastAccessedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const chatHistoryPayloadStore = new Map<string, ChatHistoryPayloadEntry>()
|
|
||||||
const chatHistoryPayloadTtlMs = 10 * 60 * 1000
|
|
||||||
const chatHistoryPayloadMaxEntries = 20
|
|
||||||
|
|
||||||
const pruneChatHistoryPayloadStore = (): void => {
|
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
for (const [payloadId, payload] of chatHistoryPayloadStore.entries()) {
|
|
||||||
if (now - payload.createdAt > chatHistoryPayloadTtlMs) {
|
|
||||||
chatHistoryPayloadStore.delete(payloadId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (chatHistoryPayloadStore.size > chatHistoryPayloadMaxEntries) {
|
|
||||||
const oldestPayloadId = chatHistoryPayloadStore.keys().next().value as string | undefined
|
|
||||||
if (!oldestPayloadId) break
|
|
||||||
chatHistoryPayloadStore.delete(oldestPayloadId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||||
|
|
||||||
@@ -686,62 +659,6 @@ const setupCustomTitleBarWindow = (win: BrowserWindow): void => {
|
|||||||
win.webContents.on('did-finish-load', emitMaximizeState)
|
win.webContents.on('did-finish-load', emitMaximizeState)
|
||||||
}
|
}
|
||||||
|
|
||||||
let notificationNavigateHandlerRegistered = false
|
|
||||||
const focusMainWindowAndNavigate = (sessionId: string): void => {
|
|
||||||
const targetWindow = mainWindow
|
|
||||||
if (!targetWindow || targetWindow.isDestroyed()) return
|
|
||||||
if (targetWindow.isMinimized()) targetWindow.restore()
|
|
||||||
targetWindow.show()
|
|
||||||
targetWindow.focus()
|
|
||||||
targetWindow.webContents.send('navigate-to-session', sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureNotificationNavigateHandlerRegistered = (): void => {
|
|
||||||
if (notificationNavigateHandlerRegistered) return
|
|
||||||
notificationNavigateHandlerRegistered = true
|
|
||||||
ipcMain.on('notification-clicked', (_event, sessionId) => {
|
|
||||||
focusMainWindowAndNavigate(String(sessionId || ''))
|
|
||||||
})
|
|
||||||
setNotificationNavigateHandler((sessionId: string) => {
|
|
||||||
focusMainWindowAndNavigate(String(sessionId || ''))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let wechatRequestHeaderInterceptorRegistered = false
|
|
||||||
const ensureWeChatRequestHeaderInterceptor = (): void => {
|
|
||||||
if (wechatRequestHeaderInterceptorRegistered) return
|
|
||||||
wechatRequestHeaderInterceptorRegistered = true
|
|
||||||
|
|
||||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
|
||||||
{
|
|
||||||
urls: [
|
|
||||||
'*://*.qpic.cn/*',
|
|
||||||
'*://*.qlogo.cn/*',
|
|
||||||
'*://*.wechat.com/*',
|
|
||||||
'*://*.weixin.qq.com/*',
|
|
||||||
'*://*.wx.qq.com/*'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
(details, callback) => {
|
|
||||||
details.requestHeaders['User-Agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351"
|
|
||||||
details.requestHeaders['Accept'] = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
|
|
||||||
details.requestHeaders['Accept-Encoding'] = "gzip, deflate, br"
|
|
||||||
details.requestHeaders['Accept-Language'] = "zh-CN,zh;q=0.9"
|
|
||||||
details.requestHeaders['Connection'] = "keep-alive"
|
|
||||||
details.requestHeaders['Range'] = "bytes=0-"
|
|
||||||
|
|
||||||
let host = ''
|
|
||||||
try {
|
|
||||||
host = new URL(details.url).hostname.toLowerCase()
|
|
||||||
} catch {}
|
|
||||||
const isWxQQ = host === 'wx.qq.com' || host.endsWith('.wx.qq.com')
|
|
||||||
details.requestHeaders['Referer'] = isWxQQ ? 'https://wx.qq.com/' : 'https://servicewechat.com/'
|
|
||||||
|
|
||||||
callback({ cancel: false, requestHeaders: details.requestHeaders })
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getWindowCloseBehavior = (): WindowCloseBehavior => {
|
const getWindowCloseBehavior = (): WindowCloseBehavior => {
|
||||||
const behavior = configService?.get('windowCloseBehavior')
|
const behavior = configService?.get('windowCloseBehavior')
|
||||||
return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask'
|
return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask'
|
||||||
@@ -817,6 +734,44 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
win.loadFile(join(__dirname, '../dist/index.html'))
|
win.loadFile(join(__dirname, '../dist/index.html'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle notification click navigation
|
||||||
|
ipcMain.on('notification-clicked', (_, sessionId) => {
|
||||||
|
if (win.isMinimized()) win.restore()
|
||||||
|
win.show()
|
||||||
|
win.focus()
|
||||||
|
win.webContents.send('navigate-to-session', sessionId)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置用于D-Bus通知的Linux通知导航处理程序
|
||||||
|
setNotificationNavigateHandler((sessionId: string) => {
|
||||||
|
if (win.isMinimized()) win.restore()
|
||||||
|
win.show()
|
||||||
|
win.focus()
|
||||||
|
win.webContents.send('navigate-to-session', sessionId)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
|
{
|
||||||
|
urls: [
|
||||||
|
'*://*.qpic.cn/*',
|
||||||
|
'*://*.qlogo.cn/*',
|
||||||
|
'*://*.wechat.com/*',
|
||||||
|
'*://*.weixin.qq.com/*'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
(details, callback) => {
|
||||||
|
details.requestHeaders['User-Agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351"
|
||||||
|
details.requestHeaders['Accept'] = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
|
||||||
|
details.requestHeaders['Accept-Encoding'] = "gzip, deflate, br"
|
||||||
|
details.requestHeaders['Accept-Language'] = "zh-CN,zh;q=0.9"
|
||||||
|
details.requestHeaders['Referer'] = "https://servicewechat.com/"
|
||||||
|
details.requestHeaders['Connection'] = "keep-alive"
|
||||||
|
details.requestHeaders['Range'] = "bytes=0-"
|
||||||
|
callback({ cancel: false, requestHeaders: details.requestHeaders })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 忽略微信 CDN 域名的证书错误(部分节点证书配置不正确)
|
// 忽略微信 CDN 域名的证书错误(部分节点证书配置不正确)
|
||||||
win.webContents.on('certificate-error', (event, url, _error, _cert, callback) => {
|
win.webContents.on('certificate-error', (event, url, _error, _cert, callback) => {
|
||||||
const trusted = ['.qq.com', '.qpic.cn', '.weixin.qq.com', '.wechat.com']
|
const trusted = ['.qq.com', '.qpic.cn', '.weixin.qq.com', '.wechat.com']
|
||||||
@@ -1224,11 +1179,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createChatHistoryPayloadWindow(payloadId: string) {
|
function createChatHistoryPayloadWindow(payloadId: string) {
|
||||||
const win = createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`)
|
return createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`)
|
||||||
win.on('closed', () => {
|
|
||||||
chatHistoryPayloadStore.delete(payloadId)
|
|
||||||
})
|
|
||||||
return win
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChatHistoryRouteWindow(route: string) {
|
function createChatHistoryRouteWindow(route: string) {
|
||||||
@@ -1661,7 +1612,6 @@ const runLegacySnsCacheMigration = async (
|
|||||||
// 注册 IPC 处理器
|
// 注册 IPC 处理器
|
||||||
function registerIpcHandlers() {
|
function registerIpcHandlers() {
|
||||||
registerNotificationHandlers()
|
registerNotificationHandlers()
|
||||||
ensureNotificationNavigateHandlerRegistered()
|
|
||||||
bizService.registerHandlers()
|
bizService.registerHandlers()
|
||||||
// 配置相关
|
// 配置相关
|
||||||
ipcMain.handle('config:get', async (_, key: string) => {
|
ipcMain.handle('config:get', async (_, key: string) => {
|
||||||
@@ -2039,38 +1989,19 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
ipcMain.handle('window:openChatHistoryPayloadWindow', (_, payload: { sessionId: string; title?: string; recordList: any[] }) => {
|
ipcMain.handle('window:openChatHistoryPayloadWindow', (_, payload: { sessionId: string; title?: string; recordList: any[] }) => {
|
||||||
const payloadId = randomUUID()
|
const payloadId = randomUUID()
|
||||||
pruneChatHistoryPayloadStore()
|
|
||||||
const now = Date.now()
|
|
||||||
chatHistoryPayloadStore.set(payloadId, {
|
chatHistoryPayloadStore.set(payloadId, {
|
||||||
sessionId: String(payload?.sessionId || '').trim(),
|
sessionId: String(payload?.sessionId || '').trim(),
|
||||||
title: String(payload?.title || '').trim() || '聊天记录',
|
title: String(payload?.title || '').trim() || '聊天记录',
|
||||||
recordList: Array.isArray(payload?.recordList) ? payload.recordList : [],
|
recordList: Array.isArray(payload?.recordList) ? payload.recordList : []
|
||||||
createdAt: now,
|
|
||||||
lastAccessedAt: now
|
|
||||||
})
|
})
|
||||||
pruneChatHistoryPayloadStore()
|
|
||||||
createChatHistoryPayloadWindow(payloadId)
|
createChatHistoryPayloadWindow(payloadId)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('window:getChatHistoryPayload', (_, payloadId: string) => {
|
ipcMain.handle('window:getChatHistoryPayload', (_, payloadId: string) => {
|
||||||
pruneChatHistoryPayloadStore()
|
const payload = chatHistoryPayloadStore.get(String(payloadId || '').trim())
|
||||||
const normalizedPayloadId = String(payloadId || '').trim()
|
|
||||||
const payload = chatHistoryPayloadStore.get(normalizedPayloadId)
|
|
||||||
if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' }
|
if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' }
|
||||||
const nextPayload: ChatHistoryPayloadEntry = {
|
return { success: true, payload }
|
||||||
...payload,
|
|
||||||
lastAccessedAt: Date.now()
|
|
||||||
}
|
|
||||||
chatHistoryPayloadStore.set(normalizedPayloadId, nextPayload)
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
payload: {
|
|
||||||
sessionId: nextPayload.sessionId,
|
|
||||||
title: nextPayload.title,
|
|
||||||
recordList: nextPayload.recordList
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
|
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
|
||||||
@@ -2459,8 +2390,6 @@ function registerIpcHandlers() {
|
|||||||
allowStaleCache?: boolean
|
allowStaleCache?: boolean
|
||||||
preferAccurateSpecialTypes?: boolean
|
preferAccurateSpecialTypes?: boolean
|
||||||
cacheOnly?: boolean
|
cacheOnly?: boolean
|
||||||
beginTimestamp?: number
|
|
||||||
endTimestamp?: number
|
|
||||||
}) => {
|
}) => {
|
||||||
return chatService.getExportSessionStats(sessionIds, options)
|
return chatService.getExportSessionStats(sessionIds, options)
|
||||||
})
|
})
|
||||||
@@ -3123,7 +3052,6 @@ function registerIpcHandlers() {
|
|||||||
ipcMain.handle('cache:clearImages', async () => {
|
ipcMain.handle('cache:clearImages', async () => {
|
||||||
const imageResult = await imageDecryptService.clearCache()
|
const imageResult = await imageDecryptService.clearCache()
|
||||||
const emojiResult = chatService.clearCaches({ includeMessages: false, includeContacts: false, includeEmojis: true })
|
const emojiResult = chatService.clearCaches({ includeMessages: false, includeContacts: false, includeEmojis: true })
|
||||||
snsService.clearMemoryCache()
|
|
||||||
const errors = [imageResult, emojiResult]
|
const errors = [imageResult, emojiResult]
|
||||||
.filter((result) => !result.success)
|
.filter((result) => !result.success)
|
||||||
.map((result) => result.error)
|
.map((result) => result.error)
|
||||||
@@ -3140,7 +3068,6 @@ function registerIpcHandlers() {
|
|||||||
imageDecryptService.clearCache()
|
imageDecryptService.clearCache()
|
||||||
])
|
])
|
||||||
const chatResult = chatService.clearCaches()
|
const chatResult = chatService.clearCaches()
|
||||||
snsService.clearMemoryCache()
|
|
||||||
const errors = [analyticsResult, imageResult, chatResult]
|
const errors = [analyticsResult, imageResult, chatResult]
|
||||||
.filter((result) => !result.success)
|
.filter((result) => !result.success)
|
||||||
.map((result) => result.error)
|
.map((result) => result.error)
|
||||||
@@ -3863,7 +3790,6 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
// 创建主窗口(不显示,由启动流程统一控制)
|
// 创建主窗口(不显示,由启动流程统一控制)
|
||||||
updateSplashProgress(70, '正在准备主窗口...')
|
updateSplashProgress(70, '正在准备主窗口...')
|
||||||
ensureWeChatRequestHeaderInterceptor()
|
|
||||||
mainWindow = createWindow({ autoShow: false })
|
mainWindow = createWindow({ autoShow: false })
|
||||||
|
|
||||||
let iconName = 'icon.ico';
|
let iconName = 'icon.ico';
|
||||||
@@ -3923,6 +3849,17 @@ app.whenReady().then(async () => {
|
|||||||
console.warn('[Tray] Failed to create tray icon:', e)
|
console.warn('[Tray] Failed to create tray icon:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 配置网络服务
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
|
{
|
||||||
|
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
|
||||||
|
},
|
||||||
|
(details, callback) => {
|
||||||
|
details.requestHeaders['Referer'] = 'https://wx.qq.com/'
|
||||||
|
callback({ requestHeaders: details.requestHeaders })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
|
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
|
||||||
updateSplashProgress(70, '正在准备主窗口...', true)
|
updateSplashProgress(70, '正在准备主窗口...', true)
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
@@ -3997,3 +3934,5 @@ app.on('window-all-closed', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -219,8 +219,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
allowStaleCache?: boolean
|
allowStaleCache?: boolean
|
||||||
preferAccurateSpecialTypes?: boolean
|
preferAccurateSpecialTypes?: boolean
|
||||||
cacheOnly?: boolean
|
cacheOnly?: boolean
|
||||||
beginTimestamp?: number
|
|
||||||
endTimestamp?: number
|
|
||||||
}
|
}
|
||||||
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
|
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
|
||||||
getGroupMyMessageCountHint: (chatroomId: string) =>
|
getGroupMyMessageCountHint: (chatroomId: string) =>
|
||||||
@@ -567,3 +565,4 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid)
|
validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -103,10 +103,8 @@ class AnalyticsService {
|
|||||||
if (username === 'filehelper') return false
|
if (username === 'filehelper') return false
|
||||||
if (username.startsWith('gh_')) return false
|
if (username.startsWith('gh_')) return false
|
||||||
|
|
||||||
if (username.toLowerCase() === 'weixin') return false
|
|
||||||
|
|
||||||
const excludeList = [
|
const excludeList = [
|
||||||
'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||||
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
||||||
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
||||||
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
|
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ class AnnualReportService {
|
|||||||
const rows = sessionResult.sessions as Record<string, any>[]
|
const rows = sessionResult.sessions as Record<string, any>[]
|
||||||
|
|
||||||
const excludeList = [
|
const excludeList = [
|
||||||
'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||||
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
||||||
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
||||||
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
|
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
|
||||||
@@ -185,7 +185,6 @@ class AnnualReportService {
|
|||||||
if (username === 'filehelper') return false
|
if (username === 'filehelper') return false
|
||||||
if (username.startsWith('gh_')) return false
|
if (username.startsWith('gh_')) return false
|
||||||
if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
|
if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
|
||||||
if (username.toLowerCase() === 'weixin') return false
|
|
||||||
|
|
||||||
for (const prefix of excludeList) {
|
for (const prefix of excludeList) {
|
||||||
if (username.startsWith(prefix) || username === prefix) return false
|
if (username.startsWith(prefix) || username === prefix) return false
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -76,12 +76,6 @@ interface ApiExportedMedia {
|
|||||||
relativePath: string
|
relativePath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessagePushReplayEvent {
|
|
||||||
id: number
|
|
||||||
body: string
|
|
||||||
createdAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChatLab 消息类型映射
|
// ChatLab 消息类型映射
|
||||||
const ChatLabType = {
|
const ChatLabType = {
|
||||||
TEXT: 0,
|
TEXT: 0,
|
||||||
@@ -113,12 +107,8 @@ class HttpService {
|
|||||||
private running: boolean = false
|
private running: boolean = false
|
||||||
private connections: Set<import('net').Socket> = new Set()
|
private connections: Set<import('net').Socket> = new Set()
|
||||||
private messagePushClients: Set<http.ServerResponse> = new Set()
|
private messagePushClients: Set<http.ServerResponse> = new Set()
|
||||||
private messagePushReplayBuffer: MessagePushReplayEvent[] = []
|
|
||||||
private messagePushHeartbeatTimer: ReturnType<typeof setInterval> | null = null
|
private messagePushHeartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||||
private connectionMutex: boolean = false
|
private connectionMutex: boolean = false
|
||||||
private messagePushEventId = 0
|
|
||||||
private readonly messagePushReplayLimit = 1000
|
|
||||||
private readonly messagePushReplayTtlMs = 10 * 60 * 1000
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = ConfigService.getInstance()
|
this.configService = ConfigService.getInstance()
|
||||||
@@ -188,7 +178,6 @@ class HttpService {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
this.messagePushClients.clear()
|
this.messagePushClients.clear()
|
||||||
this.messagePushReplayBuffer = []
|
|
||||||
if (this.messagePushHeartbeatTimer) {
|
if (this.messagePushHeartbeatTimer) {
|
||||||
clearInterval(this.messagePushHeartbeatTimer)
|
clearInterval(this.messagePushHeartbeatTimer)
|
||||||
this.messagePushHeartbeatTimer = null
|
this.messagePushHeartbeatTimer = null
|
||||||
@@ -243,57 +232,9 @@ class HttpService {
|
|||||||
return `http://${this.host}:${this.port}/api/v1/push/messages`
|
return `http://${this.host}:${this.port}/api/v1/push/messages`
|
||||||
}
|
}
|
||||||
|
|
||||||
private nextMessagePushEventId(): number {
|
|
||||||
this.messagePushEventId += 1
|
|
||||||
if (!Number.isSafeInteger(this.messagePushEventId) || this.messagePushEventId <= 0) {
|
|
||||||
this.messagePushEventId = 1
|
|
||||||
}
|
|
||||||
return this.messagePushEventId
|
|
||||||
}
|
|
||||||
|
|
||||||
private rememberMessagePushEvent(id: number, body: string): void {
|
|
||||||
this.pruneMessagePushReplayBuffer()
|
|
||||||
this.messagePushReplayBuffer.push({ id, body, createdAt: Date.now() })
|
|
||||||
if (this.messagePushReplayBuffer.length > this.messagePushReplayLimit) {
|
|
||||||
this.messagePushReplayBuffer.splice(0, this.messagePushReplayBuffer.length - this.messagePushReplayLimit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private pruneMessagePushReplayBuffer(): void {
|
|
||||||
const cutoff = Date.now() - this.messagePushReplayTtlMs
|
|
||||||
while (this.messagePushReplayBuffer.length > 0 && this.messagePushReplayBuffer[0].createdAt < cutoff) {
|
|
||||||
this.messagePushReplayBuffer.shift()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseMessagePushLastEventId(req: http.IncomingMessage, url?: URL): number {
|
|
||||||
const queryValue = url?.searchParams.get('lastEventId') || url?.searchParams.get('last_event_id') || ''
|
|
||||||
const headerValue = Array.isArray(req.headers['last-event-id'])
|
|
||||||
? req.headers['last-event-id'][0]
|
|
||||||
: req.headers['last-event-id']
|
|
||||||
const parsed = Number.parseInt(String(queryValue || headerValue || '0').trim(), 10)
|
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private replayMessagePushEvents(res: http.ServerResponse, lastEventId: number): void {
|
|
||||||
this.pruneMessagePushReplayBuffer()
|
|
||||||
const events = lastEventId > 0
|
|
||||||
? this.messagePushReplayBuffer.filter((event) => event.id > lastEventId)
|
|
||||||
: this.messagePushReplayBuffer
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
if (res.writableEnded || res.destroyed) return
|
|
||||||
res.write(event.body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
broadcastMessagePush(payload: Record<string, unknown>): void {
|
broadcastMessagePush(payload: Record<string, unknown>): void {
|
||||||
if (!this.running) return
|
if (!this.running || this.messagePushClients.size === 0) return
|
||||||
const eventId = this.nextMessagePushEventId()
|
const eventBody = `event: message.new\ndata: ${JSON.stringify(payload)}\n\n`
|
||||||
const eventName = this.getMessagePushEventName(payload)
|
|
||||||
const eventBody = `id: ${eventId}\nevent: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`
|
|
||||||
this.rememberMessagePushEvent(eventId, eventBody)
|
|
||||||
if (this.messagePushClients.size === 0) return
|
|
||||||
|
|
||||||
for (const client of Array.from(this.messagePushClients)) {
|
for (const client of Array.from(this.messagePushClients)) {
|
||||||
try {
|
try {
|
||||||
@@ -309,11 +250,6 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMessagePushEventName(payload: Record<string, unknown>): string {
|
|
||||||
const eventName = String(payload?.event || '').trim()
|
|
||||||
return /^[a-z0-9._-]+$/i.test(eventName) ? eventName : 'message.new'
|
|
||||||
}
|
|
||||||
|
|
||||||
async autoStart(): Promise<void> {
|
async autoStart(): Promise<void> {
|
||||||
const enabled = this.configService.get('httpApiEnabled')
|
const enabled = this.configService.get('httpApiEnabled')
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
@@ -429,7 +365,7 @@ class HttpService {
|
|||||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||||
this.sendJson(res, { status: 'ok' })
|
this.sendJson(res, { status: 'ok' })
|
||||||
} else if (pathname === '/api/v1/push/messages') {
|
} else if (pathname === '/api/v1/push/messages') {
|
||||||
this.handleMessagePushStream(req, res, url)
|
this.handleMessagePushStream(req, res)
|
||||||
} else if (pathname === '/api/v1/messages') {
|
} else if (pathname === '/api/v1/messages') {
|
||||||
await this.handleMessages(url, res)
|
await this.handleMessages(url, res)
|
||||||
} else if (pathname === '/api/v1/sessions') {
|
} else if (pathname === '/api/v1/sessions') {
|
||||||
@@ -504,7 +440,7 @@ class HttpService {
|
|||||||
}, 25000)
|
}, 25000)
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMessagePushStream(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void {
|
private handleMessagePushStream(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||||
if (this.configService.get('messagePushEnabled') !== true) {
|
if (this.configService.get('messagePushEnabled') !== true) {
|
||||||
this.sendError(res, 403, 'Message push is disabled')
|
this.sendError(res, 403, 'Message push is disabled')
|
||||||
return
|
return
|
||||||
@@ -517,10 +453,9 @@ class HttpService {
|
|||||||
'X-Accel-Buffering': 'no'
|
'X-Accel-Buffering': 'no'
|
||||||
})
|
})
|
||||||
res.flushHeaders?.()
|
res.flushHeaders?.()
|
||||||
|
res.write(`event: ready\ndata: ${JSON.stringify({ success: true, stream: this.getMessagePushStreamUrl() })}\n\n`)
|
||||||
|
|
||||||
this.messagePushClients.add(res)
|
this.messagePushClients.add(res)
|
||||||
res.write(`event: ready\ndata: ${JSON.stringify({ success: true, stream: this.getMessagePushStreamUrl() })}\n\n`)
|
|
||||||
this.replayMessagePushEvents(res, this.parseMessagePushLastEventId(req, url))
|
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
this.messagePushClients.delete(res)
|
this.messagePushClients.delete(res)
|
||||||
@@ -561,20 +496,11 @@ class HttpService {
|
|||||||
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(fullPath)
|
const fileBuffer = fs.readFileSync(fullPath)
|
||||||
res.setHeader('Content-Type', contentType)
|
res.setHeader('Content-Type', contentType)
|
||||||
res.setHeader('Content-Length', stat.size)
|
res.setHeader('Content-Length', fileBuffer.length)
|
||||||
res.writeHead(200)
|
res.writeHead(200)
|
||||||
|
res.end(fileBuffer)
|
||||||
const stream = fs.createReadStream(fullPath)
|
|
||||||
stream.on('error', () => {
|
|
||||||
if (!res.headersSent) {
|
|
||||||
this.sendError(res, 500, 'Failed to read media file')
|
|
||||||
} else {
|
|
||||||
try { res.destroy() } catch {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
stream.pipe(res)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.sendError(res, 500, 'Failed to read media file')
|
this.sendError(res, 500, 'Failed to read media file')
|
||||||
}
|
}
|
||||||
@@ -590,29 +516,27 @@ class HttpService {
|
|||||||
limit: number,
|
limit: number,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
ascending: boolean,
|
ascending: boolean
|
||||||
useLiteMapping: boolean = true
|
|
||||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
// 深分页时放大 batch,避免 offset 很大时出现大量小批次循环。
|
// 使用固定 batch 大小(与 limit 相同或最多 500)来减少循环次数
|
||||||
const batchSize = Math.min(2000, Math.max(500, limit))
|
const batchSize = Math.min(limit, 500)
|
||||||
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
||||||
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
||||||
|
|
||||||
const cursorResult = await wcdbService.openMessageCursorLite(talker, batchSize, ascending, beginTimestamp, endTimestamp)
|
const cursorResult = await wcdbService.openMessageCursor(talker, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||||
if (!cursorResult.success || !cursorResult.cursor) {
|
if (!cursorResult.success || !cursorResult.cursor) {
|
||||||
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const cursor = cursorResult.cursor
|
const cursor = cursorResult.cursor
|
||||||
try {
|
try {
|
||||||
const collectedRows: Record<string, any>[] = []
|
const allRows: Record<string, any>[] = []
|
||||||
let hasMore = true
|
let hasMore = true
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
let reachedLimit = false
|
|
||||||
|
|
||||||
// 循环获取消息,处理 offset 跳过 + limit 累积
|
// 循环获取消息,处理 offset 跳过 + limit 累积
|
||||||
while (collectedRows.length < limit && hasMore) {
|
while (allRows.length < limit && hasMore) {
|
||||||
const batch = await wcdbService.fetchMessageBatch(cursor)
|
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||||
if (!batch.success || !batch.rows || batch.rows.length === 0) {
|
if (!batch.success || !batch.rows || batch.rows.length === 0) {
|
||||||
hasMore = false
|
hasMore = false
|
||||||
@@ -633,20 +557,12 @@ class HttpService {
|
|||||||
skipped = offset
|
skipped = offset
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingCapacity = limit - collectedRows.length
|
allRows.push(...rows)
|
||||||
if (rows.length > remainingCapacity) {
|
|
||||||
collectedRows.push(...rows.slice(0, remainingCapacity))
|
|
||||||
reachedLimit = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
collectedRows.push(...rows)
|
const trimmedRows = allRows.slice(0, limit)
|
||||||
}
|
const finalHasMore = hasMore || allRows.length > limit
|
||||||
|
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
|
||||||
const finalHasMore = hasMore || reachedLimit
|
|
||||||
const messages = useLiteMapping
|
|
||||||
? chatService.mapRowsToMessagesLiteForApi(collectedRows)
|
|
||||||
: chatService.mapRowsToMessagesForApi(collectedRows)
|
|
||||||
await this.backfillMissingSenderUsernames(talker, messages)
|
await this.backfillMissingSenderUsernames(talker, messages)
|
||||||
return { success: true, messages, hasMore: finalHasMore }
|
return { success: true, messages, hasMore: finalHasMore }
|
||||||
} finally {
|
} finally {
|
||||||
@@ -674,35 +590,9 @@ class HttpService {
|
|||||||
if (targets.length === 0) return
|
if (targets.length === 0) return
|
||||||
|
|
||||||
const myWxid = (this.configService.get('myWxid') || '').trim()
|
const myWxid = (this.configService.get('myWxid') || '').trim()
|
||||||
const MAX_DETAIL_BACKFILL = 120
|
|
||||||
if (targets.length > MAX_DETAIL_BACKFILL) {
|
|
||||||
for (const msg of targets) {
|
for (const msg of targets) {
|
||||||
if (!msg.senderUsername && msg.isSend === 1 && myWxid) {
|
|
||||||
msg.senderUsername = myWxid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const queue = [...targets]
|
|
||||||
const workerCount = Math.max(1, Math.min(6, queue.length))
|
|
||||||
const state = {
|
|
||||||
attempted: 0,
|
|
||||||
hydrated: 0,
|
|
||||||
consecutiveMiss: 0
|
|
||||||
}
|
|
||||||
const MAX_DETAIL_LOOKUPS = 80
|
|
||||||
const MAX_CONSECUTIVE_MISS = 36
|
|
||||||
const runWorker = async (): Promise<void> => {
|
|
||||||
while (queue.length > 0) {
|
|
||||||
if (state.attempted >= MAX_DETAIL_LOOKUPS) break
|
|
||||||
if (state.consecutiveMiss >= MAX_CONSECUTIVE_MISS && state.hydrated <= 0) break
|
|
||||||
const msg = queue.shift()
|
|
||||||
if (!msg) break
|
|
||||||
|
|
||||||
const localId = Number(msg.localId || 0)
|
const localId = Number(msg.localId || 0)
|
||||||
if (Number.isFinite(localId) && localId > 0) {
|
if (Number.isFinite(localId) && localId > 0) {
|
||||||
state.attempted += 1
|
|
||||||
try {
|
try {
|
||||||
const detail = await wcdbService.getMessageById(talker, localId)
|
const detail = await wcdbService.getMessageById(talker, localId)
|
||||||
if (detail.success && detail.message) {
|
if (detail.success && detail.message) {
|
||||||
@@ -716,18 +606,9 @@ class HttpService {
|
|||||||
if (!msg.rawContent && hydrated?.rawContent) {
|
if (!msg.rawContent && hydrated?.rawContent) {
|
||||||
msg.rawContent = hydrated.rawContent
|
msg.rawContent = hydrated.rawContent
|
||||||
}
|
}
|
||||||
if (msg.senderUsername) {
|
|
||||||
state.hydrated += 1
|
|
||||||
state.consecutiveMiss = 0
|
|
||||||
} else {
|
|
||||||
state.consecutiveMiss += 1
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
state.consecutiveMiss += 1
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[HttpService] backfill sender failed:', error)
|
console.warn('[HttpService] backfill sender failed:', error)
|
||||||
state.consecutiveMiss += 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,9 +618,6 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(Array.from({ length: workerCount }, () => runWorker()))
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
|
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const raw = url.searchParams.get(key)
|
const raw = url.searchParams.get(key)
|
||||||
@@ -785,7 +663,7 @@ class HttpService {
|
|||||||
const talker = (url.searchParams.get('talker') || '').trim()
|
const talker = (url.searchParams.get('talker') || '').trim()
|
||||||
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
||||||
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
||||||
const keyword = (url.searchParams.get('keyword') || '').trim()
|
const keyword = (url.searchParams.get('keyword') || '').trim().toLowerCase()
|
||||||
const startParam = url.searchParams.get('start')
|
const startParam = url.searchParams.get('start')
|
||||||
const endParam = url.searchParams.get('end')
|
const endParam = url.searchParams.get('end')
|
||||||
const chatlab = this.parseBooleanParam(url, ['chatlab'], false)
|
const chatlab = this.parseBooleanParam(url, ['chatlab'], false)
|
||||||
@@ -805,41 +683,26 @@ class HttpService {
|
|||||||
|
|
||||||
const startTime = this.parseTimeParam(startParam)
|
const startTime = this.parseTimeParam(startParam)
|
||||||
const endTime = this.parseTimeParam(endParam, true)
|
const endTime = this.parseTimeParam(endParam, true)
|
||||||
let messages: Message[] = []
|
const queryOffset = keyword ? 0 : offset
|
||||||
let hasMore = false
|
const queryLimit = keyword ? 10000 : limit
|
||||||
|
|
||||||
if (keyword) {
|
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, false)
|
||||||
const searchLimit = Math.max(1, limit) + 1
|
|
||||||
const searchResult = await chatService.searchMessages(
|
|
||||||
keyword,
|
|
||||||
talker,
|
|
||||||
searchLimit,
|
|
||||||
offset,
|
|
||||||
startTime,
|
|
||||||
endTime
|
|
||||||
)
|
|
||||||
if (!searchResult.success || !searchResult.messages) {
|
|
||||||
this.sendError(res, 500, searchResult.error || 'Failed to search messages')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hasMore = searchResult.messages.length > limit
|
|
||||||
messages = hasMore ? searchResult.messages.slice(0, limit) : searchResult.messages
|
|
||||||
} else {
|
|
||||||
const result = await this.fetchMessagesBatch(
|
|
||||||
talker,
|
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
false,
|
|
||||||
!mediaOptions.enabled
|
|
||||||
)
|
|
||||||
if (!result.success || !result.messages) {
|
if (!result.success || !result.messages) {
|
||||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
messages = result.messages
|
|
||||||
hasMore = result.hasMore === true
|
let messages = result.messages
|
||||||
|
let hasMore = result.hasMore === true
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
const filtered = messages.filter((msg) => {
|
||||||
|
const content = (msg.parsedContent || msg.rawContent || '').toLowerCase()
|
||||||
|
return content.includes(keyword)
|
||||||
|
})
|
||||||
|
const endIndex = offset + limit
|
||||||
|
hasMore = filtered.length > endIndex
|
||||||
|
messages = filtered.slice(offset, endIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaMap = mediaOptions.enabled
|
const mediaMap = mediaOptions.enabled
|
||||||
@@ -949,7 +812,7 @@ class HttpService {
|
|||||||
const endTime = endParam ? this.parseTimeParam(endParam, true) : 0
|
const endTime = endParam ? this.parseTimeParam(endParam, true) : 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.fetchMessagesBatch(sessionId, offset, limit, startTime, endTime, true, true)
|
const result = await this.fetchMessagesBatch(sessionId, offset, limit, startTime, endTime, true)
|
||||||
if (!result.success || !result.messages) {
|
if (!result.success || !result.messages) {
|
||||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ export class KeyServiceMac {
|
|||||||
private machVmReadOverwrite: any = null
|
private machVmReadOverwrite: any = null
|
||||||
private machPortDeallocate: any = null
|
private machPortDeallocate: any = null
|
||||||
private _needsElevation = false
|
private _needsElevation = false
|
||||||
private restrictedFailureCount = 0
|
|
||||||
private restrictedFailureAt = 0
|
|
||||||
private readonly restrictedFailureWindowMs = 8 * 60_000
|
|
||||||
|
|
||||||
private getHelperPath(): string {
|
private getHelperPath(): string {
|
||||||
const isPackaged = app.isPackaged
|
const isPackaged = app.isPackaged
|
||||||
@@ -189,25 +186,18 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const errorMsg = this.enrichDbKeyErrorMessage(
|
const errorMsg = this.mapDbKeyErrorMessage(parsed.code, parsed.detail)
|
||||||
this.mapDbKeyErrorMessage(parsed.code, parsed.detail),
|
|
||||||
parsed.code,
|
|
||||||
parsed.detail
|
|
||||||
)
|
|
||||||
onStatus?.(errorMsg, 2)
|
onStatus?.(errorMsg, 2)
|
||||||
return { success: false, error: errorMsg }
|
return { success: false, error: errorMsg }
|
||||||
}
|
}
|
||||||
|
|
||||||
this.resetRestrictedFailureState()
|
|
||||||
onStatus?.('密钥获取成功', 1)
|
onStatus?.('密钥获取成功', 1)
|
||||||
return { success: true, key: parsed.key }
|
return { success: true, key: parsed.key }
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('[KeyServiceMac] Error:', e)
|
console.error('[KeyServiceMac] Error:', e)
|
||||||
console.error('[KeyServiceMac] Stack:', e.stack)
|
console.error('[KeyServiceMac] Stack:', e.stack)
|
||||||
const rawError = `${e?.message || e || ''}`.trim()
|
onStatus?.('获取失败: ' + e.message, 2)
|
||||||
const resolvedError = this.resolveUnexpectedDbKeyErrorMessage(rawError)
|
return { success: false, error: e.message }
|
||||||
onStatus?.(resolvedError, 2)
|
|
||||||
return { success: false, error: resolvedError }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,149 +223,6 @@ export class KeyServiceMac {
|
|||||||
return this.parseDbKeyResult(helperResult)
|
return this.parseDbKeyResult(helperResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetRestrictedFailureState(): void {
|
|
||||||
this.restrictedFailureCount = 0
|
|
||||||
this.restrictedFailureAt = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private markRestrictedFailureAndGetCount(): number {
|
|
||||||
const now = Date.now()
|
|
||||||
if (now - this.restrictedFailureAt > this.restrictedFailureWindowMs) {
|
|
||||||
this.restrictedFailureCount = 0
|
|
||||||
}
|
|
||||||
this.restrictedFailureAt = now
|
|
||||||
this.restrictedFailureCount += 1
|
|
||||||
return this.restrictedFailureCount
|
|
||||||
}
|
|
||||||
|
|
||||||
private isRestrictedEnvironmentFailure(code?: string, detail?: string): boolean {
|
|
||||||
const normalizedCode = String(code || '').toUpperCase()
|
|
||||||
const normalizedDetail = String(detail || '').toLowerCase()
|
|
||||||
if (!normalizedCode && !normalizedDetail) return false
|
|
||||||
|
|
||||||
if (normalizedCode === 'SCAN_FAILED') {
|
|
||||||
return normalizedDetail.includes('sink pattern not found')
|
|
||||||
|| normalizedDetail.includes('no suitable module found')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedCode === 'HOOK_FAILED') {
|
|
||||||
return normalizedDetail.includes('patch_breakpoint_failed')
|
|
||||||
|| normalizedDetail.includes('thread_get_state_failed')
|
|
||||||
|| normalizedDetail.includes('native hook failed')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedCode === 'ATTACH_FAILED') {
|
|
||||||
return normalizedDetail.includes('task_for_pid:5')
|
|
||||||
|| normalizedDetail.includes('thread_get_state_failed')
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizedDetail.includes('patch_breakpoint_failed')
|
|
||||||
|| normalizedDetail.includes('thread_get_state_failed')
|
|
||||||
|| normalizedDetail.includes('sink pattern not found')
|
|
||||||
|| normalizedDetail.includes('no suitable module found')
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMacRecoveryHint(isRepeatedFailure: boolean): string {
|
|
||||||
const steps = isRepeatedFailure
|
|
||||||
? '建议步骤:彻底退出微信 -> 重启电脑(冷启动)-> 降级微信到 4.1.7 -> 仅尝试一次自动获取 -> 成功后再升级微信。'
|
|
||||||
: '建议步骤:降级微信到 4.1.7 -> 重启电脑(冷启动)-> 自动获取密钥 -> 成功后再升级微信。'
|
|
||||||
return `${steps}\n请不要连续重试,以免触发微信安全模式或系统内存保护。`
|
|
||||||
}
|
|
||||||
|
|
||||||
private simplifyDbKeyDetail(detail?: string): string {
|
|
||||||
const raw = String(detail || '')
|
|
||||||
.replace(/^WF_OK::/i, '')
|
|
||||||
.replace(/^WF_ERR::/i, '')
|
|
||||||
.replace(/\r?\n/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
if (!raw) return ''
|
|
||||||
|
|
||||||
const keys = [
|
|
||||||
'No suitable module found',
|
|
||||||
'Sink pattern not found',
|
|
||||||
'patch_breakpoint_failed',
|
|
||||||
'thread_get_state_failed',
|
|
||||||
'task_for_pid:5',
|
|
||||||
'attach_wait_timeout',
|
|
||||||
'HOOK_TIMEOUT',
|
|
||||||
'FRIDA_TIMEOUT'
|
|
||||||
]
|
|
||||||
for (const key of keys) {
|
|
||||||
if (raw.includes(key)) return key
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripped = raw
|
|
||||||
.replace(/\[xkey_helper\]/gi, ' ')
|
|
||||||
.replace(/\[debug\]/gi, ' ')
|
|
||||||
.replace(/\[\*\]/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
if (!stripped) return ''
|
|
||||||
return stripped.length > 140 ? `${stripped.slice(0, 140)}...` : stripped
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractDbKeyErrorFromAnyText(text?: string): { code?: string; detail?: string } {
|
|
||||||
const raw = String(text || '')
|
|
||||||
if (!raw) return {}
|
|
||||||
|
|
||||||
const explicit = raw.match(/ERROR:([A-Z_]+):([^\r\n]*)/)
|
|
||||||
if (explicit) {
|
|
||||||
return {
|
|
||||||
code: explicit[1] || 'UNKNOWN',
|
|
||||||
detail: this.simplifyDbKeyDetail(explicit[2] || '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raw.includes('No suitable module found')) {
|
|
||||||
return { code: 'SCAN_FAILED', detail: 'No suitable module found' }
|
|
||||||
}
|
|
||||||
if (raw.includes('Sink pattern not found')) {
|
|
||||||
return { code: 'SCAN_FAILED', detail: 'Sink pattern not found' }
|
|
||||||
}
|
|
||||||
if (raw.includes('patch_breakpoint_failed')) {
|
|
||||||
return { code: 'HOOK_FAILED', detail: 'patch_breakpoint_failed' }
|
|
||||||
}
|
|
||||||
if (raw.includes('thread_get_state_failed')) {
|
|
||||||
return { code: 'HOOK_FAILED', detail: 'thread_get_state_failed' }
|
|
||||||
}
|
|
||||||
if (raw.includes('task_for_pid:5')) {
|
|
||||||
return { code: 'ATTACH_FAILED', detail: 'task_for_pid:5' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveUnexpectedDbKeyErrorMessage(rawError?: string): string {
|
|
||||||
const text = String(rawError || '').trim()
|
|
||||||
const { code, detail } = this.extractDbKeyErrorFromAnyText(text)
|
|
||||||
if (code) {
|
|
||||||
const mapped = this.mapDbKeyErrorMessage(code, detail)
|
|
||||||
return this.enrichDbKeyErrorMessage(mapped, code, detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text.includes('helper timeout')) {
|
|
||||||
return '获取密钥超时:请保持微信前台并进行一次会话操作后重试。'
|
|
||||||
}
|
|
||||||
if (text.includes('helper returned empty output') || text.includes('invalid json')) {
|
|
||||||
return '获取失败:helper 未返回可识别结果,请彻底退出微信后重启电脑再试。'
|
|
||||||
}
|
|
||||||
if (text.includes('xkey_helper not found')) {
|
|
||||||
return '获取失败:未找到 xkey_helper,请重新安装 WeFlow 后重试。'
|
|
||||||
}
|
|
||||||
return '自动获取密钥失败:环境可能受限或版本暂未适配,请稍后重试。'
|
|
||||||
}
|
|
||||||
|
|
||||||
private enrichDbKeyErrorMessage(baseMessage: string, code?: string, detail?: string): string {
|
|
||||||
if (!this.isRestrictedEnvironmentFailure(code, detail)) return baseMessage
|
|
||||||
|
|
||||||
const failureCount = this.markRestrictedFailureAndGetCount()
|
|
||||||
if (failureCount >= 2) {
|
|
||||||
return `${baseMessage}\n检测到连续失败,疑似已进入受限状态。请先彻底退出微信并重启电脑,再按下方步骤处理。\n${this.getMacRecoveryHint(true)}`
|
|
||||||
}
|
|
||||||
return `${baseMessage}\n${this.getMacRecoveryHint(false)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getWeChatPid(): Promise<number> {
|
private async getWeChatPid(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
// 优先使用 pgrep -x 精确匹配进程名
|
// 优先使用 pgrep -x 精确匹配进程名
|
||||||
@@ -651,12 +498,7 @@ export class KeyServiceMac {
|
|||||||
const errNum = parts[1] || 'unknown'
|
const errNum = parts[1] || 'unknown'
|
||||||
const errMsg = parts[2] || 'unknown'
|
const errMsg = parts[2] || 'unknown'
|
||||||
const partial = parts.slice(3).join('::')
|
const partial = parts.slice(3).join('::')
|
||||||
if (errNum === '-128' || String(errMsg).includes('User canceled')) {
|
throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${errMsg}, partial=${partial || '(empty)'}`)
|
||||||
throw new Error('User canceled')
|
|
||||||
}
|
|
||||||
const inferred = this.extractDbKeyErrorFromAnyText(`${errMsg}\n${partial}`)
|
|
||||||
if (inferred.code) return `ERROR:${inferred.code}:${inferred.detail || ''}`
|
|
||||||
throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${this.simplifyDbKeyDetail(errMsg) || 'unknown'}`)
|
|
||||||
}
|
}
|
||||||
const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined
|
const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined
|
||||||
|
|
||||||
@@ -678,57 +520,49 @@ export class KeyServiceMac {
|
|||||||
// 其次找 result 字段
|
// 其次找 result 字段
|
||||||
const resultPayload = allJson.find(p => typeof p?.result === 'string')
|
const resultPayload = allJson.find(p => typeof p?.result === 'string')
|
||||||
if (resultPayload) return resultPayload.result
|
if (resultPayload) return resultPayload.result
|
||||||
const inferred = this.extractDbKeyErrorFromAnyText(normalizedOutput)
|
throw new Error('elevated helper returned invalid json: ' + lines[lines.length - 1])
|
||||||
if (inferred.code) return `ERROR:${inferred.code}:${inferred.detail || ''}`
|
|
||||||
throw new Error('elevated helper returned invalid output')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapDbKeyErrorMessage(code?: string, detail?: string): string {
|
private mapDbKeyErrorMessage(code?: string, detail?: string): string {
|
||||||
const normalizedDetail = this.simplifyDbKeyDetail(detail)
|
|
||||||
if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行'
|
if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行'
|
||||||
if (code === 'ATTACH_FAILED') {
|
if (code === 'ATTACH_FAILED') {
|
||||||
const isDevElectron = process.execPath.includes('/node_modules/electron/')
|
const isDevElectron = process.execPath.includes('/node_modules/electron/')
|
||||||
if (normalizedDetail.includes('task_for_pid:5')) {
|
if ((detail || '').includes('task_for_pid:5')) {
|
||||||
if (isDevElectron) {
|
if (isDevElectron) {
|
||||||
return `无法附加到微信进程(task_for_pid 被拒绝)。当前为开发环境 Electron:${process.execPath}\n建议使用打包后的 WeFlow.app(已携带调试 entitlements)再重试。`
|
return `无法附加到微信进程(task_for_pid 被拒绝)。当前为开发环境 Electron:${process.execPath}\n建议使用打包后的 WeFlow.app(已携带调试 entitlements)再重试。`
|
||||||
}
|
}
|
||||||
return '无法附加到微信进程(task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements,优先使用打包版 WeFlow.app。'
|
return '无法附加到微信进程(task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements。'
|
||||||
}
|
}
|
||||||
if (normalizedDetail.includes('thread_get_state_failed')) {
|
return `无法附加到进程 (${detail || ''})`
|
||||||
return `无法附加到进程:系统拒绝读取线程状态(${normalizedDetail})。`
|
|
||||||
}
|
|
||||||
return `无法附加到进程 (${normalizedDetail || ''})`
|
|
||||||
}
|
}
|
||||||
if (code === 'FRIDA_FAILED') {
|
if (code === 'FRIDA_FAILED') {
|
||||||
if (normalizedDetail.includes('FRIDA_TIMEOUT')) {
|
if ((detail || '').includes('FRIDA_TIMEOUT')) {
|
||||||
return '定位已成功但在等待时间内未捕获到密钥调用。请保持微信前台并进行一次会话/数据库访问后重试。'
|
return '定位已成功但在等待时间内未捕获到密钥调用。请保持微信前台并进行一次会话/数据库访问后重试。'
|
||||||
}
|
}
|
||||||
return `Frida 语义定位失败 (${normalizedDetail || ''})`
|
return `Frida 语义定位失败 (${detail || ''})`
|
||||||
}
|
}
|
||||||
if (code === 'HOOK_FAILED') {
|
if (code === 'HOOK_FAILED') {
|
||||||
if (normalizedDetail.includes('HOOK_TIMEOUT')) {
|
if ((detail || '').includes('HOOK_TIMEOUT')) {
|
||||||
return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。'
|
return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。'
|
||||||
}
|
}
|
||||||
if (normalizedDetail.includes('attach_wait_timeout')) {
|
if ((detail || '').includes('attach_wait_timeout')) {
|
||||||
return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。'
|
return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。'
|
||||||
}
|
}
|
||||||
if (normalizedDetail.includes('patch_breakpoint_failed') || normalizedDetail.includes('thread_get_state_failed')) {
|
return `原生 Hook 失败 (${detail || ''})`
|
||||||
return `原生 Hook 失败:检测到系统调试权限或内存保护冲突(${normalizedDetail})。`
|
|
||||||
}
|
|
||||||
return `原生 Hook 失败 (${normalizedDetail || ''})`
|
|
||||||
}
|
}
|
||||||
if (code === 'HOOK_TARGET_ONLY') {
|
if (code === 'HOOK_TARGET_ONLY') {
|
||||||
return `已定位到目标函数地址(${normalizedDetail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
|
return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
|
||||||
}
|
}
|
||||||
if (code === 'SCAN_FAILED') {
|
if (code === 'SCAN_FAILED') {
|
||||||
|
const normalizedDetail = (detail || '').trim()
|
||||||
if (!normalizedDetail) {
|
if (!normalizedDetail) {
|
||||||
return '内存扫描失败:未匹配到可用特征。可能是当前微信版本更新导致,请升级 WeFlow 后重试。'
|
return '内存扫描失败:未匹配到可用特征。可能是当前微信版本更新导致,请升级 WeFlow 后重试。'
|
||||||
}
|
}
|
||||||
if (normalizedDetail.includes('Sink pattern not found')) {
|
if (normalizedDetail.includes('Sink pattern not found')) {
|
||||||
return '内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。'
|
return '内存扫描失败:未匹配到目标函数特征,可使用微信 4.1.8.100 版本尝试。'
|
||||||
}
|
}
|
||||||
if (normalizedDetail.includes('No suitable module found')) {
|
if (normalizedDetail.includes('No suitable module found')) {
|
||||||
return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台;若仍失败,优先尝试微信 4.1.7。'
|
return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台,再重试。'
|
||||||
}
|
}
|
||||||
return `内存扫描失败:${normalizedDetail}`
|
return `内存扫描失败:${normalizedDetail}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export class MessageCacheService {
|
|||||||
private readonly cacheFilePath: string
|
private readonly cacheFilePath: string
|
||||||
private cache: Record<string, SessionMessageCacheEntry> = {}
|
private cache: Record<string, SessionMessageCacheEntry> = {}
|
||||||
private readonly sessionLimit = 150
|
private readonly sessionLimit = 150
|
||||||
private readonly maxSessionEntries = 48
|
|
||||||
|
|
||||||
constructor(cacheBasePath?: string) {
|
constructor(cacheBasePath?: string) {
|
||||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
@@ -37,7 +36,6 @@ export class MessageCacheService {
|
|||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
if (parsed && typeof parsed === 'object') {
|
if (parsed && typeof parsed === 'object') {
|
||||||
this.cache = parsed
|
this.cache = parsed
|
||||||
this.pruneSessionEntries()
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('MessageCacheService: 载入缓存失败', error)
|
console.error('MessageCacheService: 载入缓存失败', error)
|
||||||
@@ -45,19 +43,6 @@ export class MessageCacheService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private pruneSessionEntries(): void {
|
|
||||||
const entries = Object.entries(this.cache || {})
|
|
||||||
if (entries.length <= this.maxSessionEntries) return
|
|
||||||
|
|
||||||
entries.sort((left, right) => {
|
|
||||||
const leftAt = Number(left[1]?.updatedAt || 0)
|
|
||||||
const rightAt = Number(right[1]?.updatedAt || 0)
|
|
||||||
return rightAt - leftAt
|
|
||||||
})
|
|
||||||
|
|
||||||
this.cache = Object.fromEntries(entries.slice(0, this.maxSessionEntries))
|
|
||||||
}
|
|
||||||
|
|
||||||
get(sessionId: string): SessionMessageCacheEntry | undefined {
|
get(sessionId: string): SessionMessageCacheEntry | undefined {
|
||||||
return this.cache[sessionId]
|
return this.cache[sessionId]
|
||||||
}
|
}
|
||||||
@@ -71,7 +56,6 @@ export class MessageCacheService {
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
messages: trimmed
|
messages: trimmed
|
||||||
}
|
}
|
||||||
this.pruneSessionEntries()
|
|
||||||
this.persist()
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -324,9 +324,6 @@ class SnsService {
|
|||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private contactCache: ContactCacheService
|
private contactCache: ContactCacheService
|
||||||
private imageCache = new Map<string, string>()
|
private imageCache = new Map<string, string>()
|
||||||
private imageCacheMeta = new Map<string, number>()
|
|
||||||
private readonly imageCacheTtlMs = 15 * 60 * 1000
|
|
||||||
private readonly imageCacheMaxEntries = 120
|
|
||||||
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
|
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
|
||||||
private userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
|
private userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
|
||||||
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
|
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
|
||||||
@@ -339,38 +336,6 @@ class SnsService {
|
|||||||
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
|
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
|
||||||
}
|
}
|
||||||
|
|
||||||
clearMemoryCache(): void {
|
|
||||||
this.imageCache.clear()
|
|
||||||
this.imageCacheMeta.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
private pruneImageCache(now: number = Date.now()): void {
|
|
||||||
for (const [key, updatedAt] of this.imageCacheMeta.entries()) {
|
|
||||||
if (now - updatedAt > this.imageCacheTtlMs) {
|
|
||||||
this.imageCacheMeta.delete(key)
|
|
||||||
this.imageCache.delete(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (this.imageCache.size > this.imageCacheMaxEntries) {
|
|
||||||
const oldestKey = this.imageCache.keys().next().value as string | undefined
|
|
||||||
if (!oldestKey) break
|
|
||||||
this.imageCache.delete(oldestKey)
|
|
||||||
this.imageCacheMeta.delete(oldestKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private rememberImageCache(cacheKey: string, dataUrl: string): void {
|
|
||||||
if (!cacheKey || !dataUrl) return
|
|
||||||
const now = Date.now()
|
|
||||||
if (this.imageCache.has(cacheKey)) {
|
|
||||||
this.imageCache.delete(cacheKey)
|
|
||||||
}
|
|
||||||
this.imageCache.set(cacheKey, dataUrl)
|
|
||||||
this.imageCacheMeta.set(cacheKey, now)
|
|
||||||
this.pruneImageCache(now)
|
|
||||||
}
|
|
||||||
|
|
||||||
private toOptionalString(value: unknown): string | undefined {
|
private toOptionalString(value: unknown): string | undefined {
|
||||||
if (typeof value !== 'string') return undefined
|
if (typeof value !== 'string') return undefined
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
@@ -1274,27 +1239,20 @@ class SnsService {
|
|||||||
if (!url) return { success: false, error: 'url 不能为空' }
|
if (!url) return { success: false, error: 'url 不能为空' }
|
||||||
const cacheKey = `${url}|${key ?? ''}`
|
const cacheKey = `${url}|${key ?? ''}`
|
||||||
|
|
||||||
|
if (this.imageCache.has(cacheKey)) {
|
||||||
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
|
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
|
||||||
if (cachedDataUrl) {
|
|
||||||
const cachedAt = this.imageCacheMeta.get(cacheKey) || 0
|
|
||||||
if (cachedAt > 0 && Date.now() - cachedAt <= this.imageCacheTtlMs) {
|
|
||||||
const base64Part = cachedDataUrl.split(',')[1] || ''
|
const base64Part = cachedDataUrl.split(',')[1] || ''
|
||||||
if (base64Part) {
|
if (base64Part) {
|
||||||
try {
|
try {
|
||||||
const cachedBuf = Buffer.from(base64Part, 'base64')
|
const cachedBuf = Buffer.from(base64Part, 'base64')
|
||||||
if (detectImageMime(cachedBuf, '').startsWith('image/')) {
|
if (detectImageMime(cachedBuf, '').startsWith('image/')) {
|
||||||
this.imageCache.delete(cacheKey)
|
|
||||||
this.imageCache.set(cacheKey, cachedDataUrl)
|
|
||||||
this.imageCacheMeta.set(cacheKey, Date.now())
|
|
||||||
return { success: true, dataUrl: cachedDataUrl }
|
return { success: true, dataUrl: cachedDataUrl }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore and fall through to refetch
|
// ignore and fall through to refetch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
this.imageCache.delete(cacheKey)
|
this.imageCache.delete(cacheKey)
|
||||||
this.imageCacheMeta.delete(cacheKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.fetchAndDecryptImage(url, key)
|
const result = await this.fetchAndDecryptImage(url, key)
|
||||||
@@ -1311,7 +1269,7 @@ class SnsService {
|
|||||||
return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' }
|
return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' }
|
||||||
}
|
}
|
||||||
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
|
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
|
||||||
this.rememberImageCache(cacheKey, dataUrl)
|
this.imageCache.set(cacheKey, dataUrl)
|
||||||
return { success: true, dataUrl }
|
return { success: true, dataUrl }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -20,7 +20,7 @@
|
|||||||
"jieba-wasm": "^2.2.0",
|
"jieba-wasm": "^2.2.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"koffi": "^2.9.0",
|
"koffi": "^2.9.0",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.8.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -6737,9 +6737,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
|
||||||
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
|
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
"jieba-wasm": "^2.2.0",
|
"jieba-wasm": "^2.2.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"koffi": "^2.9.0",
|
"koffi": "^2.9.0",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.8.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
12
src/App.tsx
12
src/App.tsx
@@ -81,7 +81,6 @@ function App() {
|
|||||||
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||||
const isNotificationWindow = location.pathname === '/notification-window'
|
const isNotificationWindow = location.pathname === '/notification-window'
|
||||||
const isAnnualReportWindow = location.pathname === '/annual-report/view'
|
const isAnnualReportWindow = location.pathname === '/annual-report/view'
|
||||||
const isDualReportWindow = location.pathname === '/dual-report/view'
|
|
||||||
const isSettingsRoute = location.pathname === '/settings'
|
const isSettingsRoute = location.pathname === '/settings'
|
||||||
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
||||||
const routeLocation = isSettingsRoute
|
const routeLocation = isSettingsRoute
|
||||||
@@ -129,7 +128,7 @@ function App() {
|
|||||||
const body = document.body
|
const body = document.body
|
||||||
const appRoot = document.getElementById('app')
|
const appRoot = document.getElementById('app')
|
||||||
|
|
||||||
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow || isDualReportWindow) {
|
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow) {
|
||||||
root.style.background = 'transparent'
|
root.style.background = 'transparent'
|
||||||
body.style.background = 'transparent'
|
body.style.background = 'transparent'
|
||||||
body.style.overflow = 'hidden'
|
body.style.overflow = 'hidden'
|
||||||
@@ -146,7 +145,7 @@ function App() {
|
|||||||
appRoot.style.overflow = ''
|
appRoot.style.overflow = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow])
|
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
||||||
|
|
||||||
// 应用主题
|
// 应用主题
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -167,7 +166,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
mq.addEventListener('change', handler)
|
mq.addEventListener('change', handler)
|
||||||
return () => mq.removeEventListener('change', handler)
|
return () => mq.removeEventListener('change', handler)
|
||||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow])
|
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
||||||
|
|
||||||
// 读取已保存的主题设置
|
// 读取已保存的主题设置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -518,11 +517,6 @@ function App() {
|
|||||||
return <AnnualReportWindow />
|
return <AnnualReportWindow />
|
||||||
}
|
}
|
||||||
|
|
||||||
// 独立双人报告全屏窗口
|
|
||||||
if (isDualReportWindow) {
|
|
||||||
return <DualReportWindow />
|
|
||||||
}
|
|
||||||
|
|
||||||
// 主窗口 - 完整布局
|
// 主窗口 - 完整布局
|
||||||
const handleCloseSettings = () => {
|
const handleCloseSettings = () => {
|
||||||
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||||
|
|||||||
@@ -5,21 +5,6 @@ import './Avatar.scss'
|
|||||||
|
|
||||||
// 全局缓存已成功加载过的头像 URL,用于控制后续是否显示动画
|
// 全局缓存已成功加载过的头像 URL,用于控制后续是否显示动画
|
||||||
const loadedAvatarCache = new Set<string>()
|
const loadedAvatarCache = new Set<string>()
|
||||||
const MAX_LOADED_AVATAR_CACHE_SIZE = 3000
|
|
||||||
|
|
||||||
const rememberLoadedAvatar = (src: string): void => {
|
|
||||||
if (!src) return
|
|
||||||
if (loadedAvatarCache.has(src)) {
|
|
||||||
loadedAvatarCache.delete(src)
|
|
||||||
}
|
|
||||||
loadedAvatarCache.add(src)
|
|
||||||
|
|
||||||
while (loadedAvatarCache.size > MAX_LOADED_AVATAR_CACHE_SIZE) {
|
|
||||||
const oldest = loadedAvatarCache.values().next().value as string | undefined
|
|
||||||
if (!oldest) break
|
|
||||||
loadedAvatarCache.delete(oldest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
src?: string
|
src?: string
|
||||||
@@ -138,7 +123,7 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
if (src) {
|
if (src) {
|
||||||
avatarLoadQueue.clearFailed(src)
|
avatarLoadQueue.clearFailed(src)
|
||||||
rememberLoadedAvatar(src)
|
loadedAvatarCache.add(src)
|
||||||
}
|
}
|
||||||
setImageLoaded(true)
|
setImageLoaded(true)
|
||||||
setImageError(false)
|
setImageError(false)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function GlobalSessionMonitor() {
|
|||||||
// 去重辅助函数:获取消息 key
|
// 去重辅助函数:获取消息 key
|
||||||
const getMessageKey = (msg: Message) => {
|
const getMessageKey = (msg: Message) => {
|
||||||
if (msg.messageKey) return msg.messageKey
|
if (msg.messageKey) return msg.messageKey
|
||||||
return `fallback:${msg._db_path || ''}:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理数据库变更
|
// 处理数据库变更
|
||||||
|
|||||||
@@ -299,12 +299,6 @@
|
|||||||
opacity: 0.05;
|
opacity: 0.05;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
filter: blur(80px);
|
filter: blur(80px);
|
||||||
animation: coreBreathing 6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes coreBreathing {
|
|
||||||
0%, 100% { opacity: 0.03; transform: translate(-50%, -50%) scale(0.95); }
|
|
||||||
50% { opacity: 0.06; transform: translate(-50%, -50%) scale(1.05); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* S9: LEXICON (大气) */
|
/* S9: LEXICON (大气) */
|
||||||
@@ -649,160 +643,199 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#scene-8 {
|
#scene-8 {
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
padding: 0;
|
padding: 0 6vw;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* V2 Background: Cinematic Aura */
|
#scene-8 .s8-layout {
|
||||||
#scene-8 .s8-bg-layer {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -10%;
|
top: 18vh;
|
||||||
z-index: 1;
|
left: 50%;
|
||||||
opacity: 0;
|
transform: translateX(-50%);
|
||||||
transition: opacity 2s 0.2s var(--ease-out);
|
width: min(1240px, 86vw);
|
||||||
filter: blur(120px) contrast(1.1) brightness(0.6);
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
.bg-avatar {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene.active #scene-8 .s8-bg-layer {
|
|
||||||
opacity: 0.18;
|
|
||||||
}
|
|
||||||
|
|
||||||
#scene-8 .s8-floating-layout {
|
|
||||||
position: relative;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
z-index: 2;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(12, 1fr);
|
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
|
||||||
grid-template-rows: repeat(12, 1fr);
|
column-gap: clamp(34px, 4.8vw, 84px);
|
||||||
padding: 10vh 8vw;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The Central Pivot: Name & Meta */
|
#scene-8 .s8-left {
|
||||||
#scene-8 .s8-hero-unit {
|
|
||||||
grid-column: 2 / 8;
|
|
||||||
grid-row: 4 / 7;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
gap: clamp(2.5vh, 3.2vh, 4vh);
|
||||||
|
padding-top: clamp(8vh, 9vh, 11vh);
|
||||||
|
}
|
||||||
|
|
||||||
.s8-name {
|
#scene-8 .s8-name-wrap,
|
||||||
font-size: clamp(4.5rem, 10vw, 8.5rem);
|
#scene-8 .s8-summary-wrap,
|
||||||
font-weight: 700;
|
#scene-8 .s8-quote-wrap,
|
||||||
color: var(--c-text-bright);
|
#scene-8 .s8-letter-wrap {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-8 .s8-name {
|
||||||
|
font-size: clamp(3.2rem, 7.4vw, 5.6rem);
|
||||||
|
color: rgba(var(--c-gold-rgb), 0.88);
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
line-height: 1;
|
line-height: 1.05;
|
||||||
margin-bottom: 2vh;
|
|
||||||
background: linear-gradient(135deg, var(--c-gold-strong), var(--c-text-bright), var(--c-gold-strong));
|
|
||||||
background-size: 200% auto;
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
animation: shine 8s linear infinite;
|
|
||||||
text-shadow: 0 0 40px rgba(var(--c-gold-rgb), 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.s8-meta {
|
#scene-8 .s8-summary {
|
||||||
font-family: 'SpaceMonoLocal';
|
max-width: 34ch;
|
||||||
font-size: clamp(0.7rem, 0.85vw, 0.9rem);
|
font-size: clamp(1.06rem, 1.35vw, 1.35rem);
|
||||||
color: var(--c-gold-strong);
|
|
||||||
letter-spacing: 0.4em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1.5vw;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
flex: 1;
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(to right, rgba(var(--c-gold-rgb), 0.6), transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fragmented Storytelling */
|
|
||||||
#scene-8 .s8-fragments {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#scene-8 .fragment {
|
|
||||||
position: absolute;
|
|
||||||
max-width: 24ch;
|
|
||||||
font-size: clamp(0.95rem, 1.1vw, 1.15rem);
|
|
||||||
line-height: 2.1;
|
|
||||||
color: var(--c-text-muted);
|
|
||||||
font-weight: 300;
|
|
||||||
|
|
||||||
&.f1 {
|
|
||||||
top: 25vh;
|
|
||||||
right: 12vw;
|
|
||||||
text-align: right;
|
|
||||||
color: var(--c-text-soft);
|
color: var(--c-text-soft);
|
||||||
font-style: italic;
|
line-height: 1.95;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.f2 {
|
#scene-8 .s8-summary-count {
|
||||||
bottom: 20vh;
|
margin: 0 8px;
|
||||||
left: 15vw;
|
font-size: clamp(1.35rem, 2vw, 1.75rem);
|
||||||
max-width: 38ch;
|
color: var(--c-gold-strong);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.f3 {
|
#scene-8 .s8-quote {
|
||||||
bottom: 12vh;
|
max-width: 32ch;
|
||||||
right: 10vw;
|
font-size: clamp(0.98rem, 1.12vw, 1.1rem);
|
||||||
text-align: right;
|
color: var(--c-text-muted);
|
||||||
opacity: 0.6;
|
line-height: 1.9;
|
||||||
font-size: 0.85rem;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shine {
|
#scene-8 .s8-letter-wrap {
|
||||||
to { background-position: 200% center; }
|
margin-top: clamp(3vh, 4vh, 5.5vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-8 .s8-letter {
|
||||||
|
position: relative;
|
||||||
|
padding: clamp(24px, 3.2vh, 38px) clamp(20px, 2.6vw, 34px) clamp(24px, 3.2vh, 38px) clamp(30px, 3.2vw, 44px);
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(var(--c-gold-rgb), 0.34);
|
||||||
|
background: linear-gradient(135deg, rgba(var(--c-gold-rgb), 0.16), rgba(var(--c-gold-rgb), 0.04));
|
||||||
|
font-size: clamp(0.95rem, 1.05vw, 1.08rem);
|
||||||
|
line-height: 2;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
text-align: left;
|
||||||
|
text-shadow: 0 4px 16px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-8 .s8-letter::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 14px;
|
||||||
|
width: 2px;
|
||||||
|
height: calc(100% - 40px);
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(to bottom, rgba(var(--c-gold-rgb), 0.7), rgba(var(--c-gold-rgb), 0.08));
|
||||||
}
|
}
|
||||||
|
|
||||||
#scene-8 .s8-empty-wrap {
|
#scene-8 .s8-empty-wrap {
|
||||||
grid-column: 4 / 10;
|
display: block;
|
||||||
grid-row: 5 / 8;
|
width: min(760px, 78vw);
|
||||||
|
margin-top: 24vh;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
.s8-empty-text {
|
}
|
||||||
font-size: 1.6rem;
|
|
||||||
line-height: 2.5;
|
#scene-8 .s8-empty-text {
|
||||||
color: var(--c-text-soft);
|
color: var(--c-text);
|
||||||
font-weight: 200;
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
#scene-8 .s8-layout {
|
||||||
|
width: min(1120px, 88vw);
|
||||||
|
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
||||||
|
column-gap: clamp(28px, 4vw, 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-8 .s8-left {
|
||||||
|
padding-top: clamp(6vh, 8vh, 9vh);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
#scene-8 .s8-hero-unit {
|
#scene-8 .s8-layout {
|
||||||
grid-column: 2 / 12;
|
top: 16vh;
|
||||||
grid-row: 2 / 5;
|
width: min(900px, 90vw);
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
row-gap: clamp(3vh, 3.5vh, 4.5vh);
|
||||||
}
|
}
|
||||||
#scene-8 .fragment {
|
|
||||||
position: relative;
|
#scene-8 .s8-left {
|
||||||
inset: auto !important;
|
padding-top: 0;
|
||||||
max-width: 100%;
|
gap: clamp(1.6vh, 2.2vh, 2.8vh);
|
||||||
text-align: left !important;
|
|
||||||
margin-top: 4vh;
|
|
||||||
}
|
}
|
||||||
#scene-8 .s8-fragments {
|
|
||||||
position: relative;
|
#scene-8 .s8-name {
|
||||||
grid-column: 2 / 12;
|
font-size: clamp(2.4rem, 8.4vw, 4.2rem);
|
||||||
grid-row: 6 / 12;
|
letter-spacing: 0.06em;
|
||||||
display: flex;
|
}
|
||||||
flex-direction: column;
|
|
||||||
|
#scene-8 .s8-summary,
|
||||||
|
#scene-8 .s8-quote {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-8 .s8-letter-wrap {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-8 .s8-letter {
|
||||||
|
font-size: clamp(0.9rem, 1.9vw, 1rem);
|
||||||
|
line-height: 1.95;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
#scene-8 .s8-layout {
|
||||||
|
top: 14.5vh;
|
||||||
|
width: 92vw;
|
||||||
|
row-gap: clamp(2.2vh, 3vh, 3.8vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-8 .s8-name {
|
||||||
|
font-size: clamp(2rem, 10vw, 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-8 .s8-summary {
|
||||||
|
font-size: clamp(0.92rem, 3.9vw, 1rem);
|
||||||
|
line-height: 1.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-8 .s8-summary-count {
|
||||||
|
margin: 0 6px;
|
||||||
|
font-size: clamp(1.1rem, 4.8vw, 1.35rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-8 .s8-quote {
|
||||||
|
font-size: clamp(0.86rem, 3.5vw, 0.95rem);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-8 .s8-letter {
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px 16px 16px 24px;
|
||||||
|
font-size: clamp(0.82rem, 3.4vw, 0.9rem);
|
||||||
|
line-height: 1.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-8 .s8-letter::before {
|
||||||
|
top: 16px;
|
||||||
|
left: 11px;
|
||||||
|
height: calc(100% - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-8 .s8-empty-wrap {
|
||||||
|
width: 88vw;
|
||||||
|
margin-top: 23vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-8 .s8-empty-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -872,69 +872,45 @@ function AnnualReportWindow() {
|
|||||||
<div className="reveal-wrap en-tag">
|
<div className="reveal-wrap en-tag">
|
||||||
<div className="reveal-inner serif scene0-cn-tag">曾经的好友</div>
|
<div className="reveal-inner serif scene0-cn-tag">曾经的好友</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{reportData.lostFriend && (
|
|
||||||
<div className="s8-bg-layer">
|
|
||||||
<img src={reportData.lostFriend.avatarUrl} alt="" className="bg-avatar" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
{reportData.lostFriend ? (
|
{reportData.lostFriend ? (
|
||||||
<div className="s8-floating-layout">
|
<div className="s8-layout">
|
||||||
<div className="s8-hero-unit">
|
<div className="s8-left">
|
||||||
<div className="reveal-wrap">
|
<div className="reveal-wrap s8-name-wrap">
|
||||||
<div className="reveal-inner s8-name delay-1">
|
<div className="reveal-inner serif delay-1 s8-name">
|
||||||
{reportData.lostFriend.displayName}
|
{reportData.lostFriend.displayName}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="reveal-wrap">
|
<div className="reveal-wrap s8-summary-wrap">
|
||||||
<div className="reveal-inner s8-meta delay-2">
|
<div className="reveal-inner serif delay-2 s8-summary">
|
||||||
{reportData.lostFriend.periodDesc} /
|
后来,你们的交集停留在{reportData.lostFriend.periodDesc}这短短的
|
||||||
<span className="num-display" style={{ margin: '0 10px', fontSize: '1.4em' }}>
|
<span className="num-display s8-summary-count">
|
||||||
<DecodeText value={reportData.lostFriend.lateCount.toLocaleString()} active={currentScene === 8} />
|
<DecodeText value={reportData.lostFriend.lateCount.toLocaleString()} active={currentScene === 8} />
|
||||||
</span>
|
</span>
|
||||||
MESSAGES
|
句话里。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="reveal-wrap s8-quote-wrap">
|
||||||
|
<div className="reveal-inner serif delay-3 s8-quote">
|
||||||
|
“我一直相信我们能够再次相见,相信分别的日子总会迎来终结。”
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="reveal-wrap s8-letter-wrap">
|
||||||
<div className="s8-fragments">
|
<div className="reveal-inner serif delay-4 s8-letter">
|
||||||
<div className="reveal-wrap fragment f1">
|
所有的离散,或许都只是一场漫长的越冬。飞鸟要越过一万座雪山,才能带来春天的第一行回信;树木要褪去一万次枯叶,才能记住风的形状。如果时间注定要把我们推向不同的象限,那就在记忆的最深处建一座灯塔。哪怕要熬过几千个无法见面的黄昏,也要相信,总有一次日出的晨光,是为了照亮我们重逢的归途。
|
||||||
<div className="reveal-inner delay-3">
|
|
||||||
“我一直相信我们能够再次相见,<br />相信分别的日子总会迎来终结。”
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="reveal-wrap fragment f2">
|
|
||||||
<div className="reveal-inner delay-4">
|
|
||||||
所有的离散,或许都只是一场漫长的越冬。<br />
|
|
||||||
飞鸟要越过一万座雪山,才能带来春天的第一行回信;<br />
|
|
||||||
树木要褪去一万次枯叶,才能记住风的形状。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="reveal-wrap fragment f3">
|
|
||||||
<div className="reveal-inner delay-5">
|
|
||||||
哪怕要熬过几千个无法见面的黄昏,也要相信,<br />
|
|
||||||
总有一次日出的晨光,是为了照亮我们重逢的归途。
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="s8-floating-layout">
|
<div className="reveal-wrap desc-text s8-empty-wrap">
|
||||||
<div className="reveal-wrap s8-empty-wrap">
|
<div className="reveal-inner serif delay-1 s8-empty-text">
|
||||||
<div className="reveal-inner serif s8-empty-text delay-1">
|
|
||||||
缘分温柔地眷顾着你。<br/>
|
缘分温柔地眷顾着你。<br/>
|
||||||
这一年,所有重要的人都在,没有一次无疾而终的告别。
|
这一年,所有重要的人都在,没有一次无疾而终的告别。<br/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* S9: LEXICON & ARCHIVE */}
|
{/* S9: LEXICON & ARCHIVE */}
|
||||||
<div className={getSceneClass(9)} id="scene-9">
|
<div className={getSceneClass(9)} id="scene-9">
|
||||||
<div className="reveal-wrap en-tag">
|
<div className="reveal-wrap en-tag">
|
||||||
|
|||||||
@@ -72,146 +72,11 @@ 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_LIST_SCROLL_IDLE_MS = 160
|
||||||
const MESSAGE_TOP_EDGE_LOAD_COOLDOWN_MS = 160
|
const MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS = 160
|
||||||
const MESSAGE_EDGE_TRIGGER_DISTANCE_PX = 96
|
const MESSAGE_EDGE_TRIGGER_DISTANCE_PX = 96
|
||||||
const MESSAGE_HISTORY_INITIAL_LIMIT = 50
|
|
||||||
const MESSAGE_HISTORY_HEAVY_UNREAD_INITIAL_LIMIT = 70
|
|
||||||
const MESSAGE_HISTORY_GROWTH_STEP = 20
|
|
||||||
const MESSAGE_HISTORY_MAX_LIMIT = 180
|
|
||||||
const MESSAGE_VIRTUAL_OVERSCAN_PX = 140
|
|
||||||
const BYTES_PER_MEGABYTE = 1024 * 1024
|
|
||||||
const EMOJI_CACHE_MAX_ENTRIES = 260
|
|
||||||
const EMOJI_CACHE_MAX_BYTES = 32 * BYTES_PER_MEGABYTE
|
|
||||||
const IMAGE_CACHE_MAX_ENTRIES = 360
|
|
||||||
const IMAGE_CACHE_MAX_BYTES = 64 * BYTES_PER_MEGABYTE
|
|
||||||
const VOICE_CACHE_MAX_ENTRIES = 120
|
|
||||||
const VOICE_CACHE_MAX_BYTES = 24 * BYTES_PER_MEGABYTE
|
|
||||||
const VOICE_TRANSCRIPT_CACHE_MAX_ENTRIES = 1800
|
|
||||||
const VOICE_TRANSCRIPT_CACHE_MAX_BYTES = 2 * BYTES_PER_MEGABYTE
|
|
||||||
const SENDER_AVATAR_CACHE_MAX_ENTRIES = 2000
|
|
||||||
const AUTO_MEDIA_TASK_MAX_CONCURRENCY = 2
|
|
||||||
const AUTO_MEDIA_TASK_MAX_QUEUE = 80
|
|
||||||
|
|
||||||
type RequestIdleCallbackCompat = (callback: () => void, options?: { timeout?: number }) => number
|
type RequestIdleCallbackCompat = (callback: () => void, options?: { timeout?: number }) => number
|
||||||
|
|
||||||
type BoundedCacheOptions<V> = {
|
|
||||||
maxEntries: number
|
|
||||||
maxBytes?: number
|
|
||||||
estimate?: (value: V) => number
|
|
||||||
}
|
|
||||||
|
|
||||||
type BoundedCache<V> = {
|
|
||||||
get: (key: string) => V | undefined
|
|
||||||
set: (key: string, value: V) => void
|
|
||||||
has: (key: string) => boolean
|
|
||||||
delete: (key: string) => boolean
|
|
||||||
clear: () => void
|
|
||||||
readonly size: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function estimateStringBytes(value: string): number {
|
|
||||||
return Math.max(0, value.length * 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createBoundedCache<V>(options: BoundedCacheOptions<V>): BoundedCache<V> {
|
|
||||||
const { maxEntries, maxBytes, estimate } = options
|
|
||||||
const storage = new Map<string, V>()
|
|
||||||
const valueSizes = new Map<string, number>()
|
|
||||||
let currentBytes = 0
|
|
||||||
|
|
||||||
const estimateSize = (value: V): number => {
|
|
||||||
if (!estimate) return 1
|
|
||||||
const raw = estimate(value)
|
|
||||||
if (!Number.isFinite(raw) || raw <= 0) return 1
|
|
||||||
return Math.max(1, Math.round(raw))
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeKey = (key: string): boolean => {
|
|
||||||
if (!storage.has(key)) return false
|
|
||||||
const previousSize = valueSizes.get(key) || 0
|
|
||||||
currentBytes = Math.max(0, currentBytes - previousSize)
|
|
||||||
valueSizes.delete(key)
|
|
||||||
return storage.delete(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
const touch = (key: string, value: V) => {
|
|
||||||
storage.delete(key)
|
|
||||||
storage.set(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const prune = () => {
|
|
||||||
const shouldPruneByBytes = Number.isFinite(maxBytes) && (maxBytes as number) > 0
|
|
||||||
while (storage.size > maxEntries || (shouldPruneByBytes && currentBytes > (maxBytes as number))) {
|
|
||||||
const oldestKey = storage.keys().next().value as string | undefined
|
|
||||||
if (!oldestKey) break
|
|
||||||
removeKey(oldestKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
get(key: string) {
|
|
||||||
const value = storage.get(key)
|
|
||||||
if (value === undefined) return undefined
|
|
||||||
touch(key, value)
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
set(key: string, value: V) {
|
|
||||||
const nextSize = estimateSize(value)
|
|
||||||
if (storage.has(key)) {
|
|
||||||
const previousSize = valueSizes.get(key) || 0
|
|
||||||
currentBytes = Math.max(0, currentBytes - previousSize)
|
|
||||||
}
|
|
||||||
storage.set(key, value)
|
|
||||||
valueSizes.set(key, nextSize)
|
|
||||||
currentBytes += nextSize
|
|
||||||
prune()
|
|
||||||
},
|
|
||||||
has(key: string) {
|
|
||||||
return storage.has(key)
|
|
||||||
},
|
|
||||||
delete(key: string) {
|
|
||||||
return removeKey(key)
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
storage.clear()
|
|
||||||
valueSizes.clear()
|
|
||||||
currentBytes = 0
|
|
||||||
},
|
|
||||||
get size() {
|
|
||||||
return storage.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoMediaTaskQueue: Array<() => void> = []
|
|
||||||
let autoMediaTaskRunningCount = 0
|
|
||||||
|
|
||||||
function enqueueAutoMediaTask<T>(task: () => Promise<T>): Promise<T> {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const runTask = () => {
|
|
||||||
autoMediaTaskRunningCount += 1
|
|
||||||
task()
|
|
||||||
.then(resolve)
|
|
||||||
.catch(reject)
|
|
||||||
.finally(() => {
|
|
||||||
autoMediaTaskRunningCount = Math.max(0, autoMediaTaskRunningCount - 1)
|
|
||||||
const next = autoMediaTaskQueue.shift()
|
|
||||||
if (next) next()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (autoMediaTaskRunningCount < AUTO_MEDIA_TASK_MAX_CONCURRENCY) {
|
|
||||||
runTask()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (autoMediaTaskQueue.length >= AUTO_MEDIA_TASK_MAX_QUEUE) {
|
|
||||||
reject(new Error('AUTO_MEDIA_TASK_QUEUE_FULL'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
autoMediaTaskQueue.push(runTask)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleWhenIdle(task: () => void, options?: { timeout?: number; fallbackDelay?: number }): void {
|
function scheduleWhenIdle(task: () => void, options?: { timeout?: number; fallbackDelay?: number }): void {
|
||||||
const requestIdleCallbackFn = (
|
const requestIdleCallbackFn = (
|
||||||
globalThis as typeof globalThis & { requestIdleCallback?: RequestIdleCallbackCompat }
|
globalThis as typeof globalThis & { requestIdleCallback?: RequestIdleCallbackCompat }
|
||||||
@@ -1428,7 +1293,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
const getMessageKey = useCallback((msg: Message): string => {
|
const getMessageKey = useCallback((msg: Message): string => {
|
||||||
if (msg.messageKey) return msg.messageKey
|
if (msg.messageKey) return msg.messageKey
|
||||||
return `fallback:${msg._db_path || ''}:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||||
}, [])
|
}, [])
|
||||||
const initialRevealTimerRef = useRef<number | null>(null)
|
const initialRevealTimerRef = useRef<number | null>(null)
|
||||||
const sessionListRef = useRef<HTMLDivElement>(null)
|
const sessionListRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -1608,7 +1473,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const searchKeywordRef = useRef('')
|
const searchKeywordRef = useRef('')
|
||||||
const preloadImageKeysRef = useRef<Set<string>>(new Set())
|
const preloadImageKeysRef = useRef<Set<string>>(new Set())
|
||||||
const lastPreloadSessionRef = useRef<string | null>(null)
|
const lastPreloadSessionRef = useRef<string | null>(null)
|
||||||
const messageMediaPreloadTimerRef = useRef<number | null>(null)
|
|
||||||
const detailRequestSeqRef = useRef(0)
|
const detailRequestSeqRef = useRef(0)
|
||||||
const groupMembersRequestSeqRef = useRef(0)
|
const groupMembersRequestSeqRef = useRef(0)
|
||||||
const groupMembersPanelCacheRef = useRef<Map<string, GroupMembersPanelCacheEntry>>(new Map())
|
const groupMembersPanelCacheRef = useRef<Map<string, GroupMembersPanelCacheEntry>>(new Map())
|
||||||
@@ -2929,11 +2793,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [loadMyAvatar, resolveChatCacheScope])
|
}, [loadMyAvatar, resolveChatCacheScope])
|
||||||
|
|
||||||
const handleAccountChanged = useCallback(async () => {
|
const handleAccountChanged = useCallback(async () => {
|
||||||
emojiDataUrlCache.clear()
|
|
||||||
imageDataUrlCache.clear()
|
|
||||||
voiceDataUrlCache.clear()
|
|
||||||
voiceTranscriptCache.clear()
|
|
||||||
imageDecryptInFlight.clear()
|
|
||||||
senderAvatarCache.clear()
|
senderAvatarCache.clear()
|
||||||
senderAvatarLoading.clear()
|
senderAvatarLoading.clear()
|
||||||
quotedSenderDisplayCache.clear()
|
quotedSenderDisplayCache.clear()
|
||||||
@@ -2945,10 +2804,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
sessionContactEnrichAttemptAtRef.current.clear()
|
sessionContactEnrichAttemptAtRef.current.clear()
|
||||||
preloadImageKeysRef.current.clear()
|
preloadImageKeysRef.current.clear()
|
||||||
lastPreloadSessionRef.current = null
|
lastPreloadSessionRef.current = null
|
||||||
if (messageMediaPreloadTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(messageMediaPreloadTimerRef.current)
|
|
||||||
messageMediaPreloadTimerRef.current = null
|
|
||||||
}
|
|
||||||
pendingSessionLoadRef.current = null
|
pendingSessionLoadRef.current = null
|
||||||
initialLoadRequestedSessionRef.current = null
|
initialLoadRequestedSessionRef.current = null
|
||||||
sessionSwitchRequestSeqRef.current += 1
|
sessionSwitchRequestSeqRef.current += 1
|
||||||
@@ -3466,8 +3321,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setIsRefreshingMessages(false)
|
setIsRefreshingMessages(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 消息批量大小控制(会话内逐步增大,减少频繁触顶加载)
|
// 消息批量大小控制(保持稳定,避免游标反复重建)
|
||||||
const currentBatchSizeRef = useRef(MESSAGE_HISTORY_INITIAL_LIMIT)
|
const currentBatchSizeRef = useRef(50)
|
||||||
|
|
||||||
const warmupGroupSenderProfiles = useCallback((usernames: string[], defer = false) => {
|
const warmupGroupSenderProfiles = useCallback((usernames: string[], defer = false) => {
|
||||||
if (!Array.isArray(usernames) || usernames.length === 0) return
|
if (!Array.isArray(usernames) || usernames.length === 0) return
|
||||||
@@ -3531,21 +3386,14 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
let messageLimit: number
|
let messageLimit: number
|
||||||
|
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
const defaultInitialLimit = unreadCount > 99
|
|
||||||
? MESSAGE_HISTORY_HEAVY_UNREAD_INITIAL_LIMIT
|
|
||||||
: MESSAGE_HISTORY_INITIAL_LIMIT
|
|
||||||
const preferredLimit = Number.isFinite(options.forceInitialLimit)
|
const preferredLimit = Number.isFinite(options.forceInitialLimit)
|
||||||
? Math.max(10, Math.floor(options.forceInitialLimit as number))
|
? Math.max(10, Math.floor(options.forceInitialLimit as number))
|
||||||
: defaultInitialLimit
|
: (unreadCount > 99 ? 30 : 40)
|
||||||
currentBatchSizeRef.current = Math.min(preferredLimit, MESSAGE_HISTORY_MAX_LIMIT)
|
currentBatchSizeRef.current = preferredLimit
|
||||||
messageLimit = currentBatchSizeRef.current
|
messageLimit = preferredLimit
|
||||||
} else {
|
} else {
|
||||||
const grownBatchSize = Math.min(
|
// 同一会话内保持固定批量,避免后端游标因 batch 改变而重建
|
||||||
Math.max(currentBatchSizeRef.current, MESSAGE_HISTORY_INITIAL_LIMIT) + MESSAGE_HISTORY_GROWTH_STEP,
|
messageLimit = currentBatchSizeRef.current
|
||||||
MESSAGE_HISTORY_MAX_LIMIT
|
|
||||||
)
|
|
||||||
currentBatchSizeRef.current = grownBatchSize
|
|
||||||
messageLimit = grownBatchSize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3597,10 +3445,10 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
if (result.success && result.messages) {
|
if (result.success && result.messages) {
|
||||||
const resultMessages = result.messages
|
const resultMessages = result.messages
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
setNoMessageTable(false)
|
|
||||||
setMessages(resultMessages)
|
setMessages(resultMessages)
|
||||||
persistSessionPreviewCache(sessionId, resultMessages)
|
persistSessionPreviewCache(sessionId, resultMessages)
|
||||||
if (resultMessages.length === 0) {
|
if (resultMessages.length === 0) {
|
||||||
|
setNoMessageTable(true)
|
||||||
setHasMoreMessages(false)
|
setHasMoreMessages(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3701,10 +3549,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
: offset + resultMessages.length
|
: offset + resultMessages.length
|
||||||
setCurrentOffset(nextOffset)
|
setCurrentOffset(nextOffset)
|
||||||
} else if (!result.success) {
|
} else if (!result.success) {
|
||||||
const errorText = String(result.error || '')
|
setNoMessageTable(true)
|
||||||
const shouldMarkNoTable =
|
|
||||||
/schema mismatch|no message db|no table|消息数据库未找到|消息表|message schema/i.test(errorText)
|
|
||||||
setNoMessageTable(shouldMarkNoTable)
|
|
||||||
setHasMoreMessages(false)
|
setHasMoreMessages(false)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -3712,7 +3557,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setConnectionError('加载消息失败')
|
setConnectionError('加载消息失败')
|
||||||
setHasMoreMessages(false)
|
setHasMoreMessages(false)
|
||||||
if (offset === 0 && currentSessionRef.current === sessionId) {
|
if (offset === 0 && currentSessionRef.current === sessionId) {
|
||||||
setNoMessageTable(false)
|
|
||||||
setMessages([])
|
setMessages([])
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -4251,7 +4095,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
void loadMessages(normalizedSessionId, 0, 0, 0, false, {
|
void loadMessages(normalizedSessionId, 0, 0, 0, false, {
|
||||||
preferLatestPath: true,
|
preferLatestPath: true,
|
||||||
deferGroupSenderWarmup: true,
|
deferGroupSenderWarmup: true,
|
||||||
forceInitialLimit: MESSAGE_HISTORY_INITIAL_LIMIT,
|
forceInitialLimit: 30,
|
||||||
switchRequestSeq
|
switchRequestSeq
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -4742,40 +4586,24 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setShowScrollToBottom(prev => (prev === shouldShow ? prev : shouldShow))
|
setShowScrollToBottom(prev => (prev === shouldShow ? prev : shouldShow))
|
||||||
}, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching])
|
}, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching])
|
||||||
|
|
||||||
const triggerTopEdgeHistoryLoad = useCallback((): boolean => {
|
|
||||||
if (!currentSessionId || isLoadingMore || isLoadingMessages || !hasMoreMessages) return false
|
|
||||||
const listEl = messageListRef.current
|
|
||||||
if (!listEl) return false
|
|
||||||
const distanceFromTop = Math.max(0, listEl.scrollTop)
|
|
||||||
if (distanceFromTop > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return false
|
|
||||||
if (topRangeLoadLockRef.current) return false
|
|
||||||
const now = Date.now()
|
|
||||||
if (now - topRangeLoadLastTriggerAtRef.current < MESSAGE_TOP_EDGE_LOAD_COOLDOWN_MS) return false
|
|
||||||
topRangeLoadLastTriggerAtRef.current = now
|
|
||||||
topRangeLoadLockRef.current = true
|
|
||||||
isMessageListAtBottomRef.current = false
|
|
||||||
void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
|
|
||||||
return true
|
|
||||||
}, [
|
|
||||||
currentSessionId,
|
|
||||||
isLoadingMore,
|
|
||||||
isLoadingMessages,
|
|
||||||
hasMoreMessages,
|
|
||||||
loadMessages,
|
|
||||||
currentOffset,
|
|
||||||
jumpStartTime,
|
|
||||||
jumpEndTime
|
|
||||||
])
|
|
||||||
|
|
||||||
const handleMessageListWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
const handleMessageListWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
||||||
markMessageListScrolling()
|
markMessageListScrolling()
|
||||||
if (!currentSessionId || isLoadingMore || isLoadingMessages) 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 (event.deltaY <= -18) {
|
if (event.deltaY <= -18) {
|
||||||
triggerTopEdgeHistoryLoad()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4791,21 +4619,22 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [
|
}, [
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
hasMoreLater,
|
hasMoreLater,
|
||||||
|
hasMoreMessages,
|
||||||
isLoadingMessages,
|
isLoadingMessages,
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
|
currentOffset,
|
||||||
|
jumpStartTime,
|
||||||
|
jumpEndTime,
|
||||||
markMessageListScrolling,
|
markMessageListScrolling,
|
||||||
loadLaterMessages,
|
loadMessages,
|
||||||
triggerTopEdgeHistoryLoad
|
loadLaterMessages
|
||||||
])
|
])
|
||||||
|
|
||||||
const handleMessageAtTopStateChange = useCallback((atTop: boolean) => {
|
const handleMessageAtTopStateChange = useCallback((atTop: boolean) => {
|
||||||
if (!atTop) {
|
if (!atTop) {
|
||||||
topRangeLoadLockRef.current = false
|
topRangeLoadLockRef.current = false
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// 支持拖动右侧滚动条到顶部时直接触发加载,不依赖滚轮事件。
|
}, [])
|
||||||
triggerTopEdgeHistoryLoad()
|
|
||||||
}, [triggerTopEdgeHistoryLoad])
|
|
||||||
|
|
||||||
|
|
||||||
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
|
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
|
||||||
@@ -4958,10 +4787,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
window.clearTimeout(messageListScrollTimeoutRef.current)
|
window.clearTimeout(messageListScrollTimeoutRef.current)
|
||||||
messageListScrollTimeoutRef.current = null
|
messageListScrollTimeoutRef.current = null
|
||||||
}
|
}
|
||||||
if (messageMediaPreloadTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(messageMediaPreloadTimerRef.current)
|
|
||||||
messageMediaPreloadTimerRef.current = null
|
|
||||||
}
|
|
||||||
isMessageListScrollingRef.current = false
|
isMessageListScrollingRef.current = false
|
||||||
contactUpdateQueueRef.current.clear()
|
contactUpdateQueueRef.current.clear()
|
||||||
pendingSessionContactEnrichRef.current.clear()
|
pendingSessionContactEnrichRef.current.clear()
|
||||||
@@ -5032,18 +4857,9 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [currentSessionId])
|
}, [currentSessionId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messageMediaPreloadTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(messageMediaPreloadTimerRef.current)
|
|
||||||
messageMediaPreloadTimerRef.current = null
|
|
||||||
}
|
|
||||||
if (!currentSessionId || messages.length === 0) return
|
if (!currentSessionId || messages.length === 0) return
|
||||||
|
const preloadEdgeCount = 40
|
||||||
messageMediaPreloadTimerRef.current = window.setTimeout(() => {
|
const maxPreload = 30
|
||||||
messageMediaPreloadTimerRef.current = null
|
|
||||||
scheduleWhenIdle(() => {
|
|
||||||
if (isMessageListScrollingRef.current) return
|
|
||||||
const preloadEdgeCount = 20
|
|
||||||
const maxPreload = 12
|
|
||||||
const head = messages.slice(0, preloadEdgeCount)
|
const head = messages.slice(0, preloadEdgeCount)
|
||||||
const tail = messages.slice(-preloadEdgeCount)
|
const tail = messages.slice(-preloadEdgeCount)
|
||||||
const candidates = [...head, ...tail]
|
const candidates = [...head, ...tail]
|
||||||
@@ -5072,15 +4888,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
allowCacheIndex: false
|
allowCacheIndex: false
|
||||||
}).catch(() => { })
|
}).catch(() => { })
|
||||||
}
|
}
|
||||||
}, { timeout: 1400, fallbackDelay: 120 })
|
|
||||||
}, 120)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (messageMediaPreloadTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(messageMediaPreloadTimerRef.current)
|
|
||||||
messageMediaPreloadTimerRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [currentSessionId, messages])
|
}, [currentSessionId, messages])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -5176,7 +4983,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
void loadMessages(currentSessionId, 0, 0, 0, false, {
|
void loadMessages(currentSessionId, 0, 0, 0, false, {
|
||||||
preferLatestPath: true,
|
preferLatestPath: true,
|
||||||
deferGroupSenderWarmup: true,
|
deferGroupSenderWarmup: true,
|
||||||
forceInitialLimit: MESSAGE_HISTORY_INITIAL_LIMIT
|
forceInitialLimit: 30
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
|
}, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
|
||||||
@@ -5309,18 +5116,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSessionSortTime = (session: Pick<ChatSession, 'sortTimestamp' | 'lastTimestamp'>) =>
|
|
||||||
Number(session.sortTimestamp || session.lastTimestamp || 0)
|
|
||||||
const insertSessionByTimeDesc = (list: ChatSession[], entry: ChatSession) => {
|
|
||||||
const entryTime = getSessionSortTime(entry)
|
|
||||||
const insertIndex = list.findIndex(s => getSessionSortTime(s) < entryTime)
|
|
||||||
if (insertIndex === -1) {
|
|
||||||
list.push(entry)
|
|
||||||
} else {
|
|
||||||
list.splice(insertIndex, 0, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const officialSessions = sessions.filter(s => s.username.startsWith('gh_'))
|
const officialSessions = sessions.filter(s => s.username.startsWith('gh_'))
|
||||||
|
|
||||||
// 检查是否有折叠的群聊
|
// 检查是否有折叠的群聊
|
||||||
@@ -5335,12 +5130,11 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
const latestOfficial = officialSessions.reduce<ChatSession | null>((latest, current) => {
|
const latestOfficial = officialSessions.reduce<ChatSession | null>((latest, current) => {
|
||||||
if (!latest) return current
|
if (!latest) return current
|
||||||
const latestTime = getSessionSortTime(latest)
|
const latestTime = latest.sortTimestamp || latest.lastTimestamp
|
||||||
const currentTime = getSessionSortTime(current)
|
const currentTime = current.sortTimestamp || current.lastTimestamp
|
||||||
return currentTime > latestTime ? current : latest
|
return currentTime > latestTime ? current : latest
|
||||||
}, null)
|
}, null)
|
||||||
const officialUnreadCount = officialSessions.reduce((sum, s) => sum + (s.unreadCount || 0), 0)
|
const officialUnreadCount = officialSessions.reduce((sum, s) => sum + (s.unreadCount || 0), 0)
|
||||||
const officialLatestTime = latestOfficial ? getSessionSortTime(latestOfficial) : 0
|
|
||||||
|
|
||||||
const bizEntry: ChatSession = {
|
const bizEntry: ChatSession = {
|
||||||
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
|
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
|
||||||
@@ -5349,8 +5143,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}`
|
? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}`
|
||||||
: '查看公众号历史消息',
|
: '查看公众号历史消息',
|
||||||
type: 0,
|
type: 0,
|
||||||
sortTimestamp: officialLatestTime,
|
sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下
|
||||||
lastTimestamp: officialLatestTime,
|
lastTimestamp: latestOfficial?.lastTimestamp || latestOfficial?.sortTimestamp || 0,
|
||||||
lastMsgType: latestOfficial?.lastMsgType || 0,
|
lastMsgType: latestOfficial?.lastMsgType || 0,
|
||||||
unreadCount: officialUnreadCount,
|
unreadCount: officialUnreadCount,
|
||||||
isMuted: false,
|
isMuted: false,
|
||||||
@@ -5358,7 +5152,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!visible.some(s => s.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)) {
|
if (!visible.some(s => s.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)) {
|
||||||
insertSessionByTimeDesc(visible, bizEntry)
|
visible.unshift(bizEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
|
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
|
||||||
@@ -5382,7 +5176,17 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
isFolded: false
|
isFolded: false
|
||||||
}
|
}
|
||||||
|
|
||||||
insertSessionByTimeDesc(visible, foldEntry)
|
// 按时间戳插入到正确位置
|
||||||
|
const foldTime = foldEntry.sortTimestamp || foldEntry.lastTimestamp
|
||||||
|
const insertIndex = visible.findIndex(s => {
|
||||||
|
const sTime = s.sortTimestamp || s.lastTimestamp
|
||||||
|
return sTime < foldTime
|
||||||
|
})
|
||||||
|
if (insertIndex === -1) {
|
||||||
|
visible.push(foldEntry)
|
||||||
|
} else {
|
||||||
|
visible.splice(insertIndex, 0, foldEntry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!searchKeyword.trim()) {
|
if (!searchKeyword.trim()) {
|
||||||
@@ -7270,7 +7074,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
className="message-virtuoso"
|
className="message-virtuoso"
|
||||||
customScrollParent={messageListScrollParent ?? undefined}
|
customScrollParent={messageListScrollParent ?? undefined}
|
||||||
data={messages}
|
data={messages}
|
||||||
overscan={MESSAGE_VIRTUAL_OVERSCAN_PX}
|
overscan={220}
|
||||||
followOutput={(atBottom) => (
|
followOutput={(atBottom) => (
|
||||||
prependingHistoryRef.current
|
prependingHistoryRef.current
|
||||||
? false
|
? false
|
||||||
@@ -8214,26 +8018,10 @@ const globalVoiceManager = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 前端表情包缓存
|
// 前端表情包缓存
|
||||||
const emojiDataUrlCache = createBoundedCache<string>({
|
const emojiDataUrlCache = new Map<string, string>()
|
||||||
maxEntries: EMOJI_CACHE_MAX_ENTRIES,
|
const imageDataUrlCache = new Map<string, string>()
|
||||||
maxBytes: EMOJI_CACHE_MAX_BYTES,
|
const voiceDataUrlCache = new Map<string, string>()
|
||||||
estimate: estimateStringBytes
|
const voiceTranscriptCache = new Map<string, string>()
|
||||||
})
|
|
||||||
const imageDataUrlCache = createBoundedCache<string>({
|
|
||||||
maxEntries: IMAGE_CACHE_MAX_ENTRIES,
|
|
||||||
maxBytes: IMAGE_CACHE_MAX_BYTES,
|
|
||||||
estimate: estimateStringBytes
|
|
||||||
})
|
|
||||||
const voiceDataUrlCache = createBoundedCache<string>({
|
|
||||||
maxEntries: VOICE_CACHE_MAX_ENTRIES,
|
|
||||||
maxBytes: VOICE_CACHE_MAX_BYTES,
|
|
||||||
estimate: estimateStringBytes
|
|
||||||
})
|
|
||||||
const voiceTranscriptCache = createBoundedCache<string>({
|
|
||||||
maxEntries: VOICE_TRANSCRIPT_CACHE_MAX_ENTRIES,
|
|
||||||
maxBytes: VOICE_TRANSCRIPT_CACHE_MAX_BYTES,
|
|
||||||
estimate: estimateStringBytes
|
|
||||||
})
|
|
||||||
type SharedImageDecryptResult = {
|
type SharedImageDecryptResult = {
|
||||||
success: boolean
|
success: boolean
|
||||||
localPath?: string
|
localPath?: string
|
||||||
@@ -8242,9 +8030,7 @@ type SharedImageDecryptResult = {
|
|||||||
failureKind?: 'not_found' | 'decrypt_failed'
|
failureKind?: 'not_found' | 'decrypt_failed'
|
||||||
}
|
}
|
||||||
const imageDecryptInFlight = new Map<string, Promise<SharedImageDecryptResult>>()
|
const imageDecryptInFlight = new Map<string, Promise<SharedImageDecryptResult>>()
|
||||||
const senderAvatarCache = createBoundedCache<{ avatarUrl?: string; displayName?: string }>({
|
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
||||||
maxEntries: SENDER_AVATAR_CACHE_MAX_ENTRIES
|
|
||||||
})
|
|
||||||
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
||||||
|
|
||||||
function getSharedImageDecryptTask(
|
function getSharedImageDecryptTask(
|
||||||
@@ -8298,7 +8084,7 @@ function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
|
|||||||
|
|
||||||
if (error || (!loading && !localPath)) return <span className="quoted-type-label">[动画表情]</span>
|
if (error || (!loading && !localPath)) return <span className="quoted-type-label">[动画表情]</span>
|
||||||
if (loading) return <span className="quoted-type-label">[动画表情]</span>
|
if (loading) return <span className="quoted-type-label">[动画表情]</span>
|
||||||
return <img src={localPath} alt="动画表情" className="quoted-emoji-image" loading="lazy" decoding="async" />
|
return <img src={localPath} alt="动画表情" className="quoted-emoji-image" />
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息气泡组件
|
// 消息气泡组件
|
||||||
@@ -8401,10 +8187,7 @@ function MessageBubble({
|
|||||||
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0)
|
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0)
|
||||||
const [voiceDuration, setVoiceDuration] = useState(0)
|
const [voiceDuration, setVoiceDuration] = useState(0)
|
||||||
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
||||||
const [voiceWaveformRequested, setVoiceWaveformRequested] = useState(false)
|
|
||||||
const voiceAutoDecryptTriggered = useRef(false)
|
const voiceAutoDecryptTriggered = useRef(false)
|
||||||
const pendingScrollerDeltaRef = useRef(0)
|
|
||||||
const pendingScrollerDeltaRafRef = useRef<number | null>(null)
|
|
||||||
|
|
||||||
|
|
||||||
const [systemAlert, setSystemAlert] = useState<{
|
const [systemAlert, setSystemAlert] = useState<{
|
||||||
@@ -8495,7 +8278,7 @@ function MessageBubble({
|
|||||||
|
|
||||||
const stabilizeScrollerByDelta = useCallback((host: HTMLElement | null, delta: number) => {
|
const stabilizeScrollerByDelta = useCallback((host: HTMLElement | null, delta: number) => {
|
||||||
if (!host) return
|
if (!host) return
|
||||||
if (!Number.isFinite(delta) || Math.abs(delta) < 1.5) return
|
if (!Number.isFinite(delta) || Math.abs(delta) < 1) return
|
||||||
const scroller = host.closest('.message-list') as HTMLDivElement | null
|
const scroller = host.closest('.message-list') as HTMLDivElement | null
|
||||||
if (!scroller) return
|
if (!scroller) return
|
||||||
|
|
||||||
@@ -8508,17 +8291,7 @@ function MessageBubble({
|
|||||||
const viewportBottom = scroller.scrollTop + scroller.clientHeight
|
const viewportBottom = scroller.scrollTop + scroller.clientHeight
|
||||||
if (hostTopInScroller > viewportBottom + 24) return
|
if (hostTopInScroller > viewportBottom + 24) return
|
||||||
|
|
||||||
pendingScrollerDeltaRef.current += delta
|
scroller.scrollTop += delta
|
||||||
if (pendingScrollerDeltaRafRef.current !== null) return
|
|
||||||
pendingScrollerDeltaRafRef.current = window.requestAnimationFrame(() => {
|
|
||||||
pendingScrollerDeltaRafRef.current = null
|
|
||||||
const applyDelta = pendingScrollerDeltaRef.current
|
|
||||||
pendingScrollerDeltaRef.current = 0
|
|
||||||
if (!Number.isFinite(applyDelta) || Math.abs(applyDelta) < 1.5) return
|
|
||||||
const nextScroller = host.closest('.message-list') as HTMLDivElement | null
|
|
||||||
if (!nextScroller) return
|
|
||||||
nextScroller.scrollTop += applyDelta
|
|
||||||
})
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const bindResizeObserverForHost = useCallback((
|
const bindResizeObserverForHost = useCallback((
|
||||||
@@ -8609,12 +8382,12 @@ function MessageBubble({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isImage) return
|
if (!isImage) return
|
||||||
return bindResizeObserverForHost(imageContainerRef.current, imageObservedHeightRef, imageResizeBaselineRef)
|
return bindResizeObserverForHost(imageContainerRef.current, imageObservedHeightRef, imageResizeBaselineRef)
|
||||||
}, [isImage, bindResizeObserverForHost])
|
}, [isImage, imageLocalPath, imageLoading, imageError, bindResizeObserverForHost])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEmoji) return
|
if (!isEmoji) return
|
||||||
return bindResizeObserverForHost(emojiContainerRef.current, emojiObservedHeightRef, emojiResizeBaselineRef)
|
return bindResizeObserverForHost(emojiContainerRef.current, emojiObservedHeightRef, emojiResizeBaselineRef)
|
||||||
}, [isEmoji, bindResizeObserverForHost])
|
}, [isEmoji, emojiLocalPath, emojiLoading, emojiError, bindResizeObserverForHost])
|
||||||
|
|
||||||
// 下载表情包
|
// 下载表情包
|
||||||
const downloadEmoji = () => {
|
const downloadEmoji = () => {
|
||||||
@@ -8795,13 +8568,13 @@ function MessageBubble({
|
|||||||
return { success: false }
|
return { success: false }
|
||||||
}, [isImage, message.imageMd5, message.imageDatName, message.createTime, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
|
}, [isImage, message.imageMd5, message.imageDatName, message.createTime, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
|
||||||
|
|
||||||
const triggerForceHd = useCallback(async (): Promise<void> => {
|
const triggerForceHd = useCallback(() => {
|
||||||
if (!message.imageMd5 && !message.imageDatName) return
|
if (!message.imageMd5 && !message.imageDatName) return
|
||||||
if (imageForceHdAttempted.current === imageCacheKey) return
|
if (imageForceHdAttempted.current === imageCacheKey) return
|
||||||
if (imageForceHdPending.current) return
|
if (imageForceHdPending.current) return
|
||||||
imageForceHdAttempted.current = imageCacheKey
|
imageForceHdAttempted.current = imageCacheKey
|
||||||
imageForceHdPending.current = true
|
imageForceHdPending.current = true
|
||||||
await requestImageDecrypt(true, true).finally(() => {
|
requestImageDecrypt(true, true).finally(() => {
|
||||||
imageForceHdPending.current = false
|
imageForceHdPending.current = false
|
||||||
})
|
})
|
||||||
}, [imageCacheKey, message.imageDatName, message.imageMd5, requestImageDecrypt])
|
}, [imageCacheKey, message.imageDatName, message.imageMd5, requestImageDecrypt])
|
||||||
@@ -8889,11 +8662,6 @@ function MessageBubble({
|
|||||||
if (imageClickTimerRef.current) {
|
if (imageClickTimerRef.current) {
|
||||||
window.clearTimeout(imageClickTimerRef.current)
|
window.clearTimeout(imageClickTimerRef.current)
|
||||||
}
|
}
|
||||||
if (pendingScrollerDeltaRafRef.current !== null) {
|
|
||||||
window.cancelAnimationFrame(pendingScrollerDeltaRafRef.current)
|
|
||||||
pendingScrollerDeltaRafRef.current = null
|
|
||||||
}
|
|
||||||
pendingScrollerDeltaRef.current = 0
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -9027,16 +8795,14 @@ function MessageBubble({
|
|||||||
if (!message.imageMd5 && !message.imageDatName) return
|
if (!message.imageMd5 && !message.imageDatName) return
|
||||||
if (imageAutoDecryptTriggered.current) return
|
if (imageAutoDecryptTriggered.current) return
|
||||||
imageAutoDecryptTriggered.current = true
|
imageAutoDecryptTriggered.current = true
|
||||||
void enqueueAutoMediaTask(async () => requestImageDecrypt()).catch(() => { })
|
void requestImageDecrypt()
|
||||||
}, [isImage, imageInView, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, requestImageDecrypt])
|
}, [isImage, imageInView, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, requestImageDecrypt])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isImage || !imageHasUpdate || !imageInView) return
|
if (!isImage || !imageHasUpdate || !imageInView) return
|
||||||
if (imageAutoHdTriggered.current === imageCacheKey) return
|
if (imageAutoHdTriggered.current === imageCacheKey) return
|
||||||
imageAutoHdTriggered.current = imageCacheKey
|
imageAutoHdTriggered.current = imageCacheKey
|
||||||
void enqueueAutoMediaTask(async () => {
|
triggerForceHd()
|
||||||
await triggerForceHd()
|
|
||||||
}).catch(() => { })
|
|
||||||
}, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd])
|
}, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd])
|
||||||
|
|
||||||
|
|
||||||
@@ -9078,36 +8844,30 @@ function MessageBubble({
|
|||||||
|
|
||||||
// 生成波形数据
|
// 生成波形数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!voiceDataUrl || !voiceWaveformRequested) {
|
if (!voiceDataUrl) {
|
||||||
setVoiceWaveform([])
|
setVoiceWaveform([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
let audioCtx: AudioContext | null = null
|
|
||||||
|
|
||||||
const generateWaveform = async () => {
|
const generateWaveform = async () => {
|
||||||
try {
|
try {
|
||||||
// 从 data:audio/wav;base64,... 提取 base64
|
// 从 data:audio/wav;base64,... 提取 base64
|
||||||
const base64 = voiceDataUrl.split(',')[1]
|
const base64 = voiceDataUrl.split(',')[1]
|
||||||
if (!base64) return
|
|
||||||
const binaryString = window.atob(base64)
|
const binaryString = window.atob(base64)
|
||||||
const bytes = new Uint8Array(binaryString.length)
|
const bytes = new Uint8Array(binaryString.length)
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
bytes[i] = binaryString.charCodeAt(i)
|
bytes[i] = binaryString.charCodeAt(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||||
const audioBuffer = await audioCtx.decodeAudioData(bytes.buffer)
|
const audioBuffer = await audioCtx.decodeAudioData(bytes.buffer)
|
||||||
if (cancelled) return
|
|
||||||
const rawData = audioBuffer.getChannelData(0) // 获取单声道数据
|
const rawData = audioBuffer.getChannelData(0) // 获取单声道数据
|
||||||
const samples = 24 // 波形柱子数量(降低解码计算成本)
|
const samples = 35 // 波形柱子数量
|
||||||
const blockSize = Math.floor(rawData.length / samples)
|
const blockSize = Math.floor(rawData.length / samples)
|
||||||
if (blockSize <= 0) return
|
|
||||||
const filteredData: number[] = []
|
const filteredData: number[] = []
|
||||||
|
|
||||||
for (let i = 0; i < samples; i++) {
|
for (let i = 0; i < samples; i++) {
|
||||||
const blockStart = blockSize * i
|
let blockStart = blockSize * i
|
||||||
let sum = 0
|
let sum = 0
|
||||||
for (let j = 0; j < blockSize; j++) {
|
for (let j = 0; j < blockSize; j++) {
|
||||||
sum = sum + Math.abs(rawData[blockStart + j])
|
sum = sum + Math.abs(rawData[blockStart + j])
|
||||||
@@ -9116,39 +8876,19 @@ function MessageBubble({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 归一化
|
// 归一化
|
||||||
const peak = Math.max(...filteredData)
|
const multiplier = Math.pow(Math.max(...filteredData), -1)
|
||||||
if (!Number.isFinite(peak) || peak <= 0) return
|
|
||||||
const multiplier = Math.pow(peak, -1)
|
|
||||||
const normalizedData = filteredData.map(n => n * multiplier)
|
const normalizedData = filteredData.map(n => n * multiplier)
|
||||||
if (!cancelled) {
|
|
||||||
setVoiceWaveform(normalizedData)
|
setVoiceWaveform(normalizedData)
|
||||||
}
|
void audioCtx.close()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to generate waveform:', e)
|
console.error('Failed to generate waveform:', e)
|
||||||
// 降级:生成随机但平滑的波形
|
// 降级:生成随机但平滑的波形
|
||||||
if (!cancelled) {
|
setVoiceWaveform(Array.from({ length: 35 }, () => 0.2 + Math.random() * 0.8))
|
||||||
setVoiceWaveform(Array.from({ length: 24 }, () => 0.2 + Math.random() * 0.8))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (audioCtx) {
|
|
||||||
void audioCtx.close().catch(() => { })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleWhenIdle(() => {
|
|
||||||
if (cancelled) return
|
|
||||||
void generateWaveform()
|
void generateWaveform()
|
||||||
}, { timeout: 900, fallbackDelay: 80 })
|
}, [voiceDataUrl])
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
if (audioCtx) {
|
|
||||||
void audioCtx.close().catch(() => { })
|
|
||||||
audioCtx = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [voiceDataUrl, voiceWaveformRequested])
|
|
||||||
|
|
||||||
// 消息加载时自动检测语音缓存
|
// 消息加载时自动检测语音缓存
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -9332,9 +9072,7 @@ function MessageBubble({
|
|||||||
if (videoAutoLoadTriggered.current) return
|
if (videoAutoLoadTriggered.current) return
|
||||||
|
|
||||||
videoAutoLoadTriggered.current = true
|
videoAutoLoadTriggered.current = true
|
||||||
void enqueueAutoMediaTask(async () => requestVideoInfo()).catch(() => {
|
void requestVideoInfo()
|
||||||
videoAutoLoadTriggered.current = false
|
|
||||||
})
|
|
||||||
}, [isVideo, isVideoVisible, videoInfo, requestVideoInfo])
|
}, [isVideo, isVideoVisible, videoInfo, requestVideoInfo])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -9653,8 +9391,6 @@ function MessageBubble({
|
|||||||
src={imageLocalPath}
|
src={imageLocalPath}
|
||||||
alt="图片"
|
alt="图片"
|
||||||
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}
|
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
onClick={() => { void handleOpenImageViewer() }}
|
onClick={() => { void handleOpenImageViewer() }}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
setImageLoaded(true)
|
setImageLoaded(true)
|
||||||
@@ -9733,7 +9469,7 @@ function MessageBubble({
|
|||||||
return (
|
return (
|
||||||
<div className="video-thumb-wrapper" ref={videoContainerRef as React.RefObject<HTMLDivElement>} onClick={handlePlayVideo}>
|
<div className="video-thumb-wrapper" ref={videoContainerRef as React.RefObject<HTMLDivElement>} onClick={handlePlayVideo}>
|
||||||
{thumbSrc ? (
|
{thumbSrc ? (
|
||||||
<img src={thumbSrc} alt="视频缩略图" className="video-thumb" loading="lazy" decoding="async" />
|
<img src={thumbSrc} alt="视频缩略图" className="video-thumb" />
|
||||||
) : (
|
) : (
|
||||||
<div className="video-thumb-placeholder">
|
<div className="video-thumb-placeholder">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
@@ -9753,9 +9489,6 @@ function MessageBubble({
|
|||||||
const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : ''
|
const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : ''
|
||||||
const handleToggle = async () => {
|
const handleToggle = async () => {
|
||||||
if (voiceLoading) return
|
if (voiceLoading) return
|
||||||
if (!voiceWaveformRequested) {
|
|
||||||
setVoiceWaveformRequested(true)
|
|
||||||
}
|
|
||||||
const audio = voiceAudioRef.current || new Audio()
|
const audio = voiceAudioRef.current || new Audio()
|
||||||
if (!voiceAudioRef.current) {
|
if (!voiceAudioRef.current) {
|
||||||
voiceAudioRef.current = audio
|
voiceAudioRef.current = audio
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1915,14 +1915,14 @@
|
|||||||
--contacts-select-col-width: 34px;
|
--contacts-select-col-width: 34px;
|
||||||
--contacts-avatar-col-width: 44px;
|
--contacts-avatar-col-width: 44px;
|
||||||
--contacts-inline-padding: 12px;
|
--contacts-inline-padding: 12px;
|
||||||
--contacts-column-gap: 10px;
|
--contacts-column-gap: 12px;
|
||||||
--contacts-name-text-width: 9.5em;
|
--contacts-name-text-width: 10em;
|
||||||
--contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width));
|
--contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width));
|
||||||
--contacts-left-sticky-width: calc(var(--contacts-select-col-width) + var(--contacts-main-col-width) + var(--contacts-column-gap));
|
--contacts-left-sticky-width: calc(var(--contacts-select-col-width) + var(--contacts-main-col-width) + var(--contacts-column-gap));
|
||||||
--contacts-message-col-width: 104px;
|
--contacts-message-col-width: 120px;
|
||||||
--contacts-media-col-width: 66px;
|
--contacts-media-col-width: 72px;
|
||||||
--contacts-action-col-width: 140px;
|
--contacts-action-col-width: 140px;
|
||||||
--contacts-actions-sticky-width: 180px;
|
--contacts-actions-sticky-width: 240px;
|
||||||
--contacts-table-min-width: 1240px;
|
--contacts-table-min-width: 1240px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -2197,8 +2197,22 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
z-index: 13;
|
||||||
background: var(--contacts-header-bg);
|
background: var(--contacts-header-bg);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: -8px;
|
||||||
|
width: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(to right, transparent, var(--contacts-header-bg));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.contacts-list {
|
.contacts-list {
|
||||||
@@ -2382,7 +2396,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--contacts-column-gap);
|
gap: var(--contacts-column-gap);
|
||||||
padding: 12px var(--contacts-inline-padding);
|
padding: 12px 6px 12px var(--contacts-inline-padding);
|
||||||
min-width: max(100%, var(--contacts-table-min-width));
|
min-width: max(100%, var(--contacts-table-min-width));
|
||||||
height: 72px;
|
height: 72px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -2787,8 +2801,22 @@
|
|||||||
width: var(--contacts-actions-sticky-width);
|
width: var(--contacts-actions-sticky-width);
|
||||||
min-width: var(--contacts-actions-sticky-width);
|
min-width: var(--contacts-actions-sticky-width);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
background: var(--contacts-row-bg);
|
background: var(--contacts-row-bg);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: -9px;
|
||||||
|
width: 9px;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(to right, transparent, var(--contacts-row-bg));
|
||||||
|
}
|
||||||
|
|
||||||
.row-action-main {
|
.row-action-main {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ import type { SnsPost } from '../types/sns'
|
|||||||
import {
|
import {
|
||||||
cloneExportDateRange,
|
cloneExportDateRange,
|
||||||
cloneExportDateRangeSelection,
|
cloneExportDateRangeSelection,
|
||||||
createExportDateRangeSelectionFromPreset,
|
|
||||||
createDateRangeByLastNDays,
|
createDateRangeByLastNDays,
|
||||||
createDefaultDateRange,
|
createDefaultDateRange,
|
||||||
createDefaultExportDateRangeSelection,
|
createDefaultExportDateRangeSelection,
|
||||||
@@ -1600,19 +1599,6 @@ const areExportSelectionsEqual = (left: ExportDateRangeSelection, right: ExportD
|
|||||||
left.dateRange.end.getTime() === right.dateRange.end.getTime()
|
left.dateRange.end.getTime() === right.dateRange.end.getTime()
|
||||||
)
|
)
|
||||||
|
|
||||||
const resolveDynamicExportSelection = (
|
|
||||||
selection: ExportDateRangeSelection,
|
|
||||||
now = new Date()
|
|
||||||
): ExportDateRangeSelection => {
|
|
||||||
if (selection.useAllTime) {
|
|
||||||
return cloneExportDateRangeSelection(selection)
|
|
||||||
}
|
|
||||||
if (selection.preset === 'custom') {
|
|
||||||
return cloneExportDateRangeSelection(selection)
|
|
||||||
}
|
|
||||||
return createExportDateRangeSelectionFromPreset(selection.preset, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickSessionMediaMetric = (
|
const pickSessionMediaMetric = (
|
||||||
metricRaw: SessionExportMetric | SessionContentMetric | undefined
|
metricRaw: SessionExportMetric | SessionContentMetric | undefined
|
||||||
): SessionContentMetric | null => {
|
): SessionContentMetric | null => {
|
||||||
@@ -1913,7 +1899,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}`
|
? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}`
|
||||||
: ''
|
: ''
|
||||||
const mediaMissMetricLabel = mediaCacheMissFiles > 0
|
const mediaMissMetricLabel = mediaCacheMissFiles > 0
|
||||||
? `缓存未命中 ${mediaCacheMissFiles}`
|
? `未导出 ${mediaCacheMissFiles} 个文件/媒体`
|
||||||
: ''
|
: ''
|
||||||
const mediaDedupMetricLabel = mediaDedupReuseFiles > 0
|
const mediaDedupMetricLabel = mediaDedupReuseFiles > 0
|
||||||
? `复用 ${mediaDedupReuseFiles}`
|
? `复用 ${mediaDedupReuseFiles}`
|
||||||
@@ -1928,7 +1914,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
)
|
)
|
||||||
: ''
|
: ''
|
||||||
const mediaLiveMetricLabel = task.progress.phase === 'exporting-media'
|
const mediaLiveMetricLabel = task.progress.phase === 'exporting-media'
|
||||||
? (mediaDoneFiles > 0 ? `已写入 ${mediaDoneFiles}` : '')
|
? (mediaDoneFiles > 0 ? `已处理 ${mediaDoneFiles}` : '')
|
||||||
: ''
|
: ''
|
||||||
const sessionProgressLabel = completedSessionTotal > 0
|
const sessionProgressLabel = completedSessionTotal > 0
|
||||||
? `会话 ${completedSessionCount}/${completedSessionTotal}`
|
? `会话 ${completedSessionCount}/${completedSessionTotal}`
|
||||||
@@ -2252,27 +2238,6 @@ function ExportPage() {
|
|||||||
exportConcurrency: 2
|
exportConcurrency: 2
|
||||||
})
|
})
|
||||||
|
|
||||||
const exportStatsRangeOptions = useMemo(() => {
|
|
||||||
if (options.useAllTime || !options.dateRange) return null
|
|
||||||
const beginTimestamp = Math.floor(options.dateRange.start.getTime() / 1000)
|
|
||||||
const endTimestamp = Math.floor(options.dateRange.end.getTime() / 1000)
|
|
||||||
if (!Number.isFinite(beginTimestamp) || !Number.isFinite(endTimestamp)) return null
|
|
||||||
if (beginTimestamp <= 0 && endTimestamp <= 0) return null
|
|
||||||
return {
|
|
||||||
beginTimestamp: Math.max(0, beginTimestamp),
|
|
||||||
endTimestamp: Math.max(0, endTimestamp)
|
|
||||||
}
|
|
||||||
}, [options.useAllTime, options.dateRange])
|
|
||||||
|
|
||||||
const withExportStatsRange = useCallback((statsOptions: Record<string, any>): Record<string, any> => {
|
|
||||||
if (!exportStatsRangeOptions) return statsOptions
|
|
||||||
return {
|
|
||||||
...statsOptions,
|
|
||||||
beginTimestamp: exportStatsRangeOptions.beginTimestamp,
|
|
||||||
endTimestamp: exportStatsRangeOptions.endTimestamp
|
|
||||||
}
|
|
||||||
}, [exportStatsRangeOptions])
|
|
||||||
|
|
||||||
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
|
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
|
||||||
open: false,
|
open: false,
|
||||||
intent: 'manual',
|
intent: 'manual',
|
||||||
@@ -4038,7 +4003,7 @@ function ExportPage() {
|
|||||||
const cacheResult = await withTimeout(
|
const cacheResult = await withTimeout(
|
||||||
window.electronAPI.chat.getExportSessionStats(
|
window.electronAPI.chat.getExportSessionStats(
|
||||||
batchSessionIds,
|
batchSessionIds,
|
||||||
withExportStatsRange({ includeRelations: false, allowStaleCache: true, cacheOnly: true })
|
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||||
),
|
),
|
||||||
12000,
|
12000,
|
||||||
'cacheOnly'
|
'cacheOnly'
|
||||||
@@ -4053,7 +4018,7 @@ function ExportPage() {
|
|||||||
const freshResult = await withTimeout(
|
const freshResult = await withTimeout(
|
||||||
window.electronAPI.chat.getExportSessionStats(
|
window.electronAPI.chat.getExportSessionStats(
|
||||||
missingSessionIds,
|
missingSessionIds,
|
||||||
withExportStatsRange({ includeRelations: false, allowStaleCache: true })
|
{ includeRelations: false, allowStaleCache: true }
|
||||||
),
|
),
|
||||||
45000,
|
45000,
|
||||||
'fresh'
|
'fresh'
|
||||||
@@ -4097,7 +4062,7 @@ function ExportPage() {
|
|||||||
void runSessionMediaMetricWorker(runId)
|
void runSessionMediaMetricWorker(runId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage, withExportStatsRange])
|
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage])
|
||||||
|
|
||||||
const scheduleSessionMediaMetricWorker = useCallback(() => {
|
const scheduleSessionMediaMetricWorker = useCallback(() => {
|
||||||
if (activeTaskCountRef.current > 0) return
|
if (activeTaskCountRef.current > 0) return
|
||||||
@@ -4804,20 +4769,19 @@ function ExportPage() {
|
|||||||
const clearSelection = () => setSelectedSessions(new Set())
|
const clearSelection = () => setSelectedSessions(new Set())
|
||||||
|
|
||||||
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open' | 'intent'> & { intent?: ExportDialogState['intent'] }) => {
|
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open' | 'intent'> & { intent?: ExportDialogState['intent'] }) => {
|
||||||
const dynamicDefaultRangeSelection = resolveDynamicExportSelection(exportDefaultDateRangeSelection, new Date())
|
|
||||||
setExportDialog({ open: true, intent: payload.intent || 'manual', ...payload })
|
setExportDialog({ open: true, intent: payload.intent || 'manual', ...payload })
|
||||||
setIsTimeRangeDialogOpen(false)
|
setIsTimeRangeDialogOpen(false)
|
||||||
setTimeRangeBounds(null)
|
setTimeRangeBounds(null)
|
||||||
setTimeRangeSelection(dynamicDefaultRangeSelection)
|
setTimeRangeSelection(exportDefaultDateRangeSelection)
|
||||||
|
|
||||||
setOptions(prev => {
|
setOptions(prev => {
|
||||||
const nextDateRange = cloneExportDateRange(dynamicDefaultRangeSelection.dateRange)
|
const nextDateRange = cloneExportDateRange(exportDefaultDateRangeSelection.dateRange)
|
||||||
|
|
||||||
const next: ExportOptions = {
|
const next: ExportOptions = {
|
||||||
...prev,
|
...prev,
|
||||||
format: exportDefaultFormat,
|
format: exportDefaultFormat,
|
||||||
exportAvatars: exportDefaultAvatars,
|
exportAvatars: exportDefaultAvatars,
|
||||||
useAllTime: dynamicDefaultRangeSelection.useAllTime,
|
useAllTime: exportDefaultDateRangeSelection.useAllTime,
|
||||||
dateRange: nextDateRange,
|
dateRange: nextDateRange,
|
||||||
exportMedia: Boolean(
|
exportMedia: Boolean(
|
||||||
exportDefaultMedia.images ||
|
exportDefaultMedia.images ||
|
||||||
@@ -4878,13 +4842,9 @@ function ExportPage() {
|
|||||||
setTimeRangeBounds(null)
|
setTimeRangeBounds(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const resolveChatExportTimeRangeBounds = useCallback(async (
|
const resolveChatExportTimeRangeBounds = useCallback(async (sessionIds: string[]): Promise<TimeRangeBounds | null> => {
|
||||||
sessionIds: string[],
|
|
||||||
options?: { forceRefresh?: boolean }
|
|
||||||
): Promise<TimeRangeBounds | null> => {
|
|
||||||
const normalizedSessionIds = Array.from(new Set((sessionIds || []).map(id => String(id || '').trim()).filter(Boolean)))
|
const normalizedSessionIds = Array.from(new Set((sessionIds || []).map(id => String(id || '').trim()).filter(Boolean)))
|
||||||
if (normalizedSessionIds.length === 0) return null
|
if (normalizedSessionIds.length === 0) return null
|
||||||
const forceRefresh = options?.forceRefresh === true
|
|
||||||
|
|
||||||
const sessionRowMap = new Map<string, SessionRow>()
|
const sessionRowMap = new Map<string, SessionRow>()
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
@@ -4947,12 +4907,6 @@ function ExportPage() {
|
|||||||
return !resolved?.hasMin || !resolved?.hasMax
|
return !resolved?.hasMin || !resolved?.hasMax
|
||||||
})
|
})
|
||||||
|
|
||||||
if (forceRefresh) {
|
|
||||||
applyStatsResult(await window.electronAPI.chat.getExportSessionStats(
|
|
||||||
normalizedSessionIds,
|
|
||||||
{ includeRelations: false, forceRefresh: true }
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
const staleSessionIds = new Set<string>()
|
const staleSessionIds = new Set<string>()
|
||||||
|
|
||||||
if (missingSessionIds().length > 0) {
|
if (missingSessionIds().length > 0) {
|
||||||
@@ -4977,7 +4931,6 @@ function ExportPage() {
|
|||||||
{ includeRelations: false }
|
{ includeRelations: false }
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (missingSessionIds().length > 0) {
|
if (missingSessionIds().length > 0) {
|
||||||
return null
|
return null
|
||||||
@@ -4997,26 +4950,14 @@ function ExportPage() {
|
|||||||
if (isResolvingTimeRangeBounds) return
|
if (isResolvingTimeRangeBounds) return
|
||||||
setIsResolvingTimeRangeBounds(true)
|
setIsResolvingTimeRangeBounds(true)
|
||||||
try {
|
try {
|
||||||
const liveSelection = resolveDynamicExportSelection(timeRangeSelection, new Date())
|
|
||||||
if (!areExportSelectionsEqual(liveSelection, timeRangeSelection)) {
|
|
||||||
setTimeRangeSelection(liveSelection)
|
|
||||||
setOptions(prev => ({
|
|
||||||
...prev,
|
|
||||||
useAllTime: liveSelection.useAllTime,
|
|
||||||
dateRange: cloneExportDateRange(liveSelection.dateRange)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextBounds: TimeRangeBounds | null = null
|
let nextBounds: TimeRangeBounds | null = null
|
||||||
if (exportDialog.scope !== 'sns') {
|
if (exportDialog.scope !== 'sns') {
|
||||||
nextBounds = await resolveChatExportTimeRangeBounds(exportDialog.sessionIds, {
|
nextBounds = await resolveChatExportTimeRangeBounds(exportDialog.sessionIds)
|
||||||
forceRefresh: exportDialog.scope === 'single'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
setTimeRangeBounds(nextBounds)
|
setTimeRangeBounds(nextBounds)
|
||||||
if (nextBounds) {
|
if (nextBounds) {
|
||||||
const nextSelection = clampExportSelectionToBounds(liveSelection, nextBounds)
|
const nextSelection = clampExportSelectionToBounds(timeRangeSelection, nextBounds)
|
||||||
if (!areExportSelectionsEqual(nextSelection, liveSelection)) {
|
if (!areExportSelectionsEqual(nextSelection, timeRangeSelection)) {
|
||||||
setTimeRangeSelection(nextSelection)
|
setTimeRangeSelection(nextSelection)
|
||||||
setOptions(prev => ({
|
setOptions(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -5094,51 +5035,47 @@ function ExportPage() {
|
|||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [loadBaseConfig, openExportDialog])
|
}, [loadBaseConfig, openExportDialog])
|
||||||
|
|
||||||
const buildExportOptions = (
|
const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => {
|
||||||
scope: TaskScope,
|
|
||||||
contentType?: ContentType,
|
|
||||||
sourceOptions: ExportOptions = options
|
|
||||||
): ElectronExportOptions => {
|
|
||||||
const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared'
|
const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared'
|
||||||
const exportMediaEnabled = Boolean(
|
const exportMediaEnabled = Boolean(
|
||||||
sourceOptions.exportImages ||
|
options.exportImages ||
|
||||||
sourceOptions.exportVoices ||
|
options.exportVoices ||
|
||||||
sourceOptions.exportVideos ||
|
options.exportVideos ||
|
||||||
sourceOptions.exportEmojis ||
|
options.exportEmojis ||
|
||||||
sourceOptions.exportFiles
|
options.exportFiles
|
||||||
)
|
)
|
||||||
|
|
||||||
const base: ElectronExportOptions = {
|
const base: ElectronExportOptions = {
|
||||||
format: sourceOptions.format,
|
format: options.format,
|
||||||
exportAvatars: sourceOptions.exportAvatars,
|
exportAvatars: options.exportAvatars,
|
||||||
exportMedia: exportMediaEnabled,
|
exportMedia: exportMediaEnabled,
|
||||||
exportImages: sourceOptions.exportImages,
|
exportImages: options.exportImages,
|
||||||
exportVoices: sourceOptions.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
exportVideos: sourceOptions.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: sourceOptions.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportFiles: sourceOptions.exportFiles,
|
exportFiles: options.exportFiles,
|
||||||
maxFileSizeMb: sourceOptions.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: sourceOptions.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
excelCompactColumns: sourceOptions.excelCompactColumns,
|
excelCompactColumns: options.excelCompactColumns,
|
||||||
txtColumns: sourceOptions.txtColumns,
|
txtColumns: options.txtColumns,
|
||||||
displayNamePreference: sourceOptions.displayNamePreference,
|
displayNamePreference: options.displayNamePreference,
|
||||||
exportConcurrency: sourceOptions.exportConcurrency,
|
exportConcurrency: options.exportConcurrency,
|
||||||
fileNamingMode: exportDefaultFileNamingMode,
|
fileNamingMode: exportDefaultFileNamingMode,
|
||||||
sessionLayout,
|
sessionLayout,
|
||||||
sessionNameWithTypePrefix,
|
sessionNameWithTypePrefix,
|
||||||
dateRange: sourceOptions.useAllTime
|
dateRange: options.useAllTime
|
||||||
? null
|
? null
|
||||||
: sourceOptions.dateRange
|
: options.dateRange
|
||||||
? {
|
? {
|
||||||
start: Math.floor(sourceOptions.dateRange.start.getTime() / 1000),
|
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||||
end: Math.floor(sourceOptions.dateRange.end.getTime() / 1000)
|
end: Math.floor(options.dateRange.end.getTime() / 1000)
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === 'content' && contentType) {
|
if (scope === 'content' && contentType) {
|
||||||
if (contentType === 'text') {
|
if (contentType === 'text') {
|
||||||
const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? sourceOptions.exportConcurrency))
|
const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? options.exportConcurrency))
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
contentType,
|
contentType,
|
||||||
@@ -5169,14 +5106,14 @@ function ExportPage() {
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildSnsExportOptions = (sourceOptions: ExportOptions = options) => {
|
const buildSnsExportOptions = () => {
|
||||||
const format: SnsTimelineExportFormat = snsExportFormat
|
const format: SnsTimelineExportFormat = snsExportFormat
|
||||||
const dateRange = sourceOptions.useAllTime
|
const dateRange = options.useAllTime
|
||||||
? null
|
? null
|
||||||
: sourceOptions.dateRange
|
: options.dateRange
|
||||||
? {
|
? {
|
||||||
startTime: Math.floor(sourceOptions.dateRange.start.getTime() / 1000),
|
startTime: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||||
endTime: Math.floor(sourceOptions.dateRange.end.getTime() / 1000)
|
endTime: Math.floor(options.dateRange.end.getTime() / 1000)
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
|
|
||||||
@@ -5988,27 +5925,12 @@ function ExportPage() {
|
|||||||
if (!exportDialog.open || !exportFolder) return
|
if (!exportDialog.open || !exportFolder) return
|
||||||
if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return
|
if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return
|
||||||
|
|
||||||
const effectiveRangeSelection = resolveDynamicExportSelection(timeRangeSelection, new Date())
|
|
||||||
if (!areExportSelectionsEqual(effectiveRangeSelection, timeRangeSelection)) {
|
|
||||||
setTimeRangeSelection(effectiveRangeSelection)
|
|
||||||
}
|
|
||||||
const effectiveOptionsState: ExportOptions = {
|
|
||||||
...options,
|
|
||||||
useAllTime: effectiveRangeSelection.useAllTime,
|
|
||||||
dateRange: cloneExportDateRange(effectiveRangeSelection.dateRange)
|
|
||||||
}
|
|
||||||
setOptions(prev => ({
|
|
||||||
...prev,
|
|
||||||
useAllTime: effectiveOptionsState.useAllTime,
|
|
||||||
dateRange: cloneExportDateRange(effectiveRangeSelection.dateRange)
|
|
||||||
}))
|
|
||||||
|
|
||||||
const isAutomationCreateIntent = exportDialog.intent === 'automation-create'
|
const isAutomationCreateIntent = exportDialog.intent === 'automation-create'
|
||||||
const exportOptions = exportDialog.scope === 'sns'
|
const exportOptions = exportDialog.scope === 'sns'
|
||||||
? undefined
|
? undefined
|
||||||
: buildExportOptions(exportDialog.scope, exportDialog.contentType, effectiveOptionsState)
|
: buildExportOptions(exportDialog.scope, exportDialog.contentType)
|
||||||
const snsOptions = exportDialog.scope === 'sns'
|
const snsOptions = exportDialog.scope === 'sns'
|
||||||
? buildSnsExportOptions(effectiveOptionsState)
|
? buildSnsExportOptions()
|
||||||
: undefined
|
: undefined
|
||||||
const title =
|
const title =
|
||||||
exportDialog.scope === 'single'
|
exportDialog.scope === 'single'
|
||||||
@@ -6025,7 +5947,7 @@ function ExportPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { dateRange: _discard, ...optionTemplate } = exportOptions
|
const { dateRange: _discard, ...optionTemplate } = exportOptions
|
||||||
const normalizedRangeSelection = cloneExportDateRangeSelection(effectiveRangeSelection)
|
const normalizedRangeSelection = cloneExportDateRangeSelection(timeRangeSelection)
|
||||||
const scope = exportDialog.scope === 'single'
|
const scope = exportDialog.scope === 'single'
|
||||||
? 'single'
|
? 'single'
|
||||||
: exportDialog.scope === 'content'
|
: exportDialog.scope === 'content'
|
||||||
@@ -7321,7 +7243,7 @@ function ExportPage() {
|
|||||||
try {
|
try {
|
||||||
const quickStatsResult = await window.electronAPI.chat.getExportSessionStats(
|
const quickStatsResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
[normalizedSessionId],
|
[normalizedSessionId],
|
||||||
withExportStatsRange({ includeRelations: false, allowStaleCache: true, cacheOnly: true })
|
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||||
)
|
)
|
||||||
if (requestSeq !== detailRequestSeqRef.current) return
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
if (quickStatsResult.success) {
|
if (quickStatsResult.success) {
|
||||||
@@ -7348,7 +7270,7 @@ function ExportPage() {
|
|||||||
try {
|
try {
|
||||||
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
|
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
[normalizedSessionId],
|
[normalizedSessionId],
|
||||||
withExportStatsRange({ includeRelations: true, allowStaleCache: true, cacheOnly: true })
|
{ includeRelations: true, allowStaleCache: true, cacheOnly: true }
|
||||||
)
|
)
|
||||||
if (requestSeq !== detailRequestSeqRef.current) return
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
if (relationCacheResult.success && relationCacheResult.data) {
|
if (relationCacheResult.success && relationCacheResult.data) {
|
||||||
@@ -7373,7 +7295,7 @@ function ExportPage() {
|
|||||||
// 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列。
|
// 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列。
|
||||||
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
[normalizedSessionId],
|
[normalizedSessionId],
|
||||||
withExportStatsRange({ includeRelations: false, forceRefresh: true })
|
{ includeRelations: false, forceRefresh: true }
|
||||||
)
|
)
|
||||||
if (requestSeq !== detailRequestSeqRef.current) return
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
if (freshResult.success && freshResult.data) {
|
if (freshResult.success && freshResult.data) {
|
||||||
@@ -7408,7 +7330,7 @@ function ExportPage() {
|
|||||||
setIsLoadingSessionDetailExtra(false)
|
setIsLoadingSessionDetailExtra(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername, withExportStatsRange])
|
}, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername])
|
||||||
|
|
||||||
const loadSessionRelationStats = useCallback(async (options?: { forceRefresh?: boolean }) => {
|
const loadSessionRelationStats = useCallback(async (options?: { forceRefresh?: boolean }) => {
|
||||||
const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
|
const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
|
||||||
@@ -7421,7 +7343,7 @@ function ExportPage() {
|
|||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
|
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
[normalizedSessionId],
|
[normalizedSessionId],
|
||||||
withExportStatsRange({ includeRelations: true, allowStaleCache: true, cacheOnly: true })
|
{ includeRelations: true, allowStaleCache: true, cacheOnly: true }
|
||||||
)
|
)
|
||||||
if (requestSeq !== detailRequestSeqRef.current) return
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
|
|
||||||
@@ -7439,7 +7361,7 @@ function ExportPage() {
|
|||||||
|
|
||||||
const relationResult = await window.electronAPI.chat.getExportSessionStats(
|
const relationResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
[normalizedSessionId],
|
[normalizedSessionId],
|
||||||
withExportStatsRange({ includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true })
|
{ includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true }
|
||||||
)
|
)
|
||||||
if (requestSeq !== detailRequestSeqRef.current) return
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
|
|
||||||
@@ -7459,7 +7381,7 @@ function ExportPage() {
|
|||||||
setIsLoadingSessionRelationStats(false)
|
setIsLoadingSessionRelationStats(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid, withExportStatsRange])
|
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
|
||||||
|
|
||||||
const handleRefreshTableData = useCallback(async () => {
|
const handleRefreshTableData = useCallback(async () => {
|
||||||
const scopeKey = await ensureExportCacheScope()
|
const scopeKey = await ensureExportCacheScope()
|
||||||
|
|||||||
@@ -338,22 +338,6 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mac-key-faq-link {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #0f62fe;
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 0;
|
|
||||||
margin-top: 6px;
|
|
||||||
display: inline-block;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.manual-prompt {
|
.manual-prompt {
|
||||||
background: rgba(139, 115, 85, 0.1);
|
background: rgba(139, 115, 85, 0.1);
|
||||||
border: 1px dashed rgba(139, 115, 85, 0.3);
|
border: 1px dashed rgba(139, 115, 85, 0.3);
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootp
|
|||||||
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
||||||
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||||
const isWindows = !isMac && !isLinux
|
const isWindows = !isMac && !isLinux
|
||||||
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
|
||||||
|
|
||||||
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
||||||
const dbPathPlaceholder = isMac
|
const dbPathPlaceholder = isMac
|
||||||
@@ -226,7 +225,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
|
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
|
||||||
const [showDecryptKey, setShowDecryptKey] = useState(false)
|
const [showDecryptKey, setShowDecryptKey] = useState(false)
|
||||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||||
const [dbKeyError, setDbKeyError] = useState('')
|
|
||||||
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
||||||
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
||||||
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
|
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
|
||||||
@@ -1256,14 +1254,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
if (isFetchingDbKey) return
|
if (isFetchingDbKey) return
|
||||||
setIsFetchingDbKey(true)
|
setIsFetchingDbKey(true)
|
||||||
setIsManualStartPrompt(false)
|
setIsManualStartPrompt(false)
|
||||||
setDbKeyError('')
|
|
||||||
setDbKeyStatus('正在连接微信进程...')
|
setDbKeyStatus('正在连接微信进程...')
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.key.autoGetDbKey()
|
const result = await window.electronAPI.key.autoGetDbKey()
|
||||||
if (result.success && result.key) {
|
if (result.success && result.key) {
|
||||||
setDecryptKey(result.key)
|
setDecryptKey(result.key)
|
||||||
setDbKeyStatus('密钥获取成功')
|
setDbKeyStatus('密钥获取成功')
|
||||||
setDbKeyError('')
|
|
||||||
showMessage('已自动获取解密密钥', true)
|
showMessage('已自动获取解密密钥', true)
|
||||||
await syncCurrentKeys({ decryptKey: result.key, wxid })
|
await syncCurrentKeys({ decryptKey: result.key, wxid })
|
||||||
const keysOverride = buildKeysFromInputs({ decryptKey: result.key })
|
const keysOverride = buildKeysFromInputs({ decryptKey: result.key })
|
||||||
@@ -1278,26 +1274,17 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
) {
|
) {
|
||||||
setIsManualStartPrompt(true)
|
setIsManualStartPrompt(true)
|
||||||
setDbKeyStatus('需要手动启动微信')
|
setDbKeyStatus('需要手动启动微信')
|
||||||
setDbKeyError('')
|
|
||||||
} else {
|
} else {
|
||||||
const failureMessage = result.error || '自动获取密钥失败'
|
showMessage(result.error || '自动获取密钥失败', false)
|
||||||
setDbKeyError(failureMessage)
|
|
||||||
showMessage(failureMessage, false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const failureMessage = `自动获取密钥失败: ${e}`
|
showMessage(`自动获取密钥失败: ${e}`, false)
|
||||||
setDbKeyError(failureMessage)
|
|
||||||
showMessage(failureMessage, false)
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetchingDbKey(false)
|
setIsFetchingDbKey(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openMacKeyFaq = () => {
|
|
||||||
void window.electronAPI.shell.openExternal(MAC_KEY_FAQ_URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleManualConfirm = async () => {
|
const handleManualConfirm = async () => {
|
||||||
setIsManualStartPrompt(false)
|
setIsManualStartPrompt(false)
|
||||||
handleAutoGetDbKey()
|
handleAutoGetDbKey()
|
||||||
@@ -2220,11 +2207,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{dbKeyStatus && <div className="form-hint status-text">{dbKeyStatus}</div>}
|
{dbKeyStatus && <div className="form-hint status-text">{dbKeyStatus}</div>}
|
||||||
{isMac && dbKeyError && (
|
|
||||||
<button type="button" className="mac-key-faq-link" onClick={openMacKeyFaq}>
|
|
||||||
查看 macOS 获取密钥排障指引
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -4120,16 +4102,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>推送内容</label>
|
<label>推送内容</label>
|
||||||
<span className="form-hint">SSE 事件名包含 `message.new` 和 `message.revoke`;私聊推送 `rawid/avatarUrl/sourceName/content/timestamp`,群聊额外附带 `groupName`,其中 `timestamp` 为秒级 Unix 时间戳</span>
|
<span className="form-hint">SSE 事件名为 `message.new`;私聊推送 `avatarUrl/sourceName/content/timestamp`,群聊额外附带 `groupName`,其中 `timestamp` 为秒级 Unix 时间戳</span>
|
||||||
<div className="api-docs">
|
<div className="api-docs">
|
||||||
<div className="api-item">
|
<div className="api-item">
|
||||||
<div className="api-endpoint">
|
<div className="api-endpoint">
|
||||||
<span className="method get">GET</span>
|
<span className="method get">GET</span>
|
||||||
<code>{`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages`}</code>
|
<code>{`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages`}</code>
|
||||||
</div>
|
</div>
|
||||||
<p className="api-desc">通过 SSE 长连接接收消息事件,建议接收端按 `event + rawid` 去重。</p>
|
<p className="api-desc">通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。</p>
|
||||||
<div className="api-params">
|
<div className="api-params">
|
||||||
{['event', 'sessionId', 'sessionType', 'rawid', 'avatarUrl', 'sourceName', 'groupName?', 'content', 'timestamp'].map((param) => (
|
{['event', 'sessionId', 'sessionType', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content', 'timestamp'].map((param) => (
|
||||||
<span key={param} className="param">
|
<span key={param} className="param">
|
||||||
<code>{param}</code>
|
<code>{param}</code>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -666,28 +666,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-link-btn {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #0f62fe;
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.intro-footer {
|
.intro-footer {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import './WelcomePage.scss'
|
|||||||
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
||||||
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||||
const isWindows = !isMac && !isLinux
|
const isWindows = !isMac && !isLinux
|
||||||
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
|
||||||
|
|
||||||
const DB_PATH_CHINESE_ERROR = '路径包含中文字符,迁移至全英文目录后再试'
|
const DB_PATH_CHINESE_ERROR = '路径包含中文字符,迁移至全英文目录后再试'
|
||||||
const dbPathPlaceholder = isMac
|
const dbPathPlaceholder = isMac
|
||||||
@@ -40,19 +39,10 @@ interface WelcomePageProps {
|
|||||||
|
|
||||||
const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
|
const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
|
||||||
const base = String(error || '自动获取密钥失败').trim()
|
const base = String(error || '自动获取密钥失败').trim()
|
||||||
const isInternalLine = (line: string): boolean => {
|
|
||||||
const lower = line.toLowerCase()
|
|
||||||
return lower.includes('xkey_helper')
|
|
||||||
|| lower.includes('[debug]')
|
|
||||||
|| lower.includes('breakpoint')
|
|
||||||
|| lower.includes('hook installed @')
|
|
||||||
|| lower.includes('scanner ')
|
|
||||||
}
|
|
||||||
const tailLogs = Array.isArray(logs)
|
const tailLogs = Array.isArray(logs)
|
||||||
? logs
|
? logs
|
||||||
.map(item => String(item || '').trim())
|
.map(item => String(item || '').trim())
|
||||||
.filter(item => Boolean(item) && !isInternalLine(item))
|
.filter(Boolean)
|
||||||
.map(item => item.length > 80 ? `${item.slice(0, 80)}...` : item)
|
|
||||||
.slice(-6)
|
.slice(-6)
|
||||||
: []
|
: []
|
||||||
if (tailLogs.length === 0) return base
|
if (tailLogs.length === 0) return base
|
||||||
@@ -127,7 +117,6 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
const [isImageStepAutoCompleted, setIsImageStepAutoCompleted] = useState(false)
|
const [isImageStepAutoCompleted, setIsImageStepAutoCompleted] = useState(false)
|
||||||
const [hasReacquiredDbKey, setHasReacquiredDbKey] = useState(!isAddAccountMode)
|
const [hasReacquiredDbKey, setHasReacquiredDbKey] = useState(!isAddAccountMode)
|
||||||
const [showDbKeyConfirm, setShowDbKeyConfirm] = useState(false)
|
const [showDbKeyConfirm, setShowDbKeyConfirm] = useState(false)
|
||||||
const [lastDbKeyError, setLastDbKeyError] = useState('')
|
|
||||||
const imagePrefetchAttemptRef = useRef<string>('')
|
const imagePrefetchAttemptRef = useRef<string>('')
|
||||||
|
|
||||||
// 安全相关 state
|
// 安全相关 state
|
||||||
@@ -487,7 +476,6 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
setShowDbKeyConfirm(false)
|
setShowDbKeyConfirm(false)
|
||||||
setIsFetchingDbKey(true)
|
setIsFetchingDbKey(true)
|
||||||
setError('')
|
setError('')
|
||||||
setLastDbKeyError('')
|
|
||||||
setIsManualStartPrompt(false)
|
setIsManualStartPrompt(false)
|
||||||
setDbKeyStatus('正在连接微信进程...')
|
setDbKeyStatus('正在连接微信进程...')
|
||||||
try {
|
try {
|
||||||
@@ -511,29 +499,20 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
) {
|
) {
|
||||||
setIsManualStartPrompt(true)
|
setIsManualStartPrompt(true)
|
||||||
setDbKeyStatus('需要手动启动微信')
|
setDbKeyStatus('需要手动启动微信')
|
||||||
setLastDbKeyError('')
|
|
||||||
} else {
|
} else {
|
||||||
if (result.error?.includes('尚未完成登录')) {
|
if (result.error?.includes('尚未完成登录')) {
|
||||||
setDbKeyStatus('请先在微信完成登录后重试')
|
setDbKeyStatus('请先在微信完成登录后重试')
|
||||||
}
|
}
|
||||||
const failureMessage = formatDbKeyFailureMessage(result.error, result.logs)
|
setError(formatDbKeyFailureMessage(result.error, result.logs))
|
||||||
setError(failureMessage)
|
|
||||||
setLastDbKeyError(failureMessage)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const failureMessage = `自动获取密钥失败: ${e}`
|
setError(`自动获取密钥失败: ${e}`)
|
||||||
setError(failureMessage)
|
|
||||||
setLastDbKeyError(failureMessage)
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetchingDbKey(false)
|
setIsFetchingDbKey(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openMacKeyFaq = () => {
|
|
||||||
void window.electronAPI.shell.openExternal(MAC_KEY_FAQ_URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleManualConfirm = async () => {
|
const handleManualConfirm = async () => {
|
||||||
setIsManualStartPrompt(false)
|
setIsManualStartPrompt(false)
|
||||||
handleAutoGetDbKey()
|
handleAutoGetDbKey()
|
||||||
@@ -1182,16 +1161,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && <div className="error-message">{error}</div>}
|
||||||
<div className="error-message">
|
|
||||||
<div className="error-text">{error}</div>
|
|
||||||
{isMac && error === lastDbKeyError && (
|
|
||||||
<button type="button" className="error-link-btn" onClick={openMacKeyFaq}>
|
|
||||||
查看 macOS 获取密钥排障指引
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep.id === 'intro' && (
|
{currentStep.id === 'intro' && (
|
||||||
<div className="intro-footer">
|
<div className="intro-footer">
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ import type { ChatSession, Message, Contact } from '../types/models'
|
|||||||
|
|
||||||
const messageAliasIndex = new Set<string>()
|
const messageAliasIndex = new Set<string>()
|
||||||
|
|
||||||
function buildPrimaryMessageKey(message: Message, sourceScope?: string): string {
|
function buildPrimaryMessageKey(message: Message): string {
|
||||||
if (message.messageKey) return String(message.messageKey)
|
if (message.messageKey) return String(message.messageKey)
|
||||||
const normalizedSourceScope = sourceScope ?? String(message._db_path || '').trim()
|
return `fallback:${message.serverId || 0}:${message.createTime}:${message.sortSeq || 0}:${message.localId || 0}:${message.senderUsername || ''}:${message.localType || 0}`
|
||||||
return `fallback:${normalizedSourceScope}:${message.serverId || 0}:${message.createTime}:${message.sortSeq || 0}:${message.localId || 0}:${message.senderUsername || ''}:${message.localType || 0}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMessageAliasKeys(message: Message): string[] {
|
function buildMessageAliasKeys(message: Message): string[] {
|
||||||
const sourceScope = String(message._db_path || '').trim()
|
const keys = [buildPrimaryMessageKey(message)]
|
||||||
const keys = [buildPrimaryMessageKey(message, sourceScope)]
|
|
||||||
const localId = Math.max(0, Number(message.localId || 0))
|
const localId = Math.max(0, Number(message.localId || 0))
|
||||||
const serverId = Math.max(0, Number(message.serverId || 0))
|
const serverId = Math.max(0, Number(message.serverId || 0))
|
||||||
const createTime = Math.max(0, Number(message.createTime || 0))
|
const createTime = Math.max(0, Number(message.createTime || 0))
|
||||||
@@ -20,26 +18,15 @@ function buildMessageAliasKeys(message: Message): string[] {
|
|||||||
const isSend = Number(message.isSend ?? -1)
|
const isSend = Number(message.isSend ?? -1)
|
||||||
|
|
||||||
if (localId > 0) {
|
if (localId > 0) {
|
||||||
// 跨 message_*.db 时 local_id 可能重复,必须带分库上下文避免误去重。
|
keys.push(`lid:${localId}`)
|
||||||
if (sourceScope) {
|
|
||||||
keys.push(`lid:${sourceScope}:${localId}`)
|
|
||||||
} else {
|
|
||||||
// 缺库信息时使用更保守组合,尽量避免把不同消息误判成重复。
|
|
||||||
keys.push(`lid_fallback:${localId}:${createTime}:${sender}:${localType}:${serverId}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (serverId > 0) {
|
if (serverId > 0) {
|
||||||
// server_id 在跨库场景并非绝对全局唯一;必须带来源作用域避免误去重。
|
keys.push(`sid:${serverId}`)
|
||||||
if (sourceScope) {
|
|
||||||
keys.push(`sid:${sourceScope}:${serverId}`)
|
|
||||||
} else {
|
|
||||||
keys.push(`sid_fallback:${serverId}:${createTime}:${sender}:${localType}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (localType === 3) {
|
if (localType === 3) {
|
||||||
const imageIdentity = String(message.imageMd5 || message.imageDatName || '').trim()
|
const imageIdentity = String(message.imageMd5 || message.imageDatName || '').trim()
|
||||||
if (imageIdentity) {
|
if (imageIdentity) {
|
||||||
keys.push(`img:${sourceScope}:${createTime}:${sender}:${isSend}:${imageIdentity}`)
|
keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,9 +37,7 @@ function rebuildMessageAliasIndex(messages: Message[]): void {
|
|||||||
messageAliasIndex.clear()
|
messageAliasIndex.clear()
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
const aliasKeys = buildMessageAliasKeys(message)
|
const aliasKeys = buildMessageAliasKeys(message)
|
||||||
for (const key of aliasKeys) {
|
aliasKeys.forEach((key) => messageAliasIndex.add(key))
|
||||||
messageAliasIndex.add(key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,18 +136,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const filtered: Message[] = []
|
const filtered: Message[] = []
|
||||||
newMessages.forEach((msg) => {
|
newMessages.forEach((msg) => {
|
||||||
const aliasKeys = buildMessageAliasKeys(msg)
|
const aliasKeys = buildMessageAliasKeys(msg)
|
||||||
let exists = false
|
const exists = aliasKeys.some((key) => messageAliasIndex.has(key))
|
||||||
for (const key of aliasKeys) {
|
|
||||||
if (messageAliasIndex.has(key)) {
|
|
||||||
exists = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (exists) return
|
if (exists) return
|
||||||
filtered.push(msg)
|
filtered.push(msg)
|
||||||
for (const key of aliasKeys) {
|
aliasKeys.forEach((key) => messageAliasIndex.add(key))
|
||||||
messageAliasIndex.add(key)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (filtered.length === 0) return state
|
if (filtered.length === 0) return state
|
||||||
|
|||||||
4
src/types/electron.d.ts
vendored
4
src/types/electron.d.ts
vendored
@@ -311,8 +311,6 @@ export interface ElectronAPI {
|
|||||||
allowStaleCache?: boolean
|
allowStaleCache?: boolean
|
||||||
preferAccurateSpecialTypes?: boolean
|
preferAccurateSpecialTypes?: boolean
|
||||||
cacheOnly?: boolean
|
cacheOnly?: boolean
|
||||||
beginTimestamp?: number
|
|
||||||
endTimestamp?: number
|
|
||||||
}
|
}
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -1221,3 +1219,5 @@ declare global {
|
|||||||
|
|
||||||
export { }
|
export { }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user