Merge pull request #23 from hicccc77/main

同步
This commit is contained in:
cc
2026-01-12 22:26:36 +08:00
committed by GitHub
17 changed files with 1781 additions and 234 deletions

View File

@@ -15,6 +15,8 @@ jobs:
steps: steps:
- name: Check out git repository - name: Check out git repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -30,7 +32,37 @@ jobs:
npx tsc npx tsc
npx vite build npx vite build
- name: Build Changelog
id: build_changelog
uses: mikepenz/release-changelog-builder-action@v4
with:
outputFile: "release-notes.md"
configurationJson: |
{
"template": "# v${{ github.ref_name }} 版本发布\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建",
"categories": [
{
"title": "## 新功能",
"filter": { "pattern": "^feat:.*", "flags": "i" }
},
{
"title": "## 修复",
"filter": { "pattern": "^fix:.*", "flags": "i" }
},
{
"title": "## 性能与维护",
"filter": { "pattern": "^(chore|docs|perf|refactor):.*", "flags": "i" }
}
],
"ignore_labels": [],
"commitMode": true,
"empty_summary": "## 更新详情\n- 常规代码优化与维护"
}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Package and Publish - name: Package and Publish
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx electron-builder --publish always run: |
npx electron-builder --publish always "-c.releaseInfo.releaseNotesFile=release-notes.md"

View File

@@ -20,8 +20,8 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
<a href="https://github.com/hicccc77/WeFlow/issues"> <a href="https://github.com/hicccc77/WeFlow/issues">
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues"> <img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
</a> </a>
<a href="https://github.com/hicccc77/WeFlow/blob/main/LICENSE"> <a href="https://t.me/+hn3QzNc4DbA0MzNl">
<img src="https://img.shields.io/github/license/hicccc77/WeFlow?style=flat-square" alt="License"> <img src="https://img.shields.io/badge/Telegram%20交流群-点击加入-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
</a> </a>
</p> </p>
@@ -92,7 +92,7 @@ WeFlow/
## 致谢 ## 致谢
- [miyu](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架 - [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
## Star History ## Star History

View File

@@ -390,6 +390,10 @@ function registerIpcHandlers() {
return chatService.getSessions() return chatService.getSessions()
}) })
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => {
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) => {
return chatService.getMessages(sessionId, offset, limit) return chatService.getMessages(sessionId, offset, limit)
}) })

View File

