mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-24 07:26:48 +00:00
Compare commits
1 Commits
dev
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe02ff0d84 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -75,5 +75,4 @@ pnpm-lock.yaml
|
||||
wechat-research-site
|
||||
.codex
|
||||
weflow-web-offical
|
||||
/Wedecrypt
|
||||
/scripts/syncwcdb.py
|
||||
/Wedecrypt
|
||||
173
electron/main.ts
173
electron/main.ts
@@ -375,34 +375,7 @@ let isAppQuitting = false
|
||||
let shutdownPromise: Promise<void> | null = null
|
||||
let tray: Tray | null = null
|
||||
let isClosePromptVisible = false
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
const chatHistoryPayloadStore = new Map<string, { sessionId: string; title?: string; recordList: any[] }>()
|
||||
|
||||
type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||
|
||||
@@ -686,62 +659,6 @@ const setupCustomTitleBarWindow = (win: BrowserWindow): void => {
|
||||
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 behavior = configService?.get('windowCloseBehavior')
|
||||
return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask'
|
||||
@@ -817,6 +734,44 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
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 域名的证书错误(部分节点证书配置不正确)
|
||||
win.webContents.on('certificate-error', (event, url, _error, _cert, callback) => {
|
||||
const trusted = ['.qq.com', '.qpic.cn', '.weixin.qq.com', '.wechat.com']
|
||||
@@ -1224,11 +1179,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
||||
}
|
||||
|
||||
function createChatHistoryPayloadWindow(payloadId: string) {
|
||||
const win = createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`)
|
||||
win.on('closed', () => {
|
||||
chatHistoryPayloadStore.delete(payloadId)
|
||||
})
|
||||
return win
|
||||
return createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`)
|
||||
}
|
||||
|
||||
function createChatHistoryRouteWindow(route: string) {
|
||||
@@ -1661,7 +1612,6 @@ const runLegacySnsCacheMigration = async (
|
||||
// 注册 IPC 处理器
|
||||
function registerIpcHandlers() {
|
||||
registerNotificationHandlers()
|
||||
ensureNotificationNavigateHandlerRegistered()
|
||||
bizService.registerHandlers()
|
||||
// 配置相关
|
||||
ipcMain.handle('config:get', async (_, key: string) => {
|
||||
@@ -2039,38 +1989,19 @@ function registerIpcHandlers() {
|
||||
|
||||
ipcMain.handle('window:openChatHistoryPayloadWindow', (_, payload: { sessionId: string; title?: string; recordList: any[] }) => {
|
||||
const payloadId = randomUUID()
|
||||
pruneChatHistoryPayloadStore()
|
||||
const now = Date.now()
|
||||
chatHistoryPayloadStore.set(payloadId, {
|
||||
sessionId: String(payload?.sessionId || '').trim(),
|
||||
title: String(payload?.title || '').trim() || '聊天记录',
|
||||
recordList: Array.isArray(payload?.recordList) ? payload.recordList : [],
|
||||
createdAt: now,
|
||||
lastAccessedAt: now
|
||||
recordList: Array.isArray(payload?.recordList) ? payload.recordList : []
|
||||
})
|
||||
pruneChatHistoryPayloadStore()
|
||||
createChatHistoryPayloadWindow(payloadId)
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('window:getChatHistoryPayload', (_, payloadId: string) => {
|
||||
pruneChatHistoryPayloadStore()
|
||||
const normalizedPayloadId = String(payloadId || '').trim()
|
||||
const payload = chatHistoryPayloadStore.get(normalizedPayloadId)
|
||||
const payload = chatHistoryPayloadStore.get(String(payloadId || '').trim())
|
||||
if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' }
|
||||
const nextPayload: ChatHistoryPayloadEntry = {
|
||||
...payload,
|
||||
lastAccessedAt: Date.now()
|
||||
}
|
||||
chatHistoryPayloadStore.set(normalizedPayloadId, nextPayload)
|
||||
return {
|
||||
success: true,
|
||||
payload: {
|
||||
sessionId: nextPayload.sessionId,
|
||||
title: nextPayload.title,
|
||||
recordList: nextPayload.recordList
|
||||
}
|
||||
}
|
||||
return { success: true, payload }
|
||||
})
|
||||
|
||||
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
|
||||
@@ -2459,8 +2390,6 @@ function registerIpcHandlers() {
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
}) => {
|
||||
return chatService.getExportSessionStats(sessionIds, options)
|
||||
})
|
||||
@@ -3123,7 +3052,6 @@ function registerIpcHandlers() {
|
||||
ipcMain.handle('cache:clearImages', async () => {
|
||||
const imageResult = await imageDecryptService.clearCache()
|
||||
const emojiResult = chatService.clearCaches({ includeMessages: false, includeContacts: false, includeEmojis: true })
|
||||
snsService.clearMemoryCache()
|
||||
const errors = [imageResult, emojiResult]
|
||||
.filter((result) => !result.success)
|
||||
.map((result) => result.error)
|
||||
@@ -3140,7 +3068,6 @@ function registerIpcHandlers() {
|
||||
imageDecryptService.clearCache()
|
||||
])
|
||||
const chatResult = chatService.clearCaches()
|
||||
snsService.clearMemoryCache()
|
||||
const errors = [analyticsResult, imageResult, chatResult]
|
||||
.filter((result) => !result.success)
|
||||
.map((result) => result.error)
|
||||
@@ -3863,7 +3790,6 @@ app.whenReady().then(async () => {
|
||||
|
||||
// 创建主窗口(不显示,由启动流程统一控制)
|
||||
updateSplashProgress(70, '正在准备主窗口...')
|
||||
ensureWeChatRequestHeaderInterceptor()
|
||||
mainWindow = createWindow({ autoShow: false })
|
||||
|
||||
let iconName = 'icon.ico';
|
||||
@@ -3923,6 +3849,17 @@ app.whenReady().then(async () => {
|
||||
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)
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -3997,3 +3934,5 @@ app.on('window-all-closed', () => {
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -219,8 +219,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
}
|
||||
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
|
||||
getGroupMyMessageCountHint: (chatroomId: string) =>
|
||||
@@ -567,3 +565,4 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -103,10 +103,8 @@ class AnalyticsService {
|
||||
if (username === 'filehelper') return false
|
||||
if (username.startsWith('gh_')) return false
|
||||
|
||||
if (username.toLowerCase() === 'weixin') return false
|
||||
|
||||
const excludeList = [
|
||||
'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
||||
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
||||
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
|
||||
|
||||
@@ -170,7 +170,7 @@ class AnnualReportService {
|
||||
const rows = sessionResult.sessions as Record<string, any>[]
|
||||
|
||||
const excludeList = [
|
||||
'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
||||
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
||||
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
|
||||
@@ -185,7 +185,6 @@ class AnnualReportService {
|
||||
if (username === 'filehelper') return false
|
||||
if (username.startsWith('gh_')) return false
|
||||
if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
|
||||
if (username.toLowerCase() === 'weixin') return false
|
||||
|
||||
for (const prefix of excludeList) {
|
||||
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
@@ -496,20 +496,11 @@ class HttpService {
|
||||
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(fullPath)
|
||||
const fileBuffer = fs.readFileSync(fullPath)
|
||||
res.setHeader('Content-Type', contentType)
|
||||
res.setHeader('Content-Length', stat.size)
|
||||
res.setHeader('Content-Length', fileBuffer.length)
|
||||
res.writeHead(200)
|
||||
|
||||
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)
|
||||
res.end(fileBuffer)
|
||||
} catch (e) {
|
||||
this.sendError(res, 500, 'Failed to read media file')
|
||||
}
|
||||
@@ -525,29 +516,27 @@ class HttpService {
|
||||
limit: number,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
ascending: boolean,
|
||||
useLiteMapping: boolean = true
|
||||
ascending: boolean
|
||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||
try {
|
||||
// 深分页时放大 batch,避免 offset 很大时出现大量小批次循环。
|
||||
const batchSize = Math.min(2000, Math.max(500, limit))
|
||||
// 使用固定 batch 大小(与 limit 相同或最多 500)来减少循环次数
|
||||
const batchSize = Math.min(limit, 500)
|
||||
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
||||
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
||||
|
||||
const cursorResult = await wcdbService.openMessageCursorLite(talker, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
const cursorResult = await wcdbService.openMessageCursor(talker, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
||||
}
|
||||
|
||||
const cursor = cursorResult.cursor
|
||||
try {
|
||||
const collectedRows: Record<string, any>[] = []
|
||||
const allRows: Record<string, any>[] = []
|
||||
let hasMore = true
|
||||
let skipped = 0
|
||||
let reachedLimit = false
|
||||
|
||||
// 循环获取消息,处理 offset 跳过 + limit 累积
|
||||
while (collectedRows.length < limit && hasMore) {
|
||||
while (allRows.length < limit && hasMore) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||
if (!batch.success || !batch.rows || batch.rows.length === 0) {
|
||||
hasMore = false
|
||||
@@ -568,20 +557,12 @@ class HttpService {
|
||||
skipped = offset
|
||||
}
|
||||
|
||||
const remainingCapacity = limit - collectedRows.length
|
||||
if (rows.length > remainingCapacity) {
|
||||
collectedRows.push(...rows.slice(0, remainingCapacity))
|
||||
reachedLimit = true
|
||||
break
|
||||
}
|
||||
|
||||
collectedRows.push(...rows)
|
||||
allRows.push(...rows)
|
||||
}
|
||||
|
||||
const finalHasMore = hasMore || reachedLimit
|
||||
const messages = useLiteMapping
|
||||
? chatService.mapRowsToMessagesLiteForApi(collectedRows)
|
||||
: chatService.mapRowsToMessagesForApi(collectedRows)
|
||||
const trimmedRows = allRows.slice(0, limit)
|
||||
const finalHasMore = hasMore || allRows.length > limit
|
||||
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
|
||||
await this.backfillMissingSenderUsernames(talker, messages)
|
||||
return { success: true, messages, hasMore: finalHasMore }
|
||||
} finally {
|
||||
@@ -609,70 +590,32 @@ class HttpService {
|
||||
if (targets.length === 0) return
|
||||
|
||||
const myWxid = (this.configService.get('myWxid') || '').trim()
|
||||
const MAX_DETAIL_BACKFILL = 120
|
||||
if (targets.length > MAX_DETAIL_BACKFILL) {
|
||||
for (const msg of targets) {
|
||||
if (!msg.senderUsername && msg.isSend === 1 && myWxid) {
|
||||
msg.senderUsername = myWxid
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const queue = [...targets]
|
||||
const workerCount = Math.max(1, Math.min(6, queue.length))
|
||||
const state = {
|
||||
attempted: 0,
|
||||
hydrated: 0,
|
||||
consecutiveMiss: 0
|
||||
}
|
||||
const MAX_DETAIL_LOOKUPS = 80
|
||||
const MAX_CONSECUTIVE_MISS = 36
|
||||
const runWorker = async (): Promise<void> => {
|
||||
while (queue.length > 0) {
|
||||
if (state.attempted >= MAX_DETAIL_LOOKUPS) break
|
||||
if (state.consecutiveMiss >= MAX_CONSECUTIVE_MISS && state.hydrated <= 0) break
|
||||
const msg = queue.shift()
|
||||
if (!msg) break
|
||||
|
||||
const localId = Number(msg.localId || 0)
|
||||
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
|
||||
for (const msg of targets) {
|
||||
const localId = Number(msg.localId || 0)
|
||||
if (Number.isFinite(localId) && localId > 0) {
|
||||
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
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[HttpService] backfill sender failed:', error)
|
||||
state.consecutiveMiss += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (!msg.senderUsername && msg.isSend === 1 && myWxid) {
|
||||
msg.senderUsername = myWxid
|
||||
} catch (error) {
|
||||
console.warn('[HttpService] backfill sender failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, () => runWorker()))
|
||||
if (!msg.senderUsername && msg.isSend === 1 && myWxid) {
|
||||
msg.senderUsername = myWxid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
|
||||
@@ -720,7 +663,7 @@ class HttpService {
|
||||
const talker = (url.searchParams.get('talker') || '').trim()
|
||||
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 keyword = (url.searchParams.get('keyword') || '').trim()
|
||||
const keyword = (url.searchParams.get('keyword') || '').trim().toLowerCase()
|
||||
const startParam = url.searchParams.get('start')
|
||||
const endParam = url.searchParams.get('end')
|
||||
const chatlab = this.parseBooleanParam(url, ['chatlab'], false)
|
||||
@@ -740,41 +683,26 @@ class HttpService {
|
||||
|
||||
const startTime = this.parseTimeParam(startParam)
|
||||
const endTime = this.parseTimeParam(endParam, true)
|
||||
let messages: Message[] = []
|
||||
let hasMore = false
|
||||
const queryOffset = keyword ? 0 : offset
|
||||
const queryLimit = keyword ? 10000 : limit
|
||||
|
||||
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) {
|
||||
const searchLimit = Math.max(1, limit) + 1
|
||||
const searchResult = await chatService.searchMessages(
|
||||
keyword,
|
||||
talker,
|
||||
searchLimit,
|
||||
offset,
|
||||
startTime,
|
||||
endTime
|
||||
)
|
||||
if (!searchResult.success || !searchResult.messages) {
|
||||
this.sendError(res, 500, searchResult.error || 'Failed to search messages')
|
||||
return
|
||||
}
|
||||
hasMore = searchResult.messages.length > limit
|
||||
messages = hasMore ? searchResult.messages.slice(0, limit) : searchResult.messages
|
||||
} else {
|
||||
const result = await this.fetchMessagesBatch(
|
||||
talker,
|
||||
offset,
|
||||
limit,
|
||||
startTime,
|
||||
endTime,
|
||||
false,
|
||||
!mediaOptions.enabled
|
||||
)
|
||||
if (!result.success || !result.messages) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||
return
|
||||
}
|
||||
messages = result.messages
|
||||
hasMore = result.hasMore === true
|
||||
const filtered = messages.filter((msg) => {
|
||||
const content = (msg.parsedContent || msg.rawContent || '').toLowerCase()
|
||||
return content.includes(keyword)
|
||||
})
|
||||
const endIndex = offset + limit
|
||||
hasMore = filtered.length > endIndex
|
||||
messages = filtered.slice(offset, endIndex)
|
||||
}
|
||||
|
||||
const mediaMap = mediaOptions.enabled
|
||||
@@ -884,7 +812,7 @@ class HttpService {
|
||||
const endTime = endParam ? this.parseTimeParam(endParam, true) : 0
|
||||
|
||||
try {
|
||||
const result = await this.fetchMessagesBatch(sessionId, offset, limit, startTime, endTime, true, true)
|
||||
const result = await this.fetchMessagesBatch(sessionId, offset, limit, startTime, endTime, true)
|
||||
if (!result.success || !result.messages) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||
return
|
||||
|
||||
@@ -12,7 +12,6 @@ export class MessageCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private cache: Record<string, SessionMessageCacheEntry> = {}
|
||||
private readonly sessionLimit = 150
|
||||
private readonly maxSessionEntries = 48
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
@@ -37,7 +36,6 @@ export class MessageCacheService {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
this.cache = parsed
|
||||
this.pruneSessionEntries()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MessageCacheService: 载入缓存失败', error)
|
||||
@@ -45,19 +43,6 @@ export class MessageCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
private pruneSessionEntries(): void {
|
||||
const entries = Object.entries(this.cache || {})
|
||||
if (entries.length <= this.maxSessionEntries) return
|
||||
|
||||
entries.sort((left, right) => {
|
||||
const leftAt = Number(left[1]?.updatedAt || 0)
|
||||
const rightAt = Number(right[1]?.updatedAt || 0)
|
||||
return rightAt - leftAt
|
||||
})
|
||||
|
||||
this.cache = Object.fromEntries(entries.slice(0, this.maxSessionEntries))
|
||||
}
|
||||
|
||||
get(sessionId: string): SessionMessageCacheEntry | undefined {
|
||||
return this.cache[sessionId]
|
||||
}
|
||||
@@ -71,7 +56,6 @@ export class MessageCacheService {
|
||||
updatedAt: Date.now(),
|
||||
messages: trimmed
|
||||
}
|
||||
this.pruneSessionEntries()
|
||||
this.persist()
|
||||
}
|
||||
|
||||
|
||||
@@ -324,9 +324,6 @@ class SnsService {
|
||||
private configService: ConfigService
|
||||
private contactCache: ContactCacheService
|
||||
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 userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
|
||||
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
|
||||
@@ -339,38 +336,6 @@ class SnsService {
|
||||
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 {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const trimmed = value.trim()
|
||||
@@ -1274,27 +1239,20 @@ class SnsService {
|
||||
if (!url) return { success: false, error: 'url 不能为空' }
|
||||
const cacheKey = `${url}|${key ?? ''}`
|
||||
|
||||
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
|
||||
if (cachedDataUrl) {
|
||||
const cachedAt = this.imageCacheMeta.get(cacheKey) || 0
|
||||
if (cachedAt > 0 && Date.now() - cachedAt <= this.imageCacheTtlMs) {
|
||||
const base64Part = cachedDataUrl.split(',')[1] || ''
|
||||
if (base64Part) {
|
||||
try {
|
||||
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
|
||||
if (this.imageCache.has(cacheKey)) {
|
||||
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
|
||||
const base64Part = cachedDataUrl.split(',')[1] || ''
|
||||
if (base64Part) {
|
||||
try {
|
||||
const cachedBuf = Buffer.from(base64Part, 'base64')
|
||||
if (detectImageMime(cachedBuf, '').startsWith('image/')) {
|
||||
return { success: true, dataUrl: cachedDataUrl }
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall through to refetch
|
||||
}
|
||||
}
|
||||
this.imageCache.delete(cacheKey)
|
||||
this.imageCacheMeta.delete(cacheKey)
|
||||
}
|
||||
|
||||
const result = await this.fetchAndDecryptImage(url, key)
|
||||
@@ -1311,7 +1269,7 @@ class SnsService {
|
||||
return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' }
|
||||
}
|
||||
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
|
||||
this.rememberImageCache(cacheKey, dataUrl)
|
||||
this.imageCache.set(cacheKey, dataUrl)
|
||||
return { success: true, dataUrl }
|
||||
}
|
||||
}
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -20,7 +20,7 @@
|
||||
"jieba-wasm": "^2.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"koffi": "^2.9.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -6737,9 +6737,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
|
||||
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
|
||||
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"jieba-wasm": "^2.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"koffi": "^2.9.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
12
src/App.tsx
12
src/App.tsx
@@ -81,7 +81,6 @@ function App() {
|
||||
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||
const isNotificationWindow = location.pathname === '/notification-window'
|
||||
const isAnnualReportWindow = location.pathname === '/annual-report/view'
|
||||
const isDualReportWindow = location.pathname === '/dual-report/view'
|
||||
const isSettingsRoute = location.pathname === '/settings'
|
||||
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
||||
const routeLocation = isSettingsRoute
|
||||
@@ -129,7 +128,7 @@ function App() {
|
||||
const body = document.body
|
||||
const appRoot = document.getElementById('app')
|
||||
|
||||
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow || isDualReportWindow) {
|
||||
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow) {
|
||||
root.style.background = 'transparent'
|
||||
body.style.background = 'transparent'
|
||||
body.style.overflow = 'hidden'
|
||||
@@ -146,7 +145,7 @@ function App() {
|
||||
appRoot.style.overflow = ''
|
||||
}
|
||||
}
|
||||
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow])
|
||||
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
||||
|
||||
// 应用主题
|
||||
useEffect(() => {
|
||||
@@ -167,7 +166,7 @@ function App() {
|
||||
}
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow])
|
||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
||||
|
||||
// 读取已保存的主题设置
|
||||
useEffect(() => {
|
||||
@@ -518,11 +517,6 @@ function App() {
|
||||
return <AnnualReportWindow />
|
||||
}
|
||||
|
||||
// 独立双人报告全屏窗口
|
||||
if (isDualReportWindow) {
|
||||
return <DualReportWindow />
|
||||
}
|
||||
|
||||
// 主窗口 - 完整布局
|
||||
const handleCloseSettings = () => {
|
||||
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||
|
||||
@@ -5,21 +5,6 @@ import './Avatar.scss'
|
||||
|
||||
// 全局缓存已成功加载过的头像 URL,用于控制后续是否显示动画
|
||||
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 {
|
||||
src?: string
|
||||
@@ -138,7 +123,7 @@ export const Avatar = React.memo(function Avatar({
|
||||
onLoad={() => {
|
||||
if (src) {
|
||||
avatarLoadQueue.clearFailed(src)
|
||||
rememberLoadedAvatar(src)
|
||||
loadedAvatarCache.add(src)
|
||||
}
|
||||
setImageLoaded(true)
|
||||
setImageError(false)
|
||||
|
||||
@@ -22,7 +22,7 @@ export function GlobalSessionMonitor() {
|
||||
// 去重辅助函数:获取消息 key
|
||||
const getMessageKey = (msg: Message) => {
|
||||
if (msg.messageKey) return msg.messageKey
|
||||
return `fallback:${msg._db_path || ''}:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||
}
|
||||
|
||||
// 处理数据库变更
|
||||
|
||||
@@ -299,12 +299,6 @@
|
||||
opacity: 0.05;
|
||||
box-shadow: none;
|
||||
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 (大气) */
|
||||
@@ -649,160 +643,199 @@
|
||||
}
|
||||
|
||||
#scene-8 {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0 6vw;
|
||||
}
|
||||
|
||||
/* V2 Background: Cinematic Aura */
|
||||
#scene-8 .s8-bg-layer {
|
||||
#scene-8 .s8-layout {
|
||||
position: absolute;
|
||||
inset: -10%;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 2s 0.2s var(--ease-out);
|
||||
filter: blur(120px) contrast(1.1) brightness(0.6);
|
||||
pointer-events: none;
|
||||
|
||||
.bg-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.scene.active #scene-8 .s8-bg-layer {
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
#scene-8 .s8-floating-layout {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 2;
|
||||
top: 18vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(1240px, 86vw);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
grid-template-rows: repeat(12, 1fr);
|
||||
padding: 10vh 8vw;
|
||||
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
|
||||
column-gap: clamp(34px, 4.8vw, 84px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* The Central Pivot: Name & Meta */
|
||||
#scene-8 .s8-hero-unit {
|
||||
grid-column: 2 / 8;
|
||||
grid-row: 4 / 7;
|
||||
#scene-8 .s8-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
gap: clamp(2.5vh, 3.2vh, 4vh);
|
||||
padding-top: clamp(8vh, 9vh, 11vh);
|
||||
}
|
||||
|
||||
/* Fragmented Storytelling */
|
||||
#scene-8 .s8-fragments {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
#scene-8 .s8-name-wrap,
|
||||
#scene-8 .s8-summary-wrap,
|
||||
#scene-8 .s8-quote-wrap,
|
||||
#scene-8 .s8-letter-wrap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#scene-8 .fragment {
|
||||
position: absolute;
|
||||
max-width: 24ch;
|
||||
font-size: clamp(0.95rem, 1.1vw, 1.15rem);
|
||||
line-height: 2.1;
|
||||
#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);
|
||||
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;
|
||||
}
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
@keyframes shine {
|
||||
to { background-position: 200% center; }
|
||||
#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;
|
||||
top: 20px;
|
||||
left: 14px;
|
||||
width: 2px;
|
||||
height: calc(100% - 40px);
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(to bottom, rgba(var(--c-gold-rgb), 0.7), rgba(var(--c-gold-rgb), 0.08));
|
||||
}
|
||||
|
||||
#scene-8 .s8-empty-wrap {
|
||||
grid-column: 4 / 10;
|
||||
grid-row: 5 / 8;
|
||||
display: block;
|
||||
width: min(760px, 78vw);
|
||||
margin-top: 24vh;
|
||||
text-align: center;
|
||||
.s8-empty-text {
|
||||
font-size: 1.6rem;
|
||||
line-height: 2.5;
|
||||
color: var(--c-text-soft);
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
#scene-8 .s8-empty-text {
|
||||
color: var(--c-text);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
#scene-8 .s8-layout {
|
||||
width: min(1120px, 88vw);
|
||||
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
||||
column-gap: clamp(28px, 4vw, 56px);
|
||||
}
|
||||
|
||||
#scene-8 .s8-left {
|
||||
padding-top: clamp(6vh, 8vh, 9vh);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
#scene-8 .s8-hero-unit {
|
||||
grid-column: 2 / 12;
|
||||
grid-row: 2 / 5;
|
||||
#scene-8 .s8-layout {
|
||||
top: 16vh;
|
||||
width: min(900px, 90vw);
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: clamp(3vh, 3.5vh, 4.5vh);
|
||||
}
|
||||
#scene-8 .fragment {
|
||||
position: relative;
|
||||
inset: auto !important;
|
||||
max-width: 100%;
|
||||
text-align: left !important;
|
||||
margin-top: 4vh;
|
||||
|
||||
#scene-8 .s8-left {
|
||||
padding-top: 0;
|
||||
gap: clamp(1.6vh, 2.2vh, 2.8vh);
|
||||
}
|
||||
#scene-8 .s8-fragments {
|
||||
position: relative;
|
||||
grid-column: 2 / 12;
|
||||
grid-row: 6 / 12;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
#scene-8 .s8-name {
|
||||
font-size: clamp(2.4rem, 8.4vw, 4.2rem);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ const DecodeText = ({
|
||||
if (i < iter) return strVal[i]
|
||||
return chars[Math.floor(Math.random() * chars.length)]
|
||||
}).join(''))
|
||||
|
||||
|
||||
if (iter >= strVal.length) {
|
||||
clearInterval(inv)
|
||||
setDisplay(strVal)
|
||||
@@ -123,7 +123,7 @@ function AnnualReportWindow() {
|
||||
const s3LayoutRef = useRef<HTMLDivElement | null>(null)
|
||||
const s3ListRef = useRef<HTMLDivElement | null>(null)
|
||||
const [s3LineVars, setS3LineVars] = useState<React.CSSProperties>({})
|
||||
|
||||
|
||||
// 提取长图逻辑变量
|
||||
const [buttonText, setButtonText] = useState('EXTRACT RECORD')
|
||||
const [isExtracting, setIsExtracting] = useState(false)
|
||||
@@ -202,7 +202,7 @@ function AnnualReportWindow() {
|
||||
|
||||
setIsAnimating(true)
|
||||
setCurrentScene(index)
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
setIsAnimating(false)
|
||||
}, 1500)
|
||||
@@ -217,7 +217,7 @@ function AnnualReportWindow() {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
const now = Date.now()
|
||||
if (now - lastWheelTime < 1000) return // Throttle wheel events
|
||||
|
||||
|
||||
if (Math.abs(e.deltaY) > 30) {
|
||||
lastWheelTime = now
|
||||
goToScene(e.deltaY > 0 ? currentScene + 1 : currentScene - 1)
|
||||
@@ -564,21 +564,21 @@ function AnnualReportWindow() {
|
||||
<canvas ref={p0CanvasRef} className="p0-particle-canvas" />
|
||||
<div className="p0-center-glow" />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="film-grain"></div>
|
||||
|
||||
|
||||
<div id="memory-core"></div>
|
||||
|
||||
<div className="pagination">
|
||||
{Array.from({ length: TOTAL_SCENES }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
<div
|
||||
key={i}
|
||||
className={`dot-nav ${currentScene === i ? 'active' : ''}`}
|
||||
onClick={() => goToScene(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="swipe-hint">向下滑动以继续</div>
|
||||
|
||||
{/* S0: THE ARCHIVE */}
|
||||
@@ -590,7 +590,7 @@ function AnnualReportWindow() {
|
||||
<div className={`reveal-inner serif title-year ${yearTitleVariantClass} delay-1`}>{yearTitle}</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -606,7 +606,7 @@ function AnnualReportWindow() {
|
||||
</div>
|
||||
<div className="reveal-wrap desc-text">
|
||||
<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>
|
||||
@@ -618,20 +618,20 @@ function AnnualReportWindow() {
|
||||
</div>
|
||||
<div className="reveal-wrap">
|
||||
<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 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>
|
||||
</div>
|
||||
<div className="reveal-wrap desc-text">
|
||||
<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' }}>
|
||||
<DecodeText value={(reportData.midnightKing?.count || 0).toLocaleString()} active={currentScene === 2} />
|
||||
<DecodeText value={(reportData.midnightKing?.count || 0).toLocaleString()} active={currentScene === 2} />
|
||||
</strong>条消息在那些无人知晓的夜里,代替星光照亮了彼此”
|
||||
</div>
|
||||
</div>
|
||||
@@ -689,56 +689,56 @@ function AnnualReportWindow() {
|
||||
{reportData.monthlyTopFriends.length > 0 ? (
|
||||
<div style={{ position: 'absolute', top: '55vh', left: '10vw', width: '80vw', height: '1px', background: 'transparent' }}>
|
||||
{reportData.monthlyTopFriends.map((m, i) => {
|
||||
const leftPos = (i / 11) * 100; // 0% to 100%
|
||||
const isTop = i % 2 === 0; // Alternate up and down to prevent crowding
|
||||
const isRightSide = i >= 6; // Center-focus alignment logic
|
||||
const leftPos = (i / 11) * 100; // 0% to 100%
|
||||
const isTop = i % 2 === 0; // Alternate up and down to prevent crowding
|
||||
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 };
|
||||
|
||||
// Pseudo-random organic height variation for audio-wave feel (from 8vh to 18vh)
|
||||
const heightVariation = 12 + (Math.sin(i * 1.5) * 6);
|
||||
return (
|
||||
<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` }}>
|
||||
|
||||
{/* 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)'
|
||||
}} />
|
||||
|
||||
const alignStyle = isRightSide ? { right: '10px', alignItems: 'flex-end', textAlign: 'right' as const } : { left: '10px', alignItems: 'flex-start', textAlign: 'left' as const };
|
||||
{/* Center Glowing Dot */}
|
||||
<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)' }} />
|
||||
|
||||
return (
|
||||
<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` }}>
|
||||
|
||||
{/* 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 */}
|
||||
<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 */}
|
||||
<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')}
|
||||
{/* 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 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 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>
|
||||
) : (
|
||||
@@ -757,29 +757,29 @@ function AnnualReportWindow() {
|
||||
<>
|
||||
<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' }}>
|
||||
{reportData.mutualFriend.displayName}
|
||||
{reportData.mutualFriend.displayName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<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 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 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>
|
||||
<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 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 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>
|
||||
|
||||
<div className="reveal-wrap desc-text" style={{ position: 'absolute', bottom: '20vh' }}>
|
||||
<div className="reveal-inner serif delay-3">
|
||||
你们之间收发的消息高达 <strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.5rem' }}>{reportData.mutualFriend.ratio}</strong> 的平衡率
|
||||
<br />
|
||||
<span style={{ fontSize: '1rem', color: COLOR.textMuted, marginTop: '15px', display: 'block' }}>“你抛出的每一句话,都落在了对方的心里。<br />所谓重逢,就是我走向你的时候,你也在走向我。”</span>
|
||||
<br/>
|
||||
<span style={{ fontSize: '1rem', color: COLOR.textMuted, marginTop: '15px', display: 'block' }}>“你抛出的每一句话,都落在了对方的心里。<br/>所谓重逢,就是我走向你的时候,你也在走向我。”</span>
|
||||
</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>
|
||||
|
||||
@@ -790,45 +790,45 @@ function AnnualReportWindow() {
|
||||
</div>
|
||||
{reportData.socialInitiative || reportData.responseSpeed ? (
|
||||
<div style={{ position: 'absolute', top: '0', left: '0', width: '100%', height: '100%' }}>
|
||||
{reportData.socialInitiative && (
|
||||
<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 num-display delay-2" style={{ fontSize: 'clamp(4.5rem, 8vw, 7rem)', color: COLOR.accentGold, lineHeight: '1', margin: '2vh 0' }}>
|
||||
{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' }}>
|
||||
你的聊天开场大多由你发起。
|
||||
{reportData.socialInitiative && (
|
||||
<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 num-display delay-2" style={{ fontSize: 'clamp(4.5rem, 8vw, 7rem)', color: COLOR.accentGold, lineHeight: '1', margin: '2vh 0' }}>
|
||||
{reportData.socialInitiative.initiativeRate}%
|
||||
</div>
|
||||
{reportData.socialInitiative.topInitiatedFriend && (reportData.socialInitiative.topInitiatedCount || 0) > 0 ? (
|
||||
<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 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 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>
|
||||
{reportData.socialInitiative.topInitiatedFriend && (reportData.socialInitiative.topInitiatedCount || 0) > 0 ? (
|
||||
<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 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>
|
||||
)}
|
||||
{reportData.responseSpeed && (
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', bottom: '22vh', right: '15vw', width: '38vw', textAlign: 'right' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-4" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em' }}>回应速度</div>
|
||||
<div className="reveal-inner num-display delay-5" style={{ fontSize: 'clamp(3.5rem, 6vw, 5rem)', color: COLOR.accentSoft, lineHeight: '1', margin: '2vh 0' }}>
|
||||
<DecodeText value={reportData.responseSpeed.fastestTime} active={currentScene === 6} />S
|
||||
)}
|
||||
{reportData.responseSpeed && (
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', bottom: '22vh', right: '15vw', width: '38vw', textAlign: 'right' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-4" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em' }}>回应速度</div>
|
||||
<div className="reveal-inner num-display delay-5" style={{ fontSize: 'clamp(3.5rem, 6vw, 5rem)', color: COLOR.accentSoft, lineHeight: '1', margin: '2vh 0' }}>
|
||||
<DecodeText value={reportData.responseSpeed.fastestTime} active={currentScene === 6} />S
|
||||
</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 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-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>
|
||||
|
||||
@@ -837,33 +837,33 @@ function AnnualReportWindow() {
|
||||
<div className="reveal-wrap en-tag">
|
||||
<div className="reveal-inner serif scene0-cn-tag">聊天火花</div>
|
||||
</div>
|
||||
|
||||
|
||||
{reportData.longestStreak ? (
|
||||
<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 delay-2" style={{ fontSize: 'clamp(3rem, 6vw, 5rem)', color: COLOR.accentGold, letterSpacing: '0.02em' }}>
|
||||
{reportData.longestStreak.friendName}
|
||||
</div>
|
||||
<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 />那些舍不得说再见的日夜,连成了最漫长的春天。
|
||||
</div>
|
||||
</div>
|
||||
<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 delay-2" style={{ fontSize: 'clamp(3rem, 6vw, 5rem)', color: COLOR.accentGold, letterSpacing: '0.02em' }}>
|
||||
{reportData.longestStreak.friendName}
|
||||
</div>
|
||||
<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/>那些舍不得说再见的日夜,连成了最漫长的春天。
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{reportData.peakDay ? (
|
||||
<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 num-display delay-5" style={{ fontSize: 'clamp(2.5rem, 5vw, 4rem)', color: COLOR.accentGold, letterSpacing: '0.02em' }}>
|
||||
{reportData.peakDay.date}
|
||||
</div>
|
||||
<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 />好像要把积攒了很久的想念,一天全都说完。”
|
||||
</div>
|
||||
</div>
|
||||
<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 num-display delay-5" style={{ fontSize: 'clamp(2.5rem, 5vw, 4rem)', color: COLOR.accentGold, letterSpacing: '0.02em' }}>
|
||||
{reportData.peakDay.date}
|
||||
</div>
|
||||
<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/>好像要把积攒了很久的想念,一天全都说完。”
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
{!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>
|
||||
|
||||
@@ -872,69 +872,45 @@ function AnnualReportWindow() {
|
||||
<div className="reveal-wrap en-tag">
|
||||
<div className="reveal-inner serif scene0-cn-tag">曾经的好友</div>
|
||||
</div>
|
||||
|
||||
{reportData.lostFriend && (
|
||||
<div className="s8-bg-layer">
|
||||
<img src={reportData.lostFriend.avatarUrl} alt="" className="bg-avatar" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{reportData.lostFriend ? (
|
||||
<div className="s8-floating-layout">
|
||||
<div className="s8-hero-unit">
|
||||
<div className="reveal-wrap">
|
||||
<div className="reveal-inner s8-name delay-1">
|
||||
<div className="s8-layout">
|
||||
<div className="s8-left">
|
||||
<div className="reveal-wrap s8-name-wrap">
|
||||
<div className="reveal-inner serif delay-1 s8-name">
|
||||
{reportData.lostFriend.displayName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap">
|
||||
<div className="reveal-inner s8-meta delay-2">
|
||||
{reportData.lostFriend.periodDesc} /
|
||||
<span className="num-display" style={{ margin: '0 10px', fontSize: '1.4em' }}>
|
||||
<div className="reveal-wrap s8-summary-wrap">
|
||||
<div className="reveal-inner serif delay-2 s8-summary">
|
||||
后来,你们的交集停留在{reportData.lostFriend.periodDesc}这短短的
|
||||
<span className="num-display s8-summary-count">
|
||||
<DecodeText value={reportData.lostFriend.lateCount.toLocaleString()} active={currentScene === 8} />
|
||||
</span>
|
||||
MESSAGES
|
||||
句话里。
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap s8-quote-wrap">
|
||||
<div className="reveal-inner serif delay-3 s8-quote">
|
||||
“我一直相信我们能够再次相见,相信分别的日子总会迎来终结。”
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 className="reveal-wrap s8-letter-wrap">
|
||||
<div className="reveal-inner serif delay-4 s8-letter">
|
||||
所有的离散,或许都只是一场漫长的越冬。飞鸟要越过一万座雪山,才能带来春天的第一行回信;树木要褪去一万次枯叶,才能记住风的形状。如果时间注定要把我们推向不同的象限,那就在记忆的最深处建一座灯塔。哪怕要熬过几千个无法见面的黄昏,也要相信,总有一次日出的晨光,是为了照亮我们重逢的归途。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="s8-floating-layout">
|
||||
<div className="reveal-wrap s8-empty-wrap">
|
||||
<div className="reveal-inner serif s8-empty-text delay-1">
|
||||
缘分温柔地眷顾着你。<br />
|
||||
这一年,所有重要的人都在,没有一次无疾而终的告别。
|
||||
</div>
|
||||
<div className="reveal-wrap desc-text s8-empty-wrap">
|
||||
<div className="reveal-inner serif delay-1 s8-empty-text">
|
||||
缘分温柔地眷顾着你。<br/>
|
||||
这一年,所有重要的人都在,没有一次无疾而终的告别。<br/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* S9: LEXICON & ARCHIVE */}
|
||||
<div className={getSceneClass(9)} id="scene-9">
|
||||
<div className="reveal-wrap en-tag">
|
||||
@@ -960,16 +936,16 @@ function AnnualReportWindow() {
|
||||
const st = demoStyles[i];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={phrase.phrase + i}
|
||||
<div
|
||||
key={phrase.phrase + i}
|
||||
className="word-burst"
|
||||
style={{
|
||||
left: st.left,
|
||||
top: st.top,
|
||||
fontSize: st.fontSize,
|
||||
color: st.color,
|
||||
transitionDelay: st.delay,
|
||||
'--target-op': st.targetOp
|
||||
style={{
|
||||
left: st.left,
|
||||
top: st.top,
|
||||
fontSize: st.fontSize,
|
||||
color: st.color,
|
||||
transitionDelay: st.delay,
|
||||
'--target-op': st.targetOp
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<span className="float-el" style={{ animationDelay: st.floatDelay }}>{phrase.phrase}</span>
|
||||
@@ -977,7 +953,7 @@ function AnnualReportWindow() {
|
||||
)
|
||||
})}
|
||||
{(!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>
|
||||
|
||||
@@ -986,7 +962,7 @@ function AnnualReportWindow() {
|
||||
<div className="reveal-wrap en-tag" style={{ zIndex: 20 }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag" style={{ color: COLOR.paperMuted }}>旅程的终点</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 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-inner delay-1" style={{ display: 'flex', flexDirection: 'column', gap: '3vh' }}>
|
||||
@@ -996,7 +972,7 @@ function AnnualReportWindow() {
|
||||
<div className="mono" style={{ fontSize: '0.8rem', color: COLOR.paperMuted, letterSpacing: '0.4em' }}>
|
||||
TRANSMISSION COMPLETE
|
||||
</div>
|
||||
|
||||
|
||||
{/* 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', flexDirection: 'column', alignItems: 'center' }}>
|
||||
@@ -1012,9 +988,9 @@ function AnnualReportWindow() {
|
||||
<div className="num-display" style={{ fontSize: '2.5rem', color: COLOR.accentMuted, fontWeight: 600 }}>“{endingTopPhrase}”</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="serif" style={{ fontSize: '1.2rem', color: 'rgba(34, 28, 16, 0.82)', marginTop: '4vh', letterSpacing: '0.05em' }}>
|
||||
“故事的最后,我们把这一切悄悄还给岁月<br />只要这些文字还在,所有的离别,就都只是一场短暂的缺席。”
|
||||
“故事的最后,我们把这一切悄悄还给岁月<br/>只要这些文字还在,所有的离别,就都只是一场短暂的缺席。”
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1033,15 +1009,15 @@ function AnnualReportWindow() {
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
数据数得清一万句落笔的寒暄,却度量不出一个默契的眼神。<br />在这片由数字构建的大海里,热烈的回应未必是感情的全部轮廓。<br />真正的爱与羁绊,从来都不在跳动的屏幕里,而在无法被量化的现实。
|
||||
数据数得清一万句落笔的寒暄,却度量不出一个默契的眼神。<br/>在这片由数字构建的大海里,热烈的回应未必是感情的全部轮廓。<br/>真正的爱与羁绊,从来都不在跳动的屏幕里,而在无法被量化的现实。
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap">
|
||||
<button
|
||||
className="btn num-display reveal-inner delay-3"
|
||||
<button
|
||||
className="btn num-display reveal-inner delay-3"
|
||||
onClick={handleExtract}
|
||||
disabled={isExtracting}
|
||||
style={{
|
||||
style={{
|
||||
background: isExtracting ? '#CDC4B0' : (buttonText === 'SAVED TO DEVICE' ? '#1A140A' : '#101010'),
|
||||
color: 'var(--c-gold-strong)',
|
||||
fontSize: '0.85rem',
|
||||
|
||||
@@ -72,146 +72,11 @@ const GLOBAL_MSG_SEARCH_CANCELED_ERROR = '__WEFLOW_GLOBAL_MSG_SEARCH_CANCELED__'
|
||||
const GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE = 0.2
|
||||
const GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY = 'weflow.debug.searchShadowCompare'
|
||||
const MESSAGE_LIST_SCROLL_IDLE_MS = 160
|
||||
const MESSAGE_TOP_EDGE_LOAD_COOLDOWN_MS = 160
|
||||
const MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS = 160
|
||||
const MESSAGE_EDGE_TRIGGER_DISTANCE_PX = 96
|
||||
const MESSAGE_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 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 {
|
||||
const requestIdleCallbackFn = (
|
||||
globalThis as typeof globalThis & { requestIdleCallback?: RequestIdleCallbackCompat }
|
||||
@@ -1428,7 +1293,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
|
||||
const getMessageKey = useCallback((msg: Message): string => {
|
||||
if (msg.messageKey) return msg.messageKey
|
||||
return `fallback:${msg._db_path || ''}:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||
}, [])
|
||||
const initialRevealTimerRef = useRef<number | null>(null)
|
||||
const sessionListRef = useRef<HTMLDivElement>(null)
|
||||
@@ -1608,7 +1473,6 @@ function ChatPage(props: ChatPageProps) {
|
||||
const searchKeywordRef = useRef('')
|
||||
const preloadImageKeysRef = useRef<Set<string>>(new Set())
|
||||
const lastPreloadSessionRef = useRef<string | null>(null)
|
||||
const messageMediaPreloadTimerRef = useRef<number | null>(null)
|
||||
const detailRequestSeqRef = useRef(0)
|
||||
const groupMembersRequestSeqRef = useRef(0)
|
||||
const groupMembersPanelCacheRef = useRef<Map<string, GroupMembersPanelCacheEntry>>(new Map())
|
||||
@@ -2929,11 +2793,6 @@ function ChatPage(props: ChatPageProps) {
|
||||
}, [loadMyAvatar, resolveChatCacheScope])
|
||||
|
||||
const handleAccountChanged = useCallback(async () => {
|
||||
emojiDataUrlCache.clear()
|
||||
imageDataUrlCache.clear()
|
||||
voiceDataUrlCache.clear()
|
||||
voiceTranscriptCache.clear()
|
||||
imageDecryptInFlight.clear()
|
||||
senderAvatarCache.clear()
|
||||
senderAvatarLoading.clear()
|
||||
quotedSenderDisplayCache.clear()
|
||||
@@ -2945,10 +2804,6 @@ function ChatPage(props: ChatPageProps) {
|
||||
sessionContactEnrichAttemptAtRef.current.clear()
|
||||
preloadImageKeysRef.current.clear()
|
||||
lastPreloadSessionRef.current = null
|
||||
if (messageMediaPreloadTimerRef.current !== null) {
|
||||
window.clearTimeout(messageMediaPreloadTimerRef.current)
|
||||
messageMediaPreloadTimerRef.current = null
|
||||
}
|
||||
pendingSessionLoadRef.current = null
|
||||
initialLoadRequestedSessionRef.current = null
|
||||
sessionSwitchRequestSeqRef.current += 1
|
||||
@@ -3466,8 +3321,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
setIsRefreshingMessages(false)
|
||||
}
|
||||
}
|
||||
// 消息批量大小控制(会话内逐步增大,减少频繁触顶加载)
|
||||
const currentBatchSizeRef = useRef(MESSAGE_HISTORY_INITIAL_LIMIT)
|
||||
// 消息批量大小控制(保持稳定,避免游标反复重建)
|
||||
const currentBatchSizeRef = useRef(50)
|
||||
|
||||
const warmupGroupSenderProfiles = useCallback((usernames: string[], defer = false) => {
|
||||
if (!Array.isArray(usernames) || usernames.length === 0) return
|
||||
@@ -3531,21 +3386,14 @@ function ChatPage(props: ChatPageProps) {
|
||||
let messageLimit: number
|
||||
|
||||
if (offset === 0) {
|
||||
const defaultInitialLimit = unreadCount > 99
|
||||
? MESSAGE_HISTORY_HEAVY_UNREAD_INITIAL_LIMIT
|
||||
: MESSAGE_HISTORY_INITIAL_LIMIT
|
||||
const preferredLimit = Number.isFinite(options.forceInitialLimit)
|
||||
? Math.max(10, Math.floor(options.forceInitialLimit as number))
|
||||
: defaultInitialLimit
|
||||
currentBatchSizeRef.current = Math.min(preferredLimit, MESSAGE_HISTORY_MAX_LIMIT)
|
||||
messageLimit = currentBatchSizeRef.current
|
||||
: (unreadCount > 99 ? 30 : 40)
|
||||
currentBatchSizeRef.current = preferredLimit
|
||||
messageLimit = preferredLimit
|
||||
} 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
|
||||
// 同一会话内保持固定批量,避免后端游标因 batch 改变而重建
|
||||
messageLimit = currentBatchSizeRef.current
|
||||
}
|
||||
|
||||
|
||||
@@ -3597,10 +3445,10 @@ function ChatPage(props: ChatPageProps) {
|
||||
if (result.success && result.messages) {
|
||||
const resultMessages = result.messages
|
||||
if (offset === 0) {
|
||||
setNoMessageTable(false)
|
||||
setMessages(resultMessages)
|
||||
persistSessionPreviewCache(sessionId, resultMessages)
|
||||
if (resultMessages.length === 0) {
|
||||
setNoMessageTable(true)
|
||||
setHasMoreMessages(false)
|
||||
}
|
||||
|
||||
@@ -3701,10 +3549,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
: offset + resultMessages.length
|
||||
setCurrentOffset(nextOffset)
|
||||
} else if (!result.success) {
|
||||
const errorText = String(result.error || '')
|
||||
const shouldMarkNoTable =
|
||||
/schema mismatch|no message db|no table|消息数据库未找到|消息表|message schema/i.test(errorText)
|
||||
setNoMessageTable(shouldMarkNoTable)
|
||||
setNoMessageTable(true)
|
||||
setHasMoreMessages(false)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -3712,7 +3557,6 @@ function ChatPage(props: ChatPageProps) {
|
||||
setConnectionError('加载消息失败')
|
||||
setHasMoreMessages(false)
|
||||
if (offset === 0 && currentSessionRef.current === sessionId) {
|
||||
setNoMessageTable(false)
|
||||
setMessages([])
|
||||
}
|
||||
} finally {
|
||||
@@ -4251,7 +4095,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
void loadMessages(normalizedSessionId, 0, 0, 0, false, {
|
||||
preferLatestPath: true,
|
||||
deferGroupSenderWarmup: true,
|
||||
forceInitialLimit: MESSAGE_HISTORY_INITIAL_LIMIT,
|
||||
forceInitialLimit: 30,
|
||||
switchRequestSeq
|
||||
})
|
||||
}
|
||||
@@ -4742,40 +4586,24 @@ function ChatPage(props: ChatPageProps) {
|
||||
setShowScrollToBottom(prev => (prev === shouldShow ? prev : shouldShow))
|
||||
}, [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>) => {
|
||||
markMessageListScrolling()
|
||||
if (!currentSessionId || isLoadingMore || isLoadingMessages) return
|
||||
const listEl = messageListRef.current
|
||||
if (!listEl) return
|
||||
const distanceFromTop = listEl.scrollTop
|
||||
const distanceFromBottom = listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight)
|
||||
|
||||
if (event.deltaY <= -18) {
|
||||
triggerTopEdgeHistoryLoad()
|
||||
if (!hasMoreMessages) return
|
||||
if (distanceFromTop > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return
|
||||
if (topRangeLoadLockRef.current) return
|
||||
const now = Date.now()
|
||||
if (now - topRangeLoadLastTriggerAtRef.current < MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS) return
|
||||
topRangeLoadLastTriggerAtRef.current = now
|
||||
topRangeLoadLockRef.current = true
|
||||
isMessageListAtBottomRef.current = false
|
||||
void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4791,21 +4619,22 @@ function ChatPage(props: ChatPageProps) {
|
||||
}, [
|
||||
currentSessionId,
|
||||
hasMoreLater,
|
||||
hasMoreMessages,
|
||||
isLoadingMessages,
|
||||
isLoadingMore,
|
||||
currentOffset,
|
||||
jumpStartTime,
|
||||
jumpEndTime,
|
||||
markMessageListScrolling,
|
||||
loadLaterMessages,
|
||||
triggerTopEdgeHistoryLoad
|
||||
loadMessages,
|
||||
loadLaterMessages
|
||||
])
|
||||
|
||||
const handleMessageAtTopStateChange = useCallback((atTop: boolean) => {
|
||||
if (!atTop) {
|
||||
topRangeLoadLockRef.current = false
|
||||
return
|
||||
}
|
||||
// 支持拖动右侧滚动条到顶部时直接触发加载,不依赖滚轮事件。
|
||||
triggerTopEdgeHistoryLoad()
|
||||
}, [triggerTopEdgeHistoryLoad])
|
||||
}, [])
|
||||
|
||||
|
||||
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
|
||||
@@ -4958,10 +4787,6 @@ function ChatPage(props: ChatPageProps) {
|
||||
window.clearTimeout(messageListScrollTimeoutRef.current)
|
||||
messageListScrollTimeoutRef.current = null
|
||||
}
|
||||
if (messageMediaPreloadTimerRef.current !== null) {
|
||||
window.clearTimeout(messageMediaPreloadTimerRef.current)
|
||||
messageMediaPreloadTimerRef.current = null
|
||||
}
|
||||
isMessageListScrollingRef.current = false
|
||||
contactUpdateQueueRef.current.clear()
|
||||
pendingSessionContactEnrichRef.current.clear()
|
||||
@@ -5032,54 +4857,36 @@ function ChatPage(props: ChatPageProps) {
|
||||
}, [currentSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (messageMediaPreloadTimerRef.current !== null) {
|
||||
window.clearTimeout(messageMediaPreloadTimerRef.current)
|
||||
messageMediaPreloadTimerRef.current = null
|
||||
}
|
||||
if (!currentSessionId || messages.length === 0) return
|
||||
|
||||
messageMediaPreloadTimerRef.current = window.setTimeout(() => {
|
||||
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
|
||||
}
|
||||
const preloadEdgeCount = 40
|
||||
const maxPreload = 30
|
||||
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(() => { })
|
||||
}
|
||||
}, [currentSessionId, messages])
|
||||
|
||||
@@ -5176,7 +4983,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
void loadMessages(currentSessionId, 0, 0, 0, false, {
|
||||
preferLatestPath: true,
|
||||
deferGroupSenderWarmup: true,
|
||||
forceInitialLimit: MESSAGE_HISTORY_INITIAL_LIMIT
|
||||
forceInitialLimit: 30
|
||||
})
|
||||
}
|
||||
}, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
|
||||
@@ -5309,18 +5116,6 @@ function ChatPage(props: ChatPageProps) {
|
||||
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_'))
|
||||
|
||||
// 检查是否有折叠的群聊
|
||||
@@ -5335,12 +5130,11 @@ function ChatPage(props: ChatPageProps) {
|
||||
|
||||
const latestOfficial = officialSessions.reduce<ChatSession | null>((latest, current) => {
|
||||
if (!latest) return current
|
||||
const latestTime = getSessionSortTime(latest)
|
||||
const currentTime = getSessionSortTime(current)
|
||||
const latestTime = latest.sortTimestamp || latest.lastTimestamp
|
||||
const currentTime = current.sortTimestamp || current.lastTimestamp
|
||||
return currentTime > latestTime ? current : latest
|
||||
}, null)
|
||||
const officialUnreadCount = officialSessions.reduce((sum, s) => sum + (s.unreadCount || 0), 0)
|
||||
const officialLatestTime = latestOfficial ? getSessionSortTime(latestOfficial) : 0
|
||||
|
||||
const bizEntry: ChatSession = {
|
||||
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
|
||||
@@ -5349,8 +5143,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}`
|
||||
: '查看公众号历史消息',
|
||||
type: 0,
|
||||
sortTimestamp: officialLatestTime,
|
||||
lastTimestamp: officialLatestTime,
|
||||
sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下
|
||||
lastTimestamp: latestOfficial?.lastTimestamp || latestOfficial?.sortTimestamp || 0,
|
||||
lastMsgType: latestOfficial?.lastMsgType || 0,
|
||||
unreadCount: officialUnreadCount,
|
||||
isMuted: false,
|
||||
@@ -5358,7 +5152,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
|
||||
if (!visible.some(s => s.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)) {
|
||||
insertSessionByTimeDesc(visible, bizEntry)
|
||||
visible.unshift(bizEntry)
|
||||
}
|
||||
|
||||
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
|
||||
@@ -5382,7 +5176,17 @@ function ChatPage(props: ChatPageProps) {
|
||||
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()) {
|
||||
@@ -7270,7 +7074,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
className="message-virtuoso"
|
||||
customScrollParent={messageListScrollParent ?? undefined}
|
||||
data={messages}
|
||||
overscan={MESSAGE_VIRTUAL_OVERSCAN_PX}
|
||||
overscan={220}
|
||||
followOutput={(atBottom) => (
|
||||
prependingHistoryRef.current
|
||||
? false
|
||||
@@ -8214,26 +8018,10 @@ const globalVoiceManager = {
|
||||
}
|
||||
|
||||
// 前端表情包缓存
|
||||
const emojiDataUrlCache = createBoundedCache<string>({
|
||||
maxEntries: EMOJI_CACHE_MAX_ENTRIES,
|
||||
maxBytes: EMOJI_CACHE_MAX_BYTES,
|
||||
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
|
||||
})
|
||||
const emojiDataUrlCache = new Map<string, string>()
|
||||
const imageDataUrlCache = new Map<string, string>()
|
||||
const voiceDataUrlCache = new Map<string, string>()
|
||||
const voiceTranscriptCache = new Map<string, string>()
|
||||
type SharedImageDecryptResult = {
|
||||
success: boolean
|
||||
localPath?: string
|
||||
@@ -8242,9 +8030,7 @@ type SharedImageDecryptResult = {
|
||||
failureKind?: 'not_found' | 'decrypt_failed'
|
||||
}
|
||||
const imageDecryptInFlight = new Map<string, Promise<SharedImageDecryptResult>>()
|
||||
const senderAvatarCache = createBoundedCache<{ avatarUrl?: string; displayName?: string }>({
|
||||
maxEntries: SENDER_AVATAR_CACHE_MAX_ENTRIES
|
||||
})
|
||||
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
||||
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
||||
|
||||
function getSharedImageDecryptTask(
|
||||
@@ -8298,7 +8084,7 @@ function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
|
||||
|
||||
if (error || (!loading && !localPath)) return <span className="quoted-type-label">[动画表情]</span>
|
||||
if (loading) return <span className="quoted-type-label">[动画表情]</span>
|
||||
return <img src={localPath} alt="动画表情" className="quoted-emoji-image" loading="lazy" decoding="async" />
|
||||
return <img src={localPath} alt="动画表情" className="quoted-emoji-image" />
|
||||
}
|
||||
|
||||
// 消息气泡组件
|
||||
@@ -8401,10 +8187,7 @@ function MessageBubble({
|
||||
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0)
|
||||
const [voiceDuration, setVoiceDuration] = useState(0)
|
||||
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
||||
const [voiceWaveformRequested, setVoiceWaveformRequested] = useState(false)
|
||||
const voiceAutoDecryptTriggered = useRef(false)
|
||||
const pendingScrollerDeltaRef = useRef(0)
|
||||
const pendingScrollerDeltaRafRef = useRef<number | null>(null)
|
||||
|
||||
|
||||
const [systemAlert, setSystemAlert] = useState<{
|
||||
@@ -8495,7 +8278,7 @@ function MessageBubble({
|
||||
|
||||
const stabilizeScrollerByDelta = useCallback((host: HTMLElement | null, delta: number) => {
|
||||
if (!host) return
|
||||
if (!Number.isFinite(delta) || Math.abs(delta) < 1.5) return
|
||||
if (!Number.isFinite(delta) || Math.abs(delta) < 1) return
|
||||
const scroller = host.closest('.message-list') as HTMLDivElement | null
|
||||
if (!scroller) return
|
||||
|
||||
@@ -8508,17 +8291,7 @@ function MessageBubble({
|
||||
const viewportBottom = scroller.scrollTop + scroller.clientHeight
|
||||
if (hostTopInScroller > viewportBottom + 24) return
|
||||
|
||||
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
|
||||
})
|
||||
scroller.scrollTop += delta
|
||||
}, [])
|
||||
|
||||
const bindResizeObserverForHost = useCallback((
|
||||
@@ -8609,12 +8382,12 @@ function MessageBubble({
|
||||
useEffect(() => {
|
||||
if (!isImage) return
|
||||
return bindResizeObserverForHost(imageContainerRef.current, imageObservedHeightRef, imageResizeBaselineRef)
|
||||
}, [isImage, bindResizeObserverForHost])
|
||||
}, [isImage, imageLocalPath, imageLoading, imageError, bindResizeObserverForHost])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmoji) return
|
||||
return bindResizeObserverForHost(emojiContainerRef.current, emojiObservedHeightRef, emojiResizeBaselineRef)
|
||||
}, [isEmoji, bindResizeObserverForHost])
|
||||
}, [isEmoji, emojiLocalPath, emojiLoading, emojiError, bindResizeObserverForHost])
|
||||
|
||||
// 下载表情包
|
||||
const downloadEmoji = () => {
|
||||
@@ -8795,13 +8568,13 @@ function MessageBubble({
|
||||
return { success: false }
|
||||
}, [isImage, message.imageMd5, message.imageDatName, message.createTime, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
|
||||
|
||||
const triggerForceHd = useCallback(async (): Promise<void> => {
|
||||
const triggerForceHd = useCallback(() => {
|
||||
if (!message.imageMd5 && !message.imageDatName) return
|
||||
if (imageForceHdAttempted.current === imageCacheKey) return
|
||||
if (imageForceHdPending.current) return
|
||||
imageForceHdAttempted.current = imageCacheKey
|
||||
imageForceHdPending.current = true
|
||||
await requestImageDecrypt(true, true).finally(() => {
|
||||
requestImageDecrypt(true, true).finally(() => {
|
||||
imageForceHdPending.current = false
|
||||
})
|
||||
}, [imageCacheKey, message.imageDatName, message.imageMd5, requestImageDecrypt])
|
||||
@@ -8889,11 +8662,6 @@ function MessageBubble({
|
||||
if (imageClickTimerRef.current) {
|
||||
window.clearTimeout(imageClickTimerRef.current)
|
||||
}
|
||||
if (pendingScrollerDeltaRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(pendingScrollerDeltaRafRef.current)
|
||||
pendingScrollerDeltaRafRef.current = null
|
||||
}
|
||||
pendingScrollerDeltaRef.current = 0
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -9027,16 +8795,14 @@ function MessageBubble({
|
||||
if (!message.imageMd5 && !message.imageDatName) return
|
||||
if (imageAutoDecryptTriggered.current) return
|
||||
imageAutoDecryptTriggered.current = true
|
||||
void enqueueAutoMediaTask(async () => requestImageDecrypt()).catch(() => { })
|
||||
void requestImageDecrypt()
|
||||
}, [isImage, imageInView, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, requestImageDecrypt])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage || !imageHasUpdate || !imageInView) return
|
||||
if (imageAutoHdTriggered.current === imageCacheKey) return
|
||||
imageAutoHdTriggered.current = imageCacheKey
|
||||
void enqueueAutoMediaTask(async () => {
|
||||
await triggerForceHd()
|
||||
}).catch(() => { })
|
||||
triggerForceHd()
|
||||
}, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd])
|
||||
|
||||
|
||||
@@ -9078,36 +8844,30 @@ function MessageBubble({
|
||||
|
||||
// 生成波形数据
|
||||
useEffect(() => {
|
||||
if (!voiceDataUrl || !voiceWaveformRequested) {
|
||||
if (!voiceDataUrl) {
|
||||
setVoiceWaveform([])
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let audioCtx: AudioContext | null = null
|
||||
|
||||
const generateWaveform = async () => {
|
||||
try {
|
||||
// 从 data:audio/wav;base64,... 提取 base64
|
||||
const base64 = voiceDataUrl.split(',')[1]
|
||||
if (!base64) return
|
||||
const binaryString = window.atob(base64)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
|
||||
audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
const audioBuffer = await audioCtx.decodeAudioData(bytes.buffer)
|
||||
if (cancelled) return
|
||||
const rawData = audioBuffer.getChannelData(0) // 获取单声道数据
|
||||
const samples = 24 // 波形柱子数量(降低解码计算成本)
|
||||
const samples = 35 // 波形柱子数量
|
||||
const blockSize = Math.floor(rawData.length / samples)
|
||||
if (blockSize <= 0) return
|
||||
const filteredData: number[] = []
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const blockStart = blockSize * i
|
||||
let blockStart = blockSize * i
|
||||
let sum = 0
|
||||
for (let j = 0; j < blockSize; j++) {
|
||||
sum = sum + Math.abs(rawData[blockStart + j])
|
||||
@@ -9116,39 +8876,19 @@ function MessageBubble({
|
||||
}
|
||||
|
||||
// 归一化
|
||||
const peak = Math.max(...filteredData)
|
||||
if (!Number.isFinite(peak) || peak <= 0) return
|
||||
const multiplier = Math.pow(peak, -1)
|
||||
const multiplier = Math.pow(Math.max(...filteredData), -1)
|
||||
const normalizedData = filteredData.map(n => n * multiplier)
|
||||
if (!cancelled) {
|
||||
setVoiceWaveform(normalizedData)
|
||||
}
|
||||
setVoiceWaveform(normalizedData)
|
||||
void audioCtx.close()
|
||||
} catch (e) {
|
||||
console.error('Failed to generate waveform:', e)
|
||||
// 降级:生成随机但平滑的波形
|
||||
if (!cancelled) {
|
||||
setVoiceWaveform(Array.from({ length: 24 }, () => 0.2 + Math.random() * 0.8))
|
||||
}
|
||||
} finally {
|
||||
if (audioCtx) {
|
||||
void audioCtx.close().catch(() => { })
|
||||
}
|
||||
setVoiceWaveform(Array.from({ length: 35 }, () => 0.2 + Math.random() * 0.8))
|
||||
}
|
||||
}
|
||||
|
||||
scheduleWhenIdle(() => {
|
||||
if (cancelled) return
|
||||
void generateWaveform()
|
||||
}, { timeout: 900, fallbackDelay: 80 })
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (audioCtx) {
|
||||
void audioCtx.close().catch(() => { })
|
||||
audioCtx = null
|
||||
}
|
||||
}
|
||||
}, [voiceDataUrl, voiceWaveformRequested])
|
||||
void generateWaveform()
|
||||
}, [voiceDataUrl])
|
||||
|
||||
// 消息加载时自动检测语音缓存
|
||||
useEffect(() => {
|
||||
@@ -9332,9 +9072,7 @@ function MessageBubble({
|
||||
if (videoAutoLoadTriggered.current) return
|
||||
|
||||
videoAutoLoadTriggered.current = true
|
||||
void enqueueAutoMediaTask(async () => requestVideoInfo()).catch(() => {
|
||||
videoAutoLoadTriggered.current = false
|
||||
})
|
||||
void requestVideoInfo()
|
||||
}, [isVideo, isVideoVisible, videoInfo, requestVideoInfo])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -9653,8 +9391,6 @@ function MessageBubble({
|
||||
src={imageLocalPath}
|
||||
alt="图片"
|
||||
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onClick={() => { void handleOpenImageViewer() }}
|
||||
onLoad={() => {
|
||||
setImageLoaded(true)
|
||||
@@ -9731,9 +9467,9 @@ function MessageBubble({
|
||||
// 默认显示缩略图,点击打开独立播放窗口
|
||||
const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl
|
||||
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 ? (
|
||||
<img src={thumbSrc} alt="视频缩略图" className="video-thumb" loading="lazy" decoding="async" />
|
||||
<img src={thumbSrc} alt="视频缩略图" className="video-thumb" />
|
||||
) : (
|
||||
<div className="video-thumb-placeholder">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
@@ -9753,9 +9489,6 @@ function MessageBubble({
|
||||
const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : ''
|
||||
const handleToggle = async () => {
|
||||
if (voiceLoading) return
|
||||
if (!voiceWaveformRequested) {
|
||||
setVoiceWaveformRequested(true)
|
||||
}
|
||||
const audio = voiceAudioRef.current || new Audio()
|
||||
if (!voiceAudioRef.current) {
|
||||
voiceAudioRef.current = audio
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1899,7 +1899,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}`
|
||||
: ''
|
||||
const mediaMissMetricLabel = mediaCacheMissFiles > 0
|
||||
? `缓存未命中 ${mediaCacheMissFiles}`
|
||||
? `未导出 ${mediaCacheMissFiles} 个文件/媒体`
|
||||
: ''
|
||||
const mediaDedupMetricLabel = mediaDedupReuseFiles > 0
|
||||
? `复用 ${mediaDedupReuseFiles}`
|
||||
@@ -1914,7 +1914,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
)
|
||||
: ''
|
||||
const mediaLiveMetricLabel = task.progress.phase === 'exporting-media'
|
||||
? (mediaDoneFiles > 0 ? `已写入 ${mediaDoneFiles}` : '')
|
||||
? (mediaDoneFiles > 0 ? `已处理 ${mediaDoneFiles}` : '')
|
||||
: ''
|
||||
const sessionProgressLabel = completedSessionTotal > 0
|
||||
? `会话 ${completedSessionCount}/${completedSessionTotal}`
|
||||
@@ -2238,27 +2238,6 @@ function ExportPage() {
|
||||
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>({
|
||||
open: false,
|
||||
intent: 'manual',
|
||||
@@ -4024,7 +4003,7 @@ function ExportPage() {
|
||||
const cacheResult = await withTimeout(
|
||||
window.electronAPI.chat.getExportSessionStats(
|
||||
batchSessionIds,
|
||||
withExportStatsRange({ includeRelations: false, allowStaleCache: true, cacheOnly: true })
|
||||
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||
),
|
||||
12000,
|
||||
'cacheOnly'
|
||||
@@ -4039,7 +4018,7 @@ function ExportPage() {
|
||||
const freshResult = await withTimeout(
|
||||
window.electronAPI.chat.getExportSessionStats(
|
||||
missingSessionIds,
|
||||
withExportStatsRange({ includeRelations: false, allowStaleCache: true })
|
||||
{ includeRelations: false, allowStaleCache: true }
|
||||
),
|
||||
45000,
|
||||
'fresh'
|
||||
@@ -4083,7 +4062,7 @@ function ExportPage() {
|
||||
void runSessionMediaMetricWorker(runId)
|
||||
}
|
||||
}
|
||||
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage, withExportStatsRange])
|
||||
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage])
|
||||
|
||||
const scheduleSessionMediaMetricWorker = useCallback(() => {
|
||||
if (activeTaskCountRef.current > 0) return
|
||||
@@ -7264,7 +7243,7 @@ function ExportPage() {
|
||||
try {
|
||||
const quickStatsResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
withExportStatsRange({ includeRelations: false, allowStaleCache: true, cacheOnly: true })
|
||||
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
if (quickStatsResult.success) {
|
||||
@@ -7291,7 +7270,7 @@ function ExportPage() {
|
||||
try {
|
||||
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
withExportStatsRange({ includeRelations: true, allowStaleCache: true, cacheOnly: true })
|
||||
{ includeRelations: true, allowStaleCache: true, cacheOnly: true }
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
if (relationCacheResult.success && relationCacheResult.data) {
|
||||
@@ -7316,7 +7295,7 @@ function ExportPage() {
|
||||
// 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列。
|
||||
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
withExportStatsRange({ includeRelations: false, forceRefresh: true })
|
||||
{ includeRelations: false, forceRefresh: true }
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
if (freshResult.success && freshResult.data) {
|
||||
@@ -7351,7 +7330,7 @@ function ExportPage() {
|
||||
setIsLoadingSessionDetailExtra(false)
|
||||
}
|
||||
}
|
||||
}, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername, withExportStatsRange])
|
||||
}, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername])
|
||||
|
||||
const loadSessionRelationStats = useCallback(async (options?: { forceRefresh?: boolean }) => {
|
||||
const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
|
||||
@@ -7364,7 +7343,7 @@ function ExportPage() {
|
||||
if (!forceRefresh) {
|
||||
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
withExportStatsRange({ includeRelations: true, allowStaleCache: true, cacheOnly: true })
|
||||
{ includeRelations: true, allowStaleCache: true, cacheOnly: true }
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
|
||||
@@ -7382,7 +7361,7 @@ function ExportPage() {
|
||||
|
||||
const relationResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
withExportStatsRange({ includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true })
|
||||
{ includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true }
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
|
||||
@@ -7402,7 +7381,7 @@ function ExportPage() {
|
||||
setIsLoadingSessionRelationStats(false)
|
||||
}
|
||||
}
|
||||
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid, withExportStatsRange])
|
||||
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
|
||||
|
||||
const handleRefreshTableData = useCallback(async () => {
|
||||
const scopeKey = await ensureExportCacheScope()
|
||||
|
||||
@@ -3,15 +3,13 @@ import type { ChatSession, Message, Contact } from '../types/models'
|
||||
|
||||
const messageAliasIndex = new Set<string>()
|
||||
|
||||
function buildPrimaryMessageKey(message: Message, sourceScope?: string): string {
|
||||
function buildPrimaryMessageKey(message: Message): string {
|
||||
if (message.messageKey) return String(message.messageKey)
|
||||
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}`
|
||||
return `fallback:${message.serverId || 0}:${message.createTime}:${message.sortSeq || 0}:${message.localId || 0}:${message.senderUsername || ''}:${message.localType || 0}`
|
||||
}
|
||||
|
||||
function buildMessageAliasKeys(message: Message): string[] {
|
||||
const sourceScope = String(message._db_path || '').trim()
|
||||
const keys = [buildPrimaryMessageKey(message, sourceScope)]
|
||||
const keys = [buildPrimaryMessageKey(message)]
|
||||
const localId = Math.max(0, Number(message.localId || 0))
|
||||
const serverId = Math.max(0, Number(message.serverId || 0))
|
||||
const createTime = Math.max(0, Number(message.createTime || 0))
|
||||
@@ -20,26 +18,15 @@ function buildMessageAliasKeys(message: Message): string[] {
|
||||
const isSend = Number(message.isSend ?? -1)
|
||||
|
||||
if (localId > 0) {
|
||||
// 跨 message_*.db 时 local_id 可能重复,必须带分库上下文避免误去重。
|
||||
if (sourceScope) {
|
||||
keys.push(`lid:${sourceScope}:${localId}`)
|
||||
} else {
|
||||
// 缺库信息时使用更保守组合,尽量避免把不同消息误判成重复。
|
||||
keys.push(`lid_fallback:${localId}:${createTime}:${sender}:${localType}:${serverId}`)
|
||||
}
|
||||
keys.push(`lid:${localId}`)
|
||||
}
|
||||
if (serverId > 0) {
|
||||
// server_id 在跨库场景并非绝对全局唯一;必须带来源作用域避免误去重。
|
||||
if (sourceScope) {
|
||||
keys.push(`sid:${sourceScope}:${serverId}`)
|
||||
} else {
|
||||
keys.push(`sid_fallback:${serverId}:${createTime}:${sender}:${localType}`)
|
||||
}
|
||||
keys.push(`sid:${serverId}`)
|
||||
}
|
||||
if (localType === 3) {
|
||||
const imageIdentity = String(message.imageMd5 || message.imageDatName || '').trim()
|
||||
if (imageIdentity) {
|
||||
keys.push(`img:${sourceScope}:${createTime}:${sender}:${isSend}:${imageIdentity}`)
|
||||
keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +37,7 @@ function rebuildMessageAliasIndex(messages: Message[]): void {
|
||||
messageAliasIndex.clear()
|
||||
for (const message of messages) {
|
||||
const aliasKeys = buildMessageAliasKeys(message)
|
||||
for (const key of aliasKeys) {
|
||||
messageAliasIndex.add(key)
|
||||
}
|
||||
aliasKeys.forEach((key) => messageAliasIndex.add(key))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,18 +136,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
const filtered: Message[] = []
|
||||
newMessages.forEach((msg) => {
|
||||
const aliasKeys = buildMessageAliasKeys(msg)
|
||||
let exists = false
|
||||
for (const key of aliasKeys) {
|
||||
if (messageAliasIndex.has(key)) {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
const exists = aliasKeys.some((key) => messageAliasIndex.has(key))
|
||||
if (exists) return
|
||||
filtered.push(msg)
|
||||
for (const key of aliasKeys) {
|
||||
messageAliasIndex.add(key)
|
||||
}
|
||||
aliasKeys.forEach((key) => messageAliasIndex.add(key))
|
||||
})
|
||||
|
||||
if (filtered.length === 0) return state
|
||||
|
||||
4
src/types/electron.d.ts
vendored
4
src/types/electron.d.ts
vendored
@@ -311,8 +311,6 @@ export interface ElectronAPI {
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
}
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
@@ -1221,3 +1219,5 @@ declare global {
|
||||
|
||||
export { }
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user