* fix:尝试修复闪退的问题

* hhhhh

* fix(chatService): 优化头像加载兜底机制:收集无 URL 的用户名,从 head_image.db 批量获取并转换为 base64 格式,更新头像缓存并添加错误处理,避免聊天界面头像缺失。(解决了部分,我电脑上有几个不显示)

* 优化表诉

* 导出优化

* fix: 尝试修复运行库缺失的问题

* 优化表述

* feat: 实现朋友圈获取; 实现聊天页面跳转到指定日期

* fix:修复了头像加载失败的问题

* Bump version from 1.3.1 to 1.3.2

---------

Co-authored-by: Forrest <jin648862@gmail.com>
Co-authored-by: cc <98377878+hicccc77@users.noreply.github.com>
This commit is contained in:
xuncha
2026-01-23 10:06:16 +08:00
committed by GitHub
parent 49614bf6d8
commit 6436c39c90
30 changed files with 2170 additions and 153 deletions

View File

@@ -42,7 +42,7 @@ jobs:
- name: Inject Configuration - name: Inject Configuration
shell: bash shell: bash
run: | run: |
npm pkg set build.releaseInfo.releaseNotes="修复了一些已知问题" npm pkg set build.releaseInfo.releaseNotes=$'仅适配微信 4.0 及以上版本\n\n修复了一些已知问题\n\n详情前往 Telegram 群查看'
- name: Package and Publish - name: Package and Publish
env: env:

View File

@@ -44,6 +44,9 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
- 导出聊天记录为 HTML 等格式 - 导出聊天记录为 HTML 等格式
- 本地解密与数据库管理 - 本地解密与数据库管理
> [!NOTE]
> ⚠️ 本工具仅适配微信 **4.0 及以上**版本,请确保你的微信版本符合要求
## 快速开始 ## 快速开始
若你只想使用成品版本,可前往 Release 下载并安装。 若你只想使用成品版本,可前往 Release 下载并安装。

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron' import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
import { join, dirname } from 'path' import { join, dirname } from 'path'
import { autoUpdater } from 'electron-updater' import { autoUpdater } from 'electron-updater'
@@ -17,6 +17,7 @@ import { exportService, ExportOptions, ExportProgress } from './services/exportS
import { KeyService } from './services/keyService' import { KeyService } from './services/keyService'
import { voiceTranscribeService } from './services/voiceTranscribeService' import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService' import { videoService } from './services/videoService'
import { snsService } from './services/snsService'
// 配置自动更新 // 配置自动更新
@@ -614,8 +615,8 @@ function registerIpcHandlers() {
return chatService.enrichSessionsContactInfo(usernames) return chatService.enrichSessionsContactInfo(usernames)
}) })
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => { ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
return chatService.getMessages(sessionId, offset, limit) return chatService.getMessages(sessionId, offset, limit, startTime, endTime, ascending)
}) })
ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => { ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
@@ -672,6 +673,10 @@ function registerIpcHandlers() {
return chatService.getMessageById(sessionId, localId) return chatService.getMessageById(sessionId, localId)
}) })
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
})
// 私聊克隆 // 私聊克隆
@@ -977,6 +982,17 @@ app.whenReady().then(() => {
createOnboardingWindow() createOnboardingWindow()
} }
// 解决朋友圈图片无法加载问题(添加 Referer
session.defaultSession.webRequest.onBeforeSendHeaders(
{
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
},
(details, callback) => {
details.requestHeaders['Referer'] = 'https://wx.qq.com/'
callback({ requestHeaders: details.requestHeaders })
}
)
// 启动时检测更新 // 启动时检测更新
checkForUpdatesOnStartup() checkForUpdatesOnStartup()

View File

@@ -98,8 +98,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSessions: () => ipcRenderer.invoke('chat:getSessions'), getSessions: () => ipcRenderer.invoke('chat:getSessions'),
enrichSessionsContactInfo: (usernames: string[]) => enrichSessionsContactInfo: (usernames: string[]) =>
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
getMessages: (sessionId: string, offset?: number, limit?: number) => getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit), ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
getLatestMessages: (sessionId: string, limit?: number) => getLatestMessages: (sessionId: string, limit?: number) =>
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit), ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username), getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
@@ -207,5 +207,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload)) ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('whisper:downloadProgress') return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
} }
},
// 朋友圈
sns: {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime)
} }
}) })

View File