@@ -91,6 +91,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
chat: { chat: {
connect: () => ipcRenderer.invoke('chat:connect'), connect: () => ipcRenderer.invoke('chat:connect'),
getSessions: () => ipcRenderer.invoke('chat:getSessions'), getSessions: () => ipcRenderer.invoke('chat:getSessions'),
enrichSessionsContactInfo: (usernames: string[]) =>
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
getMessages: (sessionId: string, offset?: number, limit?: number) => getMessages: (sessionId: string, offset?: number, limit?: number) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit), ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
getLatestMessages: (sessionId: string, limit?: number) => getLatestMessages: (sessionId: string, limit?: number) =>

View File

@@ -166,7 +166,7 @@ class ChatService {
} }
/** /**
* 获取会话列表 * 获取会话列表(优化:先返回基础数据,不等待联系人信息加载)
*/ */
async getSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> { async getSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> {
try { try {
@@ -189,8 +189,10 @@ class ChatService {
return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` } return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` }
} }
// 转换为 ChatSession // 转换为 ChatSession(先加载缓存,但不等待数据库查询)
const sessions: ChatSession[] = [] const sessions: ChatSession[] = []
const now = Date.now()
for (const row of rows) { for (const row of rows) {
const username = const username =
row.username || row.username ||
@@ -225,6 +227,15 @@ class ChatService {
const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '') const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '')
const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10) const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10)
// 先尝试从缓存获取联系人信息(快速路径)
let displayName = username
let avatarUrl: string | undefined = undefined
const cached = this.avatarCache.get(username)
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) {
displayName = cached.displayName || username
avatarUrl = cached.avatarUrl
}
sessions.push({ sessions.push({
username, username,
type: parseInt(row.type || '0', 10), type: parseInt(row.type || '0', 10),
@@ -233,13 +244,13 @@ class ChatService {
sortTimestamp: sortTs, sortTimestamp: sortTs,
lastTimestamp: lastTs, lastTimestamp: lastTs,
lastMsgType, lastMsgType,
displayName: username displayName,
avatarUrl
}) })
} }
// 获取联系人信息 // 不等待联系人信息加载,直接返回基础会话列表
await this.enrichSessionsWithContacts(sessions) // 前端可以异步调用 enrichSessionsWithContacts 来补充信息
return { success: true, sessions } return { success: true, sessions }
} catch (e) { } catch (e) {
console.error('ChatService: 获取会话列表失败:', e) console.error('ChatService: 获取会话列表失败:', e)
@@ -248,45 +259,85 @@ class ChatService {
} }
/** /**
* 补充联系人信息 * 异步补充会话列表的联系人信息(公开方法,供前端调用)
*/
async enrichSessionsContactInfo(usernames: string[]): Promise<{
success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string
}> {
try {
if (usernames.length === 0) {
return { success: true, contacts: {} }
}
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error }
}
const now = Date.now()
const missing: string[] = []
const result: Record<string, { displayName?: string; avatarUrl?: string }> = {}
// 检查缓存
for (const username of usernames) {
const cached = this.avatarCache.get(username)
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) {
result[username] = {
displayName: cached.displayName,
avatarUrl: cached.avatarUrl
}
} else {
missing.push(username)
}
}
// 批量查询缺失的联系人信息
if (missing.length > 0) {
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(missing),
wcdbService.getAvatarUrls(missing)
])
for (const username of missing) {
const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
result[username] = { displayName, avatarUrl }
// 更新缓存
this.avatarCache.set(username, {
displayName: displayName || username,
avatarUrl,
updatedAt: now
})
}
}
return { success: true, contacts: result }
} catch (e) {
console.error('ChatService: 补充联系人信息失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 补充联系人信息(私有方法,保持向后兼容)
*/ */
private async enrichSessionsWithContacts(sessions: ChatSession[]): Promise<void> { private async enrichSessionsWithContacts(sessions: ChatSession[]): Promise<void> {
if (sessions.length === 0) return if (sessions.length === 0) return
try { try {
const now = Date.now() const usernames = sessions.map(s => s.username)
const missing: string[] = [] const result = await this.enrichSessionsContactInfo(usernames)
if (result.success && result.contacts) {
for (const session of sessions) { for (const session of sessions) {
const cached = this.avatarCache.get(session.username) const contact = result.contacts![session.username]
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) { if (contact) {
if (cached.displayName) session.displayName = cached.displayName if (contact.displayName) session.displayName = contact.displayName
if (cached.avatarUrl) { if (contact.avatarUrl) session.avatarUrl = contact.avatarUrl
session.avatarUrl = cached.avatarUrl
continue
} }
} }
missing.push(session.username)
}
if (missing.length === 0) return
const missingSet = new Set(missing)
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(missing),
wcdbService.getAvatarUrls(missing)
])
for (const session of sessions) {
if (!missingSet.has(session.username)) continue
const displayName = displayNames.success && displayNames.map ? displayNames.map[session.username] : undefined
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[session.username] : undefined
if (displayName) session.displayName = displayName
if (avatarUrl) session.avatarUrl = avatarUrl
this.avatarCache.set(session.username, {
displayName: session.displayName,
avatarUrl: session.avatarUrl,
updatedAt: now
})
} }
} catch (e) { } catch (e) {
console.error('ChatService: 获取联系人信息失败:', e) console.error('ChatService: 获取联系人信息失败:', e)
@@ -721,7 +772,7 @@ class ChatService {
case 49: case 49:
return this.parseType49(content) return this.parseType49(content)
case 50: case 50:
return '[通话]' return this.parseVoipMessage(content)
case 10000: case 10000:
return this.cleanSystemMessage(content) return this.cleanSystemMessage(content)
case 244813135921: case 244813135921:
@@ -847,6 +898,67 @@ class ChatService {
} }
} }
/**
* 解析通话消息
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
* room_type: 0 = 语音通话, 1 = 视频通话
* msg 状态: 通话时长 XX:XX, 对方无应答, 已取消, 已在其它设备接听, 对方已拒绝 等
*/
private parseVoipMessage(content: string): string {
try {
if (!content) return '[通话]'
// 提取 msg 内容(中文通话状态)
const msgMatch = /<msg><!\[CDATA\[(.*?)\]\]><\/msg>/i.exec(content)
const msg = msgMatch?.[1]?.trim() || ''
// 提取 room_type0=视频1=语音)
const roomTypeMatch = /<room_type>(\d+)<\/room_type>/i.exec(content)
const roomType = roomTypeMatch ? parseInt(roomTypeMatch[1], 10) : -1
// 构建通话类型标签
let callType: string
if (roomType === 0) {
callType = '视频通话'
} else if (roomType === 1) {
callType = '语音通话'
} else {
callType = '通话'
}
// 解析通话状态
if (msg.includes('通话时长')) {
// 已接听的通话,提取时长
const durationMatch = /通话时长\s*(\d{1,2}:\d{2}(?::\d{2})?)/i.exec(msg)
const duration = durationMatch?.[1] || ''
if (duration) {
return `[${callType}] ${duration}`
}
return `[${callType}] 已接听`
} else if (msg.includes('对方无应答')) {
return `[${callType}] 对方无应答`
} else if (msg.includes('已取消')) {
return `[${callType}] 已取消`
} else if (msg.includes('已在其它设备接听') || msg.includes('已在其他设备接听')) {
return `[${callType}] 已在其他设备接听`
} else if (msg.includes('对方已拒绝') || msg.includes('已拒绝')) {
return `[${callType}] 对方已拒绝`
} else if (msg.includes('忙线未接听') || msg.includes('忙线')) {
return `[${callType}] 忙线未接听`
} else if (msg.includes('未接听')) {
return `[${callType}] 未接听`
} else if (msg) {
// 其他状态直接使用 msg 内容
return `[${callType}] ${msg}`
}
return `[${callType}]`
} catch (e) {
console.error('[ChatService] Failed to parse VOIP message:', e)
return '[通话]'
}
}
private parseImageDatNameFromRow(row: Record<string, any>): string | undefined { private parseImageDatNameFromRow(row: Record<string, any>): string | undefined {
const packed = this.getRowField(row, [ const packed = this.getRowField(row, [
'packed_info_data', 'packed_info_data',
@@ -980,6 +1092,118 @@ class ChatService {
} }
} }
//手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback
private async findMediaDbsManually(): Promise<string[]> {
try {
const dbPath = this.configService.get('dbPath')
const myWxid = this.configService.get('myWxid')
if (!dbPath || !myWxid) return []
// 可能的目录结构:
// 1. dbPath 直接指向 db_storage: D:\weixin\WeChat Files\wxid_xxx\db_storage
// 2. dbPath 指向账号目录: D:\weixin\WeChat Files\wxid_xxx
// 3. dbPath 指向 WeChat Files: D:\weixin\WeChat Files
// 4. dbPath 指向微信根目录: D:\weixin
// 5. dbPath 指向非标准目录: D:\weixin\xwechat_files
const searchDirs: string[] = []
// 尝试1: dbPath 本身就是 db_storage
if (basename(dbPath).toLowerCase() === 'db_storage') {
searchDirs.push(dbPath)
}
// 尝试2: dbPath/db_storage
const dbStorage1 = join(dbPath, 'db_storage')
if (existsSync(dbStorage1)) {
searchDirs.push(dbStorage1)
}
// 尝试3: dbPath/WeChat Files/[wxid]/db_storage
const wechatFiles = join(dbPath, 'WeChat Files')
if (existsSync(wechatFiles)) {
const wxidDir = join(wechatFiles, myWxid)
if (existsSync(wxidDir)) {
const dbStorage2 = join(wxidDir, 'db_storage')
if (existsSync(dbStorage2)) {
searchDirs.push(dbStorage2)
}
}
}
// 尝试4: 如果 dbPath 已经包含 WeChat Files直接在其中查找
if (dbPath.includes('WeChat Files')) {
const parts = dbPath.split(path.sep)
const wechatFilesIndex = parts.findIndex(p => p === 'WeChat Files')
if (wechatFilesIndex >= 0) {
const wechatFilesPath = parts.slice(0, wechatFilesIndex + 1).join(path.sep)
const wxidDir = join(wechatFilesPath, myWxid)
if (existsSync(wxidDir)) {
const dbStorage3 = join(wxidDir, 'db_storage')
if (existsSync(dbStorage3) && !searchDirs.includes(dbStorage3)) {
searchDirs.push(dbStorage3)
}
}
}
}
// 尝试5: 直接尝试 dbPath/[wxid]/db_storage (适用于 xwechat_files 等非标准目录名)
const wxidDirDirect = join(dbPath, myWxid)
if (existsSync(wxidDirDirect)) {
const dbStorage5 = join(wxidDirDirect, 'db_storage')
if (existsSync(dbStorage5) && !searchDirs.includes(dbStorage5)) {
searchDirs.push(dbStorage5)
}
}
// 在所有可能的目录中查找 media_*.db
const mediaDbFiles: string[] = []
for (const dir of searchDirs) {
if (!existsSync(dir)) continue
// 直接在当前目录查找
const entries = readdirSync(dir)
for (const entry of entries) {
if (entry.toLowerCase().startsWith('media_') && entry.toLowerCase().endsWith('.db')) {
const fullPath = join(dir, entry)
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
if (!mediaDbFiles.includes(fullPath)) {
mediaDbFiles.push(fullPath)
}
}
}
}
// 也检查子目录(特别是 message 子目录)
for (const entry of entries) {
const subDir = join(dir, entry)
if (existsSync(subDir) && statSync(subDir).isDirectory()) {
try {
const subEntries = readdirSync(subDir)
for (const subEntry of subEntries) {
if (subEntry.toLowerCase().startsWith('media_') && subEntry.toLowerCase().endsWith('.db')) {
const fullPath = join(subDir, subEntry)
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
if (!mediaDbFiles.includes(fullPath)) {
mediaDbFiles.push(fullPath)
}
}
}
}
} catch (e) {
// 忽略无法访问的子目录
}
}
}
}
return mediaDbFiles
} catch (e) {
console.error('[ChatService] 手动查找 media 数据库失败:', e)
return []
}
}
private getVoiceLookupCandidates(sessionId: string, msg: Message): string[] { private getVoiceLookupCandidates(sessionId: string, msg: Message): string[] {
const candidates: string[] = [] const candidates: string[] = []
const add = (value?: string | null) => { const add = (value?: string | null) => {
@@ -1833,13 +2057,20 @@ class ChatService {
}) })
// 2. 查找所有的 media_*.db // 2. 查找所有的 media_*.db
const mediaDbs = await wcdbService.listMediaDbs() let mediaDbs = await wcdbService.listMediaDbs()
if (!mediaDbs.success || !mediaDbs.data) return { success: false, error: '获取媒体库失败' } // Fallback: 如果 WCDB DLL 不支持 listMediaDbs手动查找
console.info('[ChatService][Voice] media dbs', mediaDbs.data) if (!mediaDbs.success || !mediaDbs.data || mediaDbs.data.length === 0) {
const manualMediaDbs = await this.findMediaDbsManually()
if (manualMediaDbs.length > 0) {
mediaDbs = { success: true, data: manualMediaDbs }
} else {
return { success: false, error: '未找到媒体库文件 (media_*.db)' }
}
}
// 3. 在所有媒体库中查找该消息的语音数据 // 3. 在所有媒体库中查找该消息的语音数据
let silkData: Buffer | null = null let silkData: Buffer | null = null
for (const dbPath of mediaDbs.data) { for (const dbPath of (mediaDbs.data || [])) {
const voiceTable = await this.resolveVoiceInfoTableName(dbPath) const voiceTable = await this.resolveVoiceInfoTableName(dbPath)
if (!voiceTable) { if (!voiceTable) {
console.warn('[ChatService][Voice] voice table not found', dbPath) console.warn('[ChatService][Voice] voice table not found', dbPath)
@@ -2165,7 +2396,7 @@ class ChatService {
.prepare(`SELECT dir_name FROM ${state.dirTable} WHERE dir_id = ? AND username = ? LIMIT 1`) .prepare(`SELECT dir_name FROM ${state.dirTable} WHERE dir_id = ? AND username = ? LIMIT 1`)
.get(dir2, sessionId) as { dir_name?: string } | undefined .get(dir2, sessionId) as { dir_name?: string } | undefined
if (dirRow?.dir_name) dirName = dirRow.dir_name as string if (dirRow?.dir_name) dirName = dirRow.dir_name as string
} catch {} } catch { }
} }
const fullPath = join(accountDir, dir1, dirName, fileName) const fullPath = join(accountDir, dir1, dirName, fileName)
@@ -2173,7 +2404,7 @@ class ChatService {
const withDat = `${fullPath}.dat` const withDat = `${fullPath}.dat`
if (existsSync(withDat)) return withDat if (existsSync(withDat)) return withDat
} catch {} } catch { }
return null return null
} }

View File

@@ -1,5 +1,8 @@
import * as fs from 'fs' import * as fs from 'fs'
import * as path from 'path' import * as path from 'path'
import * as http from 'http'
import * as https from 'https'
import { fileURLToPath } from 'url'
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
@@ -229,7 +232,7 @@ class ExportService {
const title = this.extractXmlValue(content, 'title') const title = this.extractXmlValue(content, 'title')
return title || '[链接]' return title || '[链接]'
} }
case 50: return '[通话]' case 50: return this.parseVoipMessage(content)
case 10000: return this.cleanSystemMessage(content) case 10000: return this.cleanSystemMessage(content)
default: default:
if (content.includes('<type>57</type>')) { if (content.includes('<type>57</type>')) {
@@ -261,6 +264,64 @@ class ExportService {
.trim() || '[系统消息]' .trim() || '[系统消息]'
} }
/**
* 解析通话消息
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
* room_type: 0 = 语音通话, 1 = 视频通话
*/
private parseVoipMessage(content: string): string {
try {
if (!content) return '[通话]'
// 提取 msg 内容(中文通话状态)
const msgMatch = /<msg><!\[CDATA\[(.*?)\]\]><\/msg>/i.exec(content)
const msg = msgMatch?.[1]?.trim() || ''
// 提取 room_type0=视频1=语音)
const roomTypeMatch = /<room_type>(\d+)<\/room_type>/i.exec(content)
const roomType = roomTypeMatch ? parseInt(roomTypeMatch[1], 10) : -1
// 构建通话类型标签
let callType: string
if (roomType === 0) {
callType = '视频通话'
} else if (roomType === 1) {
callType = '语音通话'
} else {
callType = '通话'
}
// 解析通话状态
if (msg.includes('通话时长')) {
const durationMatch = /通话时长\s*(\d{1,2}:\d{2}(?::\d{2})?)/i.exec(msg)
const duration = durationMatch?.[1] || ''
if (duration) {
return `[${callType}] ${duration}`
}
return `[${callType}] 已接听`
} else if (msg.includes('对方无应答')) {
return `[${callType}] 对方无应答`
} else if (msg.includes('已取消')) {
return `[${callType}] 已取消`
} else if (msg.includes('已在其它设备接听') || msg.includes('已在其他设备接听')) {
return `[${callType}] 已在其他设备接听`
} else if (msg.includes('对方已拒绝') || msg.includes('已拒绝')) {
return `[${callType}] 对方已拒绝`
} else if (msg.includes('忙线未接听') || msg.includes('忙线')) {
return `[${callType}] 忙线未接听`
} else if (msg.includes('未接听')) {
return `[${callType}] 未接听`
} else if (msg) {
return `[${callType}] ${msg}`
}
return `[${callType}]`
} catch (e) {
console.error('[ExportService] Failed to parse VOIP message:', e)
return '[通话]'
}
}
/** /**
* 获取消息类型名称 * 获取消息类型名称
*/ */
@@ -298,9 +359,9 @@ class ExportService {
sessionId: string, sessionId: string,
cleanedMyWxid: string, cleanedMyWxid: string,
dateRange?: { start: number; end: number } | null dateRange?: { start: number; end: number } | null
): Promise<{ rows: any[]; memberSet: Map<string, ChatLabMember>; firstTime: number | null; lastTime: number | null }> { ): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
const rows: any[] = [] const rows: any[] = []
const memberSet = new Map<string, ChatLabMember>() const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
let firstTime: number | null = null let firstTime: number | null = null
let lastTime: number | null = null let lastTime: number | null = null
@@ -336,8 +397,11 @@ class ExportService {
const memberInfo = await this.getContactInfo(actualSender) const memberInfo = await this.getContactInfo(actualSender)
if (!memberSet.has(actualSender)) { if (!memberSet.has(actualSender)) {
memberSet.set(actualSender, { memberSet.set(actualSender, {
platformId: actualSender, member: {
accountName: memberInfo.displayName platformId: actualSender,
accountName: memberInfo.displayName
},
avatarUrl: memberInfo.avatarUrl
}) })
} }
@@ -361,6 +425,121 @@ class ExportService {
return { rows, memberSet, firstTime, lastTime } return { rows, memberSet, firstTime, lastTime }
} }
private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null {
if (!avatarUrl) return null
if (avatarUrl.startsWith('data:')) {
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(avatarUrl)
if (!match) return null
const mime = match[1].toLowerCase()
const data = Buffer.from(match[2], 'base64')
const ext = mime.includes('png') ? '.png'
: mime.includes('gif') ? '.gif'
: mime.includes('webp') ? '.webp'
: '.jpg'
return { data, ext, mime }
}
if (avatarUrl.startsWith('file://')) {
try {
const sourcePath = fileURLToPath(avatarUrl)
const ext = path.extname(sourcePath) || '.jpg'
return { sourcePath, ext }
} catch {
return null
}
}
if (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')) {
const url = new URL(avatarUrl)
const ext = path.extname(url.pathname) || '.jpg'
return { sourceUrl: avatarUrl, ext }
}
const sourcePath = avatarUrl
const ext = path.extname(sourcePath) || '.jpg'
return { sourcePath, ext }
}
private async downloadToBuffer(url: string, remainingRedirects = 2): Promise<{ data: Buffer; mime?: string } | null> {
const client = url.startsWith('https:') ? https : http
return new Promise((resolve) => {
const request = client.get(url, (res) => {
const status = res.statusCode || 0
if (status >= 300 && status < 400 && res.headers.location && remainingRedirects > 0) {
res.resume()
const redirectedUrl = new URL(res.headers.location, url).href
this.downloadToBuffer(redirectedUrl, remainingRedirects - 1)
.then(resolve)
return
}
if (status < 200 || status >= 300) {
res.resume()
resolve(null)
return
}
const chunks: Buffer[] = []
res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
res.on('end', () => {
const data = Buffer.concat(chunks)
const mime = typeof res.headers['content-type'] === 'string' ? res.headers['content-type'] : undefined
resolve({ data, mime })
})
})
request.on('error', () => resolve(null))
request.setTimeout(15000, () => {
request.destroy()
resolve(null)
})
})
}
private async exportAvatars(
members: Array<{ username: string; avatarUrl?: string }>
): Promise<Map<string, string>> {
const result = new Map<string, string>()
if (members.length === 0) return result
for (const member of members) {
const fileInfo = this.resolveAvatarFile(member.avatarUrl)
if (!fileInfo) continue
try {
let data: Buffer | null = null
let mime = fileInfo.mime
if (fileInfo.data) {
data = fileInfo.data
} else if (fileInfo.sourcePath && fs.existsSync(fileInfo.sourcePath)) {
data = await fs.promises.readFile(fileInfo.sourcePath)
} else if (fileInfo.sourceUrl) {
const downloaded = await this.downloadToBuffer(fileInfo.sourceUrl)
if (downloaded) {
data = downloaded.data
mime = downloaded.mime || mime
}
}
if (!data) continue
const finalMime = mime || this.inferImageMime(fileInfo.ext)
const base64 = data.toString('base64')
result.set(member.username, `data:${finalMime};base64,${base64}`)
} catch {
continue
}
}
return result
}
private inferImageMime(ext: string): string {
switch (ext.toLowerCase()) {
case '.png':
return 'image/png'
case '.gif':
return 'image/gif'
case '.webp':
return 'image/webp'
case '.bmp':
return 'image/bmp'
default:
return 'image/jpeg'
}
}
/** /**
* 导出单个会话为 ChatLab 格式 * 导出单个会话为 ChatLab 格式
*/ */
@@ -399,7 +578,7 @@ class ExportService {
}) })
const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => { const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => {
const memberInfo = collected.memberSet.get(msg.senderUsername) || { const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
platformId: msg.senderUsername, platformId: msg.senderUsername,
accountName: msg.senderUsername accountName: msg.senderUsername
} }
@@ -412,6 +591,23 @@ class ExportService {
} }
}) })
const avatarMap = options.exportAvatars
? await this.exportAvatars(
[
...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
username,
avatarUrl: info.avatarUrl
})),
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl }
]
)
: new Map<string, string>()
const members = Array.from(collected.memberSet.values()).map((info) => {
const avatar = avatarMap.get(info.member.platformId)
return avatar ? { ...info.member, avatar } : info.member
})
const chatLabExport: ChatLabExport = { const chatLabExport: ChatLabExport = {
chatlab: { chatlab: {
version: '0.0.1', version: '0.0.1',
@@ -424,7 +620,7 @@ class ExportService {
type: isGroup ? 'group' : 'private', type: isGroup ? 'group' : 'private',
...(isGroup && { groupId: sessionId }) ...(isGroup && { groupId: sessionId })
}, },
members: Array.from(collected.memberSet.values()), members,
messages: chatLabMessages messages: chatLabMessages
} }
@@ -538,6 +734,29 @@ class ExportService {
messages: allMessages messages: allMessages
} }
if (options.exportAvatars) {
const avatarMap = await this.exportAvatars(
[
...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
username,
avatarUrl: info.avatarUrl
})),
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl }
]
)
const avatars: Record<string, string> = {}
for (const [username, relPath] of avatarMap.entries()) {
avatars[username] = relPath
}
if (Object.keys(avatars).length > 0) {
detailedExport.session = {
...detailedExport.session,
avatar: avatars[sessionId]
}
; (detailedExport as any).avatars = avatars
}
}
fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8') fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
onProgress?.({ onProgress?.({

View File

@@ -695,33 +695,41 @@ export class KeyService {
} }
private getXorKey(templateFiles: string[]): number | null { private getXorKey(templateFiles: string[]): number | null {
const counts = new Map<string, number>() const counts = new Map<number, number>()
const tailSignatures = [
Buffer.from([0xFF, 0xD9]),
Buffer.from([0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82])
]
for (const file of templateFiles) { for (const file of templateFiles) {
try { try {
const bytes = readFileSync(file) const bytes = readFileSync(file)
if (bytes.length < 2) continue for (const signature of tailSignatures) {
const x = bytes[bytes.length - 2] if (bytes.length < signature.length) continue
const y = bytes[bytes.length - 1] const tail = bytes.subarray(bytes.length - signature.length)
const key = `${x}_${y}` const xorKey = tail[0] ^ signature[0]
counts.set(key, (counts.get(key) ?? 0) + 1) let valid = true
for (let i = 1; i < signature.length; i++) {
if ((tail[i] ^ xorKey) !== signature[i]) {
valid = false
break
}
}
if (valid) {
counts.set(xorKey, (counts.get(xorKey) ?? 0) + 1)
}
}
} catch { } } catch { }
} }
if (!counts.size) return null if (!counts.size) return null
let mostKey = '' let bestKey: number | null = null
let mostCount = 0 let bestCount = 0
for (const [key, count] of counts) { for (const [key, count] of counts) {
if (count > mostCount) { if (count > bestCount) {
mostCount = count bestCount = count
mostKey = key bestKey = key
} }
} }
if (!mostKey) return null return bestKey
const [xStr, yStr] = mostKey.split('_')
const x = Number(xStr)
const y = Number(yStr)
const xorKey = x ^ 0xFF
const check = y ^ 0xD9
return xorKey === check ? xorKey : null
} }
private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null { private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null {
@@ -766,7 +774,17 @@ export class KeyService {
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null) const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
decipher.setAutoPadding(false) decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
return decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff const isJpeg = decrypted.length >= 3 && decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff
const isPng = decrypted.length >= 8 &&
decrypted[0] === 0x89 &&
decrypted[1] === 0x50 &&
decrypted[2] === 0x4e &&
decrypted[3] === 0x47 &&
decrypted[4] === 0x0d &&
decrypted[5] === 0x0a &&
decrypted[6] === 0x1a &&
decrypted[7] === 0x0a
return isJpeg || isPng
} catch { } catch {
return false return false
} }

View File

@@ -49,6 +49,8 @@ export class WcdbService {
private wcdbGetEmoticonCdnUrl: any = null private wcdbGetEmoticonCdnUrl: 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 lastLogTail: string | null = null
setPaths(resourcesPath: string, userDataPath: string): void { setPaths(resourcesPath: string, userDataPath: string): void {
this.resourcesPath = resourcesPath this.resourcesPath = resourcesPath
@@ -57,6 +59,11 @@ export class WcdbService {
setLogEnabled(enabled: boolean): void { setLogEnabled(enabled: boolean): void {
this.logEnabled = enabled this.logEnabled = enabled
if (this.isLogEnabled() && this.initialized) {
this.startLogPolling()
} else {
this.stopLogPolling()
}
} }
/** /**
@@ -433,6 +440,49 @@ export class WcdbService {
} }
} }
private startLogPolling(): void {
if (this.logTimer || !this.isLogEnabled()) return
this.logTimer = setInterval(() => {
void this.pollLogs()
}, 2000)
}
private stopLogPolling(): void {
if (this.logTimer) {
clearInterval(this.logTimer)
this.logTimer = null
}
this.lastLogTail = null
}
private async pollLogs(): Promise<void> {
try {
if (!this.wcdbGetLogs || !this.isLogEnabled()) return
const outPtr = [null as any]
const result = this.wcdbGetLogs(outPtr)
if (result !== 0 || !outPtr[0]) return
let jsonStr = ''
try {
jsonStr = this.koffi.decode(outPtr[0], 'char', -1)
} finally {
try { this.wcdbFreeString(outPtr[0]) } catch { }
}
const logs = JSON.parse(jsonStr) as string[]
if (!Array.isArray(logs) || logs.length === 0) return
let startIdx = 0
if (this.lastLogTail) {
const idx = logs.lastIndexOf(this.lastLogTail)
if (idx >= 0) startIdx = idx + 1
}
for (let i = startIdx; i < logs.length; i += 1) {
this.writeLog(`wcdb: ${logs[i]}`)
}
this.lastLogTail = logs[logs.length - 1]
} catch (e) {
// ignore polling errors
}
}
private decodeJsonPtr(outPtr: any): string | null { private decodeJsonPtr(outPtr: any): string | null {
if (!outPtr) return null if (!outPtr) return null
try { try {
@@ -545,6 +595,9 @@ export class WcdbService {
console.warn('设置 wxid 失败:', e) console.warn('设置 wxid 失败:', e)
} }
} }
if (this.isLogEnabled()) {
this.startLogPolling()
}
this.writeLog(`open ok handle=${handle}`) this.writeLog(`open ok handle=${handle}`)
return true return true
} catch (e) { } catch (e) {
@@ -571,6 +624,7 @@ export class WcdbService {
this.currentKey = null this.currentKey = null
this.currentWxid = null this.currentWxid = null
this.initialized = false this.initialized = false
this.stopLogPolling()
} }
} }
@@ -594,8 +648,15 @@ export class WcdbService {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }
} }
try { try {
// 使用 setImmediate 让事件循环有机会处理其他任务,避免长时间阻塞
await new Promise(resolve => setImmediate(resolve))
const outPtr = [null as any] const outPtr = [null as any]
const result = this.wcdbGetSessions(this.handle, outPtr) const result = this.wcdbGetSessions(this.handle, outPtr)
// DLL 调用后再次让出控制权
await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) { if (result !== 0 || !outPtr[0]) {
this.writeLog(`getSessions failed: code=${result}`) this.writeLog(`getSessions failed: code=${result}`)
return { success: false, error: `获取会话失败: ${result}` } return { success: false, error: `获取会话失败: ${result}` }
@@ -652,8 +713,15 @@ export class WcdbService {
} }
if (usernames.length === 0) return { success: true, map: {} } if (usernames.length === 0) return { success: true, map: {} }
try { try {
// 让出控制权,避免阻塞事件循环
await new Promise(resolve => setImmediate(resolve))
const outPtr = [null as any] const outPtr = [null as any]
const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr) const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr)
// DLL 调用后再次让出控制权
await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) { if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取昵称失败: ${result}` } return { success: false, error: `获取昵称失败: ${result}` }
} }
@@ -692,8 +760,15 @@ export class WcdbService {
return { success: true, map: resultMap } return { success: true, map: resultMap }
} }
// 让出控制权,避免阻塞事件循环
await new Promise(resolve => setImmediate(resolve))
const outPtr = [null as any] const outPtr = [null as any]
const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr) const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr)
// DLL 调用后再次让出控制权
await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) { if (result !== 0 || !outPtr[0]) {
if (Object.keys(resultMap).length > 0) { if (Object.keys(resultMap).length > 0) {
return { success: true, map: resultMap, error: `获取头像失败: ${result}` } return { success: true, map: resultMap, error: `获取头像失败: ${result}` }

View File

@@ -1,8 +1,9 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.0.0", "version": "1.0.4",
"description": "WeFlow - 微信聊天记录查看工具", "description": "WeFlow - 微信聊天记录查看工具",
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"author": "cc",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build && electron-builder", "build": "tsc && vite build && electron-builder",
@@ -46,6 +47,10 @@
}, },
"build": { "build": {
"appId": "com.WeFlow.app", "appId": "com.WeFlow.app",
"publish": {
"provider": "github",
"releaseType": "release"
},
"productName": "WeFlow", "productName": "WeFlow",
"artifactName": "${productName}-${version}-Setup.${ext}", "artifactName": "${productName}-${version}-Setup.${ext}",
"directories": { "directories": {
@@ -59,6 +64,7 @@
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
"differentialPackage": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true, "createDesktopShortcut": true,
"unicode": true, "unicode": true,
@@ -92,4 +98,4 @@
"dist-electron/**/*" "dist-electron/**/*"
] ]
} }
} }

Binary file not shown.

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Loader2, Download, Image, Check, X } from 'lucide-react' import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react'
import html2canvas from 'html2canvas' import html2canvas from 'html2canvas'
import { useThemeStore } from '../stores/themeStore' import { useThemeStore } from '../stores/themeStore'
import './AnnualReportWindow.scss' import './AnnualReportWindow.scss'
@@ -249,6 +249,7 @@ function AnnualReportWindow() {
const [fabOpen, setFabOpen] = useState(false) const [fabOpen, setFabOpen] = useState(false)
const [loadingProgress, setLoadingProgress] = useState(0) const [loadingProgress, setLoadingProgress] = useState(0)
const [loadingStage, setLoadingStage] = useState('正在初始化...') const [loadingStage, setLoadingStage] = useState('正在初始化...')
const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate')
const { currentTheme, themeMode } = useThemeStore() const { currentTheme, themeMode } = useThemeStore()
@@ -490,7 +491,7 @@ function AnnualReportWindow() {
} }
// 导出整个报告为长图 // 导出整个报告为长图
const exportFullReport = async () => { const exportFullReport = async (filterIds?: Set<string>) => {
if (!containerRef.current) { if (!containerRef.current) {
return return
} }
@@ -516,6 +517,16 @@ function AnnualReportWindow() {
el.style.padding = '40px 0' el.style.padding = '40px 0'
}) })
// 如果有筛选,隐藏未选中的板块
if (filterIds) {
const available = getAvailableSections()
available.forEach(s => {
if (!filterIds.has(s.id) && s.ref.current) {
s.ref.current.style.display = 'none'
}
})
}
// 修复词云导出问题 // 修复词云导出问题
const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement
const wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement> const wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
@@ -584,7 +595,7 @@ function AnnualReportWindow() {
const dataUrl = outputCanvas.toDataURL('image/png') const dataUrl = outputCanvas.toDataURL('image/png')
const link = document.createElement('a') const link = document.createElement('a')
link.download = `${reportData?.year}年度报告.png` link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png`
link.href = dataUrl link.href = dataUrl
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
@@ -607,6 +618,13 @@ function AnnualReportWindow() {
return return
} }
if (exportMode === 'long') {
setShowExportModal(false)
await exportFullReport(selectedSections)
setSelectedSections(new Set())
return
}
setIsExporting(true) setIsExporting(true)
setShowExportModal(false) setShowExportModal(false)
@@ -735,9 +753,12 @@ function AnnualReportWindow() {
{/* 浮动操作按钮 */} {/* 浮动操作按钮 */}
<div className={`fab-container ${fabOpen ? 'open' : ''}`}> <div className={`fab-container ${fabOpen ? 'open' : ''}`}>
<button className="fab-item" onClick={() => { setFabOpen(false); setShowExportModal(true) }} title="分模块导出"> <button className="fab-item" onClick={() => { setFabOpen(false); setExportMode('separate'); setShowExportModal(true) }} title="分模块导出">
<Image size={18} /> <Image size={18} />
</button> </button>
<button className="fab-item" onClick={() => { setFabOpen(false); setExportMode('long'); setShowExportModal(true) }} title="自定义导出长图">
<SlidersHorizontal size={18} />
</button>
<button className="fab-item" onClick={() => { setFabOpen(false); exportFullReport() }} title="导出长图"> <button className="fab-item" onClick={() => { setFabOpen(false); exportFullReport() }} title="导出长图">
<Download size={18} /> <Download size={18} />
</button> </button>
@@ -765,7 +786,7 @@ function AnnualReportWindow() {
<div className="export-overlay" onClick={() => setShowExportModal(false)}> <div className="export-overlay" onClick={() => setShowExportModal(false)}>
<div className="export-modal section-selector" onClick={e => e.stopPropagation()}> <div className="export-modal section-selector" onClick={e => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h3></h3> <h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3>
<button className="close-btn" onClick={() => setShowExportModal(false)}> <button className="close-btn" onClick={() => setShowExportModal(false)}>
<X size={20} /> <X size={20} />
</button> </button>
@@ -793,7 +814,7 @@ function AnnualReportWindow() {
onClick={exportSelectedSections} onClick={exportSelectedSections}
disabled={selectedSections.size === 0} disabled={selectedSections.size === 0}
> >
{selectedSections.size > 0 ? `(${selectedSections.size})` : ''} {exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
</button> </button>
</div> </div>
</div> </div>
@@ -838,7 +859,7 @@ function AnnualReportWindow() {
<span className="hl">{formatNumber(topFriend.sentCount)}</span> · <span className="hl">{formatNumber(topFriend.sentCount)}</span> ·
TA发来 <span className="hl">{formatNumber(topFriend.receivedCount)}</span> TA发来 <span className="hl">{formatNumber(topFriend.receivedCount)}</span>
</p> </p>
<br/> <br />
<p className="hero-desc"> <p className="hero-desc">
</p> </p>

View File

@@ -883,6 +883,23 @@
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
position: relative;
&.loading .message-list {
opacity: 0;
transform: translateY(8px);
pointer-events: none;
}
&.loaded .message-list {
opacity: 1;
transform: translateY(0);
}
&.loaded .loading-overlay {
opacity: 0;
pointer-events: none;
}
} }
.message-list { .message-list {
@@ -898,6 +915,7 @@
background-color: var(--bg-tertiary); background-color: var(--bg-tertiary);
position: relative; position: relative;
-webkit-app-region: no-drag !important; -webkit-app-region: no-drag !important;
transition: opacity 240ms ease, transform 240ms ease;
// 滚动条样式 // 滚动条样式
&::-webkit-scrollbar { &::-webkit-scrollbar {
@@ -918,6 +936,19 @@
} }
} }
.loading-messages.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
background: rgba(10, 10, 10, 0.28);
backdrop-filter: blur(6px);
transition: opacity 200ms ease;
z-index: 2;
}
.message-list * { .message-list * {
-webkit-app-region: no-drag !important; -webkit-app-region: no-drag !important;
} }
@@ -1108,6 +1139,7 @@
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
word-break: break-word; word-break: break-word;
white-space: pre-wrap;
} }
// 表情包消息 // 表情包消息
@@ -1432,6 +1464,7 @@
.quoted-text { .quoted-text {
color: var(--text-secondary); color: var(--text-secondary);
white-space: pre-wrap;
} }
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon } from 'lucide-react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon } from 'lucide-react'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import type { ChatSession, Message } from '../types/models' import type { ChatSession, Message } from '../types/models'
@@ -23,11 +23,128 @@ interface SessionDetail {
messageTables: { dbName: string; tableName: string; count: number }[] messageTables: { dbName: string; tableName: string; count: number }[]
} }
// 头像组件 - 支持骨架屏加载 // 全局头像加载队列管理器(限制并发,避免卡顿)
function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) { class AvatarLoadQueue {
private queue: Array<{ url: string; resolve: () => void; reject: () => void }> = []
private loading = new Set<string>()
private readonly maxConcurrent = 1 // 一次只加载1个头像避免卡顿
private readonly delayBetweenBatches = 100 // 批次间延迟100ms给UI喘息时间
async enqueue(url: string): Promise<void> {
// 如果已经在加载中,直接返回
if (this.loading.has(url)) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
this.queue.push({ url, resolve, reject })
this.processQueue()
})
}
private async processQueue() {
// 如果已达到最大并发数,等待
if (this.loading.size >= this.maxConcurrent) {
return
}
// 如果队列为空,返回
if (this.queue.length === 0) {
return
}
// 取出一个任务
const task = this.queue.shift()
if (!task) return
this.loading.add(task.url)
// 加载图片
const img = new Image()
img.onload = () => {
this.loading.delete(task.url)
task.resolve()
// 延迟一下再处理下一个,避免一次性加载太多
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
}
img.onerror = () => {
this.loading.delete(task.url)
task.reject()
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
}
img.src = task.url
}
clear() {
this.queue = []
this.loading.clear()
}
}
const avatarLoadQueue = new AvatarLoadQueue()
// 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染)
// 会话项组件(使用 memo 优化,避免不必要的重渲染)
const SessionItem = React.memo(function SessionItem({
session,
isActive,
onSelect,
formatTime
}: {
session: ChatSession
isActive: boolean
onSelect: (session: ChatSession) => void
formatTime: (timestamp: number) => string
}) {
// 缓存格式化的时间
const timeText = useMemo(() =>
formatTime(session.lastTimestamp || session.sortTimestamp),
[formatTime, session.lastTimestamp, session.sortTimestamp]
)
return (
<div
className={`session-item ${isActive ? 'active' : ''}`}
onClick={() => onSelect(session)}
>
<SessionAvatar session={session} size={48} />
<div className="session-info">
<div className="session-top">
<span className="session-name">{session.displayName || session.username}</span>
<span className="session-time">{timeText}</span>
</div>
<div className="session-bottom">
<span className="session-summary">{session.summary || '暂无消息'}</span>
{session.unreadCount > 0 && (
<span className="unread-badge">
{session.unreadCount > 99 ? '99+' : session.unreadCount}
</span>
)}
</div>
</div>
</div>
)
}, (prevProps, nextProps) => {
// 自定义比较:只在关键属性变化时重渲染
return (
prevProps.session.username === nextProps.session.username &&
prevProps.session.displayName === nextProps.session.displayName &&
prevProps.session.avatarUrl === nextProps.session.avatarUrl &&
prevProps.session.summary === nextProps.session.summary &&
prevProps.session.unreadCount === nextProps.session.unreadCount &&
prevProps.session.lastTimestamp === nextProps.session.lastTimestamp &&
prevProps.session.sortTimestamp === nextProps.session.sortTimestamp &&
prevProps.isActive === nextProps.isActive
)
})
const SessionAvatar = React.memo(function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) {
const [imageLoaded, setImageLoaded] = useState(false) const [imageLoaded, setImageLoaded] = useState(false)
const [imageError, setImageError] = useState(false) const [imageError, setImageError] = useState(false)
const [shouldLoad, setShouldLoad] = useState(false)
const [isInQueue, setIsInQueue] = useState(false)
const imgRef = useRef<HTMLImageElement>(null) const imgRef = useRef<HTMLImageElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const isGroup = session.username.includes('@chatroom') const isGroup = session.username.includes('@chatroom')
const getAvatarLetter = (): string => { const getAvatarLetter = (): string => {
@@ -37,23 +154,63 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu
return chars[0] || '?' return chars[0] || '?'
} }
// 使用 Intersection Observer 实现懒加载(优化性能)
useEffect(() => {
if (!containerRef.current || shouldLoad || isInQueue) return
if (!session.avatarUrl) {
// 没有头像URL不需要加载
return
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isInQueue) {
// 加入加载队列,而不是立即加载
setIsInQueue(true)
avatarLoadQueue.enqueue(session.avatarUrl!).then(() => {
setShouldLoad(true)
}).catch(() => {
setImageError(true)
}).finally(() => {
setIsInQueue(false)
})
observer.disconnect()
}
})
},
{
rootMargin: '50px' // 减少预加载距离只提前50px
}
)
observer.observe(containerRef.current)
return () => {
observer.disconnect()
}
}, [session.avatarUrl, shouldLoad, isInQueue])
// 当 avatarUrl 变化时重置状态 // 当 avatarUrl 变化时重置状态
useEffect(() => { useEffect(() => {
setImageLoaded(false) setImageLoaded(false)
setImageError(false) setImageError(false)
setShouldLoad(false)
setIsInQueue(false)
}, [session.avatarUrl]) }, [session.avatarUrl])
// 检查图片是否已经从缓存加载完成 // 检查图片是否已经从缓存加载完成
useEffect(() => { useEffect(() => {
if (imgRef.current?.complete && imgRef.current?.naturalWidth > 0) { if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
setImageLoaded(true) setImageLoaded(true)
} }
}, [session.avatarUrl]) }, [session.avatarUrl, shouldLoad])
const hasValidUrl = session.avatarUrl && !imageError const hasValidUrl = session.avatarUrl && !imageError && shouldLoad
return ( return (
<div <div
ref={containerRef}
className={`session-avatar ${isGroup ? 'group' : ''} ${hasValidUrl && !imageLoaded ? 'loading' : ''}`} className={`session-avatar ${isGroup ? 'group' : ''} ${hasValidUrl && !imageLoaded ? 'loading' : ''}`}
style={{ width: size, height: size }} style={{ width: size, height: size }}
> >
@@ -67,6 +224,7 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu
className={imageLoaded ? 'loaded' : ''} className={imageLoaded ? 'loaded' : ''}
onLoad={() => setImageLoaded(true)} onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)} onError={() => setImageError(true)}
loading="lazy"
/> />
</> </>
) : ( ) : (
@@ -74,7 +232,15 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu
)} )}
</div> </div>
) )
} }, (prevProps, nextProps) => {
// 自定义比较函数,只在关键属性变化时重渲染
return (
prevProps.session.username === nextProps.session.username &&
prevProps.session.displayName === nextProps.session.displayName &&
prevProps.session.avatarUrl === nextProps.session.avatarUrl &&
prevProps.size === nextProps.size
)
})
function ChatPage(_props: ChatPageProps) { function ChatPage(_props: ChatPageProps) {
const { const {
@@ -108,6 +274,8 @@ function ChatPage(_props: ChatPageProps) {
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 initialRevealTimerRef = useRef<number | null>(null)
const sessionListRef = useRef<HTMLDivElement>(null)
const [currentOffset, setCurrentOffset] = useState(0) const [currentOffset, setCurrentOffset] = useState(0)
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined) const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false)
@@ -118,6 +286,13 @@ function ChatPage(_props: ChatPageProps) {
const [isLoadingDetail, setIsLoadingDetail] = useState(false) const [isLoadingDetail, setIsLoadingDetail] = useState(false)
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([]) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
const [hasInitialMessages, setHasInitialMessages] = useState(false)
// 联系人信息加载控制
const isEnrichingRef = useRef(false)
const enrichCancelledRef = useRef(false)
const isScrollingRef = useRef(false)
const sessionScrollTimeoutRef = useRef<number | null>(null)
const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys]) const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys])
@@ -126,6 +301,7 @@ function ChatPage(_props: ChatPageProps) {
const sessionMapRef = useRef<Map<string, ChatSession>>(new Map()) const sessionMapRef = useRef<Map<string, ChatSession>>(new Map())
const sessionsRef = useRef<ChatSession[]>([]) const sessionsRef = useRef<ChatSession[]>([])
const currentSessionRef = useRef<string | null>(null) const currentSessionRef = useRef<string | null>(null)
const prevSessionRef = useRef<string | null>(null)
const isLoadingMessagesRef = useRef(false) const isLoadingMessagesRef = useRef(false)
const isLoadingMoreRef = useRef(false) const isLoadingMoreRef = useRef(false)
const isConnectedRef = useRef(false) const isConnectedRef = useRef(false)
@@ -188,7 +364,7 @@ function ChatPage(_props: ChatPageProps) {
} }
}, [loadMyAvatar]) }, [loadMyAvatar])
// 加载会话列表 // 加载会话列表(优化:先返回基础数据,异步加载联系人信息)
const loadSessions = async (options?: { silent?: boolean }) => { const loadSessions = async (options?: { silent?: boolean }) => {
if (options?.silent) { if (options?.silent) {
setIsRefreshingSessions(true) setIsRefreshingSessions(true)
@@ -198,8 +374,21 @@ function ChatPage(_props: ChatPageProps) {
try { try {
const result = await window.electronAPI.chat.getSessions() const result = await window.electronAPI.chat.getSessions()
if (result.success && result.sessions) { if (result.success && result.sessions) {
const nextSessions = options?.silent ? mergeSessions(result.sessions) : result.sessions // 确保 sessions 是数组
setSessions(nextSessions) const sessionsArray = Array.isArray(result.sessions) ? result.sessions : []
const nextSessions = options?.silent ? mergeSessions(sessionsArray) : sessionsArray
// 确保 nextSessions 也是数组
if (Array.isArray(nextSessions)) {
setSessions(nextSessions)
// 延迟启动联系人信息加载确保UI先渲染完成
setTimeout(() => {
void enrichSessionsContactInfo(nextSessions)
}, 500)
} else {
console.error('mergeSessions returned non-array:', nextSessions)
setSessions(sessionsArray)
void enrichSessionsContactInfo(sessionsArray)
}
} else if (!result.success) { } else if (!result.success) {
setConnectionError(result.error || '获取会话失败') setConnectionError(result.error || '获取会话失败')
} }
@@ -215,6 +404,198 @@ function ChatPage(_props: ChatPageProps) {
} }
} }
// 分批异步加载联系人信息(优化性能:防止重复加载,滚动时暂停,只在空闲时加载)
const enrichSessionsContactInfo = async (sessions: ChatSession[]) => {
if (sessions.length === 0) return
// 防止重复加载
if (isEnrichingRef.current) {
console.log('[性能监控] 联系人信息正在加载中,跳过重复请求')
return
}
isEnrichingRef.current = true
enrichCancelledRef.current = false
console.log(`[性能监控] 开始加载联系人信息,会话数: ${sessions.length}`)
const totalStart = performance.now()
// 延迟启动等待UI渲染完成
await new Promise(resolve => setTimeout(resolve, 500))
// 检查是否被取消
if (enrichCancelledRef.current) {
isEnrichingRef.current = false
return
}
try {
// 找出需要加载联系人信息的会话(没有缓存的)
const needEnrich = sessions.filter(s => !s.avatarUrl && (!s.displayName || s.displayName === s.username))
if (needEnrich.length === 0) {
console.log('[性能监控] 所有联系人信息已缓存,跳过加载')
isEnrichingRef.current = false
return
}
console.log(`[性能监控] 需要加载的联系人信息: ${needEnrich.length}`)
// 进一步减少批次大小每批3个避免DLL调用阻塞
const batchSize = 3
let loadedCount = 0
for (let i = 0; i < needEnrich.length; i += batchSize) {
// 如果正在滚动,暂停加载
if (isScrollingRef.current) {
console.log('[性能监控] 检测到滚动,暂停加载联系人信息')
// 等待滚动结束
while (isScrollingRef.current && !enrichCancelledRef.current) {
await new Promise(resolve => setTimeout(resolve, 200))
}
if (enrichCancelledRef.current) break
}
// 检查是否被取消
if (enrichCancelledRef.current) break
const batchStart = performance.now()
const batch = needEnrich.slice(i, i + batchSize)
const usernames = batch.map(s => s.username)
// 使用 requestIdleCallback 延迟执行避免阻塞UI
await new Promise<void>((resolve) => {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(() => {
void loadContactInfoBatch(usernames).then(() => resolve())
}, { timeout: 2000 })
} else {
setTimeout(() => {
void loadContactInfoBatch(usernames).then(() => resolve())
}, 300)
}
})
loadedCount += batch.length
const batchTime = performance.now() - batchStart
if (batchTime > 200) {
console.warn(`[性能监控] 批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(needEnrich.length / batchSize)} 耗时: ${batchTime.toFixed(2)}ms (已加载: ${loadedCount}/${needEnrich.length})`)
}
// 批次间延迟给UI更多时间DLL调用可能阻塞需要更长的延迟
if (i + batchSize < needEnrich.length && !enrichCancelledRef.current) {
// 如果不在滚动,可以延迟短一点
const delay = isScrollingRef.current ? 1000 : 800
await new Promise(resolve => setTimeout(resolve, delay))
}
}
const totalTime = performance.now() - totalStart
if (!enrichCancelledRef.current) {
console.log(`[性能监控] 联系人信息加载完成,总耗时: ${totalTime.toFixed(2)}ms, 已加载: ${loadedCount}/${needEnrich.length}`)
} else {
console.log(`[性能监控] 联系人信息加载被取消,已加载: ${loadedCount}/${needEnrich.length}`)
}
} catch (e) {
console.error('加载联系人信息失败:', e)
} finally {
isEnrichingRef.current = false
}
}
// 联系人信息更新队列(防抖批量更新,避免频繁重渲染)
const contactUpdateQueueRef = useRef<Map<string, { displayName?: string; avatarUrl?: string }>>(new Map())
const contactUpdateTimerRef = useRef<number | null>(null)
const lastUpdateTimeRef = useRef(0)
// 批量更新联系人信息(防抖,减少重渲染次数,增加延迟避免阻塞滚动)
const flushContactUpdates = useCallback(() => {
if (contactUpdateTimerRef.current) {
clearTimeout(contactUpdateTimerRef.current)
contactUpdateTimerRef.current = null
}
// 增加防抖延迟到500ms避免在滚动时频繁更新
contactUpdateTimerRef.current = window.setTimeout(() => {
const updates = contactUpdateQueueRef.current
if (updates.size === 0) return
const now = Date.now()
// 如果距离上次更新太近小于1秒继续延迟
if (now - lastUpdateTimeRef.current < 1000) {
contactUpdateTimerRef.current = window.setTimeout(() => {
flushContactUpdates()
}, 1000 - (now - lastUpdateTimeRef.current))
return
}
const { sessions: currentSessions } = useChatStore.getState()
if (!Array.isArray(currentSessions)) return
let hasChanges = false
const updatedSessions = currentSessions.map(session => {
const update = updates.get(session.username)
if (update) {
const newDisplayName = update.displayName || session.displayName || session.username
const newAvatarUrl = update.avatarUrl || session.avatarUrl
if (newDisplayName !== session.displayName || newAvatarUrl !== session.avatarUrl) {
hasChanges = true
return {
...session,
displayName: newDisplayName,
avatarUrl: newAvatarUrl
}
}
}
return session
})
if (hasChanges) {
const updateStart = performance.now()
setSessions(updatedSessions)
lastUpdateTimeRef.current = Date.now()
const updateTime = performance.now() - updateStart
if (updateTime > 50) {
console.warn(`[性能监控] setSessions更新耗时: ${updateTime.toFixed(2)}ms, 更新了 ${updates.size} 个联系人`)
}
}
updates.clear()
contactUpdateTimerRef.current = null
}, 500) // 500ms 防抖,减少更新频率
}, [setSessions])
// 加载一批联系人信息并更新会话列表(优化:使用队列批量更新)
const loadContactInfoBatch = async (usernames: string[]) => {
const startTime = performance.now()
try {
// 在 DLL 调用前让出控制权(使用 setTimeout 0 代替 setImmediate
await new Promise(resolve => setTimeout(resolve, 0))
const dllStart = performance.now()
const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
const dllTime = performance.now() - dllStart
// DLL 调用后再次让出控制权
await new Promise(resolve => setTimeout(resolve, 0))
const totalTime = performance.now() - startTime
if (dllTime > 50 || totalTime > 100) {
console.warn(`[性能监控] DLL调用耗时: ${dllTime.toFixed(2)}ms, 总耗时: ${totalTime.toFixed(2)}ms, usernames: ${usernames.length}`)
}
if (result.success && result.contacts) {
// 将更新加入队列,而不是立即更新
for (const [username, contact] of Object.entries(result.contacts)) {
contactUpdateQueueRef.current.set(username, contact)
}
// 触发批量更新
flushContactUpdates()
}
} catch (e) {
console.error('加载联系人信息批次失败:', e)
}
}
// 刷新会话列表 // 刷新会话列表
const handleRefresh = async () => { const handleRefresh = async () => {
await loadSessions({ silent: true }) await loadSessions({ silent: true })
@@ -326,6 +707,10 @@ function ChatPage(_props: ChatPageProps) {
// 搜索过滤 // 搜索过滤
const handleSearch = (keyword: string) => { const handleSearch = (keyword: string) => {
setSearchKeyword(keyword) setSearchKeyword(keyword)
if (!Array.isArray(sessions)) {
setFilteredSessions([])
return
}
if (!keyword.trim()) { if (!keyword.trim()) {
setFilteredSessions(sessions) setFilteredSessions(sessions)
return return
@@ -342,27 +727,37 @@ function ChatPage(_props: ChatPageProps) {
// 关闭搜索框 // 关闭搜索框
const handleCloseSearch = () => { const handleCloseSearch = () => {
setSearchKeyword('') setSearchKeyword('')
setFilteredSessions(sessions) setFilteredSessions(Array.isArray(sessions) ? sessions : [])
} }
// 滚动加载更多 + 显示/隐藏回到底部按钮 // 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
const scrollTimeoutRef = useRef<number | null>(null)
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
if (!messageListRef.current) return if (!messageListRef.current) return
const { scrollTop, clientHeight, scrollHeight } = messageListRef.current // 节流:延迟执行,避免滚动时频繁计算
if (scrollTimeoutRef.current) {
// 显示回到底部按钮:距离底部超过 300px cancelAnimationFrame(scrollTimeoutRef.current)
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
setShowScrollToBottom(distanceFromBottom > 300)
// 预加载:当滚动到顶部 30% 区域时开始加载
if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) {
const threshold = clientHeight * 0.3
if (scrollTop < threshold) {
loadMessages(currentSessionId, currentOffset)
}
} }
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset])
scrollTimeoutRef.current = requestAnimationFrame(() => {
if (!messageListRef.current) return
const { scrollTop, clientHeight, scrollHeight } = messageListRef.current
// 显示回到底部按钮:距离底部超过 300px
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
setShowScrollToBottom(distanceFromBottom > 300)
// 预加载:当滚动到顶部 30% 区域时开始加载
if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) {
const threshold = clientHeight * 0.3
if (scrollTop < threshold) {
loadMessages(currentSessionId, currentOffset)
}
}
})
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset, loadMessages])
const getMessageKey = useCallback((msg: Message): string => { const getMessageKey = useCallback((msg: Message): string => {
if (msg.localId && msg.localId > 0) return `l:${msg.localId}` if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
@@ -384,7 +779,14 @@ function ChatPage(_props: ChatPageProps) {
}, []) }, [])
const mergeSessions = useCallback((nextSessions: ChatSession[]) => { const mergeSessions = useCallback((nextSessions: ChatSession[]) => {
if (sessionsRef.current.length === 0) return nextSessions // 确保输入是数组
if (!Array.isArray(nextSessions)) {
console.warn('mergeSessions: nextSessions is not an array:', nextSessions)
return Array.isArray(sessionsRef.current) ? sessionsRef.current : []
}
if (!Array.isArray(sessionsRef.current) || sessionsRef.current.length === 0) {
return nextSessions
}
const prevMap = new Map(sessionsRef.current.map((s) => [s.username, s])) const prevMap = new Map(sessionsRef.current.map((s) => [s.username, s]))
return nextSessions.map((next) => { return nextSessions.map((next) => {
const prev = prevMap.get(next.username) const prev = prevMap.get(next.username)
@@ -440,6 +842,20 @@ function ChatPage(_props: ChatPageProps) {
if (!isConnected && !isConnecting) { if (!isConnected && !isConnecting) {
connect() connect()
} }
// 组件卸载时清理
return () => {
avatarLoadQueue.clear()
if (contactUpdateTimerRef.current) {
clearTimeout(contactUpdateTimerRef.current)
}
if (sessionScrollTimeoutRef.current) {
clearTimeout(sessionScrollTimeoutRef.current)
}
contactUpdateQueueRef.current.clear()
enrichCancelledRef.current = true
isEnrichingRef.current = false
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -496,14 +912,16 @@ function ChatPage(_props: ChatPageProps) {
useEffect(() => { useEffect(() => {
const nextMap = new Map<string, ChatSession>() const nextMap = new Map<string, ChatSession>()
for (const session of sessions) { if (Array.isArray(sessions)) {
nextMap.set(session.username, session) for (const session of sessions) {
nextMap.set(session.username, session)
}
} }
sessionMapRef.current = nextMap sessionMapRef.current = nextMap
}, [sessions]) }, [sessions])
useEffect(() => { useEffect(() => {
sessionsRef.current = sessions sessionsRef.current = Array.isArray(sessions) ? sessions : []
}, [sessions]) }, [sessions])
useEffect(() => { useEffect(() => {
@@ -511,6 +929,53 @@ function ChatPage(_props: ChatPageProps) {
isLoadingMoreRef.current = isLoadingMore isLoadingMoreRef.current = isLoadingMore
}, [isLoadingMessages, isLoadingMore]) }, [isLoadingMessages, isLoadingMore])
useEffect(() => {
if (initialRevealTimerRef.current !== null) {
window.clearTimeout(initialRevealTimerRef.current)
initialRevealTimerRef.current = null
}
if (!isLoadingMessages) {
if (messages.length === 0) {
setHasInitialMessages(true)
} else {
initialRevealTimerRef.current = window.setTimeout(() => {
setHasInitialMessages(true)
initialRevealTimerRef.current = null
}, 120)
}
}
}, [isLoadingMessages, messages.length])
useEffect(() => {
if (currentSessionId !== prevSessionRef.current) {
prevSessionRef.current = currentSessionId
if (initialRevealTimerRef.current !== null) {
window.clearTimeout(initialRevealTimerRef.current)
initialRevealTimerRef.current = null
}
if (messages.length === 0) {
setHasInitialMessages(false)
} else if (!isLoadingMessages) {
setHasInitialMessages(true)
}
}
}, [currentSessionId, messages.length, isLoadingMessages])
useEffect(() => {
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore) {
loadMessages(currentSessionId, 0)
}
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore])
useEffect(() => {
return () => {
if (initialRevealTimerRef.current !== null) {
window.clearTimeout(initialRevealTimerRef.current)
initialRevealTimerRef.current = null
}
}
}, [])
useEffect(() => { useEffect(() => {
isConnectedRef.current = isConnected isConnectedRef.current = isConnected
}, [isConnected]) }, [isConnected])
@@ -520,7 +985,14 @@ function ChatPage(_props: ChatPageProps) {
}, [searchKeyword]) }, [searchKeyword])
useEffect(() => { useEffect(() => {
if (!searchKeyword.trim()) return if (!Array.isArray(sessions)) {
setFilteredSessions([])
return
}
if (!searchKeyword.trim()) {
setFilteredSessions(sessions)
return
}
const lower = searchKeyword.toLowerCase() const lower = searchKeyword.toLowerCase()
const filtered = sessions.filter(s => const filtered = sessions.filter(s =>
s.displayName?.toLowerCase().includes(lower) || s.displayName?.toLowerCase().includes(lower) ||
@@ -531,8 +1003,8 @@ function ChatPage(_props: ChatPageProps) {
}, [sessions, searchKeyword, setFilteredSessions]) }, [sessions, searchKeyword, setFilteredSessions])
// 格式化会话时间(相对时间)- 与原项目一致 // 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算
const formatSessionTime = (timestamp: number): string => { const formatSessionTime = useCallback((timestamp: number): string => {
if (!Number.isFinite(timestamp) || timestamp <= 0) return '' if (!Number.isFinite(timestamp) || timestamp <= 0) return ''
const now = Date.now() const now = Date.now()
@@ -555,10 +1027,10 @@ function ChatPage(_props: ChatPageProps) {
} }
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
} }, [])
// 获取当前会话信息 // 获取当前会话信息
const currentSession = sessions.find(s => s.username === currentSessionId) const currentSession = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
// 判断是否为群聊 // 判断是否为群聊
const isGroupChat = (username: string) => username.includes('@chatroom') const isGroupChat = (username: string) => username.includes('@chatroom')
@@ -641,30 +1113,31 @@ function ChatPage(_props: ChatPageProps) {
</div> </div>
))} ))}
</div> </div>
) : filteredSessions.length > 0 ? ( ) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
<div className="session-list"> <div
className="session-list"
ref={sessionListRef}
onScroll={() => {
// 标记正在滚动,暂停联系人信息加载
isScrollingRef.current = true
if (sessionScrollTimeoutRef.current) {
clearTimeout(sessionScrollTimeoutRef.current)
}
// 滚动结束后200ms才认为滚动停止
sessionScrollTimeoutRef.current = window.setTimeout(() => {
isScrollingRef.current = false
sessionScrollTimeoutRef.current = null
}, 200)
}}
>
{filteredSessions.map(session => ( {filteredSessions.map(session => (
<div <SessionItem
key={session.username} key={session.username}
className={`session-item ${currentSessionId === session.username ? 'active' : ''}`} session={session}
onClick={() => handleSelectSession(session)} isActive={currentSessionId === session.username}
> onSelect={handleSelectSession}
<SessionAvatar session={session} size={48} /> formatTime={formatSessionTime}
<div className="session-info"> />
<div className="session-top">
<span className="session-name">{session.displayName || session.username}</span>
<span className="session-time">{formatSessionTime(session.lastTimestamp || session.sortTimestamp)}</span>
</div>
<div className="session-bottom">
<span className="session-summary">{session.summary || '暂无消息'}</span>
{session.unreadCount > 0 && (
<span className="unread-badge">
{session.unreadCount > 99 ? '99+' : session.unreadCount}
</span>
)}
</div>
</div>
</div>
))} ))}
</div> </div>
) : ( ) : (
@@ -710,18 +1183,18 @@ function ChatPage(_props: ChatPageProps) {
</div> </div>
</div> </div>
<div className="message-content-wrapper"> <div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'}`}>
{isLoadingMessages ? ( {isLoadingMessages && !hasInitialMessages && (
<div className="loading-messages"> <div className="loading-messages loading-overlay">
<Loader2 size={24} /> <Loader2 size={24} />
<span>...</span> <span>...</span>
</div> </div>
) : ( )}
<div <div
className="message-list" className={`message-list ${hasInitialMessages ? 'loaded' : 'loading'}`}
ref={messageListRef} ref={messageListRef}
onScroll={handleScroll} onScroll={handleScroll}
> >
{hasMoreMessages && ( {hasMoreMessages && (
<div className={`load-more-trigger ${isLoadingMore ? 'loading' : ''}`}> <div className={`load-more-trigger ${isLoadingMore ? 'loading' : ''}`}>
{isLoadingMore ? ( {isLoadingMore ? (
@@ -772,7 +1245,6 @@ function ChatPage(_props: ChatPageProps) {
<span></span> <span></span>
</div> </div>
</div> </div>
)}
{/* 会话详情面板 */} {/* 会话详情面板 */}
{showDetailPanel && ( {showDetailPanel && (

View File

@@ -379,29 +379,21 @@
border-radius: 10px; border-radius: 10px;
font-size: 14px; font-size: 14px;
color: var(--text-primary); color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
}
svg { svg {
color: var(--text-tertiary); color: var(--text-tertiary);
flex-shrink: 0;
} }
span { span {
flex: 1; flex: 1;
} }
.change-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: var(--text-primary);
}
}
} }
.media-options { .media-options {
@@ -471,6 +463,43 @@
margin: 8px 0 0; margin: 8px 0 0;
} }
.select-folder-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
margin-top: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
svg {
color: var(--primary);
}
}
&:active {
transform: scale(0.98);
}
svg {
color: var(--text-secondary);
transition: color 0.2s;
}
}
.export-action { .export-action {
padding: 20px 24px; padding: 20px 24px;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
@@ -649,9 +678,245 @@
} }
} }
} }
.date-picker-modal {
background: var(--card-bg);
padding: 28px 32px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
min-width: 420px;
max-width: 500px;
h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 20px;
}
.quick-select {
display: flex;
gap: 8px;
margin-bottom: 20px;
.quick-btn {
flex: 1;
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
&:active {
transform: scale(0.98);
}
}
}
.date-display {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--bg-secondary);
border-radius: 12px;
margin-bottom: 24px;
.date-display-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(var(--primary-rgb), 0.05);
}
&.active {
background: rgba(var(--primary-rgb), 0.1);
border: 1px solid var(--primary);
}
.date-label {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 500;
}
.date-value {
font-size: 15px;
color: var(--text-primary);
font-weight: 600;
}
}
.date-separator {
font-size: 14px;
color: var(--text-tertiary);
padding: 0 4px;
}
}
.calendar-container {
margin-bottom: 20px;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding: 0 4px;
.calendar-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;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
&:active {
transform: scale(0.95);
}
}
.calendar-month {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 8px;
.calendar-weekday {
text-align: center;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
padding: 8px 0;
}
}
.calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
.calendar-day {
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;
position: relative;
&.empty {
cursor: default;
}
&:not(.empty):hover {
background: var(--bg-hover);
}
&.in-range {
background: rgba(var(--primary-rgb), 0.08);
}
&.start,
&.end {
background: var(--primary);
color: #fff;
font-weight: 600;
&:hover {
background: var(--primary-hover);
}
}
}
}
.date-picker-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
button {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:active {
transform: scale(0.98);
}
}
.cancel-btn {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
&:hover {
background: var(--bg-hover);
}
}
.confirm-btn {
background: var(--primary);
color: #fff;
border: none;
&:hover {
background: var(--primary-hover);
}
}
}
}
} }
@keyframes exportSpin { @keyframes exportSpin {
from { transform: rotate(0deg); } from {
to { transform: rotate(360deg); } transform: rotate(0deg);
} }
to {
transform: rotate(360deg);
}
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react' import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
import * as configService from '../services/config' import * as configService from '../services/config'
import './ExportPage.scss' import './ExportPage.scss'
@@ -15,6 +15,7 @@ interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql' format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql'
dateRange: { start: Date; end: Date } | null dateRange: { start: Date; end: Date } | null
useAllTime: boolean useAllTime: boolean
exportAvatars: boolean
} }
interface ExportResult { interface ExportResult {
@@ -34,14 +35,18 @@ function ExportPage() {
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' }) const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' })
const [exportResult, setExportResult] = useState<ExportResult | null>(null) const [exportResult, setExportResult] = useState<ExportResult | null>(null)
const [showDatePicker, setShowDatePicker] = useState(false)
const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'chatlab', format: 'chatlab',
dateRange: { dateRange: {
start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
end: new Date() end: new Date()
}, },
useAllTime: true useAllTime: true,
exportAvatars: true
}) })
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
@@ -140,9 +145,11 @@ function ExportPage() {
const sessionList = Array.from(selectedSessions) const sessionList = Array.from(selectedSessions)
const exportOptions = { const exportOptions = {
format: options.format, format: options.format,
exportAvatars: options.exportAvatars,
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),
end: Math.floor(options.dateRange.end.getTime() / 1000) // 将结束日期设置为当天的 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)
} : null } : null
} }
@@ -164,6 +171,54 @@ function ExportPage() {
} }
} }
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 handleDateSelect = (day: number) => {
const year = calendarDate.getFullYear()
const month = calendarDate.getMonth()
const selectedDate = new Date(year, month, day)
if (selectingStart) {
setOptions({
...options,
dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() }
})
setSelectingStart(false)
} else {
setOptions({
...options,
dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate }
})
setSelectingStart(true)
}
}
const formatOptions = [ const formatOptions = [
{ value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' }, { value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' }, { value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' },
@@ -278,24 +333,55 @@ function ExportPage() {
<span></span> <span></span>
</label> </label>
{!options.useAllTime && options.dateRange && ( {!options.useAllTime && options.dateRange && (
<div className="date-range"> <div className="date-range" onClick={() => setShowDatePicker(true)}>
<Calendar size={16} /> <Calendar size={16} />
<span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span> <span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span>
<button className="change-btn"> <ChevronDown size={14} />
<ChevronDown size={14} />
</button>
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="setting-section">
<h3></h3>
<div className="time-options">
<label className="checkbox-item">
<input
type="checkbox"
checked={options.exportAvatars}
onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })}
/>
<span></span>
</label>
</div>
</div>
<div className="setting-section"> <div className="setting-section">
<h3></h3> <h3></h3>
<div className="export-path-display"> <div className="export-path-display">
<FolderOpen size={16} /> <FolderOpen size={16} />
<span>{exportFolder || '未设置'}</span> <span>{exportFolder || '未设置'}</span>
</div> </div>
<p className="path-hint"></p> <button
className="select-folder-btn"
onClick={async () => {
try {
const result = await window.electronAPI.dialog.openFile({
title: '选择导出目录',
properties: ['openDirectory']
})
if (!result.canceled && result.filePaths.length > 0) {
setExportFolder(result.filePaths[0])
await configService.setExportPath(result.filePaths[0])
}
} catch (e) {
console.error('选择目录失败:', e)
}
}}
>
<FolderOpen size={16} />
<span></span>
</button>
</div> </div>
</div> </div>
@@ -370,6 +456,130 @@ function ExportPage() {
</div> </div>
</div> </div>
)} )}
{/* 日期选择弹窗 */}
{showDatePicker && (
<div className="export-overlay" onClick={() => setShowDatePicker(false)}>
<div className="date-picker-modal" onClick={e => e.stopPropagation()}>
<h3></h3>
<div className="quick-select">
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
>
7
</button>
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
>
30
</button>
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 90 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
>
90
</button>
</div>
<div className="date-display">
<div
className={`date-display-item ${selectingStart ? 'active' : ''}`}
onClick={() => setSelectingStart(true)}
>
<span className="date-label"></span>
<span className="date-value">
{options.dateRange?.start.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})}
</span>
</div>
<span className="date-separator"></span>
<div
className={`date-display-item ${!selectingStart ? 'active' : ''}`}
onClick={() => setSelectingStart(false)}
>
<span className="date-label"></span>
<span className="date-value">
{options.dateRange?.end.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})}
</span>
</div>
</div>
<div className="calendar-container">
<div className="calendar-header">
<button
className="calendar-nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
>
<ChevronLeft size={18} />
</button>
<span className="calendar-month">
{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}
</span>
<button
className="calendar-nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
>
<ChevronRight size={18} />
</button>
</div>
<div className="calendar-weekdays">
{['日', '一', '二', '三', '四', '五', '六'].map(day => (
<div key={day} className="calendar-weekday">{day}</div>
))}
</div>
<div className="calendar-days">
{generateCalendar().map((day, index) => {
if (day === null) {
return <div key={`empty-${index}`} className="calendar-day empty" />
}
const currentDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString()
const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString()
const isInRange = options.dateRange && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end
return (
<div
key={day}
className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''}`}
onClick={() => handleDateSelect(day)}
>
{day}
</div>
)
})}
</div>
</div>
<div className="date-picker-actions">
<button className="cancel-btn" onClick={() => setShowDatePicker(false)}>
</button>
<button className="confirm-btn" onClick={() => setShowDatePicker(false)}>
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -3,19 +3,18 @@ import { useAppStore } from '../stores/appStore'
import { useThemeStore, themes } from '../stores/themeStore' import { useThemeStore, themes } from '../stores/themeStore'
import { dialog } from '../services/ipc' import { dialog } from '../services/ipc'
import * as configService from '../services/config' import * as configService from '../services/config'
import { import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Save, Plug, Check, Sun, Moon, RotateCcw, Trash2, Save, Plug, Check, Sun, Moon,
Palette, Database, Download, HardDrive, Info, RefreshCw Palette, Database, Download, HardDrive, Info, RefreshCw
} from 'lucide-react' } from 'lucide-react'
import './SettingsPage.scss' import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'database' | 'export' | 'cache' | 'about' type SettingsTab = 'appearance' | 'database' | 'cache' | 'about'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette }, { id: 'appearance', label: '外观', icon: Palette },
{ id: 'database', label: '数据库连接', icon: Database }, { id: 'database', label: '数据库连接', icon: Database },
{ id: 'export', label: '导出', icon: Download },
{ id: 'cache', label: '缓存', icon: HardDrive }, { id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'about', label: '关于', icon: Info } { id: 'about', label: '关于', icon: Info }
] ]
@@ -31,10 +30,8 @@ function SettingsPage() {
const [dbPath, setDbPath] = useState('') const [dbPath, setDbPath] = useState('')
const [wxid, setWxid] = useState('') const [wxid, setWxid] = useState('')
const [cachePath, setCachePath] = useState('') const [cachePath, setCachePath] = useState('')
const [exportPath, setExportPath] = useState('')
const [defaultExportPath, setDefaultExportPath] = useState('')
const [logEnabled, setLogEnabled] = useState(false) const [logEnabled, setLogEnabled] = useState(false)
const [isLoading, setIsLoadingState] = useState(false) const [isLoading, setIsLoadingState] = useState(false)
const [isTesting, setIsTesting] = useState(false) const [isTesting, setIsTesting] = useState(false)
const [isDetectingPath, setIsDetectingPath] = useState(false) const [isDetectingPath, setIsDetectingPath] = useState(false)
@@ -53,7 +50,6 @@ function SettingsPage() {
useEffect(() => { useEffect(() => {
loadConfig() loadConfig()
loadDefaultExportPath()
loadAppVersion() loadAppVersion()
}, []) }, [])
@@ -80,12 +76,11 @@ function SettingsPage() {
const savedLogEnabled = await configService.getLogEnabled() const savedLogEnabled = await configService.getLogEnabled()
const savedImageXorKey = await configService.getImageXorKey() const savedImageXorKey = await configService.getImageXorKey()
const savedImageAesKey = await configService.getImageAesKey() const savedImageAesKey = await configService.getImageAesKey()
if (savedKey) setDecryptKey(savedKey) if (savedKey) setDecryptKey(savedKey)
if (savedPath) setDbPath(savedPath) if (savedPath) setDbPath(savedPath)
if (savedWxid) setWxid(savedWxid) if (savedWxid) setWxid(savedWxid)
if (savedCachePath) setCachePath(savedCachePath) if (savedCachePath) setCachePath(savedCachePath)
if (savedExportPath) setExportPath(savedExportPath)
if (savedImageXorKey != null) { if (savedImageXorKey != null) {
setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`) setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`)
} }
@@ -96,14 +91,7 @@ function SettingsPage() {
} }
} }
const loadDefaultExportPath = async () => {
try {
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
setDefaultExportPath(downloadsPath)
} catch (e) {
console.error('获取默认导出路径失败:', e)
}
}
const loadAppVersion = async () => { const loadAppVersion = async () => {
try { try {
@@ -166,7 +154,7 @@ function SettingsPage() {
setDbPath(result.path) setDbPath(result.path)
await configService.setDbPath(result.path) await configService.setDbPath(result.path)
showMessage(`自动检测成功:${result.path}`, true) showMessage(`自动检测成功:${result.path}`, true)
const wxids = await window.electronAPI.dbPath.scanWxids(result.path) const wxids = await window.electronAPI.dbPath.scanWxids(result.path)
if (wxids.length === 1) { if (wxids.length === 1) {
setWxid(wxids[0].wxid) setWxid(wxids[0].wxid)
@@ -230,18 +218,7 @@ function SettingsPage() {
} }
} }
const handleSelectExportPath = async () => {
try {
const result = await dialog.openFile({ title: '选择导出目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) {
setExportPath(result.filePaths[0])
await configService.setExportPath(result.filePaths[0])
showMessage('已设置导出目录', true)
}
} catch (e) {
showMessage('选择目录失败', false)
}
}
const handleAutoGetDbKey = async () => { const handleAutoGetDbKey = async () => {
if (isFetchingDbKey) return if (isFetchingDbKey) return
@@ -303,16 +280,7 @@ function SettingsPage() {
} }
} }
const handleResetExportPath = async () => {
try {
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
setExportPath(downloadsPath)
await configService.setExportPath(downloadsPath)
showMessage('已恢复为下载目录', true)
} catch (e) {
showMessage('恢复默认失败', false)
}
}
const handleTestConnection = async () => { const handleTestConnection = async () => {
if (!dbPath) { showMessage('请先选择数据库目录', false); return } if (!dbPath) { showMessage('请先选择数据库目录', false); return }
@@ -396,7 +364,6 @@ function SettingsPage() {
setDbPath('') setDbPath('')
setWxid('') setWxid('')
setCachePath('') setCachePath('')
setExportPath('')
setLogEnabled(false) setLogEnabled(false)
setDbConnected(false) setDbConnected(false)
await window.electronAPI.window.openOnboardingWindow() await window.electronAPI.window.openOnboardingWindow()
@@ -562,19 +529,7 @@ function SettingsPage() {
</div> </div>
) )
const renderExportTab = () => (
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<input type="text" placeholder={defaultExportPath || '系统下载目录'} value={exportPath || defaultExportPath} onChange={(e) => setExportPath(e.target.value)} />
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectExportPath}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary" onClick={handleResetExportPath}><RotateCcw size={16} /> </button>
</div>
</div>
</div>
)
const renderCacheTab = () => ( const renderCacheTab = () => (
<div className="tab-content"> <div className="tab-content">
@@ -603,7 +558,7 @@ function SettingsPage() {
<h2 className="about-name">WeFlow</h2> <h2 className="about-name">WeFlow</h2>
<p className="about-slogan">WeFlow</p> <p className="about-slogan">WeFlow</p>
<p className="about-version">v{appVersion || '...'}</p> <p className="about-version">v{appVersion || '...'}</p>
<div className="about-update"> <div className="about-update">
{updateInfo?.hasUpdate ? ( {updateInfo?.hasUpdate ? (
<> <>
@@ -672,7 +627,6 @@ function SettingsPage() {
<div className="settings-body"> <div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()} {activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'database' && renderDatabaseTab()} {activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'export' && renderExportTab()}
{activeTab === 'cache' && renderCacheTab()} {activeTab === 'cache' && renderCacheTab()}
{activeTab === 'about' && renderAboutTab()} {activeTab === 'about' && renderAboutTab()}
</div> </div>

View File

@@ -55,6 +55,11 @@ export interface ElectronAPI {
chat: { chat: {
connect: () => Promise<{ success: boolean; error?: string }> connect: () => Promise<{ success: boolean; error?: string }>
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
enrichSessionsContactInfo: (usernames: string[]) => Promise<{
success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string
}>
getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{ getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{
success: boolean; success: boolean;
messages?: Message[]; messages?: Message[];