mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
34
.github/workflows/release.yml
vendored
34
.github/workflows/release.yml
vendored
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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_type(0=视频,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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_type(0=视频,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?.({
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}` }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Binary file not shown.
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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,6 +35,9 @@ 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',
|
||||||
@@ -41,7 +45,8 @@ function ExportPage() {
|
|||||||
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,11 @@ import {
|
|||||||
} 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,8 +30,6 @@ 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)
|
||||||
@@ -53,7 +50,6 @@ function SettingsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
loadDefaultExportPath()
|
|
||||||
loadAppVersion()
|
loadAppVersion()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -85,7 +81,6 @@ function SettingsPage() {
|
|||||||
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 {
|
||||||
@@ -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">
|
||||||
@@ -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>
|
||||||
|
|||||||
5
src/types/electron.d.ts
vendored
5
src/types/electron.d.ts
vendored
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user