@@ -74,7 +74,7 @@ const emojiDownloading: Map<string, Promise<string | null>> = new Map()
class ChatService { class ChatService {
private configService: ConfigService private configService: ConfigService
private connected = false private connected = false
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number }> = new Map() private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean }> = new Map()
private readonly messageBatchDefault = 50 private readonly messageBatchDefault = 50
private avatarCache: Map<string, ContactCacheEntry> private avatarCache: Map<string, ContactCacheEntry>
private readonly avatarCacheTtlMs = 10 * 60 * 1000 private readonly avatarCacheTtlMs = 10 * 60 * 1000
@@ -326,7 +326,11 @@ class ChatService {
// 检查缓存 // 检查缓存
for (const username of usernames) { for (const username of usernames) {
const cached = this.avatarCache.get(username) const cached = this.avatarCache.get(username)
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) { // 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取
// 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取
const isValidAvatar = cached?.avatarUrl &&
!cached.avatarUrl.includes('base64,ffd8') // 检测错误的 hex 格式
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) {
result[username] = { result[username] = {
displayName: cached.displayName, displayName: cached.displayName,
avatarUrl: cached.avatarUrl avatarUrl: cached.avatarUrl
@@ -343,9 +347,17 @@ class ChatService {
wcdbService.getAvatarUrls(missing) wcdbService.getAvatarUrls(missing)
]) ])
// 收集没有头像 URL 的用户名
const missingAvatars: string[] = []
for (const username of missing) { for (const username of missing) {
const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined let avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
// 如果没有头像 URL记录下来稍后从 head_image.db 获取
if (!avatarUrl) {
missingAvatars.push(username)
}
const cacheEntry: ContactCacheEntry = { const cacheEntry: ContactCacheEntry = {
displayName: displayName || username, displayName: displayName || username,
@@ -357,6 +369,23 @@ class ChatService {
this.avatarCache.set(username, cacheEntry) this.avatarCache.set(username, cacheEntry)
updatedEntries[username] = cacheEntry updatedEntries[username] = cacheEntry
} }
// 从 head_image.db 获取缺失的头像
if (missingAvatars.length > 0) {
const headImageAvatars = await this.getAvatarsFromHeadImageDb(missingAvatars)
for (const username of missingAvatars) {
const avatarUrl = headImageAvatars[username]
if (avatarUrl) {
result[username].avatarUrl = avatarUrl
const cached = this.avatarCache.get(username)
if (cached) {
cached.avatarUrl = avatarUrl
updatedEntries[username] = cached
}
}
}
}
if (Object.keys(updatedEntries).length > 0) { if (Object.keys(updatedEntries).length > 0) {
this.contactCacheService.setEntries(updatedEntries) this.contactCacheService.setEntries(updatedEntries)
} }
@@ -368,6 +397,81 @@ class ChatService {
} }
} }
/**
* 从 head_image.db 批量获取头像(转换为 base64 data URL
*/
private async getAvatarsFromHeadImageDb(usernames: string[]): Promise<Record<string, string>> {
const result: Record<string, string> = {}
if (usernames.length === 0) return result
try {
const dbPath = this.configService.get('dbPath')
const wxid = this.configService.get('myWxid')
if (!dbPath || !wxid) return result
const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) return result
// head_image.db 可能在不同位置
const headImageDbPaths = [
join(accountDir, 'db_storage', 'head_image', 'head_image.db'),
join(accountDir, 'db_storage', 'head_image.db'),
join(accountDir, 'head_image.db')
]
let headImageDbPath: string | null = null
for (const path of headImageDbPaths) {
if (existsSync(path)) {
headImageDbPath = path
break
}
}
if (!headImageDbPath) return result
// 使用 wcdbService.execQuery 查询加密的 head_image.db
for (const username of usernames) {
try {
const escapedUsername = username.replace(/'/g, "''")
const queryResult = await wcdbService.execQuery(
'media',
headImageDbPath,
`SELECT image_buffer FROM head_image WHERE username = '${escapedUsername}' LIMIT 1`
)
if (queryResult.success && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0] as any
if (row?.image_buffer) {
let base64Data: string
if (typeof row.image_buffer === 'string') {
// WCDB 返回的 BLOB 是十六进制字符串,需要转换为 base64
if (row.image_buffer.toLowerCase().startsWith('ffd8')) {
const buffer = Buffer.from(row.image_buffer, 'hex')
base64Data = buffer.toString('base64')
} else {
base64Data = row.image_buffer
}
} else if (Buffer.isBuffer(row.image_buffer)) {
base64Data = row.image_buffer.toString('base64')
} else if (Array.isArray(row.image_buffer)) {
base64Data = Buffer.from(row.image_buffer).toString('base64')
} else {
continue
}
result[username] = `data:image/jpeg;base64,${base64Data}`
}
}
} catch {
// 静默处理单个用户的错误
}
}
} catch (e) {
console.error('从 head_image.db 获取头像失败:', e)
}
return result
}
/** /**
* 补充联系人信息(私有方法,保持向后兼容) * 补充联系人信息(私有方法,保持向后兼容)
*/ */
@@ -396,7 +500,10 @@ class ChatService {
async getMessages( async getMessages(
sessionId: string, sessionId: string,
offset: number = 0, offset: number = 0,
limit: number = 50 limit: number = 50,
startTime: number = 0,
endTime: number = 0,
ascending: boolean = false
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> { ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
try { try {
const connectResult = await this.ensureConnected() const connectResult = await this.ensureConnected()
@@ -411,7 +518,14 @@ class ChatService {
// 1. 没有游标状态 // 1. 没有游标状态
// 2. offset 为 0 (重新加载会话) // 2. offset 为 0 (重新加载会话)
// 3. batchSize 改变 // 3. batchSize 改变
const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize // 4. startTime 改变
// 5. ascending 改变
const needNewCursor = !state ||
offset === 0 ||
state.batchSize !== batchSize ||
state.startTime !== startTime ||
state.endTime !== endTime ||
state.ascending !== ascending
if (needNewCursor) { if (needNewCursor) {
// 关闭旧游标 // 关闭旧游标
@@ -424,13 +538,16 @@ class ChatService {
} }
// 创建新游标 // 创建新游标
const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, false, 0, 0) // 注意WeFlow 数据库中的 create_time 是以秒为单位的
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, ascending, beginTimestamp, endTimestamp)
if (!cursorResult.success || !cursorResult.cursor) { if (!cursorResult.success || !cursorResult.cursor) {
console.error('[ChatService] 打开消息游标失败:', cursorResult.error) console.error('[ChatService] 打开消息游标失败:', cursorResult.error)
return { success: false, error: cursorResult.error || '打开消息游标失败' } return { success: false, error: cursorResult.error || '打开消息游标失败' }
} }
state = { cursor: cursorResult.cursor, fetched: 0, batchSize } state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
this.messageCursors.set(sessionId, state) this.messageCursors.set(sessionId, state)
// 如果需要跳过消息(offset > 0),逐批获取但不返回 // 如果需要跳过消息(offset > 0),逐批获取但不返回
@@ -1706,7 +1823,9 @@ class ChatService {
const connectResult = await this.ensureConnected() const connectResult = await this.ensureConnected()
if (!connectResult.success) return null if (!connectResult.success) return null
const cached = this.avatarCache.get(username) const cached = this.avatarCache.get(username)
if (cached && cached.avatarUrl && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) { // 检查缓存是否有效,且头像不是错误的 hex 格式
const isValidAvatar = cached?.avatarUrl && !cached.avatarUrl.includes('base64,ffd8')
if (cached && isValidAvatar && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) {
return { avatarUrl: cached.avatarUrl, displayName: cached.displayName } return { avatarUrl: cached.avatarUrl, displayName: cached.displayName }
} }
@@ -2979,10 +3098,26 @@ class ChatService {
private resolveAccountDir(dbPath: string, wxid: string): string | null { private resolveAccountDir(dbPath: string, wxid: string): string | null {
const normalized = dbPath.replace(/[\\\\/]+$/, '') const normalized = dbPath.replace(/[\\\\/]+$/, '')
// 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件)
// 则向上回溯到账号目录
if (basename(normalized).toLowerCase() === 'db_storage') {
return dirname(normalized)
}
const dir = dirname(normalized) const dir = dirname(normalized)
if (basename(normalized).toLowerCase() === 'db_storage') return dir if (basename(dir).toLowerCase() === 'db_storage') {
if (basename(dir).toLowerCase() === 'db_storage') return dirname(dir) return dirname(dir)
return dir // 兜底 }
// 否则dbPath 应该是数据库根目录(如 xwechat_files
// 账号目录应该是 {dbPath}/{wxid}
const accountDirWithWxid = join(normalized, wxid)
if (existsSync(accountDirWithWxid)) {
return accountDirWithWxid
}
// 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录)
return normalized
} }
private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> { private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> {

View File

@@ -34,6 +34,14 @@ export class ContactCacheService {
const raw = readFileSync(this.cacheFilePath, 'utf8') const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') { if (parsed && typeof parsed === 'object') {
// 清除无效的头像数据hex 格式而非正确的 base64
for (const key of Object.keys(parsed)) {
const entry = parsed[key]
if (entry?.avatarUrl && entry.avatarUrl.includes('base64,ffd8')) {
// 这是错误的 hex 格式,清除它
entry.avatarUrl = undefined
}
}
this.cache = parsed this.cache = parsed
} }
} catch (error) { } catch (error) {

View File

@@ -84,7 +84,32 @@ export interface ExportProgress {
current: number current: number
total: number total: number
currentSession: string currentSession: string
phase: 'preparing' | 'exporting' | 'writing' | 'complete' phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
}
// 并发控制:限制同时执行的 Promise 数量
async function parallelLimit<T, R>(
items: T[],
limit: number,
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length)
let currentIndex = 0
async function runNext(): Promise<void> {
while (currentIndex < items.length) {
const index = currentIndex++
results[index] = await fn(items[index], index)
}
}
// 启动 limit 个并发任务
const workers = Array(Math.min(limit, items.length))
.fill(null)
.map(() => runNext())
await Promise.all(workers)
return results
} }
class ExportService { class ExportService {
@@ -1122,7 +1147,7 @@ class ExportService {
} }
/** /**
* 导出单个会话为 ChatLab 格式 * 导出单个会话为 ChatLab 格式(并行优化版本)
*/ */
async exportSessionToChatLab( async exportSessionToChatLab(
sessionId: string, sessionId: string,
@@ -1154,51 +1179,101 @@ class ExportService {
allMessages.sort((a, b) => a.createTime - b.createTime) allMessages.sort((a, b) => a.createTime - b.createTime)
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
// ========== 阶段1并行导出媒体文件 ==========
const mediaMessages = exportMediaEnabled
? allMessages.filter(msg => {
const t = msg.localType
return (t === 3 && options.exportImages) || // 图片
(t === 47 && options.exportEmojis) || // 表情
(t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字)
})
: []
const mediaCache = new Map<string, MediaExportItem | null>()
if (mediaMessages.length > 0) {
onProgress?.({
current: 20,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media'
})
// 并行导出媒体,限制 8 个并发
const MEDIA_CONCURRENCY = 8
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
mediaCache.set(mediaKey, mediaItem)
}
})
}
// ========== 阶段2并行语音转文字 ==========
const voiceMessages = options.exportVoiceAsText
? allMessages.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) {
onProgress?.({
current: 40,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice'
})
// 并行转写语音,限制 4 个并发(转写比较耗资源)
const VOICE_CONCURRENCY = 4
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId))
voiceTranscriptMap.set(msg.localId, transcript)
})
}
// ========== 阶段3构建消息列表 ==========
onProgress?.({ onProgress?.({
current: 50, current: 60,
total: 100, total: 100,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
phase: 'exporting' phase: 'exporting'
}) })
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const chatLabMessages: ChatLabMessage[] = allMessages.map(msg => {
const mediaCache = new Map<string, MediaExportItem | null>() const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
const chatLabMessages: ChatLabMessage[] = [] platformId: msg.senderUsername,
for (const msg of allMessages) { accountName: msg.senderUsername,
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { groupNickname: undefined
platformId: msg.senderUsername,
accountName: msg.senderUsername,
groupNickname: undefined
}
let content = this.parseMessageContent(msg.content, msg.localType)
if (exportMediaEnabled) {
const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
mediaCache.set(mediaKey, mediaItem)
}
}
if (msg.localType === 34 && options.exportVoiceAsText) {
// 如果是语音消息且开启了转文字
content = await this.transcribeVoice(sessionId, String(msg.localId))
}
chatLabMessages.push({
sender: msg.senderUsername,
accountName: memberInfo.accountName,
groupNickname: memberInfo.groupNickname,
timestamp: msg.createTime,
type: this.convertMessageType(msg.localType, msg.content),
content: content
})
} }
// 确定消息内容
let content: string | null
if (msg.localType === 34 && options.exportVoiceAsText) {
// 使用预先转写的文字
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
} else {
content = this.parseMessageContent(msg.content, msg.localType)
}
return {
sender: msg.senderUsername,
accountName: memberInfo.accountName,
groupNickname: memberInfo.groupNickname,
timestamp: msg.createTime,
type: this.convertMessageType(msg.localType, msg.content),
content: content
}
})
const avatarMap = options.exportAvatars const avatarMap = options.exportAvatars
? await this.exportAvatars( ? await this.exportAvatars(
[ [
@@ -1265,7 +1340,7 @@ class ExportService {
} }
/** /**
* 导出单个会话为详细 JSON 格式(原项目格式) * 导出单个会话为详细 JSON 格式(原项目格式)- 并行优化版本
*/ */
async exportSessionToDetailedJson( async exportSessionToDetailedJson(
sessionId: string, sessionId: string,
@@ -1290,41 +1365,95 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const allMessages: any[] = []
for (const msg of collected.rows) { // ========== 阶段1并行导出媒体文件 ==========
const senderInfo = await this.getContactInfo(msg.senderUsername) const mediaMessages = exportMediaEnabled
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '') ? collected.rows.filter(msg => {
const source = sourceMatch ? sourceMatch[0] : '' const t = msg.localType
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
})
: []
let content = this.parseMessageContent(msg.content, msg.localType) const mediaCache = new Map<string, MediaExportItem | null>()
let mediaItem: MediaExportItem | null = null
if (exportMediaEnabled) { if (mediaMessages.length > 0) {
const mediaKey = `${msg.localType}_${msg.localId}` onProgress?.({
if (mediaCache.has(mediaKey)) { current: 15,
mediaItem = mediaCache.get(mediaKey) || null total: 100,
} else { currentSession: sessionInfo.displayName,
mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { phase: 'exporting-media'
exportImages: options.exportImages, })
exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis, const MEDIA_CONCURRENCY = 8
exportVoiceAsText: options.exportVoiceAsText await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
}) const mediaKey = `${msg.localType}_${msg.localId}`
mediaCache.set(mediaKey, mediaItem) if (!mediaCache.has(mediaKey)) {
} const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
} exportImages: options.exportImages,
if (mediaItem) { exportVoices: options.exportVoices,
content = mediaItem.relativePath exportEmojis: options.exportEmojis,
} else if (msg.localType === 34 && options.exportVoiceAsText) { exportVoiceAsText: options.exportVoiceAsText
content = await this.transcribeVoice(sessionId, String(msg.localId)) })
mediaCache.set(mediaKey, mediaItem)
} }
})
}
allMessages.push({ // ========== 阶段2并行语音转文字 ==========
localId: allMessages.length + 1, const voiceMessages = options.exportVoiceAsText
createTime: msg.createTime, ? collected.rows.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) {
onProgress?.({
current: 35,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice'
})
const VOICE_CONCURRENCY = 4
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId))
voiceTranscriptMap.set(msg.localId, transcript)
})
}
// ========== 阶段3构建消息列表 ==========
onProgress?.({
current: 55,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
const allMessages: any[] = []
for (const msg of collected.rows) {
const senderInfo = await this.getContactInfo(msg.senderUsername)
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
const source = sourceMatch ? sourceMatch[0] : ''
let content: string | null
const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey)
if (mediaItem) {
content = mediaItem.relativePath
} else if (msg.localType === 34 && options.exportVoiceAsText) {
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
} else {
content = this.parseMessageContent(msg.content, msg.localType)
}
allMessages.push({
localId: allMessages.length + 1,
createTime: msg.createTime,
formattedTime: this.formatTimestamp(msg.createTime), formattedTime: this.formatTimestamp(msg.createTime),
type: this.getMessageTypeName(msg.localType), type: this.getMessageTypeName(msg.localType),
localType: msg.localType, localType: msg.localType,
@@ -1550,20 +1679,31 @@ class ExportService {
// 媒体导出设置 // 媒体导出设置
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
// 媒体导出缓存 // ========== 并行预处理:媒体文件 ==========
const mediaMessages = exportMediaEnabled
? sortedMessages.filter(msg => {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
})
: []
const mediaCache = new Map<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
for (let i = 0; i < sortedMessages.length; i++) { if (mediaMessages.length > 0) {
const msg = sortedMessages[i] onProgress?.({
current: 35,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media'
})
// 导出媒体文件 const MEDIA_CONCURRENCY = 8
let mediaItem: MediaExportItem | null = null await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
if (exportMediaEnabled) {
const mediaKey = `${msg.localType}_${msg.localId}` const mediaKey = `${msg.localType}_${msg.localId}`
if (mediaCache.has(mediaKey)) { if (!mediaCache.has(mediaKey)) {
mediaItem = mediaCache.get(mediaKey) || null const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
} else {
mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages, exportImages: options.exportImages,
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
@@ -1571,7 +1711,45 @@ class ExportService {
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
} }
} })
}
// ========== 并行预处理:语音转文字 ==========
const voiceMessages = options.exportVoiceAsText
? sortedMessages.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) {
onProgress?.({
current: 50,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice'
})
const VOICE_CONCURRENCY = 4
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId))
voiceTranscriptMap.set(msg.localId, transcript)
})
}
onProgress?.({
current: 65,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
// ========== 写入 Excel 行 ==========
for (let i = 0; i < sortedMessages.length; i++) {
const msg = sortedMessages[i]
// 从缓存获取媒体信息
const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey) || null
// 确定发送者信息 // 确定发送者信息
let senderRole: string let senderRole: string
@@ -1620,12 +1798,15 @@ class ExportService {
const row = worksheet.getRow(currentRow) const row = worksheet.getRow(currentRow)
row.height = 24 row.height = 24
// 确定内容:如果有媒体文件导出成功则显示相对路径,否则显示解析后的内容 // 确定内容:优先使用预处理的缓存
let contentValue = mediaItem let contentValue: string
? mediaItem.relativePath if (mediaItem) {
: (this.parseMessageContent(msg.content, msg.localType) || '') contentValue = mediaItem.relativePath
if (!mediaItem && msg.localType === 34 && options.exportVoiceAsText) { } else if (msg.localType === 34 && options.exportVoiceAsText) {
contentValue = await this.transcribeVoice(sessionId, String(msg.localId)) // 使用预处理的语音转文字结果
contentValue = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
} else {
contentValue = this.parseMessageContent(msg.content, msg.localType) || ''
} }
// 调试日志 // 调试日志

View File

@@ -629,6 +629,11 @@ export class KeyService {
if (!ok) { if (!ok) {
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '' const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
if (error) { if (error) {
// 检测权限不足错误 (NTSTATUS 0xC0000022 = STATUS_ACCESS_DENIED)
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件如360、火绒等\n3. 确保微信没有以管理员权限运行'
return { success: false, error: friendlyError }
}
return { success: false, error } return { success: false, error }
} }
const statusBuffer = Buffer.alloc(256) const statusBuffer = Buffer.alloc(256)

View File

@@ -0,0 +1,64 @@
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { ContactCacheService } from './contactCacheService'
export interface SnsPost {
id: string
username: string
nickname: string
avatarUrl?: string
createTime: number
contentDesc: string
type?: number
media: { url: string; thumb: string }[]
likes: string[]
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
}
class SnsService {
private contactCache: ContactCacheService
constructor() {
const config = new ConfigService()
this.contactCache = new ContactCacheService(config.get('cachePath') as string)
}
async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> {
console.log('[SnsService] getTimeline called with:', { limit, offset, usernames, keyword, startTime, endTime })
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
console.log('[SnsService] getSnsTimeline result:', {
success: result.success,
timelineCount: result.timeline?.length,
error: result.error
})
if (result.success && result.timeline) {
const enrichedTimeline = result.timeline.map((post: any) => {
const contact = this.contactCache.get(post.username)
// 修复媒体 URL如果是 http 则尝试用 https (虽然 qpic 可能不支持强制 https但通常支持)
const fixedMedia = post.media.map((m: any) => ({
url: m.url.replace('http://', 'https://'),
thumb: m.thumb.replace('http://', 'https://')
}))
return {
...post,
avatarUrl: contact?.avatarUrl,
nickname: post.nickname || contact?.displayName || post.username,
media: fixedMedia
}
})
console.log('[SnsService] Returning enriched timeline with', enrichedTimeline.length, 'posts')
return { ...result, timeline: enrichedTimeline }
}
console.log('[SnsService] Returning result:', result)
return result
}
}
export const snsService = new SnsService()

View File

@@ -55,6 +55,7 @@ export class WcdbCore {
private wcdbGetEmoticonCdnUrl: any = null private wcdbGetEmoticonCdnUrl: any = null
private wcdbGetDbStatus: any = null private wcdbGetDbStatus: any = null
private wcdbGetVoiceData: any = null private wcdbGetVoiceData: any = null
private wcdbGetSnsTimeline: any = null
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map() private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
private readonly avatarCacheTtlMs = 10 * 60 * 1000 private readonly avatarCacheTtlMs = 10 * 60 * 1000
private logTimer: NodeJS.Timeout | null = null private logTimer: NodeJS.Timeout | null = null
@@ -116,7 +117,8 @@ export class WcdbCore {
private writeLog(message: string, force = false): void { private writeLog(message: string, force = false): void {
if (!force && !this.isLogEnabled()) return if (!force && !this.isLogEnabled()) return
const line = `[${new Date().toISOString()}] ${message}` const line = `[${new Date().toISOString()}] ${message}`
// 移除控制台日志,只写入文件 // 同时输出到控制台和文件
console.log('[WCDB]', message)
try { try {
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd() const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
const dir = join(base, 'logs') const dir = join(base, 'logs')
@@ -385,6 +387,13 @@ export class WcdbCore {
this.wcdbGetVoiceData = null this.wcdbGetVoiceData = null
} }
// wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json)
try {
this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)')
} catch {
this.wcdbGetSnsTimeline = null
}
// 初始化 // 初始化
const initResult = this.wcdbInit() const initResult = this.wcdbInit()
if (initResult !== 0) { if (initResult !== 0) {
@@ -401,8 +410,8 @@ export class WcdbCore {
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true) this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
lastDllInitError = errorMsg lastDllInitError = errorMsg
// 检查是否是常见的 VC++ 运行时缺失错误 // 检查是否是常见的 VC++ 运行时缺失错误
if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') || if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') ||
errorMsg.includes('The specified module could not be found')) { errorMsg.includes('The specified module could not be found')) {
lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。' lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。'
} else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) { } else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) {
lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。' lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。'
@@ -1388,4 +1397,32 @@ export class WcdbCore {
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
} }
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
try {
const outPtr = [null as any]
const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : ''
const result = this.wcdbGetSnsTimeline(
this.handle,
limit,
offset,
usernamesJson,
keyword || '',
startTime || 0,
endTime || 0,
outPtr
)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取朋友圈失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析朋友圈数据失败' }
const timeline = JSON.parse(jsonStr)
return { success: true, timeline }
} catch (e) {
return { success: false, error: String(e) }
}
}
} }

View File

@@ -362,6 +362,13 @@ export class WcdbService {
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId }) return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
} }
/**
* 获取朋友圈
*/
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
}
} }
export const wcdbService = new WcdbService() export const wcdbService = new WcdbService()

