Compare commits

..

17 Commits

Author SHA1 Message Date
xuncha
2acbe0fb08 Merge pull request #836 from hicccc77/dev
Dev
2026-04-25 00:59:14 +08:00
xuncha
17c13c2455 Merge pull request #835 from xunchahaha/dev
Dev
2026-04-25 00:58:34 +08:00
xuncha
032aad6539 新增撤回消息推送 2026-04-25 00:57:24 +08:00
xuncha
d3c738f9f1 Merge pull request #834 from xunchahaha/dev
Dev
2026-04-24 23:04:20 +08:00
xuncha
d1741c931f 修复sse推送丢消息 给推送新增了id [Bug]:SSE消息推送 丢消息
Fixes #832
2026-04-24 22:42:19 +08:00
xuncha
b75de26178 修复了导出页额外的滑块 2026-04-24 22:31:48 +08:00
cc
255b857e67 #820 #831 2026-04-24 18:21:09 +08:00
cc
c923327112 修复 #820;支持企业用户会话显示;优化聊天页面性能 2026-04-23 23:41:20 +08:00
cc
c25b231f9c Merge pull request #823 from Jasonzhu1207/main
fix(perf): prevent memory growth in chat and export flows
2026-04-23 18:41:04 +08:00
Jason
fbc2c8d900 Merge pull request #33 from Jasonzhu1207/fix/perf-memory-growth
fix(perf): prevent memory growth in chat and export flows
2026-04-22 23:51:18 +08:00
Jason
6304c9ed51 fix(perf): prevent memory growth in chat and export flows 2026-04-22 23:30:28 +08:00
cc
777f5b82db 优化底层游标索引性能;优化HTTPAPI索引逻辑;优化导出图片的索引写入逻辑 2026-04-22 23:02:17 +08:00
cc
5802cf36c6 年报S8临时修改 2026-04-21 23:44:20 +08:00
xuncha
e3174370bb Merge pull request #817 from xunchahaha:dev
修复双人年度报告[Bug]: 双人年度报告坏了
2026-04-21 20:21:01 +08:00
xuncha
0f8a9602bd 修复双人年度报告[Bug]: 双人年度报告坏了
Fixes #816
2026-04-21 20:20:11 +08:00
cc
30d54fcdb1 Merge pull request #807 from hicccc77/dev
Dev
2026-04-20 23:26:07 +08:00
cc
eca1411c68 Merge pull request #806 from hicccc77/dev
Dev
2026-04-20 23:23:22 +08:00
36 changed files with 4611 additions and 2882 deletions

3
.gitignore vendored
View File

@@ -75,4 +75,5 @@ pnpm-lock.yaml
wechat-research-site wechat-research-site
.codex .codex
weflow-web-offical weflow-web-offical
/Wedecrypt /Wedecrypt
/scripts/syncwcdb.py

View File

@@ -74,14 +74,14 @@ GET /api/v1/push/messages
- 需要先在设置页开启 `HTTP API 服务` - 需要先在设置页开启 `HTTP API 服务`
- 同时需要开启 `主动推送` - 同时需要开启 `主动推送`
- 响应类型为 `text/event-stream` - 响应类型为 `text/event-stream`
- 新消息事件名固定为 `message.new` - 事件名包含 `message.new``message.revoke`
- 建议接收端按 `messageKey` 去重 - 建议接收端按 `event + rawid` 去重
### 事件字段 ### 事件字段
- `event` - `event`
- `sessionId` - `sessionId`
- `messageKey` - `rawid`
- `avatarUrl` - `avatarUrl`
- `sourceName` - `sourceName`
- `groupName`(仅群聊) - `groupName`(仅群聊)
@@ -98,7 +98,14 @@ 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","messageKey":"server:123456:1760000123:1760000123000:321:wxid_member:1","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]","timestamp":1760000123} data: {"event":"message.new","sessionId":"xxx@chatroom","sessionType":"group","rawid":"1234567890123456789","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":"对方撤回了一条消息rawid1234567890123456789 内容为“你好”","timestamp":1760000180}
``` ```
--- ---

53
docs/MAC-KEY-FAQ.md Normal file
View File

@@ -0,0 +1,53 @@
# 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),会大大加快定位和修复兼容性问题的速度。

View File

@@ -375,7 +375,34 @@ 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'
@@ -659,6 +686,62 @@ 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'
@@ -734,44 +817,6 @@ 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']
@@ -1179,7 +1224,11 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
} }
function createChatHistoryPayloadWindow(payloadId: string) { function createChatHistoryPayloadWindow(payloadId: string) {
return createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`) const win = createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`)
win.on('closed', () => {
chatHistoryPayloadStore.delete(payloadId)
})
return win
} }
function createChatHistoryRouteWindow(route: string) { function createChatHistoryRouteWindow(route: string) {
@@ -1612,6 +1661,7 @@ 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) => {
@@ -1989,19 +2039,38 @@ 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) => {
const payload = chatHistoryPayloadStore.get(String(payloadId || '').trim()) pruneChatHistoryPayloadStore()
const normalizedPayloadId = String(payloadId || '').trim()
const payload = chatHistoryPayloadStore.get(normalizedPayloadId)
if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' } if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' }
return { success: true, payload } const nextPayload: ChatHistoryPayloadEntry = {
...payload,
lastAccessedAt: Date.now()
}
chatHistoryPayloadStore.set(normalizedPayloadId, nextPayload)
return {
success: true,
payload: {
sessionId: nextPayload.sessionId,
title: nextPayload.title,
recordList: nextPayload.recordList
}
}
}) })
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦) // 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
@@ -2390,6 +2459,8 @@ 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)
}) })
@@ -3052,6 +3123,7 @@ 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)
@@ -3068,6 +3140,7 @@ 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)
@@ -3790,6 +3863,7 @@ 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';
@@ -3849,17 +3923,6 @@ 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) => {
@@ -3934,5 +3997,3 @@ app.on('window-all-closed', () => {
}) })

View File

@@ -219,6 +219,8 @@ 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) =>
@@ -565,4 +567,3 @@ contextBridge.exposeInMainWorld('electronAPI', {
validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid) validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid)
} }
}) })

View File