View File

@@ -116,6 +116,9 @@ if (parentPort) {
console.error('[wcdbWorker] getVoiceData failed:', result.error) console.error('[wcdbWorker] getVoiceData failed:', result.error)
} }
break break
case 'getSnsTimeline':
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
break
default: default:
result = { success: false, error: `Unknown method: ${type}` } result = { success: false, error: `Unknown method: ${type}` }
} }

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.3.1", "version": "1.3.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.2.0", "version": "1.3.2",
"description": "WeFlow", "description": "WeFlow",
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"author": "cc", "author": "cc",
@@ -105,6 +105,24 @@
"asarUnpack": [ "asarUnpack": [
"node_modules/silk-wasm/**/*", "node_modules/silk-wasm/**/*",
"node_modules/sherpa-onnx-node/**/*" "node_modules/sherpa-onnx-node/**/*"
],
"extraFiles": [
{
"from": "resources/msvcp140.dll",
"to": "."
},
{
"from": "resources/msvcp140_1.dll",
"to": "."
},
{
"from": "resources/vcruntime140.dll",
"to": "."
},
{
"from": "resources/vcruntime140_1.dll",
"to": "."
}
] ]
} }
} }

BIN
resources/msvcp140.dll Normal file

Binary file not shown.

BIN
resources/msvcp140_1.dll Normal file

Binary file not shown.

BIN
resources/vcruntime140.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -16,6 +16,7 @@ import DataManagementPage from './pages/DataManagementPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import ExportPage from './pages/ExportPage' import ExportPage from './pages/ExportPage'
import VideoWindow from './pages/VideoWindow' import VideoWindow from './pages/VideoWindow'
import SnsPage from './pages/SnsPage'
import { useAppStore } from './stores/appStore' import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId } from './stores/themeStore' import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
@@ -205,11 +206,11 @@ function App() {
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户 // 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
// 其他错误可能需要重新配置 // 其他错误可能需要重新配置
const errorMsg = result.error || '' const errorMsg = result.error || ''
if (errorMsg.includes('Visual C++') || if (errorMsg.includes('Visual C++') ||
errorMsg.includes('DLL') || errorMsg.includes('DLL') ||
errorMsg.includes('Worker') || errorMsg.includes('Worker') ||
errorMsg.includes('126') || errorMsg.includes('126') ||
errorMsg.includes('模块')) { errorMsg.includes('模块')) {
console.warn('检测到可能的运行时依赖问题:', errorMsg) console.warn('检测到可能的运行时依赖问题:', errorMsg)
// 不清除配置,让用户安装 VC++ 后重试 // 不清除配置,让用户安装 VC++ 后重试
} }
@@ -336,6 +337,7 @@ function App() {
<Route path="/data-management" element={<DataManagementPage />} /> <Route path="/data-management" element={<DataManagementPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<ExportPage />} /> <Route path="/export" element={<ExportPage />} />
<Route path="/sns" element={<SnsPage />} />
</Routes> </Routes>
</RouteGuard> </RouteGuard>
</main> </main>

View File

@@ -0,0 +1,238 @@
.jump-date-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
animation: fadeIn 0.2s ease-out;
}
.jump-date-modal {
background: var(--card-bg);
width: 340px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.jump-date-header {
padding: 18px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
.title-area {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
svg {
color: var(--primary);
}
h3 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
}
.close-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: flex;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.calendar-view {
padding: 20px;
.calendar-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.current-month {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
}
}
}
.calendar-grid {
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 8px;
.weekday {
text-align: center;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
padding: 4px 0;
}
}
.days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
.day-cell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--text-primary);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&.empty {
cursor: default;
}
&:not(.empty):hover {
background: var(--bg-hover);
}
&.selected {
background: var(--primary);
color: #fff;
font-weight: 600;
}
&.today:not(.selected) {
color: var(--primary);
font-weight: 600;
background: var(--primary-light);
}
}
}
}
.quick-options {
display: flex;
gap: 8px;
padding: 0 20px 16px;
button {
flex: 1;
padding: 8px;
font-size: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
border-color: var(--primary);
}
}
}
.dialog-footer {
padding: 16px 20px;
display: flex;
gap: 12px;
background: var(--bg-secondary);
button {
flex: 1;
padding: 10px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.cancel-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.confirm-btn {
background: var(--primary);
border: none;
color: #fff;
&:hover {
background: var(--primary-hover);
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modalSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,156 @@
import React, { useState } from 'react'
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'
import './JumpToDateDialog.scss'
interface JumpToDateDialogProps {
isOpen: boolean
onClose: () => void
onSelect: (date: Date) => void
currentDate?: Date
}
const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
isOpen,
onClose,
onSelect,
currentDate = new Date()
}) => {
const [calendarDate, setCalendarDate] = useState(new Date(currentDate))
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
if (!isOpen) return null
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month + 1, 0).getDate()
}
const getFirstDayOfMonth = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month, 1).getDay()
}
const generateCalendar = () => {
const daysInMonth = getDaysInMonth(calendarDate)
const firstDay = getFirstDayOfMonth(calendarDate)
const days: (number | null)[] = []
for (let i = 0; i < firstDay; i++) {
days.push(null)
}
for (let i = 1; i <= daysInMonth; i++) {
days.push(i)
}
return days
}
const handleDateClick = (day: number) => {
const newDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
setSelectedDate(newDate)
}
const handleConfirm = () => {
onSelect(selectedDate)
onClose()
}
const isToday = (day: number) => {
const today = new Date()
return day === today.getDate() &&
calendarDate.getMonth() === today.getMonth() &&
calendarDate.getFullYear() === today.getFullYear()
}
const isSelected = (day: number) => {
return day === selectedDate.getDate() &&
calendarDate.getMonth() === selectedDate.getMonth() &&
calendarDate.getFullYear() === selectedDate.getFullYear()
}
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const days = generateCalendar()
return (
<div className="jump-date-overlay" onClick={onClose}>
<div className="jump-date-modal" onClick={e => e.stopPropagation()}>
<div className="jump-date-header">
<div className="title-area">
<CalendarIcon size={18} />
<h3></h3>
</div>
<button className="close-btn" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="calendar-view">
<div className="calendar-nav">
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
>
<ChevronLeft size={18} />
</button>
<span className="current-month">
{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}
</span>
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
>
<ChevronRight size={18} />
</button>
</div>
<div className="calendar-grid">
<div className="weekdays">
{weekdays.map(d => <div key={d} className="weekday">{d}</div>)}
</div>
<div className="days">
{days.map((day, i) => (
<div
key={i}
className={`day-cell ${day === null ? 'empty' : ''} ${day !== null && isSelected(day) ? 'selected' : ''} ${day !== null && isToday(day) ? 'today' : ''}`}
onClick={() => day !== null && handleDateClick(day)}
>
{day}
</div>
))}
</div>
</div>
</div>
<div className="quick-options">
<button onClick={() => {
const d = new Date()
setSelectedDate(d)
setCalendarDate(new Date(d))
}}></button>
<button onClick={() => {
const d = new Date()
d.setDate(d.getDate() - 7)
setSelectedDate(d)
setCalendarDate(new Date(d))
}}></button>
<button onClick={() => {
const d = new Date()
d.setMonth(d.getMonth() - 1)
setSelectedDate(d)
setCalendarDate(new Date(d))
}}></button>
</div>
<div className="dialog-footer">
<button className="cancel-btn" onClick={onClose}></button>
<button className="confirm-btn" onClick={handleConfirm}></button>
</div>
</div>
</div>
)
}
export default JumpToDateDialog