@@ -103,8 +103,10 @@ 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 = [
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle', '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',

View File

@@ -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 = [
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle', '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,6 +185,7 @@ 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

View File

@@ -76,6 +76,12 @@ interface ApiExportedMedia {
relativePath: string relativePath: string
} }
interface MessagePushReplayEvent {
id: number
body: string
createdAt: number
}
// ChatLab 消息类型映射 // ChatLab 消息类型映射
const ChatLabType = { const ChatLabType = {
TEXT: 0, TEXT: 0,
@@ -107,8 +113,12 @@ 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()
@@ -178,6 +188,7 @@ 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
@@ -232,9 +243,57 @@ 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 || this.messagePushClients.size === 0) return if (!this.running) return
const eventBody = `event: message.new\ndata: ${JSON.stringify(payload)}\n\n` const eventId = this.nextMessagePushEventId()
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 {
@@ -250,6 +309,11 @@ 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) {
@@ -365,7 +429,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) this.handleMessagePushStream(req, res, url)
} 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') {
@@ -440,7 +504,7 @@ class HttpService {
}, 25000) }, 25000)
} }
private handleMessagePushStream(req: http.IncomingMessage, res: http.ServerResponse): void { private handleMessagePushStream(req: http.IncomingMessage, res: http.ServerResponse, url: URL): 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
@@ -453,9 +517,10 @@ 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)
@@ -496,11 +561,20 @@ class HttpService {
const contentType = mimeTypes[ext] || 'application/octet-stream' const contentType = mimeTypes[ext] || 'application/octet-stream'
try { try {
const fileBuffer = fs.readFileSync(fullPath) const stat = fs.statSync(fullPath)
res.setHeader('Content-Type', contentType) res.setHeader('Content-Type', contentType)
res.setHeader('Content-Length', fileBuffer.length) res.setHeader('Content-Length', stat.size)
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')
} }
@@ -516,27 +590,29 @@ 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 大小(与 limit 相同或最多 500来减少循环次数 // 深分页时放大 batch避免 offset 很大时出现大量小批次循环。
const batchSize = Math.min(limit, 500) const batchSize = Math.min(2000, Math.max(500, limit))
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.openMessageCursor(talker, batchSize, ascending, beginTimestamp, endTimestamp) const cursorResult = await wcdbService.openMessageCursorLite(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 allRows: Record<string, any>[] = [] const collectedRows: Record<string, any>[] = []
let hasMore = true let hasMore = true
let skipped = 0 let skipped = 0
let reachedLimit = false
// 循环获取消息,处理 offset 跳过 + limit 累积 // 循环获取消息,处理 offset 跳过 + limit 累积
while (allRows.length < limit && hasMore) { while (collectedRows.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
@@ -557,12 +633,20 @@ class HttpService {
skipped = offset skipped = offset
} }
allRows.push(...rows) const remainingCapacity = limit - collectedRows.length
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 || reachedLimit
const finalHasMore = hasMore || allRows.length > limit const messages = useLiteMapping
const messages = chatService.mapRowsToMessagesForApi(trimmedRows) ? 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 {
@@ -590,32 +674,70 @@ 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()
for (const msg of targets) { const MAX_DETAIL_BACKFILL = 120
const localId = Number(msg.localId || 0) if (targets.length > MAX_DETAIL_BACKFILL) {
if (Number.isFinite(localId) && localId > 0) { for (const msg of targets) {
try { if (!msg.senderUsername && msg.isSend === 1 && myWxid) {
const detail = await wcdbService.getMessageById(talker, localId) msg.senderUsername = myWxid
if (detail.success && detail.message) {
const hydrated = chatService.mapRowsToMessagesForApi([detail.message])[0]
if (hydrated?.senderUsername) {
msg.senderUsername = hydrated.senderUsername
}
if ((msg.isSend === null || msg.isSend === undefined) && hydrated?.isSend !== undefined) {
msg.isSend = hydrated.isSend
}
if (!msg.rawContent && hydrated?.rawContent) {
msg.rawContent = hydrated.rawContent
}
}
} catch (error) {
console.warn('[HttpService] backfill sender failed:', error)
} }
} }
return
}
if (!msg.senderUsername && msg.isSend === 1 && myWxid) { const queue = [...targets]
msg.senderUsername = myWxid 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)
if (Number.isFinite(localId) && localId > 0) {
state.attempted += 1
try {
const detail = await wcdbService.getMessageById(talker, localId)
if (detail.success && detail.message) {
const hydrated = chatService.mapRowsToMessagesForApi([detail.message])[0]
if (hydrated?.senderUsername) {
msg.senderUsername = hydrated.senderUsername
}
if ((msg.isSend === null || msg.isSend === undefined) && hydrated?.isSend !== undefined) {
msg.isSend = hydrated.isSend
}
if (!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) {
console.warn('[HttpService] backfill sender failed:', error)
state.consecutiveMiss += 1
}
}
if (!msg.senderUsername && msg.isSend === 1 && myWxid) {
msg.senderUsername = myWxid
}
} }
} }
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 {
@@ -663,7 +785,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().toLowerCase() const keyword = (url.searchParams.get('keyword') || '').trim()
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)
@@ -683,26 +805,41 @@ class HttpService {
const startTime = this.parseTimeParam(startParam) const startTime = this.parseTimeParam(startParam)
const endTime = this.parseTimeParam(endParam, true) const endTime = this.parseTimeParam(endParam, true)
const queryOffset = keyword ? 0 : offset let messages: Message[] = []
const queryLimit = keyword ? 10000 : limit let hasMore = false
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, false)
if (!result.success || !result.messages) {
this.sendError(res, 500, result.error || 'Failed to get messages')
return
}
let messages = result.messages
let hasMore = result.hasMore === true
if (keyword) { if (keyword) {
const filtered = messages.filter((msg) => { const searchLimit = Math.max(1, limit) + 1
const content = (msg.parsedContent || msg.rawContent || '').toLowerCase() const searchResult = await chatService.searchMessages(
return content.includes(keyword) keyword,
}) talker,
const endIndex = offset + limit searchLimit,
hasMore = filtered.length > endIndex offset,
messages = filtered.slice(offset, endIndex) 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) {
this.sendError(res, 500, result.error || 'Failed to get messages')
return
}
messages = result.messages
hasMore = result.hasMore === true
} }
const mediaMap = mediaOptions.enabled const mediaMap = mediaOptions.enabled
@@ -812,7 +949,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) const result = await this.fetchMessagesBatch(sessionId, offset, limit, startTime, endTime, true, 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

View File

@@ -24,6 +24,9 @@ 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
@@ -186,18 +189,25 @@ export class KeyServiceMac {
} }
if (!parsed.success) { if (!parsed.success) {
const errorMsg = this.mapDbKeyErrorMessage(parsed.code, parsed.detail) const errorMsg = this.enrichDbKeyErrorMessage(
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)
onStatus?.('获取失败: ' + e.message, 2) const rawError = `${e?.message || e || ''}`.trim()
return { success: false, error: e.message } const resolvedError = this.resolveUnexpectedDbKeyErrorMessage(rawError)
onStatus?.(resolvedError, 2)
return { success: false, error: resolvedError }
} }
} }
@@ -223,6 +233,149 @@ 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 精确匹配进程名
@@ -498,7 +651,12 @@ 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('::')
throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${errMsg}, partial=${partial || '(empty)'}`) if (errNum === '-128' || String(errMsg).includes('User canceled')) {
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
@@ -520,49 +678,57 @@ 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
throw new Error('elevated helper returned invalid json: ' + lines[lines.length - 1]) const inferred = this.extractDbKeyErrorFromAnyText(normalizedOutput)
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 ((detail || '').includes('task_for_pid:5')) { if (normalizedDetail.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。' return '无法附加到微信进程task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements,优先使用打包版 WeFlow.app。'
} }
return `无法附加到进程 (${detail || ''})` if (normalizedDetail.includes('thread_get_state_failed')) {
return `无法附加到进程:系统拒绝读取线程状态(${normalizedDetail})。`
}
return `无法附加到进程 (${normalizedDetail || ''})`
} }
if (code === 'FRIDA_FAILED') { if (code === 'FRIDA_FAILED') {
if ((detail || '').includes('FRIDA_TIMEOUT')) { if (normalizedDetail.includes('FRIDA_TIMEOUT')) {
return '定位已成功但在等待时间内未捕获到密钥调用。请保持微信前台并进行一次会话/数据库访问后重试。' return '定位已成功但在等待时间内未捕获到密钥调用。请保持微信前台并进行一次会话/数据库访问后重试。'
} }
return `Frida 语义定位失败 (${detail || ''})` return `Frida 语义定位失败 (${normalizedDetail || ''})`
} }
if (code === 'HOOK_FAILED') { if (code === 'HOOK_FAILED') {
if ((detail || '').includes('HOOK_TIMEOUT')) { if (normalizedDetail.includes('HOOK_TIMEOUT')) {
return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。' return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。'
} }
if ((detail || '').includes('attach_wait_timeout')) { if (normalizedDetail.includes('attach_wait_timeout')) {
return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。' return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。'
} }
return `原生 Hook 失败 (${detail || ''})` if (normalizedDetail.includes('patch_breakpoint_failed') || normalizedDetail.includes('thread_get_state_failed')) {
return `原生 Hook 失败:检测到系统调试权限或内存保护冲突(${normalizedDetail})。`
}
return `原生 Hook 失败 (${normalizedDetail || ''})`
} }
if (code === 'HOOK_TARGET_ONLY') { if (code === 'HOOK_TARGET_ONLY') {
return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。` return `已定位到目标函数地址(${normalizedDetail || ''}),但当前原生 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 '内存扫描失败:未匹配到目标函数特征,可使用微信 4.1.8.100 版本尝试。' return '内存扫描失败:未匹配到目标函数特征Sink pattern not found当前微信版本可能暂未适配。'
} }
if (normalizedDetail.includes('No suitable module found')) { if (normalizedDetail.includes('No suitable module found')) {
return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台,再重试。' return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台;若仍失败,优先尝试微信 4.1.7。'
} }
return `内存扫描失败:${normalizedDetail}` return `内存扫描失败:${normalizedDetail}`
} }

View File

@@ -12,6 +12,7 @@ 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
@@ -36,6 +37,7 @@ 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)
@@ -43,6 +45,19 @@ 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]
} }
@@ -56,6 +71,7 @@ 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

View File

@@ -324,6 +324,9 @@ 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
@@ -336,6 +339,38 @@ 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()
@@ -1239,20 +1274,27 @@ 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 base64Part = cachedDataUrl.split(',')[1] || '' const cachedAt = this.imageCacheMeta.get(cacheKey) || 0
if (base64Part) { if (cachedAt > 0 && Date.now() - cachedAt <= this.imageCacheTtlMs) {
try { const base64Part = cachedDataUrl.split(',')[1] || ''
const cachedBuf = Buffer.from(base64Part, 'base64') if (base64Part) {
if (detectImageMime(cachedBuf, '').startsWith('image/')) { try {
return { success: true, dataUrl: cachedDataUrl } const cachedBuf = Buffer.from(base64Part, 'base64')
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 }
}
} catch {
// ignore and fall through to refetch
} }
} catch {
// 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)
@@ -1269,7 +1311,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.imageCache.set(cacheKey, dataUrl) this.rememberImageCache(cacheKey, dataUrl)
return { success: true, dataUrl } return { success: true, dataUrl }
} }
} }

8
package-lock.json generated
View File

@@ -25,7 +25,7 @@
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.14.0", "react-router-dom": "^7.14.0",
"react-virtuoso": "^4.18.5", "react-virtuoso": "^4.18.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.10.38", "sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1", "silk-wasm": "^3.7.1",
@@ -8560,9 +8560,9 @@
} }
}, },
"node_modules/react-virtuoso": { "node_modules/react-virtuoso": {
"version": "4.18.5", "version": "4.18.4",
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.5.tgz", "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.4.tgz",
"integrity": "sha512-QDyNjyNEuurZG67SOmzYyxEkQYSyGmAMixOI6M15L/Q4CF39EgG+88y6DgZRo0q7rmy0HPx3Fj90I8/tPdnRCQ==", "integrity": "sha512-DNM4Wy2tMA/J6ejMaDdqecOug31rOwgSRg4C/Dw6Iox4dJe9qwcx32M8HdhkE5uHEVVZh7h0koYwAsCSNdxGfQ==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"react": ">=16 || >=17 || >= 18 || >= 19", "react": ">=16 || >=17 || >= 18 || >= 19",

View File

@@ -39,7 +39,7 @@
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.14.0", "react-router-dom": "^7.14.0",
"react-virtuoso": "^4.18.5", "react-virtuoso": "^4.18.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.10.38", "sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1", "silk-wasm": "^3.7.1",

View File

@@ -81,6 +81,7 @@ 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
@@ -128,7 +129,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) { if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow || isDualReportWindow) {
root.style.background = 'transparent' root.style.background = 'transparent'
body.style.background = 'transparent' body.style.background = 'transparent'
body.style.overflow = 'hidden' body.style.overflow = 'hidden'
@@ -145,7 +146,7 @@ function App() {
appRoot.style.overflow = '' appRoot.style.overflow = ''
} }
} }
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow]) }, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow])
// 应用主题 // 应用主题
useEffect(() => { useEffect(() => {
@@ -166,7 +167,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]) }, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow])
// 读取已保存的主题设置 // 读取已保存的主题设置
useEffect(() => { useEffect(() => {
@@ -517,6 +518,11 @@ 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

View File

@@ -5,6 +5,21 @@ 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
@@ -123,7 +138,7 @@ export const Avatar = React.memo(function Avatar({
onLoad={() => { onLoad={() => {
if (src) { if (src) {
avatarLoadQueue.clearFailed(src) avatarLoadQueue.clearFailed(src)
loadedAvatarCache.add(src) rememberLoadedAvatar(src)
} }
setImageLoaded(true) setImageLoaded(true)
setImageError(false) setImageError(false)

View File

@@ -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.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}` return `fallback:${msg._db_path || ''}:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
} }
// 处理数据库变更 // 处理数据库变更

View File

@@ -299,6 +299,12 @@
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 (大气) */
@@ -643,199 +649,160 @@
} }
#scene-8 { #scene-8 {
align-items: flex-start; align-items: center;
justify-content: flex-start; justify-content: center;
padding: 0 6vw; padding: 0;
overflow: hidden;
} }
#scene-8 .s8-layout { /* V2 Background: Cinematic Aura */
#scene-8 .s8-bg-layer {
position: absolute; position: absolute;
top: 18vh; inset: -10%;
left: 50%; z-index: 1;
transform: translateX(-50%); opacity: 0;
width: min(1240px, 86vw); transition: opacity 2s 0.2s var(--ease-out);
display: grid; filter: blur(120px) contrast(1.1) brightness(0.6);
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr); pointer-events: none;
column-gap: clamp(34px, 4.8vw, 84px);
align-items: start; .bg-avatar {
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.2);
}
} }
#scene-8 .s8-left { .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;
grid-template-columns: repeat(12, 1fr);
grid-template-rows: repeat(12, 1fr);
padding: 10vh 8vw;
}
/* The Central Pivot: Name & Meta */
#scene-8 .s8-hero-unit {
grid-column: 2 / 8;
grid-row: 4 / 7;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: clamp(2.5vh, 3.2vh, 4vh); justify-content: center;
padding-top: clamp(8vh, 9vh, 11vh);
.s8-name {
font-size: clamp(4.5rem, 10vw, 8.5rem);
font-weight: 700;
color: var(--c-text-bright);
letter-spacing: 0.08em;
line-height: 1;
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 {
font-family: 'SpaceMonoLocal';
font-size: clamp(0.7rem, 0.85vw, 0.9rem);
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);
}
}
} }
#scene-8 .s8-name-wrap, /* Fragmented Storytelling */
#scene-8 .s8-summary-wrap, #scene-8 .s8-fragments {
#scene-8 .s8-quote-wrap,
#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;
line-height: 1.05;
}
#scene-8 .s8-summary {
max-width: 34ch;
font-size: clamp(1.06rem, 1.35vw, 1.35rem);
color: var(--c-text-soft);
line-height: 1.95;
letter-spacing: 0.02em;
}
#scene-8 .s8-summary-count {
margin: 0 8px;
font-size: clamp(1.35rem, 2vw, 1.75rem);
color: var(--c-gold-strong);
white-space: nowrap;
}
#scene-8 .s8-quote {
max-width: 32ch;
font-size: clamp(0.98rem, 1.12vw, 1.1rem);
color: var(--c-text-muted);
line-height: 1.9;
}
#scene-8 .s8-letter-wrap {
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; position: absolute;
top: 20px; inset: 0;
left: 14px; pointer-events: none;
width: 2px; }
height: calc(100% - 40px);
border-radius: 2px; #scene-8 .fragment {
background: linear-gradient(to bottom, rgba(var(--c-gold-rgb), 0.7), rgba(var(--c-gold-rgb), 0.08)); 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);
font-style: italic;
}
&.f2 {
bottom: 20vh;
left: 15vw;
max-width: 38ch;
}
&.f3 {
bottom: 12vh;
right: 10vw;
text-align: right;
opacity: 0.6;
font-size: 0.85rem;
letter-spacing: 0.05em;
}
}
@keyframes shine {
to { background-position: 200% center; }
} }
#scene-8 .s8-empty-wrap { #scene-8 .s8-empty-wrap {
display: block; grid-column: 4 / 10;
width: min(760px, 78vw); grid-row: 5 / 8;
margin-top: 24vh;
text-align: center; text-align: center;
} .s8-empty-text {
font-size: 1.6rem;
#scene-8 .s8-empty-text { line-height: 2.5;
color: var(--c-text); color: var(--c-text-soft);
line-height: 2; font-weight: 200;
}
@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-layout { #scene-8 .s8-hero-unit {
top: 16vh; grid-column: 2 / 12;
width: min(900px, 90vw); grid-row: 2 / 5;
grid-template-columns: 1fr;
row-gap: clamp(3vh, 3.5vh, 4.5vh);
} }
#scene-8 .fragment {
#scene-8 .s8-left { position: relative;
padding-top: 0; inset: auto !important;
gap: clamp(1.6vh, 2.2vh, 2.8vh); max-width: 100%;
text-align: left !important;
margin-top: 4vh;
} }
#scene-8 .s8-fragments {
#scene-8 .s8-name { position: relative;
font-size: clamp(2.4rem, 8.4vw, 4.2rem); grid-column: 2 / 12;
letter-spacing: 0.06em; grid-row: 6 / 12;
} 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;
} }
} }

View File

@@ -93,7 +93,7 @@ const DecodeText = ({
if (i < iter) return strVal[i] if (i < iter) return strVal[i]
return chars[Math.floor(Math.random() * chars.length)] return chars[Math.floor(Math.random() * chars.length)]
}).join('')) }).join(''))
if (iter >= strVal.length) { if (iter >= strVal.length) {
clearInterval(inv) clearInterval(inv)
setDisplay(strVal) setDisplay(strVal)
@@ -123,7 +123,7 @@ function AnnualReportWindow() {
const s3LayoutRef = useRef<HTMLDivElement | null>(null) const s3LayoutRef = useRef<HTMLDivElement | null>(null)
const s3ListRef = useRef<HTMLDivElement | null>(null) const s3ListRef = useRef<HTMLDivElement | null>(null)
const [s3LineVars, setS3LineVars] = useState<React.CSSProperties>({}) const [s3LineVars, setS3LineVars] = useState<React.CSSProperties>({})
// 提取长图逻辑变量 // 提取长图逻辑变量
const [buttonText, setButtonText] = useState('EXTRACT RECORD') const [buttonText, setButtonText] = useState('EXTRACT RECORD')
const [isExtracting, setIsExtracting] = useState(false) const [isExtracting, setIsExtracting] = useState(false)
@@ -202,7 +202,7 @@ function AnnualReportWindow() {
setIsAnimating(true) setIsAnimating(true)
setCurrentScene(index) setCurrentScene(index)
setTimeout(() => { setTimeout(() => {
setIsAnimating(false) setIsAnimating(false)
}, 1500) }, 1500)
@@ -217,7 +217,7 @@ function AnnualReportWindow() {
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
const now = Date.now() const now = Date.now()
if (now - lastWheelTime < 1000) return // Throttle wheel events if (now - lastWheelTime < 1000) return // Throttle wheel events
if (Math.abs(e.deltaY) > 30) { if (Math.abs(e.deltaY) > 30) {
lastWheelTime = now lastWheelTime = now
goToScene(e.deltaY > 0 ? currentScene + 1 : currentScene - 1) goToScene(e.deltaY > 0 ? currentScene + 1 : currentScene - 1)
@@ -564,21 +564,21 @@ function AnnualReportWindow() {
<canvas ref={p0CanvasRef} className="p0-particle-canvas" /> <canvas ref={p0CanvasRef} className="p0-particle-canvas" />
<div className="p0-center-glow" /> <div className="p0-center-glow" />
</div> </div>
<div className="film-grain"></div> <div className="film-grain"></div>
<div id="memory-core"></div> <div id="memory-core"></div>
<div className="pagination"> <div className="pagination">
{Array.from({ length: TOTAL_SCENES }).map((_, i) => ( {Array.from({ length: TOTAL_SCENES }).map((_, i) => (
<div <div
key={i} key={i}
className={`dot-nav ${currentScene === i ? 'active' : ''}`} className={`dot-nav ${currentScene === i ? 'active' : ''}`}
onClick={() => goToScene(i)} onClick={() => goToScene(i)}
/> />
))} ))}
</div> </div>
<div className="swipe-hint"></div> <div className="swipe-hint"></div>
{/* S0: THE ARCHIVE */} {/* S0: THE ARCHIVE */}
@@ -590,7 +590,7 @@ function AnnualReportWindow() {
<div className={`reveal-inner serif title-year ${yearTitleVariantClass} delay-1`}>{yearTitle}</div> <div className={`reveal-inner serif title-year ${yearTitleVariantClass} delay-1`}>{yearTitle}</div>
</div> </div>
<div className="reveal-wrap desc-text p0-desc"> <div className="reveal-wrap desc-text p0-desc">
<div className="reveal-inner serif delay-2 p0-desc-inner"><br/></div> <div className="reveal-inner serif delay-2 p0-desc-inner"><br /></div>
</div> </div>
</div> </div>
@@ -606,7 +606,7 @@ function AnnualReportWindow() {
</div> </div>
<div className="reveal-wrap desc-text"> <div className="reveal-wrap desc-text">
<div className="reveal-inner serif delay-2"> <div className="reveal-inner serif delay-2">
<strong className="num-display" style={{ color: COLOR.accentGold }}>{reportData.totalMessages.toLocaleString()}</strong> <br/> <strong className="num-display" style={{ color: COLOR.accentGold }}>{reportData.totalMessages.toLocaleString()}</strong> <br />
</div> </div>
</div> </div>
</div> </div>
@@ -618,20 +618,20 @@ function AnnualReportWindow() {
</div> </div>
<div className="reveal-wrap"> <div className="reveal-wrap">
<div className="reveal-inner serif title-time delay-1"> <div className="reveal-inner serif title-time delay-1">
{reportData.midnightKing ? reportData.midnightKing.displayName : '00:00'} {reportData.midnightKing ? reportData.midnightKing.displayName : '00:00'}
</div> </div>
</div> </div>
<div className="reveal-wrap"> <div className="reveal-wrap">
<br/> <br />
<div className="reveal-inner serif scene0-cn-tag delay-1" style={{ fontSize: '1rem', color: 'var(--c-text-muted)', margin: '1vh 0' }}> <div className="reveal-inner serif scene0-cn-tag delay-1" style={{ fontSize: '1rem', color: 'var(--c-text-muted)', margin: '1vh 0' }}>
</div> </div>
</div> </div>
<div className="reveal-wrap desc-text"> <div className="reveal-wrap desc-text">
<div className="reveal-inner serif delay-2"> <div className="reveal-inner serif delay-2">
{reportData.midnightKing ? reportData.midnightKing.displayName : '00:00'}<br/> {reportData.midnightKing ? reportData.midnightKing.displayName : '00:00'}<br />
<strong className="num-display" style={{ color: COLOR.accentGold, margin: '0 10px', fontSize: '1.5rem' }}> <strong className="num-display" style={{ color: COLOR.accentGold, margin: '0 10px', fontSize: '1.5rem' }}>
<DecodeText value={(reportData.midnightKing?.count || 0).toLocaleString()} active={currentScene === 2} /> <DecodeText value={(reportData.midnightKing?.count || 0).toLocaleString()} active={currentScene === 2} />
</strong> </strong>
</div> </div>
</div> </div>
@@ -689,56 +689,56 @@ function AnnualReportWindow() {
{reportData.monthlyTopFriends.length > 0 ? ( {reportData.monthlyTopFriends.length > 0 ? (
<div style={{ position: 'absolute', top: '55vh', left: '10vw', width: '80vw', height: '1px', background: 'transparent' }}> <div style={{ position: 'absolute', top: '55vh', left: '10vw', width: '80vw', height: '1px', background: 'transparent' }}>
{reportData.monthlyTopFriends.map((m, i) => { {reportData.monthlyTopFriends.map((m, i) => {
const leftPos = (i / 11) * 100; // 0% to 100% const leftPos = (i / 11) * 100; // 0% to 100%
const isTop = i % 2 === 0; // Alternate up and down to prevent crowding const isTop = i % 2 === 0; // Alternate up and down to prevent crowding
const isRightSide = i >= 6; // Center-focus alignment logic const isRightSide = i >= 6; // Center-focus alignment logic
// Pseudo-random organic height variation for audio-wave feel (from 8vh to 18vh)
const heightVariation = 12 + (Math.sin(i * 1.5) * 6);
const alignStyle = isRightSide ? { right: '10px', alignItems: 'flex-end', textAlign: 'right' as const } : { left: '10px', alignItems: 'flex-start', textAlign: 'left' as const };
return ( // Pseudo-random organic height variation for audio-wave feel (from 8vh to 18vh)
<div key={m.month} className="reveal-wrap float-el" style={{ position: 'absolute', left: `${leftPos}%`, top: 0, width: '1px', height: '1px', overflow: 'visible', animationDelay: `${-(i%4)*0.5}s` }}> const heightVariation = 12 + (Math.sin(i * 1.5) * 6);
{/* The connecting thread (gradient fades away from center line) */}
<div className={`reveal-inner delay-${(i % 5) + 1}`} style={{
position: 'absolute',
left: '-0px',
top: isTop ? `-${heightVariation}vh` : '0px',
width: '1px',
height: `${heightVariation}vh`,
background: isTop
? 'linear-gradient(to top, rgba(184,148,90,0.34), transparent)'
: 'linear-gradient(to bottom, rgba(184,148,90,0.34), transparent)'
}} />
{/* Center Glowing Dot */} const alignStyle = isRightSide ? { right: '10px', alignItems: 'flex-end', textAlign: 'right' as const } : { left: '10px', alignItems: 'flex-start', textAlign: 'left' as const };
<div className={`reveal-inner delay-${(i % 5) + 1}`} style={{ position: 'absolute', left: '-2.5px', top: '-2.5px', width: '6px', height: '6px', borderRadius: '50%', background: 'rgba(184,148,90,0.72)', boxShadow: '0 0 10px rgba(184,148,90,0.34)' }} />
{/* Text Payload */} return (
<div className={`reveal-inner delay-${(i % 5) + 1}`} style={{ <div key={m.month} className="reveal-wrap float-el" style={{ position: 'absolute', left: `${leftPos}%`, top: 0, width: '1px', height: '1px', overflow: 'visible', animationDelay: `${-(i % 4) * 0.5}s` }}>
position: 'absolute',
...alignStyle, {/* The connecting thread (gradient fades away from center line) */}
top: isTop ? `-${heightVariation + 2}vh` : `${heightVariation}vh`, <div className={`reveal-inner delay-${(i % 5) + 1}`} style={{
transform: 'translateY(-50%)', position: 'absolute',
display: 'flex', left: '-0px',
flexDirection: 'column', top: isTop ? `-${heightVariation}vh` : '0px',
width: '20vw' // ample space to avoid wrapping width: '1px',
}}> height: `${heightVariation}vh`,
<div className="mono num-display" style={{ fontSize: '0.9rem', color: COLOR.textFaint, marginBottom: '4px', letterSpacing: '0.1em' }}> background: isTop
{m.month.toString().padStart(2, '0')} ? 'linear-gradient(to top, rgba(184,148,90,0.34), transparent)'
</div> : 'linear-gradient(to bottom, rgba(184,148,90,0.34), transparent)'
<div className="serif" style={{ fontSize: 'clamp(1rem, 2vw, 1.4rem)', color: COLOR.textStrong, letterSpacing: '0.05em' }}> }} />
{m.displayName}
</div> {/* Center Glowing Dot */}
<div className="mono num-display" style={{ fontSize: '0.65rem', color: COLOR.textMuted, marginTop: '4px', letterSpacing: '0.1em' }}> <div className={`reveal-inner delay-${(i % 5) + 1}`} style={{ position: 'absolute', left: '-2.5px', top: '-2.5px', width: '6px', height: '6px', borderRadius: '50%', background: 'rgba(184,148,90,0.72)', boxShadow: '0 0 10px rgba(184,148,90,0.34)' }} />
{m.messageCount.toLocaleString()} M
</div> {/* Text Payload */}
<div className={`reveal-inner delay-${(i % 5) + 1}`} style={{
position: 'absolute',
...alignStyle,
top: isTop ? `-${heightVariation + 2}vh` : `${heightVariation}vh`,
transform: 'translateY(-50%)',
display: 'flex',
flexDirection: 'column',
width: '20vw' // ample space to avoid wrapping
}}>
<div className="mono num-display" style={{ fontSize: '0.9rem', color: COLOR.textFaint, marginBottom: '4px', letterSpacing: '0.1em' }}>
{m.month.toString().padStart(2, '0')}
</div> </div>
<div className="serif" style={{ fontSize: 'clamp(1rem, 2vw, 1.4rem)', color: COLOR.textStrong, letterSpacing: '0.05em' }}>
{m.displayName}
</div>
<div className="mono num-display" style={{ fontSize: '0.65rem', color: COLOR.textMuted, marginTop: '4px', letterSpacing: '0.1em' }}>
{m.messageCount.toLocaleString()} M
</div>
</div>
</div> </div>
); );
})} })}
</div> </div>
) : ( ) : (
@@ -757,29 +757,29 @@ function AnnualReportWindow() {
<> <>
<div className="reveal-wrap desc-text" style={{ position: 'absolute', top: '20vh' }}> <div className="reveal-wrap desc-text" style={{ position: 'absolute', top: '20vh' }}>
<div className="reveal-inner serif delay-1" style={{ fontSize: 'clamp(3rem, 7vw, 4rem)', color: COLOR.accentGold, letterSpacing: '0.05em' }}> <div className="reveal-inner serif delay-1" style={{ fontSize: 'clamp(3rem, 7vw, 4rem)', color: COLOR.accentGold, letterSpacing: '0.05em' }}>
{reportData.mutualFriend.displayName} {reportData.mutualFriend.displayName}
</div> </div>
</div> </div>
<div className="reveal-wrap" style={{ position: 'absolute', top: '42vh', left: '15vw' }}> <div className="reveal-wrap" style={{ position: 'absolute', top: '42vh', left: '15vw' }}>
<div className="reveal-inner serif scene0-cn-tag delay-2" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}></div> <div className="reveal-inner serif scene0-cn-tag delay-2" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}></div>
<div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(2rem, 5vw, 3.5rem)', color: COLOR.accentGold, marginTop: '10px' }}><DecodeText value={reportData.mutualFriend.sentCount.toLocaleString()} active={currentScene === 5} /></div> <div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(2rem, 5vw, 3.5rem)', color: COLOR.accentGold, marginTop: '10px' }}><DecodeText value={reportData.mutualFriend.sentCount.toLocaleString()} active={currentScene === 5} /></div>
</div> </div>
<div className="reveal-wrap" style={{ position: 'absolute', top: '42vh', right: '15vw', textAlign: 'right' }}> <div className="reveal-wrap" style={{ position: 'absolute', top: '42vh', right: '15vw', textAlign: 'right' }}>
<div className="reveal-inner serif scene0-cn-tag delay-2" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}></div> <div className="reveal-inner serif scene0-cn-tag delay-2" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}></div>
<div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(2rem, 5vw, 3.5rem)', color: COLOR.accentGold, marginTop: '10px' }}><DecodeText value={reportData.mutualFriend.receivedCount.toLocaleString()} active={currentScene === 5} /></div> <div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(2rem, 5vw, 3.5rem)', color: COLOR.accentGold, marginTop: '10px' }}><DecodeText value={reportData.mutualFriend.receivedCount.toLocaleString()} active={currentScene === 5} /></div>
</div> </div>
<div className="reveal-wrap desc-text" style={{ position: 'absolute', bottom: '20vh' }}> <div className="reveal-wrap desc-text" style={{ position: 'absolute', bottom: '20vh' }}>
<div className="reveal-inner serif delay-3"> <div className="reveal-inner serif delay-3">
<strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.5rem' }}>{reportData.mutualFriend.ratio}</strong> <strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.5rem' }}>{reportData.mutualFriend.ratio}</strong>
<br/> <br />
<span style={{ fontSize: '1rem', color: COLOR.textMuted, marginTop: '15px', display: 'block' }}><br/></span> <span style={{ fontSize: '1rem', color: COLOR.textMuted, marginTop: '15px', display: 'block' }}><br /></span>
</div> </div>
</div> </div>
</> </>
) : ( ) : (
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1"><br/>TA</div></div> <div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1"><br />TA</div></div>
)} )}
</div> </div>
@@ -790,45 +790,45 @@ function AnnualReportWindow() {
</div> </div>
{reportData.socialInitiative || reportData.responseSpeed ? ( {reportData.socialInitiative || reportData.responseSpeed ? (
<div style={{ position: 'absolute', top: '0', left: '0', width: '100%', height: '100%' }}> <div style={{ position: 'absolute', top: '0', left: '0', width: '100%', height: '100%' }}>
{reportData.socialInitiative && ( {reportData.socialInitiative && (
<div className="reveal-wrap" style={{ position: 'absolute', top: '28vh', left: '15vw', width: '38vw', textAlign: 'left' }}> <div className="reveal-wrap" style={{ position: 'absolute', top: '28vh', left: '15vw', width: '38vw', textAlign: 'left' }}>
<div className="reveal-inner serif scene0-cn-tag delay-1" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}></div> <div className="reveal-inner serif scene0-cn-tag delay-1" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}></div>
<div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(4.5rem, 8vw, 7rem)', color: COLOR.accentGold, lineHeight: '1', margin: '2vh 0' }}> <div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(4.5rem, 8vw, 7rem)', color: COLOR.accentGold, lineHeight: '1', margin: '2vh 0' }}>
{reportData.socialInitiative.initiativeRate}% {reportData.socialInitiative.initiativeRate}%
</div>
<div className="reveal-inner serif delay-3" style={{ fontSize: '1.2rem', color: COLOR.textSoft, lineHeight: '1.8' }}>
<div style={{ fontSize: '1.3rem', color: COLOR.textStrong, marginBottom: '0.6vh' }}>
</div> </div>
<div className="reveal-inner serif delay-3" style={{ fontSize: '1.2rem', color: COLOR.textSoft, lineHeight: '1.8' }}> {reportData.socialInitiative.topInitiatedFriend && (reportData.socialInitiative.topInitiatedCount || 0) > 0 ? (
<div style={{ fontSize: '1.3rem', color: COLOR.textStrong, marginBottom: '0.6vh' }}> <div style={{ marginBottom: '0.6vh' }}>
<strong style={{ color: COLOR.accentGold }}>{reportData.socialInitiative.topInitiatedFriend}</strong>
<strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.2rem', margin: '0 4px' }}>{(reportData.socialInitiative.topInitiatedCount || 0).toLocaleString()}</strong>
</div> </div>
{reportData.socialInitiative.topInitiatedFriend && (reportData.socialInitiative.topInitiatedCount || 0) > 0 ? ( ) : (
<div style={{ marginBottom: '0.6vh' }}> <div style={{ marginBottom: '0.6vh' }}>
<strong style={{ color: COLOR.accentGold }}>{reportData.socialInitiative.topInitiatedFriend}</strong> <strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.2rem', margin: '0 4px' }}>{reportData.socialInitiative.initiatedChats.toLocaleString()}</strong>
<strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.2rem', margin: '0 4px' }}>{(reportData.socialInitiative.topInitiatedCount || 0).toLocaleString()}</strong> </div>
</div> )}
) : ( <span style={{ fontSize: '0.9rem', color: COLOR.textMuted }}></span>
<div style={{ marginBottom: '0.6vh' }}>
<strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.2rem', margin: '0 4px' }}>{reportData.socialInitiative.initiatedChats.toLocaleString()}</strong>
</div>
)}
<span style={{ fontSize: '0.9rem', color: COLOR.textMuted }}></span>
</div>
</div> </div>
)} </div>
{reportData.responseSpeed && ( )}
<div className="reveal-wrap" style={{ position: 'absolute', bottom: '22vh', right: '15vw', width: '38vw', textAlign: 'right' }}> {reportData.responseSpeed && (
<div className="reveal-inner serif scene0-cn-tag delay-4" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em' }}></div> <div className="reveal-wrap" style={{ position: 'absolute', bottom: '22vh', right: '15vw', width: '38vw', textAlign: 'right' }}>
<div className="reveal-inner num-display delay-5" style={{ fontSize: 'clamp(3.5rem, 6vw, 5rem)', color: COLOR.accentSoft, lineHeight: '1', margin: '2vh 0' }}> <div className="reveal-inner serif scene0-cn-tag delay-4" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em' }}></div>
<DecodeText value={reportData.responseSpeed.fastestTime} active={currentScene === 6} />S <div className="reveal-inner num-display delay-5" style={{ fontSize: 'clamp(3.5rem, 6vw, 5rem)', color: COLOR.accentSoft, lineHeight: '1', margin: '2vh 0' }}>
</div> <DecodeText value={reportData.responseSpeed.fastestTime} active={currentScene === 6} />S
<div className="reveal-inner serif delay-6" style={{ fontSize: '1.2rem', color: COLOR.textSoft, lineHeight: '1.8' }}>
<strong style={{ color: COLOR.accentGold }}>{reportData.responseSpeed.fastestFriend}</strong> <br/>
<span style={{ fontSize: '0.9rem', color: COLOR.textMuted }}> "我在"</span>
</div>
</div> </div>
)} <div className="reveal-inner serif delay-6" style={{ fontSize: '1.2rem', color: COLOR.textSoft, lineHeight: '1.8' }}>
<strong style={{ color: COLOR.accentGold }}>{reportData.responseSpeed.fastestFriend}</strong> <br />
<span style={{ fontSize: '0.9rem', color: COLOR.textMuted }}> "我在"</span>
</div>
</div>
)}
</div> </div>
) : ( ) : (
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1"></div></div> <div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1"></div></div>
)} )}
</div> </div>
@@ -837,33 +837,33 @@ 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.longestStreak ? ( {reportData.longestStreak ? (
<div className="reveal-wrap" style={{ position: 'absolute', top: '35vh', left: '15vw', textAlign: 'left' }}> <div className="reveal-wrap" style={{ position: 'absolute', top: '35vh', left: '15vw', textAlign: 'left' }}>
<div className="reveal-inner serif scene0-cn-tag delay-1" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em', marginBottom: '2vh' }}></div> <div className="reveal-inner serif scene0-cn-tag delay-1" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em', marginBottom: '2vh' }}></div>
<div className="reveal-inner serif delay-2" style={{ fontSize: 'clamp(3rem, 6vw, 5rem)', color: COLOR.accentGold, letterSpacing: '0.02em' }}> <div className="reveal-inner serif delay-2" style={{ fontSize: 'clamp(3rem, 6vw, 5rem)', color: COLOR.accentGold, letterSpacing: '0.02em' }}>
{reportData.longestStreak.friendName} {reportData.longestStreak.friendName}
</div> </div>
<div className="reveal-inner serif delay-3" style={{ fontSize: '1.2rem', color: COLOR.textSoft, marginTop: '2vh' }}> <div className="reveal-inner serif delay-3" style={{ fontSize: '1.2rem', color: COLOR.textSoft, marginTop: '2vh' }}>
<strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.8rem' }}><DecodeText value={reportData.longestStreak.days} active={currentScene === 7} /></strong> ,<br/> <strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.8rem' }}><DecodeText value={reportData.longestStreak.days} active={currentScene === 7} /></strong> ,<br />
</div> </div>
</div> </div>
) : null} ) : null}
{reportData.peakDay ? ( {reportData.peakDay ? (
<div className="reveal-wrap" style={{ position: 'absolute', bottom: '30vh', right: '15vw', textAlign: 'right' }}> <div className="reveal-wrap" style={{ position: 'absolute', bottom: '30vh', right: '15vw', textAlign: 'right' }}>
<div className="reveal-inner serif scene0-cn-tag delay-4" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em', marginBottom: '2vh' }}></div> <div className="reveal-inner serif scene0-cn-tag delay-4" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em', marginBottom: '2vh' }}></div>
<div className="reveal-inner num-display delay-5" style={{ fontSize: 'clamp(2.5rem, 5vw, 4rem)', color: COLOR.accentGold, letterSpacing: '0.02em' }}> <div className="reveal-inner num-display delay-5" style={{ fontSize: 'clamp(2.5rem, 5vw, 4rem)', color: COLOR.accentGold, letterSpacing: '0.02em' }}>
{reportData.peakDay.date} {reportData.peakDay.date}
</div> </div>
<div className="reveal-inner serif delay-6" style={{ fontSize: '1.2rem', color: COLOR.textSoft, marginTop: '2vh' }}> <div className="reveal-inner serif delay-6" style={{ fontSize: '1.2rem', color: COLOR.textSoft, marginTop: '2vh' }}>
<strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.8rem' }}>{reportData.peakDay.messageCount}</strong> <br/> <strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.8rem' }}>{reportData.peakDay.messageCount}</strong> <br />
</div> </div>
</div> </div>
) : null} ) : null}
{!reportData.longestStreak && !reportData.peakDay && ( {!reportData.longestStreak && !reportData.peakDay && (
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1"></div></div> <div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1"></div></div>
)} )}
</div> </div>
@@ -872,45 +872,69 @@ 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-layout"> <div className="s8-floating-layout">
<div className="s8-left"> <div className="s8-hero-unit">
<div className="reveal-wrap s8-name-wrap"> <div className="reveal-wrap">
<div className="reveal-inner serif delay-1 s8-name"> <div className="reveal-inner s8-name delay-1">
{reportData.lostFriend.displayName} {reportData.lostFriend.displayName}
</div> </div>
</div> </div>
<div className="reveal-wrap s8-summary-wrap"> <div className="reveal-wrap">
<div className="reveal-inner serif delay-2 s8-summary"> <div className="reveal-inner s8-meta delay-2">
{reportData.lostFriend.periodDesc} {reportData.lostFriend.periodDesc} /
<span className="num-display s8-summary-count"> <span className="num-display" style={{ margin: '0 10px', fontSize: '1.4em' }}>
<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="reveal-inner serif delay-4 s8-letter"> <div className="s8-fragments">
<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="reveal-wrap desc-text s8-empty-wrap"> <div className="s8-floating-layout">
<div className="reveal-inner serif delay-1 s8-empty-text"> <div className="reveal-wrap s8-empty-wrap">
<br/> <div className="reveal-inner serif s8-empty-text delay-1">
<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">
@@ -936,16 +960,16 @@ function AnnualReportWindow() {
const st = demoStyles[i]; const st = demoStyles[i];
return ( return (
<div <div
key={phrase.phrase + i} key={phrase.phrase + i}
className="word-burst" className="word-burst"
style={{ style={{
left: st.left, left: st.left,
top: st.top, top: st.top,
fontSize: st.fontSize, fontSize: st.fontSize,
color: st.color, color: st.color,
transitionDelay: st.delay, transitionDelay: st.delay,
'--target-op': st.targetOp '--target-op': st.targetOp
} as React.CSSProperties} } as React.CSSProperties}
> >
<span className="float-el" style={{ animationDelay: st.floatDelay }}>{phrase.phrase}</span> <span className="float-el" style={{ animationDelay: st.floatDelay }}>{phrase.phrase}</span>
@@ -953,7 +977,7 @@ function AnnualReportWindow() {
) )
})} })}
{(!reportData.topPhrases || reportData.topPhrases.length === 0) && ( {(!reportData.topPhrases || reportData.topPhrases.length === 0) && (
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1"></div></div> <div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1"></div></div>
)} )}
</div> </div>
@@ -962,7 +986,7 @@ function AnnualReportWindow() {
<div className="reveal-wrap en-tag" style={{ zIndex: 20 }}> <div className="reveal-wrap en-tag" style={{ zIndex: 20 }}>
<div className="reveal-inner serif scene0-cn-tag" style={{ color: COLOR.paperMuted }}></div> <div className="reveal-inner serif scene0-cn-tag" style={{ color: COLOR.paperMuted }}></div>
</div> </div>
{/* The Final Summary Receipt / Dashboard */} {/* The Final Summary Receipt / Dashboard */}
<div className="reveal-wrap" style={{ position: 'absolute', top: '45vh', left: '50vw', transform: 'translate(-50%, -50%)', width: '60vw', textAlign: 'center', zIndex: 20 }}> <div className="reveal-wrap" style={{ position: 'absolute', top: '45vh', left: '50vw', transform: 'translate(-50%, -50%)', width: '60vw', textAlign: 'center', zIndex: 20 }}>
<div className="reveal-inner delay-1" style={{ display: 'flex', flexDirection: 'column', gap: '3vh' }}> <div className="reveal-inner delay-1" style={{ display: 'flex', flexDirection: 'column', gap: '3vh' }}>
@@ -972,7 +996,7 @@ function AnnualReportWindow() {
<div className="mono" style={{ fontSize: '0.8rem', color: COLOR.paperMuted, letterSpacing: '0.4em' }}> <div className="mono" style={{ fontSize: '0.8rem', color: COLOR.paperMuted, letterSpacing: '0.4em' }}>
TRANSMISSION COMPLETE TRANSMISSION COMPLETE
</div> </div>
{/* Core Stats Row */} {/* Core Stats Row */}
<div style={{ display: 'flex', justifyContent: 'space-around', marginTop: '6vh', borderTop: '1px solid rgba(110, 89, 46, 0.35)', borderBottom: '1px solid rgba(110, 89, 46, 0.35)', padding: '4vh 0' }}> <div style={{ display: 'flex', justifyContent: 'space-around', marginTop: '6vh', borderTop: '1px solid rgba(110, 89, 46, 0.35)', borderBottom: '1px solid rgba(110, 89, 46, 0.35)', padding: '4vh 0' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
@@ -988,9 +1012,9 @@ function AnnualReportWindow() {
<div className="num-display" style={{ fontSize: '2.5rem', color: COLOR.accentMuted, fontWeight: 600 }}>{endingTopPhrase}</div> <div className="num-display" style={{ fontSize: '2.5rem', color: COLOR.accentMuted, fontWeight: 600 }}>{endingTopPhrase}</div>
</div> </div>
</div> </div>
<div className="serif" style={{ fontSize: '1.2rem', color: 'rgba(34, 28, 16, 0.82)', marginTop: '4vh', letterSpacing: '0.05em' }}> <div className="serif" style={{ fontSize: '1.2rem', color: 'rgba(34, 28, 16, 0.82)', marginTop: '4vh', letterSpacing: '0.05em' }}>
<br/> <br />
</div> </div>
</div> </div>
</div> </div>
@@ -1009,15 +1033,15 @@ function AnnualReportWindow() {
fontWeight: 500 fontWeight: 500
}} }}
> >
<br/><br/> <br /><br />
</div> </div>
</div> </div>
<div className="reveal-wrap"> <div className="reveal-wrap">
<button <button
className="btn num-display reveal-inner delay-3" className="btn num-display reveal-inner delay-3"
onClick={handleExtract} onClick={handleExtract}
disabled={isExtracting} disabled={isExtracting}
style={{ style={{
background: isExtracting ? '#CDC4B0' : (buttonText === 'SAVED TO DEVICE' ? '#1A140A' : '#101010'), background: isExtracting ? '#CDC4B0' : (buttonText === 'SAVED TO DEVICE' ? '#1A140A' : '#101010'),
color: 'var(--c-gold-strong)', color: 'var(--c-gold-strong)',
fontSize: '0.85rem', fontSize: '0.85rem',

View File

@@ -72,11 +72,146 @@ 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_WHEEL_LOAD_COOLDOWN_MS = 160 const MESSAGE_TOP_EDGE_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 }
@@ -1293,7 +1428,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.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}` return `fallback:${msg._db_path || ''}:${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)
@@ -1473,6 +1608,7 @@ 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())
@@ -2793,6 +2929,11 @@ 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()
@@ -2804,6 +2945,10 @@ 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
@@ -3321,8 +3466,8 @@ function ChatPage(props: ChatPageProps) {
setIsRefreshingMessages(false) setIsRefreshingMessages(false)
} }
} }
// 消息批量大小控制(保持稳定,避免游标反复重建 // 消息批量大小控制(会话内逐步增大,减少频繁触顶加载
const currentBatchSizeRef = useRef(50) const currentBatchSizeRef = useRef(MESSAGE_HISTORY_INITIAL_LIMIT)
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
@@ -3386,14 +3531,21 @@ 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))
: (unreadCount > 99 ? 30 : 40) : defaultInitialLimit
currentBatchSizeRef.current = preferredLimit currentBatchSizeRef.current = Math.min(preferredLimit, MESSAGE_HISTORY_MAX_LIMIT)
messageLimit = preferredLimit
} else {
// 同一会话内保持固定批量,避免后端游标因 batch 改变而重建
messageLimit = currentBatchSizeRef.current messageLimit = currentBatchSizeRef.current
} else {
const grownBatchSize = Math.min(
Math.max(currentBatchSizeRef.current, MESSAGE_HISTORY_INITIAL_LIMIT) + MESSAGE_HISTORY_GROWTH_STEP,
MESSAGE_HISTORY_MAX_LIMIT
)
currentBatchSizeRef.current = grownBatchSize
messageLimit = grownBatchSize
} }
@@ -3445,10 +3597,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)
} }
@@ -3549,7 +3701,10 @@ function ChatPage(props: ChatPageProps) {
: offset + resultMessages.length : offset + resultMessages.length
setCurrentOffset(nextOffset) setCurrentOffset(nextOffset)
} else if (!result.success) { } else if (!result.success) {
setNoMessageTable(true) const errorText = String(result.error || '')
const shouldMarkNoTable =
/schema mismatch|no message db|no table|消息数据库未找到|消息表|message schema/i.test(errorText)
setNoMessageTable(shouldMarkNoTable)
setHasMoreMessages(false) setHasMoreMessages(false)
} }
} catch (e) { } catch (e) {
@@ -3557,6 +3712,7 @@ 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 {
@@ -4095,7 +4251,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: 30, forceInitialLimit: MESSAGE_HISTORY_INITIAL_LIMIT,
switchRequestSeq switchRequestSeq
}) })
} }
@@ -4586,24 +4742,40 @@ 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) {
if (!hasMoreMessages) return triggerTopEdgeHistoryLoad()
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
} }
@@ -4619,22 +4791,21 @@ function ChatPage(props: ChatPageProps) {
}, [ }, [
currentSessionId, currentSessionId,
hasMoreLater, hasMoreLater,
hasMoreMessages,
isLoadingMessages, isLoadingMessages,
isLoadingMore, isLoadingMore,
currentOffset,
jumpStartTime,
jumpEndTime,
markMessageListScrolling, markMessageListScrolling,
loadMessages, loadLaterMessages,
loadLaterMessages triggerTopEdgeHistoryLoad
]) ])
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 => {
@@ -4787,6 +4958,10 @@ 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()
@@ -4857,36 +5032,54 @@ function ChatPage(props: ChatPageProps) {
}, [currentSessionId]) }, [currentSessionId])
useEffect(() => { useEffect(() => {
if (!currentSessionId || messages.length === 0) return if (messageMediaPreloadTimerRef.current !== null) {
const preloadEdgeCount = 40 window.clearTimeout(messageMediaPreloadTimerRef.current)
const maxPreload = 30 messageMediaPreloadTimerRef.current = null
const head = messages.slice(0, preloadEdgeCount)
const tail = messages.slice(-preloadEdgeCount)
const candidates = [...head, ...tail]
const queued = preloadImageKeysRef.current
const seen = new Set<string>()
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
for (const msg of candidates) {
if (payloads.length >= maxPreload) break
if (msg.localType !== 3) continue
const cacheKey = msg.imageMd5 || msg.imageDatName || `local:${msg.localId}`
if (!msg.imageMd5 && !msg.imageDatName) continue
if (imageDataUrlCache.has(cacheKey)) continue
const taskKey = `${currentSessionId}|${cacheKey}`
if (queued.has(taskKey) || seen.has(taskKey)) continue
queued.add(taskKey)
seen.add(taskKey)
payloads.push({
sessionId: currentSessionId,
imageMd5: msg.imageMd5 || undefined,
imageDatName: msg.imageDatName,
createTime: msg.createTime
})
} }
if (payloads.length > 0) { if (!currentSessionId || messages.length === 0) return
window.electronAPI.image.preload(payloads, {
allowCacheIndex: false messageMediaPreloadTimerRef.current = window.setTimeout(() => {
}).catch(() => { }) messageMediaPreloadTimerRef.current = null
scheduleWhenIdle(() => {
if (isMessageListScrollingRef.current) return
const preloadEdgeCount = 20
const maxPreload = 12
const head = messages.slice(0, preloadEdgeCount)
const tail = messages.slice(-preloadEdgeCount)
const candidates = [...head, ...tail]
const queued = preloadImageKeysRef.current
const seen = new Set<string>()
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
for (const msg of candidates) {
if (payloads.length >= maxPreload) break
if (msg.localType !== 3) continue
const cacheKey = msg.imageMd5 || msg.imageDatName || `local:${msg.localId}`
if (!msg.imageMd5 && !msg.imageDatName) continue
if (imageDataUrlCache.has(cacheKey)) continue
const taskKey = `${currentSessionId}|${cacheKey}`
if (queued.has(taskKey) || seen.has(taskKey)) continue
queued.add(taskKey)
seen.add(taskKey)
payloads.push({
sessionId: currentSessionId,
imageMd5: msg.imageMd5 || undefined,
imageDatName: msg.imageDatName,
createTime: msg.createTime
})
}
if (payloads.length > 0) {
window.electronAPI.image.preload(payloads, {
allowCacheIndex: false
}).catch(() => { })
}
}, { timeout: 1400, fallbackDelay: 120 })
}, 120)
return () => {
if (messageMediaPreloadTimerRef.current !== null) {
window.clearTimeout(messageMediaPreloadTimerRef.current)
messageMediaPreloadTimerRef.current = null
}
} }
}, [currentSessionId, messages]) }, [currentSessionId, messages])
@@ -4983,7 +5176,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: 30 forceInitialLimit: MESSAGE_HISTORY_INITIAL_LIMIT
}) })
} }
}, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable]) }, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
@@ -5116,6 +5309,18 @@ 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_'))
// 检查是否有折叠的群聊 // 检查是否有折叠的群聊
@@ -5130,11 +5335,12 @@ 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 = latest.sortTimestamp || latest.lastTimestamp const latestTime = getSessionSortTime(latest)
const currentTime = current.sortTimestamp || current.lastTimestamp const currentTime = getSessionSortTime(current)
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,
@@ -5143,8 +5349,8 @@ function ChatPage(props: ChatPageProps) {
? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}` ? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}`
: '查看公众号历史消息', : '查看公众号历史消息',
type: 0, type: 0,
sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下 sortTimestamp: officialLatestTime,
lastTimestamp: latestOfficial?.lastTimestamp || latestOfficial?.sortTimestamp || 0, lastTimestamp: officialLatestTime,
lastMsgType: latestOfficial?.lastMsgType || 0, lastMsgType: latestOfficial?.lastMsgType || 0,
unreadCount: officialUnreadCount, unreadCount: officialUnreadCount,
isMuted: false, isMuted: false,
@@ -5152,7 +5358,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)) {
visible.unshift(bizEntry) insertSessionByTimeDesc(visible, bizEntry)
} }
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) { if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
@@ -5176,17 +5382,7 @@ 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()) {
@@ -7074,7 +7270,7 @@ function ChatPage(props: ChatPageProps) {
className="message-virtuoso" className="message-virtuoso"
customScrollParent={messageListScrollParent ?? undefined} customScrollParent={messageListScrollParent ?? undefined}
data={messages} data={messages}
overscan={220} overscan={MESSAGE_VIRTUAL_OVERSCAN_PX}
followOutput={(atBottom) => ( followOutput={(atBottom) => (
prependingHistoryRef.current prependingHistoryRef.current
? false ? false
@@ -8018,10 +8214,26 @@ const globalVoiceManager = {
} }
// 前端表情包缓存 // 前端表情包缓存
const emojiDataUrlCache = new Map<string, string>() const emojiDataUrlCache = createBoundedCache<string>({
const imageDataUrlCache = new Map<string, string>() maxEntries: EMOJI_CACHE_MAX_ENTRIES,
const voiceDataUrlCache = new Map<string, string>() maxBytes: EMOJI_CACHE_MAX_BYTES,
const voiceTranscriptCache = new Map<string, string>() estimate: estimateStringBytes
})
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
@@ -8030,7 +8242,9 @@ 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 = new Map<string, { avatarUrl?: string; displayName?: string }>() const senderAvatarCache = createBoundedCache<{ 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(
@@ -8084,7 +8298,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" /> return <img src={localPath} alt="动画表情" className="quoted-emoji-image" loading="lazy" decoding="async" />
} }
// 消息气泡组件 // 消息气泡组件
@@ -8187,7 +8401,10 @@ 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<{
@@ -8278,7 +8495,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) return if (!Number.isFinite(delta) || Math.abs(delta) < 1.5) 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
@@ -8291,7 +8508,17 @@ function MessageBubble({
const viewportBottom = scroller.scrollTop + scroller.clientHeight const viewportBottom = scroller.scrollTop + scroller.clientHeight
if (hostTopInScroller > viewportBottom + 24) return if (hostTopInScroller > viewportBottom + 24) return
scroller.scrollTop += delta pendingScrollerDeltaRef.current += 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((
@@ -8382,12 +8609,12 @@ function MessageBubble({
useEffect(() => { useEffect(() => {
if (!isImage) return if (!isImage) return
return bindResizeObserverForHost(imageContainerRef.current, imageObservedHeightRef, imageResizeBaselineRef) return bindResizeObserverForHost(imageContainerRef.current, imageObservedHeightRef, imageResizeBaselineRef)
}, [isImage, imageLocalPath, imageLoading, imageError, bindResizeObserverForHost]) }, [isImage, bindResizeObserverForHost])
useEffect(() => { useEffect(() => {
if (!isEmoji) return if (!isEmoji) return
return bindResizeObserverForHost(emojiContainerRef.current, emojiObservedHeightRef, emojiResizeBaselineRef) return bindResizeObserverForHost(emojiContainerRef.current, emojiObservedHeightRef, emojiResizeBaselineRef)
}, [isEmoji, emojiLocalPath, emojiLoading, emojiError, bindResizeObserverForHost]) }, [isEmoji, bindResizeObserverForHost])
// 下载表情包 // 下载表情包
const downloadEmoji = () => { const downloadEmoji = () => {
@@ -8568,13 +8795,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(() => { const triggerForceHd = useCallback(async (): Promise<void> => {
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
requestImageDecrypt(true, true).finally(() => { await requestImageDecrypt(true, true).finally(() => {
imageForceHdPending.current = false imageForceHdPending.current = false
}) })
}, [imageCacheKey, message.imageDatName, message.imageMd5, requestImageDecrypt]) }, [imageCacheKey, message.imageDatName, message.imageMd5, requestImageDecrypt])
@@ -8662,6 +8889,11 @@ 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
} }
}, []) }, [])
@@ -8795,14 +9027,16 @@ 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 requestImageDecrypt() void enqueueAutoMediaTask(async () => requestImageDecrypt()).catch(() => { })
}, [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
triggerForceHd() void enqueueAutoMediaTask(async () => {
await triggerForceHd()
}).catch(() => { })
}, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd]) }, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd])
@@ -8844,30 +9078,36 @@ function MessageBubble({
// 生成波形数据 // 生成波形数据
useEffect(() => { useEffect(() => {
if (!voiceDataUrl) { if (!voiceDataUrl || !voiceWaveformRequested) {
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)
} }
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)() 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 = 35 // 波形柱子数量 const samples = 24 // 波形柱子数量(降低解码计算成本)
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++) {
let blockStart = blockSize * i const 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])
@@ -8876,19 +9116,39 @@ function MessageBubble({
} }
// 归一化 // 归一化
const multiplier = Math.pow(Math.max(...filteredData), -1) const peak = Math.max(...filteredData)
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)
setVoiceWaveform(normalizedData) if (!cancelled) {
void audioCtx.close() setVoiceWaveform(normalizedData)
}
} catch (e) { } catch (e) {
console.error('Failed to generate waveform:', e) console.error('Failed to generate waveform:', e)
// 降级:生成随机但平滑的波形 // 降级:生成随机但平滑的波形
setVoiceWaveform(Array.from({ length: 35 }, () => 0.2 + Math.random() * 0.8)) if (!cancelled) {
setVoiceWaveform(Array.from({ length: 24 }, () => 0.2 + Math.random() * 0.8))
}
} finally {
if (audioCtx) {
void audioCtx.close().catch(() => { })
}
} }
} }
void generateWaveform() scheduleWhenIdle(() => {
}, [voiceDataUrl]) if (cancelled) return
void generateWaveform()
}, { timeout: 900, fallbackDelay: 80 })
return () => {
cancelled = true
if (audioCtx) {
void audioCtx.close().catch(() => { })
audioCtx = null
}
}
}, [voiceDataUrl, voiceWaveformRequested])
// 消息加载时自动检测语音缓存 // 消息加载时自动检测语音缓存
useEffect(() => { useEffect(() => {
@@ -9072,7 +9332,9 @@ function MessageBubble({
if (videoAutoLoadTriggered.current) return if (videoAutoLoadTriggered.current) return
videoAutoLoadTriggered.current = true videoAutoLoadTriggered.current = true
void requestVideoInfo() void enqueueAutoMediaTask(async () => requestVideoInfo()).catch(() => {
videoAutoLoadTriggered.current = false
})
}, [isVideo, isVideoVisible, videoInfo, requestVideoInfo]) }, [isVideo, isVideoVisible, videoInfo, requestVideoInfo])
useEffect(() => { useEffect(() => {
@@ -9391,6 +9653,8 @@ 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)
@@ -9467,9 +9731,9 @@ function MessageBubble({
// 默认显示缩略图,点击打开独立播放窗口 // 默认显示缩略图,点击打开独立播放窗口
const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl
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" /> <img src={thumbSrc} alt="视频缩略图" className="video-thumb" loading="lazy" decoding="async" />
) : ( ) : (
<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">
@@ -9489,6 +9753,9 @@ 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

View File

@@ -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: 12px; --contacts-column-gap: 10px;
--contacts-name-text-width: 10em; --contacts-name-text-width: 9.5em;
--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: 120px; --contacts-message-col-width: 104px;
--contacts-media-col-width: 72px; --contacts-media-col-width: 66px;
--contacts-action-col-width: 140px; --contacts-action-col-width: 140px;
--contacts-actions-sticky-width: 240px; --contacts-actions-sticky-width: 180px;
--contacts-table-min-width: 1240px; --contacts-table-min-width: 1240px;
overflow: hidden; overflow: hidden;
border: none; border: none;
@@ -2197,22 +2197,8 @@
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 {
@@ -2396,7 +2382,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--contacts-column-gap); gap: var(--contacts-column-gap);
padding: 12px 6px 12px var(--contacts-inline-padding); padding: 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;
@@ -2801,22 +2787,8 @@
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;

View File

@@ -65,6 +65,7 @@ import type { SnsPost } from '../types/sns'
import { import {
cloneExportDateRange, cloneExportDateRange,
cloneExportDateRangeSelection, cloneExportDateRangeSelection,
createExportDateRangeSelectionFromPreset,
createDateRangeByLastNDays, createDateRangeByLastNDays,
createDefaultDateRange, createDefaultDateRange,
createDefaultExportDateRangeSelection, createDefaultExportDateRangeSelection,
@@ -1599,6 +1600,19 @@ 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 => {
@@ -1899,7 +1913,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}`
@@ -1914,7 +1928,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}`
@@ -2238,6 +2252,27 @@ 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',
@@ -4003,7 +4038,7 @@ function ExportPage() {
const cacheResult = await withTimeout( const cacheResult = await withTimeout(
window.electronAPI.chat.getExportSessionStats( window.electronAPI.chat.getExportSessionStats(
batchSessionIds, batchSessionIds,
{ includeRelations: false, allowStaleCache: true, cacheOnly: true } withExportStatsRange({ includeRelations: false, allowStaleCache: true, cacheOnly: true })
), ),
12000, 12000,
'cacheOnly' 'cacheOnly'
@@ -4018,7 +4053,7 @@ function ExportPage() {
const freshResult = await withTimeout( const freshResult = await withTimeout(
window.electronAPI.chat.getExportSessionStats( window.electronAPI.chat.getExportSessionStats(
missingSessionIds, missingSessionIds,
{ includeRelations: false, allowStaleCache: true } withExportStatsRange({ includeRelations: false, allowStaleCache: true })
), ),
45000, 45000,
'fresh' 'fresh'
@@ -4062,7 +4097,7 @@ function ExportPage() {
void runSessionMediaMetricWorker(runId) void runSessionMediaMetricWorker(runId)
} }
} }
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage]) }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage, withExportStatsRange])
const scheduleSessionMediaMetricWorker = useCallback(() => { const scheduleSessionMediaMetricWorker = useCallback(() => {
if (activeTaskCountRef.current > 0) return if (activeTaskCountRef.current > 0) return
@@ -4769,19 +4804,20 @@ 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(exportDefaultDateRangeSelection) setTimeRangeSelection(dynamicDefaultRangeSelection)
setOptions(prev => { setOptions(prev => {
const nextDateRange = cloneExportDateRange(exportDefaultDateRangeSelection.dateRange) const nextDateRange = cloneExportDateRange(dynamicDefaultRangeSelection.dateRange)
const next: ExportOptions = { const next: ExportOptions = {
...prev, ...prev,
format: exportDefaultFormat, format: exportDefaultFormat,
exportAvatars: exportDefaultAvatars, exportAvatars: exportDefaultAvatars,
useAllTime: exportDefaultDateRangeSelection.useAllTime, useAllTime: dynamicDefaultRangeSelection.useAllTime,
dateRange: nextDateRange, dateRange: nextDateRange,
exportMedia: Boolean( exportMedia: Boolean(
exportDefaultMedia.images || exportDefaultMedia.images ||
@@ -4842,9 +4878,13 @@ function ExportPage() {
setTimeRangeBounds(null) setTimeRangeBounds(null)
}, []) }, [])
const resolveChatExportTimeRangeBounds = useCallback(async (sessionIds: string[]): Promise<TimeRangeBounds | null> => { const resolveChatExportTimeRangeBounds = useCallback(async (
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) {
@@ -4907,29 +4947,36 @@ function ExportPage() {
return !resolved?.hasMin || !resolved?.hasMax return !resolved?.hasMin || !resolved?.hasMax
}) })
const staleSessionIds = new Set<string>() if (forceRefresh) {
if (missingSessionIds().length > 0) {
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
missingSessionIds(),
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
)
applyStatsResult(cacheResult)
for (const sessionId of cacheResult?.needsRefresh || []) {
staleSessionIds.add(String(sessionId || '').trim())
}
}
const sessionsNeedingFreshStats = Array.from(new Set([
...missingSessionIds(),
...Array.from(staleSessionIds).filter(Boolean)
]))
if (sessionsNeedingFreshStats.length > 0) {
applyStatsResult(await window.electronAPI.chat.getExportSessionStats( applyStatsResult(await window.electronAPI.chat.getExportSessionStats(
sessionsNeedingFreshStats, normalizedSessionIds,
{ includeRelations: false } { includeRelations: false, forceRefresh: true }
)) ))
} else {
const staleSessionIds = new Set<string>()
if (missingSessionIds().length > 0) {
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
missingSessionIds(),
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
)
applyStatsResult(cacheResult)
for (const sessionId of cacheResult?.needsRefresh || []) {
staleSessionIds.add(String(sessionId || '').trim())
}
}
const sessionsNeedingFreshStats = Array.from(new Set([
...missingSessionIds(),
...Array.from(staleSessionIds).filter(Boolean)
]))
if (sessionsNeedingFreshStats.length > 0) {
applyStatsResult(await window.electronAPI.chat.getExportSessionStats(
sessionsNeedingFreshStats,
{ includeRelations: false }
))
}
} }
if (missingSessionIds().length > 0) { if (missingSessionIds().length > 0) {
@@ -4950,14 +4997,26 @@ 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(timeRangeSelection, nextBounds) const nextSelection = clampExportSelectionToBounds(liveSelection, nextBounds)
if (!areExportSelectionsEqual(nextSelection, timeRangeSelection)) { if (!areExportSelectionsEqual(nextSelection, liveSelection)) {
setTimeRangeSelection(nextSelection) setTimeRangeSelection(nextSelection)
setOptions(prev => ({ setOptions(prev => ({
...prev, ...prev,
@@ -5035,47 +5094,51 @@ function ExportPage() {
return unsubscribe return unsubscribe
}, [loadBaseConfig, openExportDialog]) }, [loadBaseConfig, openExportDialog])
const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => { const buildExportOptions = (
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(
options.exportImages || sourceOptions.exportImages ||
options.exportVoices || sourceOptions.exportVoices ||
options.exportVideos || sourceOptions.exportVideos ||
options.exportEmojis || sourceOptions.exportEmojis ||
options.exportFiles sourceOptions.exportFiles
) )
const base: ElectronExportOptions = { const base: ElectronExportOptions = {
format: options.format, format: sourceOptions.format,
exportAvatars: options.exportAvatars, exportAvatars: sourceOptions.exportAvatars,
exportMedia: exportMediaEnabled, exportMedia: exportMediaEnabled,
exportImages: options.exportImages, exportImages: sourceOptions.exportImages,
exportVoices: options.exportVoices, exportVoices: sourceOptions.exportVoices,
exportVideos: options.exportVideos, exportVideos: sourceOptions.exportVideos,
exportEmojis: options.exportEmojis, exportEmojis: sourceOptions.exportEmojis,
exportFiles: options.exportFiles, exportFiles: sourceOptions.exportFiles,
maxFileSizeMb: options.maxFileSizeMb, maxFileSizeMb: sourceOptions.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: sourceOptions.exportVoiceAsText,
excelCompactColumns: options.excelCompactColumns, excelCompactColumns: sourceOptions.excelCompactColumns,
txtColumns: options.txtColumns, txtColumns: sourceOptions.txtColumns,
displayNamePreference: options.displayNamePreference, displayNamePreference: sourceOptions.displayNamePreference,
exportConcurrency: options.exportConcurrency, exportConcurrency: sourceOptions.exportConcurrency,
fileNamingMode: exportDefaultFileNamingMode, fileNamingMode: exportDefaultFileNamingMode,
sessionLayout, sessionLayout,
sessionNameWithTypePrefix, sessionNameWithTypePrefix,
dateRange: options.useAllTime dateRange: sourceOptions.useAllTime
? null ? null
: options.dateRange : sourceOptions.dateRange
? { ? {
start: Math.floor(options.dateRange.start.getTime() / 1000), start: Math.floor(sourceOptions.dateRange.start.getTime() / 1000),
end: Math.floor(options.dateRange.end.getTime() / 1000) end: Math.floor(sourceOptions.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 ?? options.exportConcurrency)) const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? sourceOptions.exportConcurrency))
return { return {
...base, ...base,
contentType, contentType,
@@ -5106,14 +5169,14 @@ function ExportPage() {
return base return base
} }
const buildSnsExportOptions = () => { const buildSnsExportOptions = (sourceOptions: ExportOptions = options) => {
const format: SnsTimelineExportFormat = snsExportFormat const format: SnsTimelineExportFormat = snsExportFormat
const dateRange = options.useAllTime const dateRange = sourceOptions.useAllTime
? null ? null
: options.dateRange : sourceOptions.dateRange
? { ? {
startTime: Math.floor(options.dateRange.start.getTime() / 1000), startTime: Math.floor(sourceOptions.dateRange.start.getTime() / 1000),
endTime: Math.floor(options.dateRange.end.getTime() / 1000) endTime: Math.floor(sourceOptions.dateRange.end.getTime() / 1000)
} }
: null : null
@@ -5925,12 +5988,27 @@ 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) : buildExportOptions(exportDialog.scope, exportDialog.contentType, effectiveOptionsState)
const snsOptions = exportDialog.scope === 'sns' const snsOptions = exportDialog.scope === 'sns'
? buildSnsExportOptions() ? buildSnsExportOptions(effectiveOptionsState)
: undefined : undefined
const title = const title =
exportDialog.scope === 'single' exportDialog.scope === 'single'
@@ -5947,7 +6025,7 @@ function ExportPage() {
return return
} }
const { dateRange: _discard, ...optionTemplate } = exportOptions const { dateRange: _discard, ...optionTemplate } = exportOptions
const normalizedRangeSelection = cloneExportDateRangeSelection(timeRangeSelection) const normalizedRangeSelection = cloneExportDateRangeSelection(effectiveRangeSelection)
const scope = exportDialog.scope === 'single' const scope = exportDialog.scope === 'single'
? 'single' ? 'single'
: exportDialog.scope === 'content' : exportDialog.scope === 'content'
@@ -7243,7 +7321,7 @@ function ExportPage() {
try { try {
const quickStatsResult = await window.electronAPI.chat.getExportSessionStats( const quickStatsResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId], [normalizedSessionId],
{ includeRelations: false, allowStaleCache: true, cacheOnly: true } withExportStatsRange({ includeRelations: false, allowStaleCache: true, cacheOnly: true })
) )
if (requestSeq !== detailRequestSeqRef.current) return if (requestSeq !== detailRequestSeqRef.current) return
if (quickStatsResult.success) { if (quickStatsResult.success) {
@@ -7270,7 +7348,7 @@ function ExportPage() {
try { try {
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats( const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId], [normalizedSessionId],
{ includeRelations: true, allowStaleCache: true, cacheOnly: true } withExportStatsRange({ 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) {
@@ -7295,7 +7373,7 @@ function ExportPage() {
// 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列。 // 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列。
const freshResult = await window.electronAPI.chat.getExportSessionStats( const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId], [normalizedSessionId],
{ includeRelations: false, forceRefresh: true } withExportStatsRange({ includeRelations: false, forceRefresh: true })
) )
if (requestSeq !== detailRequestSeqRef.current) return if (requestSeq !== detailRequestSeqRef.current) return
if (freshResult.success && freshResult.data) { if (freshResult.success && freshResult.data) {
@@ -7330,7 +7408,7 @@ function ExportPage() {
setIsLoadingSessionDetailExtra(false) setIsLoadingSessionDetailExtra(false)
} }
} }
}, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername]) }, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername, withExportStatsRange])
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()
@@ -7343,7 +7421,7 @@ function ExportPage() {
if (!forceRefresh) { if (!forceRefresh) {
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats( const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId], [normalizedSessionId],
{ includeRelations: true, allowStaleCache: true, cacheOnly: true } withExportStatsRange({ includeRelations: true, allowStaleCache: true, cacheOnly: true })
) )
if (requestSeq !== detailRequestSeqRef.current) return if (requestSeq !== detailRequestSeqRef.current) return
@@ -7361,7 +7439,7 @@ function ExportPage() {
const relationResult = await window.electronAPI.chat.getExportSessionStats( const relationResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId], [normalizedSessionId],
{ includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true } withExportStatsRange({ includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true })
) )
if (requestSeq !== detailRequestSeqRef.current) return if (requestSeq !== detailRequestSeqRef.current) return
@@ -7381,7 +7459,7 @@ function ExportPage() {
setIsLoadingSessionRelationStats(false) setIsLoadingSessionRelationStats(false)
} }
} }
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid]) }, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid, withExportStatsRange])
const handleRefreshTableData = useCallback(async () => { const handleRefreshTableData = useCallback(async () => {
const scopeKey = await ensureExportCacheScope() const scopeKey = await ensureExportCacheScope()

View File

@@ -338,6 +338,22 @@
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);

View File

@@ -56,6 +56,7 @@ 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
@@ -225,6 +226,7 @@ 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)
@@ -1254,12 +1256,14 @@ 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 })
@@ -1274,17 +1278,26 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
) { ) {
setIsManualStartPrompt(true) setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信') setDbKeyStatus('需要手动启动微信')
setDbKeyError('')
} else { } else {
showMessage(result.error || '自动获取密钥失败', false) const failureMessage = result.error || '自动获取密钥失败'
setDbKeyError(failureMessage)
showMessage(failureMessage, false)
} }
} }
} catch (e: any) { } catch (e: any) {
showMessage(`自动获取密钥失败: ${e}`, false) const failureMessage = `自动获取密钥失败: ${e}`
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()
@@ -2207,6 +2220,11 @@ 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">
@@ -4102,16 +4120,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint">SSE `message.new` `avatarUrl/sourceName/content/timestamp` `groupName` `timestamp` Unix </span> <span className="form-hint">SSE `message.new` `message.revoke` `rawid/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 `messageKey` </p> <p className="api-desc"> SSE `event + rawid` </p>
<div className="api-params"> <div className="api-params">
{['event', 'sessionId', 'sessionType', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content', 'timestamp'].map((param) => ( {['event', 'sessionId', 'sessionType', 'rawid', '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>

View File

@@ -666,7 +666,28 @@
font-size: 14px; font-size: 14px;
margin-top: 16px; margin-top: 16px;
display: flex; display: flex;
align-items: center; flex-direction: column;
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 {

View File

@@ -14,6 +14,7 @@ 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
@@ -39,10 +40,19 @@ 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(Boolean) .filter(item => Boolean(item) && !isInternalLine(item))
.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
@@ -117,6 +127,7 @@ 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
@@ -476,6 +487,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
setShowDbKeyConfirm(false) setShowDbKeyConfirm(false)
setIsFetchingDbKey(true) setIsFetchingDbKey(true)
setError('') setError('')
setLastDbKeyError('')
setIsManualStartPrompt(false) setIsManualStartPrompt(false)
setDbKeyStatus('正在连接微信进程...') setDbKeyStatus('正在连接微信进程...')
try { try {
@@ -499,20 +511,29 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
) { ) {
setIsManualStartPrompt(true) setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信') setDbKeyStatus('需要手动启动微信')
setLastDbKeyError('')
} else { } else {
if (result.error?.includes('尚未完成登录')) { if (result.error?.includes('尚未完成登录')) {
setDbKeyStatus('请先在微信完成登录后重试') setDbKeyStatus('请先在微信完成登录后重试')
} }
setError(formatDbKeyFailureMessage(result.error, result.logs)) const failureMessage = formatDbKeyFailureMessage(result.error, result.logs)
setError(failureMessage)
setLastDbKeyError(failureMessage)
} }
} }
} catch (e) { } catch (e) {
setError(`自动获取密钥失败: ${e}`) const failureMessage = `自动获取密钥失败: ${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()
@@ -1161,7 +1182,16 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
)} )}
</div> </div>
{error && <div className="error-message">{error}</div>} {error && (
<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">

View File

@@ -3,13 +3,15 @@ import type { ChatSession, Message, Contact } from '../types/models'
const messageAliasIndex = new Set<string>() const messageAliasIndex = new Set<string>()
function buildPrimaryMessageKey(message: Message): string { function buildPrimaryMessageKey(message: Message, sourceScope?: string): string {
if (message.messageKey) return String(message.messageKey) if (message.messageKey) return String(message.messageKey)
return `fallback:${message.serverId || 0}:${message.createTime}:${message.sortSeq || 0}:${message.localId || 0}:${message.senderUsername || ''}:${message.localType || 0}` const normalizedSourceScope = sourceScope ?? String(message._db_path || '').trim()
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 keys = [buildPrimaryMessageKey(message)] const sourceScope = String(message._db_path || '').trim()
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))
@@ -18,15 +20,26 @@ function buildMessageAliasKeys(message: Message): string[] {
const isSend = Number(message.isSend ?? -1) const isSend = Number(message.isSend ?? -1)
if (localId > 0) { if (localId > 0) {
keys.push(`lid:${localId}`) // 跨 message_*.db 时 local_id 可能重复,必须带分库上下文避免误去重。
if (sourceScope) {
keys.push(`lid:${sourceScope}:${localId}`)
} else {
// 缺库信息时使用更保守组合,尽量避免把不同消息误判成重复。
keys.push(`lid_fallback:${localId}:${createTime}:${sender}:${localType}:${serverId}`)
}
} }
if (serverId > 0) { if (serverId > 0) {
keys.push(`sid:${serverId}`) // server_id 在跨库场景并非绝对全局唯一;必须带来源作用域避免误去重。
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:${createTime}:${sender}:${isSend}:${imageIdentity}`) keys.push(`img:${sourceScope}:${createTime}:${sender}:${isSend}:${imageIdentity}`)
} }
} }
@@ -37,7 +50,9 @@ 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)
aliasKeys.forEach((key) => messageAliasIndex.add(key)) for (const key of aliasKeys) {
messageAliasIndex.add(key)
}
} }
} }
@@ -136,10 +151,18 @@ 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)
const exists = aliasKeys.some((key) => messageAliasIndex.has(key)) let exists = false
for (const key of aliasKeys) {
if (messageAliasIndex.has(key)) {
exists = true
break
}
}
if (exists) return if (exists) return
filtered.push(msg) filtered.push(msg)
aliasKeys.forEach((key) => messageAliasIndex.add(key)) for (const key of aliasKeys) {
messageAliasIndex.add(key)
}
}) })
if (filtered.length === 0) return state if (filtered.length === 0) return state

View File

@@ -311,6 +311,8 @@ 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
@@ -1219,5 +1221,3 @@ declare global {
export { } export { }