View File

@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { NavLink, useLocation } from 'react-router-dom' import { NavLink, useLocation } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } from 'lucide-react' import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture } from 'lucide-react'
import './Sidebar.scss' import './Sidebar.scss'
function Sidebar() { function Sidebar() {
@@ -34,6 +34,16 @@ function Sidebar() {
<span className="nav-label"></span> <span className="nav-label"></span>
</NavLink> </NavLink>
{/* 朋友圈 */}
<NavLink
to="/sns"
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
title={collapsed ? '朋友圈' : undefined}
>
<span className="nav-icon"><Aperture size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 私聊分析 */} {/* 私聊分析 */}

View File

@@ -489,8 +489,21 @@
} }
.load-more-trigger { .load-more-trigger {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 0;
color: var(--text-tertiary); color: var(--text-tertiary);
font-size: 12px; font-size: 13px;
&.later {
padding: 24px 0 12px;
}
svg {
animation: spin 1s linear infinite;
}
} }
.empty-chat { .empty-chat {
@@ -1660,7 +1673,7 @@
max-width: 100%; max-width: 100%;
min-width: 0; // 允许收缩 min-width: 0; // 允许收缩
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
// 让气泡宽度由内容决定,而不是被父容器撑开 // 让气泡宽度由内容决定,而不是被父容器撑开
width: fit-content; width: fit-content;
} }

View File

@@ -7,6 +7,7 @@ import { getEmojiPath } from 'wechat-emojis'
import { ImagePreview } from '../components/ImagePreview' import { ImagePreview } from '../components/ImagePreview'
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { AnimatedStreamingText } from '../components/AnimatedStreamingText' import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
import JumpToDateDialog from '../components/JumpToDateDialog'
import * as configService from '../services/config' import * as configService from '../services/config'
import './ChatPage.scss' import './ChatPage.scss'
@@ -132,15 +133,25 @@ function ChatPage(_props: ChatPageProps) {
setLoadingMessages, setLoadingMessages,
setLoadingMore, setLoadingMore,
setHasMoreMessages, setHasMoreMessages,
hasMoreLater,
setHasMoreLater,
setSearchKeyword setSearchKeyword
} = useChatStore() } = useChatStore()
const messageListRef = useRef<HTMLDivElement>(null) const messageListRef = useRef<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
const sidebarRef = useRef<HTMLDivElement>(null) const sidebarRef = useRef<HTMLDivElement>(null)
const getMessageKey = useCallback((msg: Message): string => {
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
}, [])
const initialRevealTimerRef = useRef<number | null>(null) const initialRevealTimerRef = useRef<number | null>(null)
const sessionListRef = useRef<HTMLDivElement>(null) const sessionListRef = useRef<HTMLDivElement>(null)
const [currentOffset, setCurrentOffset] = useState(0) const [currentOffset, setCurrentOffset] = useState(0)
const [jumpStartTime, setJumpStartTime] = useState(0)
const [jumpEndTime, setJumpEndTime] = useState(0)
const [showJumpDialog, setShowJumpDialog] = useState(false)
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined) const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
const [myWxid, setMyWxid] = useState<string | undefined>(undefined) const [myWxid, setMyWxid] = useState<string | undefined>(undefined)
const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false)
@@ -477,6 +488,9 @@ function ChatPage(_props: ChatPageProps) {
// 刷新会话列表 // 刷新会话列表
const handleRefresh = async () => { const handleRefresh = async () => {
setJumpStartTime(0)
setJumpEndTime(0)
setHasMoreLater(false)
await loadSessions({ silent: true }) await loadSessions({ silent: true })
} }
@@ -484,6 +498,9 @@ function ChatPage(_props: ChatPageProps) {
const [isRefreshingMessages, setIsRefreshingMessages] = useState(false) const [isRefreshingMessages, setIsRefreshingMessages] = useState(false)
const handleRefreshMessages = async () => { const handleRefreshMessages = async () => {
if (!currentSessionId || isRefreshingMessages) return if (!currentSessionId || isRefreshingMessages) return
setJumpStartTime(0)
setJumpEndTime(0)
setHasMoreLater(false)
setIsRefreshingMessages(true) setIsRefreshingMessages(true)
try { try {
// 获取最新消息并增量添加 // 获取最新消息并增量添加
@@ -518,7 +535,7 @@ function ChatPage(_props: ChatPageProps) {
} }
// 加载消息 // 加载消息
const loadMessages = async (sessionId: string, offset = 0) => { const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => {
const listEl = messageListRef.current const listEl = messageListRef.current
const session = sessionMapRef.current.get(sessionId) const session = sessionMapRef.current.get(sessionId)
const unreadCount = session?.unreadCount ?? 0 const unreadCount = session?.unreadCount ?? 0
@@ -535,7 +552,7 @@ function ChatPage(_props: ChatPageProps) {
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
try { try {
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit) const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime)
if (result.success && result.messages) { if (result.success && result.messages) {
if (offset === 0) { if (offset === 0) {
setMessages(result.messages) setMessages(result.messages)
@@ -601,6 +618,14 @@ function ChatPage(_props: ChatPageProps) {
} }
} }
setHasMoreMessages(result.hasMore ?? false) setHasMoreMessages(result.hasMore ?? false)
// 如果是按 endTime 跳转加载,且结果刚好满批,可能后面(更晚)还有消息
if (offset === 0) {
if (endTime > 0) {
setHasMoreLater(true)
} else {
setHasMoreLater(false)
}
}
setCurrentOffset(offset + result.messages.length) setCurrentOffset(offset + result.messages.length)
} else if (!result.success) { } else if (!result.success) {
setConnectionError(result.error || '加载消息失败') setConnectionError(result.error || '加载消息失败')
@@ -616,12 +641,41 @@ function ChatPage(_props: ChatPageProps) {
} }
} }
// 加载更晚的消息
const loadLaterMessages = useCallback(async () => {
if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return
setLoadingMore(true)
try {
const lastMsg = messages[messages.length - 1]
// 从最后一条消息的时间开始往后找
const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true)
if (result.success && result.messages) {
// 过滤掉已经在列表中的重复消息
const existingKeys = messageKeySetRef.current
const newMsgs = result.messages.filter(m => !existingKeys.has(getMessageKey(m)))
if (newMsgs.length > 0) {
appendMessages(newMsgs, false)
}
setHasMoreLater(result.hasMore ?? false)
}
} catch (e) {
console.error('加载后续消息失败:', e)
} finally {
setLoadingMore(false)
}
}, [currentSessionId, isLoadingMore, isLoadingMessages, messages, getMessageKey, appendMessages, setHasMoreLater, setLoadingMore])
// 选择会话 // 选择会话
const handleSelectSession = (session: ChatSession) => { const handleSelectSession = (session: ChatSession) => {
if (session.username === currentSessionId) return if (session.username === currentSessionId) return
setCurrentSession(session.username) setCurrentSession(session.username)
setCurrentOffset(0) setCurrentOffset(0)
loadMessages(session.username, 0) setJumpStartTime(0)
setJumpEndTime(0)
loadMessages(session.username, 0, 0, 0)
// 重置详情面板 // 重置详情面板
setSessionDetail(null) setSessionDetail(null)
if (showDetailPanel) { if (showDetailPanel) {
@@ -678,16 +732,21 @@ function ChatPage(_props: ChatPageProps) {
if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) { if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) {
const threshold = clientHeight * 0.3 const threshold = clientHeight * 0.3
if (scrollTop < threshold) { if (scrollTop < threshold) {
loadMessages(currentSessionId, currentOffset) loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
}
}
// 预加载更晚的消息
if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) {
const threshold = clientHeight * 0.3
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
if (distanceFromBottom < threshold) {
loadLaterMessages()
} }
} }
}) })
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset, loadMessages]) }, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages])
const getMessageKey = useCallback((msg: Message): string => {
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
}, [])
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => { const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
return ( return (
@@ -1102,6 +1161,25 @@ function ChatPage(_props: ChatPageProps) {
)} )}
</div> </div>
<div className="header-actions"> <div className="header-actions">
<button
className="icon-btn jump-to-time-btn"
onClick={() => setShowJumpDialog(true)}
title="跳转到指定时间"
>
<Calendar size={18} />
</button>
<JumpToDateDialog
isOpen={showJumpDialog}
onClose={() => setShowJumpDialog(false)}
onSelect={(date) => {
if (!currentSessionId) return
const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000)
setCurrentOffset(0)
setJumpStartTime(0)
setJumpEndTime(end)
loadMessages(currentSessionId, 0, 0, end)
}}
/>
<button <button
className="icon-btn refresh-messages-btn" className="icon-btn refresh-messages-btn"
onClick={handleRefreshMessages} onClick={handleRefreshMessages}
@@ -1177,6 +1255,19 @@ function ChatPage(_props: ChatPageProps) {
) )
})} })}
{hasMoreLater && (
<div className={`load-more-trigger later ${isLoadingMore ? 'loading' : ''}`}>
{isLoadingMore ? (
<>
<Loader2 size={14} />
<span>...</span>
</>
) : (
<span></span>
)}
</div>
)}
{/* 回到底部按钮 */} {/* 回到底部按钮 */}
<div className={`scroll-to-bottom ${showScrollToBottom ? 'show' : ''}`} onClick={scrollToBottom}> <div className={`scroll-to-bottom ${showScrollToBottom ? 'show' : ''}`} onClick={scrollToBottom}>
<ChevronDown size={16} /> <ChevronDown size={16} />

View File

@@ -231,12 +231,12 @@ function ExportPage() {
exportImages: options.exportMedia && options.exportImages, exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices, exportVoices: options.exportMedia && options.exportVoices,
exportEmojis: options.exportMedia && options.exportEmojis, exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // ?????????exportMedia exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
excelCompactColumns: options.excelCompactColumns, excelCompactColumns: options.excelCompactColumns,
sessionLayout, sessionLayout,
dateRange: options.useAllTime ? null : options.dateRange ? { dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000), start: Math.floor(options.dateRange.start.getTime() / 1000),
// ?????????????????????????????????23:59:59,?????????????????????????????? // 将结束日期设置为当天的 23:59:59确保包含当天的所有记录
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
} : null } : null
} }
@@ -249,10 +249,10 @@ function ExportPage() {
) )
setExportResult(result) setExportResult(result)
} else { } else {
setExportResult({ success: false, error: `${options.format.toUpperCase()} ???????????????????????????...` }) setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` })
} }
} catch (e) { } catch (e) {
console.error('????????????:', e) console.error('导出过程中发生异常:', e)
setExportResult({ success: false, error: String(e) }) setExportResult({ success: false, error: String(e) })
} finally { } finally {
setIsExporting(false) setIsExporting(false)

579
src/pages/SnsPage.scss Normal file
View File

@@ -0,0 +1,579 @@
.sns-page {
height: 100%;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
.sns-container {
display: flex;
height: 100%;
}
.sns-sidebar {
width: 280px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
&.closed {
width: 0;
opacity: 0;
pointer-events: none;
}
.sidebar-header {
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.toggle-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
&:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
}
}
.filter-content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
/* 自定义滚动条 */
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 10px;
}
}
.filter-group {
padding-bottom: 4px;
border-bottom: 1px solid var(--border-color);
}
.filter-section {
padding: 10px 20px;
label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
font-weight: 500;
}
input[type="text"] {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
color: var(--text-primary);
font-size: 13px;
outline: none;
&:focus {
border-color: var(--accent-color);
}
}
.date-inputs {
display: flex;
flex-direction: column;
gap: 6px;
input[type="date"] {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 6px 10px;
color: var(--text-primary);
font-size: 12px;
outline: none;
width: 100%;
&:focus {
border-color: var(--accent-color);
}
}
span {
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
}
}
}
.contact-filter-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 200px;
padding: 12px 0 0 0;
.section-header {
padding: 0 20px 8px 20px;
display: flex;
justify-content: space-between;
align-items: center;
label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.selected-count {
font-size: 11px;
background: var(--accent-color);
color: white;
padding: 1px 6px;
border-radius: 10px;
}
}
.contact-search {
margin: 0 20px 10px 20px;
position: relative;
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
}
input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 6px 10px 6px 28px;
font-size: 12px;
color: var(--text-primary);
outline: none;
&:focus {
border-color: var(--accent-color);
}
}
}
.contact-list {
flex: 1;
overflow-y: auto;
padding: 0 10px;
.contact-item {
display: flex;
align-items: center;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
gap: 10px;
margin-bottom: 2px;
position: relative;
&:hover {
background: var(--hover-bg);
}
&.active {
background: rgba(var(--accent-color-rgb), 0.1);
.contact-name {
color: var(--accent-color);
font-weight: 600;
}
}
.contact-name {
flex: 1;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-secondary);
}
.check-mark {
color: var(--accent-color);
font-size: 12px;
font-weight: bold;
}
}
.empty-contacts {
text-align: center;
padding: 20px;
font-size: 12px;
color: var(--text-tertiary);
}
}
}
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-color);
.clear-btn {
width: 100%;
padding: 8px;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
&:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
}
}
}
.sns-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
.sns-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 60px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
.header-left {
display: flex;
align-items: center;
gap: 12px;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.icon-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
}
.spinning {
animation: spin 1s linear infinite;
}
}
.sns-content {
flex: 1;
overflow-y: auto;
padding: 24px;
scroll-behavior: smooth;
.active-filters {
max-width: 680px;
margin: 0 auto 16px auto;
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(var(--accent-color-rgb), 0.05);
border: 1px solid rgba(var(--accent-color-rgb), 0.2);
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
color: var(--accent-color);
.clear-chip-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
font-size: 12px;
text-decoration: underline;
&:hover {
color: var(--text-secondary);
}
}
}
.sns-post {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
max-width: 680px;
margin-left: auto;
margin-right: auto;
border: 1px solid var(--border-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
.post-header {
display: flex;
align-items: center;
margin-bottom: 14px;
.post-info {
margin-left: 12px;
.nickname {
font-weight: 600;
margin-bottom: 2px;
color: var(--accent-color);
}
.time {
font-size: 12px;
color: var(--text-tertiary);
}
}
}
.post-body {
margin-bottom: 16px;
.post-text {
margin-bottom: 12px;
white-space: pre-wrap;
line-height: 1.6;
font-size: 15px;
word-break: break-word;
}
.post-media-grid {
display: grid;
gap: 4px;
width: fit-content;
max-width: 100%;
&.media-count-1 {
grid-template-columns: 1fr;
.media-item {
max-width: 400px;
aspect-ratio: unset;
}
img {
height: auto;
max-height: 500px;
}
}
&.media-count-2,
&.media-count-4 {
grid-template-columns: repeat(2, 1fr);
}
&.media-count-3,
&.media-count-5,
&.media-count-6,
&.media-count-7,
&.media-count-8,
&.media-count-9 {
grid-template-columns: repeat(3, 1fr);
}
.media-item {
aspect-ratio: 1;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
&.error {
background: transparent;
border: 1px dashed var(--border-color);
color: var(--text-tertiary);
font-size: 12px;
min-width: 100px;
min-height: 100px;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.85;
}
}
}
}
.post-video-placeholder {
display: inline-flex;
align-items: center;
background: rgba(var(--accent-color-rgb), 0.1);
color: var(--accent-color);
padding: 6px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
}
.post-footer {
background: var(--bg-tertiary);
border-radius: 6px;
padding: 10px 12px;
font-size: 13.5px;
.likes-section {
display: flex;
align-items: flex-start;
color: var(--accent-color);
padding-bottom: 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
margin-bottom: 8px;
&:last-child {
padding-bottom: 0;
border-bottom: none;
margin-bottom: 0;
}
.icon {
margin-top: 3.5px;
margin-right: 8px;
flex-shrink: 0;
}
.likes-list {
line-height: 1.5;
}
}
.comments-section {
.comment-item {
margin-bottom: 6px;
line-height: 1.5;
&:last-child {
margin-bottom: 0;
}
.comment-user {
color: var(--accent-color);
font-weight: 600;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.reply-text {
color: var(--text-tertiary);
margin: 0 4px;
font-size: 13px;
}
.comment-separator {
color: var(--text-secondary);
margin-left: -2px;
margin-right: 4px;
}
.comment-content {
color: var(--text-secondary);
}
}
}
}
}
.loading-more,
.no-more,
.no-results {
text-align: center;
padding: 40px 20px;
color: var(--text-tertiary);
font-size: 14px;
.reset-inline {
margin-top: 12px;
background: var(--accent-color);
color: white;
border: none;
padding: 8px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
box-shadow: 0 2px 6px rgba(var(--accent-color-rgb), 0.3);
}
}
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

421
src/pages/SnsPage.tsx Normal file
View File

@@ -0,0 +1,421 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { RefreshCw, Heart, Search, Calendar, User, X, Filter } from 'lucide-react'
import { Avatar } from '../components/Avatar'
import { ImagePreview } from '../components/ImagePreview'
import './SnsPage.scss'
interface SnsPost {
id: string
username: string
nickname: string
avatarUrl?: string
createTime: number
contentDesc: string
type?: number
media: { url: string; thumb: string }[]
likes: string[]
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
}
const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => {
const [error, setError] = useState(false);
if (error) {
return (
<div className="media-item error">
<span></span>
</div>
);
}
return (
<div className="media-item">
<img
src={thumb || url}
alt=""
loading="lazy"
onClick={onPreview}
onError={() => setError(true)}
/>
</div>
);
};
interface Contact {
username: string
displayName: string
avatarUrl?: string
}
export default function SnsPage() {
const [posts, setPosts] = useState<SnsPost[]>([])
const [loading, setLoading] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const loadingRef = useRef(false)
// 筛选与搜索状态
const [searchKeyword, setSearchKeyword] = useState('')
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
// 联系人列表状态
const [contacts, setContacts] = useState<Contact[]>([])
const [contactSearch, setContactSearch] = useState('')
const [contactsLoading, setContactsLoading] = useState(false)
const [previewImage, setPreviewImage] = useState<string | null>(null)
const loadPosts = useCallback(async (reset = false) => {
if (loadingRef.current) return
loadingRef.current = true
setLoading(true)
try {
const currentOffset = reset ? 0 : offset
const limit = 20
// 转换日期为秒级时间戳
const startTs = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined
const endTs = endDate ? Math.floor(new Date(endDate).getTime() / 1000) + 86399 : undefined // 包含当天
const result = await window.electronAPI.sns.getTimeline(
limit,
currentOffset,
selectedUsernames,
searchKeyword,
startTs,
endTs
)
if (result.success && result.timeline) {
if (reset) {
setPosts(result.timeline)
setOffset(limit)
setHasMore(result.timeline.length >= limit)
} else {
setPosts(prev => [...prev, ...result.timeline!])
setOffset(prev => prev + limit)
if (result.timeline.length < limit) {
setHasMore(false)
}
}
}
} catch (error) {
console.error('Failed to load SNS timeline:', error)
} finally {
setLoading(false)
loadingRef.current = false
}
}, [offset, selectedUsernames, searchKeyword, startDate, endDate])
// 获取联系人列表
const loadContacts = async () => {
setContactsLoading(true)
try {
const result = await window.electronAPI.chat.getSessions()
if (result.success && result.sessions) {
// 系统账号和特殊前缀
const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder'];
// 初步提取并过滤联系人
const initialContacts = result.sessions
.filter((s: any) => {
if (!s.username) return false;
const u = s.username.toLowerCase();
// 1. 排除群聊 (WeChat 群组以 @chatroom 结尾)
if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) {
return false;
}
// 2. 排除公众号 (通常以 gh_ 开头)
if (u.startsWith('gh_')) {
return false;
}
// 3. 排除系统账号
if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) {
return false;
}
return true;
})
.map((s: any) => ({
username: s.username,
displayName: s.displayName || s.username,
avatarUrl: s.avatarUrl
}))
setContacts(initialContacts)
// 异步进一步富化(获取更多准确的昵称和头像)
const usernames = initialContacts.map(c => c.username)
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (enriched.success && enriched.contacts) {
setContacts(prev => prev.map(c => {
const extra = enriched.contacts![c.username]
if (extra) {
return {
...c,
displayName: extra.displayName || c.displayName,
avatarUrl: extra.avatarUrl || c.avatarUrl
}
}
return c
}))
}
}
} catch (error) {
console.error('Failed to load contacts:', error)
} finally {
setContactsLoading(false)
}
}
useEffect(() => {
loadContacts()
}, [])
useEffect(() => {
loadPosts(true)
}, [selectedUsernames, searchKeyword, startDate, endDate])
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
if (scrollHeight - scrollTop - clientHeight < 200 && hasMore && !loading) {
loadPosts()
}
}
const formatTime = (ts: number) => {
const date = new Date(ts * 1000)
const isCurrentYear = date.getFullYear() === new Date().getFullYear()
return date.toLocaleString('zh-CN', {
year: isCurrentYear ? undefined : 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const toggleUserSelection = (username: string) => {
setSelectedUsernames(prev => {
if (prev.includes(username)) {
return prev.filter(u => u !== username)
} else {
return [...prev, username]
}
})
}
const clearFilters = () => {
setSearchKeyword('')
setSelectedUsernames([])
setStartDate('')
setEndDate('')
}
const filteredContacts = contacts.filter(c =>
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
)
return (
<div className="sns-page">
<div className="sns-container">
{/* 侧边栏:过滤与搜索 */}
<aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}>
<div className="sidebar-header">
<h3></h3>
<button className="toggle-btn" onClick={() => setIsSidebarOpen(false)}>
<X size={18} />
</button>
</div>
<div className="filter-content">
{/* 关键词与时间 */}
<div className="filter-group">
<div className="filter-section">
<label><Search size={14} /> </label>
<input
type="text"
placeholder="搜索正文..."
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
/>
</div>
<div className="filter-section">
<label><Calendar size={14} /> </label>
<div className="date-inputs">
<input
type="date"
value={startDate}
onChange={e => setStartDate(e.target.value)}
/>
<span></span>
<input
type="date"
value={endDate}
onChange={e => setEndDate(e.target.value)}
/>
</div>
</div>
</div>
{/* 联系人列表 */}
<div className="contact-filter-section">
<div className="section-header">
<label><User size={14} /> </label>
{selectedUsernames.length > 0 && (
<span className="selected-count"> {selectedUsernames.length}</span>
)}
</div>
<div className="contact-search">
<Search size={12} className="search-icon" />
<input
type="text"
placeholder="搜索好友..."
value={contactSearch}
onChange={e => setContactSearch(e.target.value)}
/>
</div>
<div className="contact-list custom-scrollbar">
{filteredContacts.map(contact => (
<div
key={contact.username}
className={`contact-item ${selectedUsernames.includes(contact.username) ? 'active' : ''}`}
onClick={() => toggleUserSelection(contact.username)}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={28} shape="rounded" />
<span className="contact-name">{contact.displayName}</span>
{selectedUsernames.includes(contact.username) && (
<div className="check-mark"></div>
)}
</div>
))}
{contacts.length === 0 && !contactsLoading && (
<div className="empty-contacts"></div>
)}
</div>
</div>
</div>
<div className="sidebar-footer">
<button className="clear-btn" onClick={clearFilters}></button>
</div>
</aside>
<main className="sns-main">
<div className="sns-header">
<div className="header-left">
{!isSidebarOpen && (
<button className="icon-btn" onClick={() => setIsSidebarOpen(true)}>
<Filter size={20} />
</button>
)}
<h2></h2>
</div>
<div className="header-right">
<button onClick={() => loadPosts(true)} disabled={loading} className="icon-btn refresh-btn">
<RefreshCw size={18} className={loading ? 'spinning' : ''} />
</button>
</div>
</div>
<div className="sns-content" onScroll={handleScroll}>
{selectedUsernames.length > 0 && (
<div className="active-filters">
<span>: {selectedUsernames.length} </span>
<button onClick={() => setSelectedUsernames([])} className="clear-chip-btn"></button>
</div>
)}
{posts.map(post => (
<div key={post.id} className="sns-post">
<div className="post-header">
<Avatar
src={post.avatarUrl}
name={post.nickname}
size={44}
shape="rounded"
/>
<div className="post-info">
<div className="nickname">{post.nickname}</div>
<div className="time">{formatTime(post.createTime)}</div>
</div>
</div>
<div className="post-body">
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
{post.type === 15 ? (
<div className="post-video-placeholder">
[]
</div>
) : post.media.length > 0 && (
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
{post.media.map((m, idx) => (
<MediaItem key={idx} url={m.url} thumb={m.thumb} onPreview={() => setPreviewImage(m.url)} />
))}
</div>
)}
</div>
{(post.likes.length > 0 || post.comments.length > 0) && (
<div className="post-footer">
{post.likes.length > 0 && (
<div className="likes-section">
<Heart size={14} className="icon" />
<span className="likes-list">
{post.likes.join('、')}
</span>
</div>
)}
{post.comments.length > 0 && (
<div className="comments-section">
{post.comments.map((c, idx) => (
<div key={idx} className="comment-item">
<span className="comment-user">{c.nickname}</span>
{c.refNickname && (
<>
<span className="reply-text"></span>
<span className="comment-user">{c.refNickname}</span>
</>
)}
<span className="comment-separator">: </span>
<span className="comment-content">{c.content}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
{loading && <div className="loading-more">...</div>}
{!hasMore && posts.length > 0 && <div className="no-more"></div>}
{!loading && posts.length === 0 && (
<div className="no-results">
<p></p>
{selectedUsernames.length > 0 && (
<button onClick={() => setSelectedUsernames([])} className="reset-inline">
</button>
)}
</div>
)}
</div>
</main>
</div>
{previewImage && (
<ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} />
)}
</div>
)
}

View File

@@ -6,25 +6,26 @@ export interface ChatState {
isConnected: boolean isConnected: boolean
isConnecting: boolean isConnecting: boolean
connectionError: string | null connectionError: string | null
// 会话列表 // 会话列表
sessions: ChatSession[] sessions: ChatSession[]
filteredSessions: ChatSession[] filteredSessions: ChatSession[]
currentSessionId: string | null currentSessionId: string | null
isLoadingSessions: boolean isLoadingSessions: boolean
// 消息 // 消息
messages: Message[] messages: Message[]
isLoadingMessages: boolean isLoadingMessages: boolean
isLoadingMore: boolean isLoadingMore: boolean
hasMoreMessages: boolean hasMoreMessages: boolean
hasMoreLater: boolean
// 联系人缓存 // 联系人缓存
contacts: Map<string, Contact> contacts: Map<string, Contact>
// 搜索 // 搜索
searchKeyword: string searchKeyword: string
// 操作 // 操作
setConnected: (connected: boolean) => void setConnected: (connected: boolean) => void
setConnecting: (connecting: boolean) => void setConnecting: (connecting: boolean) => void
@@ -38,6 +39,7 @@ export interface ChatState {
setLoadingMessages: (loading: boolean) => void setLoadingMessages: (loading: boolean) => void
setLoadingMore: (loading: boolean) => void setLoadingMore: (loading: boolean) => void
setHasMoreMessages: (hasMore: boolean) => void setHasMoreMessages: (hasMore: boolean) => void
setHasMoreLater: (hasMore: boolean) => void
setContacts: (contacts: Contact[]) => void setContacts: (contacts: Contact[]) => void
addContact: (contact: Contact) => void addContact: (contact: Contact) => void
setSearchKeyword: (keyword: string) => void setSearchKeyword: (keyword: string) => void
@@ -56,48 +58,51 @@ export const useChatStore = create<ChatState>((set, get) => ({
isLoadingMessages: false, isLoadingMessages: false,
isLoadingMore: false, isLoadingMore: false,
hasMoreMessages: true, hasMoreMessages: true,
hasMoreLater: false,
contacts: new Map(), contacts: new Map(),
searchKeyword: '', searchKeyword: '',
setConnected: (connected) => set({ isConnected: connected }), setConnected: (connected) => set({ isConnected: connected }),
setConnecting: (connecting) => set({ isConnecting: connecting }), setConnecting: (connecting) => set({ isConnecting: connecting }),
setConnectionError: (error) => set({ connectionError: error }), setConnectionError: (error) => set({ connectionError: error }),
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }), setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }), setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
setCurrentSession: (sessionId) => set({ setCurrentSession: (sessionId) => set({
currentSessionId: sessionId, currentSessionId: sessionId,
messages: [], messages: [],
hasMoreMessages: true hasMoreMessages: true,
hasMoreLater: false
}), }),
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }), setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
setMessages: (messages) => set({ messages }), setMessages: (messages) => set({ messages }),
appendMessages: (newMessages, prepend = false) => set((state) => ({ appendMessages: (newMessages, prepend = false) => set((state) => ({
messages: prepend messages: prepend
? [...newMessages, ...state.messages] ? [...newMessages, ...state.messages]
: [...state.messages, ...newMessages] : [...state.messages, ...newMessages]
})), })),
setLoadingMessages: (loading) => set({ isLoadingMessages: loading }), setLoadingMessages: (loading) => set({ isLoadingMessages: loading }),
setLoadingMore: (loading) => set({ isLoadingMore: loading }), setLoadingMore: (loading) => set({ isLoadingMore: loading }),
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }), setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
setHasMoreLater: (hasMore) => set({ hasMoreLater: hasMore }),
setContacts: (contacts) => set({
contacts: new Map(contacts.map(c => [c.username, c])) setContacts: (contacts) => set({
contacts: new Map(contacts.map(c => [c.username, c]))
}), }),
addContact: (contact) => set((state) => { addContact: (contact) => set((state) => {
const newContacts = new Map(state.contacts) const newContacts = new Map(state.contacts)
newContacts.set(contact.username, contact) newContacts.set(contact.username, contact)
return { contacts: newContacts } return { contacts: newContacts }
}), }),
setSearchKeyword: (keyword) => set({ searchKeyword: keyword }), setSearchKeyword: (keyword) => set({ searchKeyword: keyword }),
reset: () => set({ reset: () => set({
isConnected: false, isConnected: false,
isConnecting: false, isConnecting: false,
@@ -110,6 +115,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
isLoadingMessages: false, isLoadingMessages: false,
isLoadingMore: false, isLoadingMore: false,
hasMoreMessages: true, hasMoreMessages: true,
hasMoreLater: false,
contacts: new Map(), contacts: new Map(),
searchKeyword: '' searchKeyword: ''
}) })

View File

@@ -63,7 +63,7 @@ export interface ElectronAPI {
contacts?: Record<string, { displayName?: string; avatarUrl?: string }> contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string error?: string
}> }>
getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{ getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => Promise<{
success: boolean; success: boolean;
messages?: Message[]; messages?: Message[];
hasMore?: boolean; hasMore?: boolean;
@@ -321,6 +321,24 @@ export interface ElectronAPI {
getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }> getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }>
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void
} }
sns: {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => Promise<{
success: boolean
timeline?: Array<{
id: string
username: string
nickname: string
avatarUrl?: string
createTime: number
contentDesc: string
type?: number
media: Array<{ url: string; thumb: string }>
likes: Array<string>
comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }>
}>
error?: string
}>
}
} }
export interface ExportOptions { export interface ExportOptions {