Compare commits

...

34 Commits

Author SHA1 Message Date
cc
3efaed488a Merge pull request #82 from 5xiao0qing5/dev
实现 TXT导出 和 HTML导出
2026-01-24 00:25:34 +08:00
QingXiao
decdbf95f7 Merge pull request #9 from 5xiao0qing5/codex/implement-html-export-feature-1g9o7z 2026-01-24 00:19:37 +08:00
QingXiao
cccc712814 Merge pull request #8 from 5xiao0qing5/codex/format-txt-export-for-messages
Adjust txt/excel export message formatting
2026-01-24 00:17:58 +08:00
QingXiao
135f4819fb Align HTML export parsing and voip placeholders 2026-01-24 00:07:49 +08:00
QingXiao
388923257b Handle more message types in exports 2026-01-23 23:53:33 +08:00
cc
6918e359e8 Merge pull request #86 from hicccc77/dev
Dev
2026-01-23 23:46:34 +08:00
cc
d5b33c7e77 Merge branch 'main' of https://github.com/hicccc77/WeFlow into dev 2026-01-23 23:45:26 +08:00
QingXiao
d37f53e120 Adjust txt/excel export message formatting 2026-01-23 23:37:16 +08:00
cc
26478217e7 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-23 23:33:08 +08:00
cc
a100f4ef97 feat: 一些朋友圈功能的优化实现 2026-01-23 23:33:06 +08:00
QingXiao
91b746dc59 Merge pull request #7 from 5xiao0qing5/codex/implement-html-export-feature-1g9o7z
将 HTML 导出样式移至外部文件并强化 HTML 导出(清理、表情符号、视频)
2026-01-23 22:40:12 +08:00
QingXiao
1817a847de Merge branch 'dev' into codex/implement-html-export-feature-1g9o7z 2026-01-23 22:38:56 +08:00
cc
7e99feae1e Merge pull request #85 from xunchahaha/dev
fix:修复了头像加载失败的问题
2026-01-23 22:34:42 +08:00
QingXiao
2977c45365 Move HTML export styles to CSS file 2026-01-23 22:32:26 +08:00
Forrest
3b363a3efa Merge pull request #84 from xunchahaha/docs/add-3wm-qrcode
hh
2026-01-23 22:28:01 +08:00
xuncha
e2b0bd44d9 hh 2026-01-23 22:15:42 +08:00
QingXiao
cc26860504 实现 HTML 导出功能 2026-01-23 15:06:07 +08:00
QingXiao
54f3e0481f Fix HTML export app messages and emoji rendering 2026-01-23 15:00:43 +08:00
QingXiao
a61371c8ad Refine HTML export layout and theming 2026-01-23 14:48:34 +08:00
QingXiao
fd6d5e4296 Implement HTML chat export 2026-01-23 14:34:40 +08:00
QingXiao
514a617c55 Merge pull request #4 from 5xiao0qing5/codex/add-txt-export-feature-with-configurable-options
完成未实现的 TXT 导出功能
2026-01-23 13:58:56 +08:00
QingXiao
b47007ea0c Add configurable TXT export 2026-01-23 13:52:47 +08:00
xuncha
6436c39c90 Dev (#79)
* fix:尝试修复闪退的问题

* hhhhh

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

* 优化表诉

* 导出优化

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

* 优化表述

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

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

* Bump version from 1.3.1 to 1.3.2

---------

Co-authored-by: Forrest <jin648862@gmail.com>
Co-authored-by: cc <98377878+hicccc77@users.noreply.github.com>
2026-01-23 10:06:16 +08:00
xuncha
eb2f90e605 fix:修复了头像加载失败的问题 2026-01-23 10:03:31 +08:00
cc
bdbb85175a Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-23 00:13:58 +08:00
cc
a5e1bfe49a feat: 实现朋友圈获取; 实现聊天页面跳转到指定日期 2026-01-23 00:13:55 +08:00
xuncha
b3adb54651 优化表述 2026-01-22 22:00:36 +08:00
cc
07e7bce6a9 fix: 尝试修复运行库缺失的问题 2026-01-22 21:46:04 +08:00
xuncha
baa90242a6 导出优化 2026-01-22 21:39:14 +08:00
xuncha
787db0cec2 优化表诉 2026-01-22 20:30:04 +08:00
Forrest
6359118132 fix(chatService): 优化头像加载兜底机制:收集无 URL 的用户名,从 head_image.db 批量获取并转换为 base64 格式,更新头像缓存并添加错误处理,避免聊天界面头像缺失。(解决了部分,我电脑上有几个不显示) 2026-01-22 19:36:57 +08:00
xuncha
49614bf6d8 Dev (#77)
* fix:尝试修复闪退的问题

* hhhhh
2026-01-22 18:46:22 +08:00
xuncha
0901e08c5c hhhhh 2026-01-22 18:44:42 +08:00
xuncha
0e3ab8e4d6 Merge pull request #72 from hicccc77/dev
Dev
2026-01-21 21:01:38 +08:00
35 changed files with 3943 additions and 177 deletions

View File

@@ -39,13 +39,23 @@ jobs:
npx tsc
npx vite build
- name: Inject Configuration
shell: bash
run: |
npm pkg set build.releaseInfo.releaseNotes="修复了一些已知问题"
- name: Package and Publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx electron-builder --publish always
npx electron-builder --publish always
- name: Update Release Notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
cat <<EOF > release_notes.md
## 更新日志
修复了一些已知问题
## 加入我们的群
[点击加入 Telegram 群](https://t.me/+hn3QzNc4DbA0MzNl)
EOF
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md

BIN
2wm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

BIN
3wm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -28,6 +28,16 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
> [!TIP]
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
# 加入微信交流群
> 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。
<p align="center">
<img src="2wm.png" alt="WeFlow 微信交流群二维码(一群)" width="220" style="margin-right: 16px;">
<img src="3wm.png" alt="WeFlow 微信交流群二维码(二群)" width="220">
</p>
<p align="center">一群满了加二群</p>
## 主要功能
- 本地实时查看聊天记录
@@ -36,6 +46,9 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
- 导出聊天记录为 HTML 等格式
- 本地解密与数据库管理
> [!NOTE]
> ⚠️ 本工具仅适配微信 **4.0 及以上**版本,请确保你的微信版本符合要求
## 快速开始
若你只想使用成品版本,可前往 Release 下载并安装。

View File

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

View File

@@ -98,8 +98,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
enrichSessionsContactInfo: (usernames: string[]) =>
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
getMessages: (sessionId: string, offset?: number, limit?: number) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
getLatestMessages: (sessionId: string, limit?: number) =>
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
@@ -118,7 +118,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
}
},
execQuery: (kind: string, path: string | null, sql: string) =>
ipcRenderer.invoke('chat:execQuery', kind, path, sql)
},
@@ -207,5 +209,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
}
},
// 朋友圈
sns: {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime)
}
})

View File

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

View File

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

View File

@@ -0,0 +1,301 @@
:root {
color-scheme: light;
--bg: #f6f7fb;
--card: #ffffff;
--text: #1f2a37;
--muted: #6b7280;
--accent: #4f46e5;
--sent: #dbeafe;
--received: #ffffff;
--border: #e5e7eb;
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
--radius: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
max-width: 1080px;
margin: 32px auto 60px;
padding: 0 20px;
}
.header {
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px;
margin-bottom: 24px;
}
.title {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px;
}
.meta {
color: var(--muted);
font-size: 14px;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 20px;
}
.control {
display: flex;
flex-direction: column;
gap: 6px;
}
.control label {
font-size: 13px;
color: var(--muted);
}
.control input,
.control select,
.control button {
border-radius: 12px;
border: 1px solid var(--border);
padding: 10px 12px;
font-size: 14px;
font-family: inherit;
}
.control button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
transition: transform 0.1s ease;
}
.control button:active {
transform: scale(0.98);
}
.stats {
font-size: 13px;
color: var(--muted);
display: flex;
align-items: flex-end;
}
.message-list {
display: flex;
flex-direction: column;
gap: 18px;
}
.message {
display: flex;
flex-direction: column;
gap: 8px;
}
.message.hidden {
display: none;
}
.message-time {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.message-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.message.sent .message-row {
flex-direction: row-reverse;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: #eef2ff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
color: #475569;
font-weight: 600;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bubble {
max-width: min(70%, 720px);
background: var(--received);
border-radius: 18px;
padding: 12px 14px;
border: 1px solid var(--border);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.message.sent .bubble {
background: var(--sent);
border-color: transparent;
}
.sender-name {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.message-content {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
line-height: 1.6;
}
.message-text {
word-break: break-word;
}
.inline-emoji {
width: 22px;
height: 22px;
vertical-align: text-bottom;
margin: 0 2px;
}
.message-media {
border-radius: 14px;
max-width: 100%;
}
.previewable {
cursor: zoom-in;
}
.message-media.image,
.message-media.emoji {
max-height: 260px;
object-fit: contain;
background: #f1f5f9;
padding: 6px;
}
.message-media.emoji {
max-height: 160px;
width: auto;
}
.message-media.video {
max-height: 360px;
background: #111827;
}
.message-media.audio {
width: 260px;
}
.image-preview {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.7);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 999;
}
.image-preview.active {
opacity: 1;
pointer-events: auto;
}
.image-preview img {
max-width: min(90vw, 1200px);
max-height: 90vh;
border-radius: 18px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
background: #0f172a;
transition: transform 0.1s ease;
cursor: zoom-out;
}
body[data-theme="cloud-dancer"] {
--accent: #6b8cff;
--sent: #e0e7ff;
--received: #ffffff;
--border: #d8e0f7;
--bg: #f6f7fb;
}
body[data-theme="corundum-blue"] {
--accent: #2563eb;
--sent: #dbeafe;
--received: #ffffff;
--border: #c7d2fe;
--bg: #eef2ff;
}
body[data-theme="kiwi-green"] {
--accent: #16a34a;
--sent: #dcfce7;
--received: #ffffff;
--border: #bbf7d0;
--bg: #f0fdf4;
}
body[data-theme="spicy-red"] {
--accent: #e11d48;
--sent: #ffe4e6;
--received: #ffffff;
--border: #fecdd3;
--bg: #fff1f2;
}
body[data-theme="teal-water"] {
--accent: #0f766e;
--sent: #ccfbf1;
--received: #ffffff;
--border: #99f6e4;
--bg: #f0fdfa;
}
.highlight {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 18px;
}
.empty {
text-align: center;
color: var(--muted);
padding: 40px;
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "weflow",
"version": "1.2.0",
"version": "1.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "weflow",
"version": "1.2.0",
"version": "1.3.1",
"hasInstallScript": true,
"dependencies": {
"better-sqlite3": "^12.5.0",

View File

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

BIN
resources/msvcp140.dll Normal file

Binary file not shown.

BIN
resources/msvcp140_1.dll Normal file

Binary file not shown.

BIN
resources/vcruntime140.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ interface ExportOptions {
exportEmojis: boolean
exportVoiceAsText: boolean
excelCompactColumns: boolean
txtColumns: string[]
}
interface ExportResult {
@@ -34,6 +35,7 @@ interface ExportResult {
type SessionLayout = 'shared' | 'per-session'
function ExportPage() {
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const [sessions, setSessions] = useState<ChatSession[]>([])
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
@@ -61,7 +63,8 @@ function ExportPage() {
exportVoices: true,
exportEmojis: true,
exportVoiceAsText: true,
excelCompactColumns: true
excelCompactColumns: true,
txtColumns: defaultTxtColumns
})
const buildDateRangeFromPreset = (preset: string) => {
@@ -125,17 +128,20 @@ function ExportPage() {
savedRange,
savedMedia,
savedVoiceAsText,
savedExcelCompactColumns
savedExcelCompactColumns,
savedTxtColumns
] = await Promise.all([
configService.getExportDefaultFormat(),
configService.getExportDefaultDateRange(),
configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns()
configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns()
])
const preset = savedRange || 'today'
const rangeDefaults = buildDateRangeFromPreset(preset)
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
setOptions((prev) => ({
...prev,
@@ -144,7 +150,8 @@ function ExportPage() {
dateRange: rangeDefaults.dateRange,
exportMedia: savedMedia ?? false,
exportVoiceAsText: savedVoiceAsText ?? true,
excelCompactColumns: savedExcelCompactColumns ?? true
excelCompactColumns: savedExcelCompactColumns ?? true,
txtColumns
}))
} catch (e) {
console.error('加载导出默认设置失败:', e)
@@ -209,6 +216,23 @@ function ExportPage() {
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
}
const handleFormatChange = (format: ExportOptions['format']) => {
setOptions((prev) => {
const next = { ...prev, format }
if (format === 'html') {
return {
...next,
exportMedia: true,
exportImages: true,
exportVoices: true,
exportEmojis: true,
exportVoiceAsText: true
}
}
return next
})
}
const openExportFolder = async () => {
if (exportFolder) {
await window.electronAPI.shell.openPath(exportFolder)
@@ -231,17 +255,18 @@ function ExportPage() {
exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices,
exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // ?????????exportMedia
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
excelCompactColumns: options.excelCompactColumns,
txtColumns: options.txtColumns,
sessionLayout,
dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000),
// ?????????????????????????????????23:59:59,??????????????????????????????
// 将结束日期设置为当天的 23:59:59确保包含当天的所有记录
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
} : null
}
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel') {
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html') {
const result = await window.electronAPI.export.exportSessions(
sessionList,
exportFolder,
@@ -249,10 +274,10 @@ function ExportPage() {
)
setExportResult(result)
} else {
setExportResult({ success: false, error: `${options.format.toUpperCase()} ???????????????????????????...` })
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` })
}
} catch (e) {
console.error('????????????:', e)
console.error('导出过程中发生异常:', e)
setExportResult({ success: false, error: String(e) })
} finally {
setIsExporting(false)
@@ -447,7 +472,7 @@ function ExportPage() {
<div
key={fmt.value}
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
onClick={() => setOptions({ ...options, format: fmt.value as any })}
onClick={() => handleFormatChange(fmt.value as ExportOptions['format'])}
>
<fmt.icon size={24} />
<span className="format-label">{fmt.label}</span>

View File

@@ -166,6 +166,7 @@ function SettingsPage() {
await configService.setTranscribeLanguages(defaultLanguages)
}
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
} catch (e) {
console.error('加载配置失败:', e)
@@ -1074,6 +1075,7 @@ function SettingsPage() {
)}
</div>
</div>
</div>
)
}
@@ -1225,4 +1227,3 @@ function SettingsPage() {
}
export default SettingsPage

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

@@ -0,0 +1,940 @@
.sns-page {
height: 100%;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
.sns-container {
display: flex;
height: 100%;
}
.sns-sidebar {
width: 300px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
z-index: 10;
&.closed {
width: 0;
opacity: 0;
transform: translateX(-100%);
pointer-events: none;
}
.sidebar-header {
padding: 18px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
.title-wrapper {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-primary);
.title-icon {
color: var(--accent-color);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.5px;
}
}
.toggle-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s;
&:hover {
background: var(--hover-bg);
color: var(--accent-color);
border-color: var(--accent-color);
}
}
}
.filter-content {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
.filter-card {
background: var(--bg-primary);
border-radius: 12px;
border: 1px solid var(--border-color);
padding: 14px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.04);
}
&.jump-date-card {
.jump-date-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 10px 14px;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
&.active {
border-color: var(--accent-color);
color: var(--text-primary);
font-weight: 500;
background: rgba(var(--accent-color-rgb), 0.05);
.icon {
color: var(--accent-color);
opacity: 1;
}
}
&:hover {
border-color: var(--accent-color);
background: var(--bg-primary);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(var(--accent-color-rgb), 0.08);
}
&:active {
transform: translateY(0);
}
.text {
flex: 1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.icon {
opacity: 0.5;
transition: all 0.2s;
margin-left: 8px;
}
}
.clear-jump-date-inline {
width: 100%;
margin-top: 10px;
background: rgba(var(--accent-color-rgb), 0.06);
border: 1px dashed rgba(var(--accent-color-rgb), 0.3);
color: var(--accent-color);
font-size: 12px;
cursor: pointer;
text-align: center;
padding: 6px;
border-radius: 8px;
transition: all 0.2s;
font-weight: 500;
&:hover {
background: var(--accent-color);
color: white;
border-style: solid;
}
}
}
&.contact-card {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; // 改为 0 以支持 flex 压缩
padding: 0;
overflow: hidden;
}
}
.filter-section {
margin-bottom: 20px;
label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 10px;
font-weight: 600;
svg {
color: var(--accent-color);
opacity: 0.8;
}
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
.input-icon {
position: absolute;
left: 12px;
color: var(--text-tertiary);
pointer-events: none;
}
input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 10px 10px 10px 36px;
color: var(--text-primary);
font-size: 13px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&::placeholder {
color: var(--text-tertiary);
opacity: 0.6;
}
&:focus {
outline: none;
border-color: var(--accent-color);
background: var(--bg-primary);
box-shadow: 0 0 0 4px rgba(var(--accent-color-rgb), 0.1);
}
}
.clear-input {
position: absolute;
right: 8px;
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
display: flex;
border-radius: 50%;
transition: all 0.2s;
&:hover {
color: var(--text-secondary);
background: var(--hover-bg);
transform: rotate(90deg);
}
}
}
}
.contact-filter-section {
flex: 1;
display: flex;
flex-direction: column;
.section-header {
padding: 16px 16px 1px 16px;
display: flex;
justify-content: space-between;
align-items: center;
.header-actions {
display: flex;
align-items: center;
gap: 8px;
.clear-selection-btn {
background: none;
border: none;
color: var(--text-tertiary);
font-size: 11px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.2s;
&:hover {
color: var(--accent-color);
background: rgba(var(--accent-color-rgb), 0.1);
}
}
.selected-count {
font-size: 10px;
background: var(--accent-color);
color: white;
min-width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: bold;
}
}
}
.contact-search {
padding: 0 16px 12px 16px;
position: relative;
display: flex;
align-items: center;
.search-icon {
position: absolute;
left: 26px;
color: var(--text-tertiary);
pointer-events: none;
z-index: 1;
opacity: 0.6;
}
input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 30px 8px 30px;
font-size: 12px;
color: var(--text-primary);
transition: all 0.2s;
&:focus {
outline: none;
border-color: var(--accent-color);
background: var(--bg-primary);
}
}
.clear-search-icon {
position: absolute;
right: 24px;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
border-radius: 50%;
transition: all 0.2s;
&:hover {
color: var(--text-secondary);
background: var(--hover-bg);
}
}
}
.contact-list {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
margin: 0 4px 8px 4px;
.contact-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 10px;
cursor: pointer;
gap: 12px;
margin-bottom: 2px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&:hover {
background: var(--hover-bg);
transform: translateX(2px);
}
&.active {
background: rgba(var(--accent-color-rgb), 0.08);
.contact-name {
color: var(--accent-color);
font-weight: 600;
}
.check-box {
border-color: var(--accent-color);
background: var(--accent-color);
.inner-check {
transform: scale(1);
}
}
}
.avatar-wrapper {
position: relative;
display: flex;
.active-badge {
position: absolute;
bottom: -1px;
right: -1px;
width: 10px;
height: 10px;
background: var(--accent-color);
border: 2px solid var(--bg-secondary);
border-radius: 50%;
animation: badge-pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
}
.contact-name {
flex: 1;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-secondary);
transition: color 0.2s;
}
.check-box {
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
.inner-check {
width: 8px;
height: 8px;
border-radius: 1px;
background: white;
transform: scale(0);
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
}
}
.empty-contacts {
padding: 32px 16px;
text-align: center;
font-size: 13px;
color: var(--text-tertiary);
font-style: italic;
}
}
}
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid var(--border-color);
.clear-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
&:hover {
background: var(--accent-color);
color: white;
border-color: var(--accent-color);
box-shadow: 0 4px 10px rgba(var(--accent-color-rgb), 0.2);
}
&:active {
transform: scale(0.98);
}
}
}
}
.sns-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--bg-primary);
.sns-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 64px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
backdrop-filter: blur(10px);
z-index: 5;
.header-left {
display: flex;
align-items: center;
gap: 16px;
h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.sidebar-trigger {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
&:hover {
background: var(--hover-bg);
color: var(--accent-color);
}
}
}
.icon-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
border-radius: 8px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: var(--text-primary);
background: var(--hover-bg);
}
&.refresh-btn {
&:hover {
color: var(--accent-color);
}
}
}
.spinning {
animation: spin 1s linear infinite;
}
}
.sns-content-wrapper {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
}
.sns-content {
flex: 1;
overflow-y: auto;
padding: 24px 0;
scroll-behavior: smooth;
.active-filters-bar {
max-width: 680px;
margin: 0 auto 24px auto;
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(var(--accent-color-rgb), 0.08);
border: 1px solid rgba(var(--accent-color-rgb), 0.2);
padding: 10px 16px;
border-radius: 10px;
font-size: 13px;
color: var(--accent-color);
.filter-info {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.clear-chip-btn {
background: var(--accent-color);
border: none;
color: white;
cursor: pointer;
font-size: 11px;
padding: 4px 10px;
border-radius: 4px;
font-weight: 600;
&:hover {
background: var(--accent-color-hover);
}
}
}
.posts-list {
display: flex;
flex-direction: column;
gap: 32px;
}
.sns-post-row {
display: flex;
width: 100%;
max-width: 800px;
position: relative;
}
}
.sns-post-wrapper {
width: 100%;
padding: 0 20px;
}
.sns-post {
background: var(--bg-secondary);
border-radius: 16px;
padding: 24px;
border: 1px solid var(--border-color);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.03);
transition: transform 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
}
.post-header {
display: flex;
align-items: center;
margin-bottom: 18px;
.post-info {
margin-left: 14px;
.nickname {
font-size: 15px;
font-weight: 700;
margin-bottom: 4px;
color: var(--accent-color);
}
.time {
font-size: 12px;
color: var(--text-tertiary);
display: flex;
align-items: center;
gap: 4px;
&::before {
content: '';
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
opacity: 0.5;
}
}
}
}
.post-body {
margin-bottom: 20px;
.post-text {
margin-bottom: 14px;
white-space: pre-wrap;
line-height: 1.7;
font-size: 15px;
color: var(--text-primary);
word-break: break-word;
}
.post-media-grid {
display: grid;
gap: 6px;
width: fit-content;
max-width: 100%;
&.media-count-1 {
grid-template-columns: 1fr;
.media-item {
width: 320px;
height: 240px;
max-width: 100%;
border-radius: 12px;
aspect-ratio: auto;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&.media-count-2,
&.media-count-4 {
grid-template-columns: repeat(2, 1fr);
}
&.media-count-3,
&.media-count-5,
&.media-count-6,
&.media-count-7,
&.media-count-8,
&.media-count-9 {
grid-template-columns: repeat(3, 1fr);
}
.media-item {
width: 160px; // 多图模式下项固定大小(或由 grid 控制,但确保有高度)
height: 160px;
aspect-ratio: 1;
background: var(--bg-tertiary);
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border-color);
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
cursor: zoom-in;
}
.media-error-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-deep);
color: var(--text-tertiary);
cursor: default;
}
}
}
.post-video-placeholder {
display: inline-flex;
align-items: center;
gap: 10px;
background: rgba(var(--accent-color-rgb), 0.08);
color: var(--accent-color);
padding: 10px 18px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
border: 1px solid rgba(var(--accent-color-rgb), 0.1);
cursor: pointer;
&:hover {
background: rgba(var(--accent-color-rgb), 0.12);
}
}
}
.post-footer {
background: var(--bg-tertiary);
border-radius: 10px;
padding: 14px;
position: relative;
&::after {
content: '';
position: absolute;
top: -8px;
left: 20px;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid var(--bg-tertiary);
}
.likes-section {
display: flex;
align-items: flex-start;
color: var(--accent-color);
padding-bottom: 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
margin-bottom: 10px;
font-size: 13px;
&:last-child {
padding-bottom: 0;
border-bottom: none;
margin-bottom: 0;
}
.icon {
margin-top: 3px;
margin-right: 10px;
flex-shrink: 0;
opacity: 0.8;
}
.likes-list {
line-height: 1.6;
font-weight: 500;
}
}
.comments-section {
.comment-item {
margin-bottom: 8px;
line-height: 1.6;
font-size: 13px;
&:last-child {
margin-bottom: 0;
}
.comment-user {
color: var(--accent-color);
font-weight: 700;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.reply-text {
color: var(--text-tertiary);
margin: 0 6px;
font-size: 12px;
}
.comment-content {
color: var(--text-secondary);
}
}
}
}
}
.status-indicator {
text-align: center;
padding: 40px;
color: var(--text-tertiary);
font-size: 14px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
&.loading-more,
&.loading-newer {
color: var(--accent-color);
}
&.newer-hint {
background: rgba(var(--accent-color-rgb), 0.08);
padding: 12px;
border-radius: 12px;
cursor: pointer;
border: 1px dashed rgba(var(--accent-color-rgb), 0.2);
transition: all 0.2s;
margin-bottom: 16px;
&:hover {
background: rgba(var(--accent-color-rgb), 0.15);
border-style: solid;
transform: translateY(-2px);
}
}
}
.no-results {
text-align: center;
padding: 80px 20px;
color: var(--text-tertiary);
.no-results-icon {
margin-bottom: 20px;
opacity: 0.2;
}
p {
font-size: 16px;
margin-bottom: 24px;
}
.reset-inline {
background: var(--accent-color);
color: white;
border: none;
padding: 10px 24px;
border-radius: 10px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
box-shadow: 0 4px 15px rgba(var(--accent-color-rgb), 0.3);
transition: all 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(var(--accent-color-rgb), 0.4);
}
}
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes badge-pop {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}

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

@@ -0,0 +1,595 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon } from 'lucide-react'
import { Avatar } from '../components/Avatar'
import { ImagePreview } from '../components/ImagePreview'
import JumpToDateDialog from '../components/JumpToDateDialog'
import './SnsPage.scss'
interface SnsPost {
id: string
username: string
nickname: string
avatarUrl?: string
createTime: number
contentDesc: string
type?: number
media: { url: string; thumb: string }[]
likes: string[]
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
}
const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => {
const [error, setError] = useState(false);
return (
<div className={`media-item ${error ? 'error' : ''}`}>
{!error ? (
<img
src={thumb || url}
alt=""
loading="lazy"
onClick={onPreview}
onError={() => setError(true)}
/>
) : (
<div className="media-error-placeholder" onClick={onPreview}>
<ImageIcon size={24} style={{ opacity: 0.3 }} />
</div>
)}
</div>
);
};
interface Contact {
username: string
displayName: string
avatarUrl?: string
}
export default function SnsPage() {
const [posts, setPosts] = useState<SnsPost[]>([])
const [loading, setLoading] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const loadingRef = useRef(false)
// 筛选与搜索状态
const [searchKeyword, setSearchKeyword] = useState('')
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
// 联系人列表状态
const [contacts, setContacts] = useState<Contact[]>([])
const [contactSearch, setContactSearch] = useState('')
const [contactsLoading, setContactsLoading] = useState(false)
const [showJumpDialog, setShowJumpDialog] = useState(false)
const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined)
const [previewImage, setPreviewImage] = useState<string | null>(null)
const postsContainerRef = useRef<HTMLDivElement>(null)
const [hasNewer, setHasNewer] = useState(false)
const [loadingNewer, setLoadingNewer] = useState(false)
const postsRef = useRef<SnsPost[]>([])
const scrollAdjustmentRef = useRef<number>(0)
// 同步 posts 到 ref 供 loadPosts 使用
useEffect(() => {
postsRef.current = posts
}, [posts])
// 处理向上加载动态时的滚动位置保持
useEffect(() => {
if (scrollAdjustmentRef.current !== 0 && postsContainerRef.current) {
const container = postsContainerRef.current;
const newHeight = container.scrollHeight;
const diff = newHeight - scrollAdjustmentRef.current;
if (diff > 0) {
container.scrollTop += diff;
}
scrollAdjustmentRef.current = 0;
}
}, [posts])
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
const { reset = false, direction = 'older' } = options
if (loadingRef.current) return
loadingRef.current = true
if (direction === 'newer') setLoadingNewer(true)
else setLoading(true)
try {
const limit = 20
let startTs: number | undefined = undefined
let endTs: number | undefined = undefined
if (reset) {
if (jumpTargetDate) {
endTs = Math.floor(jumpTargetDate.getTime() / 1000) + 86399
}
} else if (direction === 'newer') {
const currentPosts = postsRef.current
if (currentPosts.length > 0) {
const topTs = currentPosts[0].createTime
console.log('[SnsPage] Fetching newer posts starts from:', topTs + 1);
const result = await window.electronAPI.sns.getTimeline(
limit,
0,
selectedUsernames,
searchKeyword,
topTs + 1,
undefined
);
if (result.success && result.timeline && result.timeline.length > 0) {
if (postsContainerRef.current) {
scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight;
}
const existingIds = new Set(currentPosts.map(p => p.id));
const uniqueNewer = result.timeline.filter(p => !existingIds.has(p.id));
if (uniqueNewer.length > 0) {
setPosts(prev => [...uniqueNewer, ...prev]);
}
setHasNewer(result.timeline.length >= limit);
} else {
setHasNewer(false);
}
}
setLoadingNewer(false);
loadingRef.current = false;
return;
} else {
const currentPosts = postsRef.current
if (currentPosts.length > 0) {
endTs = currentPosts[currentPosts.length - 1].createTime - 1
}
}
const result = await window.electronAPI.sns.getTimeline(
limit,
0,
selectedUsernames,
searchKeyword,
startTs,
endTs
)
if (result.success && result.timeline) {
if (reset) {
setPosts(result.timeline)
setHasMore(result.timeline.length >= limit)
// 探测上方是否还有新动态(利用 DLL 过滤,而非底层 SQL
const topTs = result.timeline[0]?.createTime || 0;
if (topTs > 0) {
const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, searchKeyword, topTs + 1, undefined);
setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0));
} else {
setHasNewer(false);
}
if (postsContainerRef.current) {
postsContainerRef.current.scrollTop = 0
}
} else {
if (result.timeline.length > 0) {
setPosts(prev => [...prev, ...result.timeline!])
}
if (result.timeline.length < limit) {
setHasMore(false)
}
}
}
} catch (error) {
console.error('Failed to load SNS timeline:', error)
} finally {
setLoading(false)
setLoadingNewer(false)
loadingRef.current = false
}
}, [selectedUsernames, searchKeyword, jumpTargetDate])
// 获取联系人列表
const loadContacts = async () => {
setContactsLoading(true)
try {
const result = await window.electronAPI.chat.getSessions()
if (result.success && result.sessions) {
const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder'];
const initialContacts = result.sessions
.filter((s: any) => {
if (!s.username) return false;
const u = s.username.toLowerCase();
if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) return false;
if (u.startsWith('gh_')) return false;
if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) return false;
return true;
})
.map((s: any) => ({
username: s.username,
displayName: s.displayName || s.username,
avatarUrl: s.avatarUrl
}))
setContacts(initialContacts)
const usernames = initialContacts.map(c => c.username)
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (enriched.success && enriched.contacts) {
setContacts(prev => prev.map(c => {
const extra = enriched.contacts![c.username]
if (extra) {
return {
...c,
displayName: extra.displayName || c.displayName,
avatarUrl: extra.avatarUrl || c.avatarUrl
}
}
return c
}))
}
}
} catch (error) {
console.error('Failed to load contacts:', error)
} finally {
setContactsLoading(false)
}
}
// 初始加载
useEffect(() => {
const checkSchema = async () => {
try {
const schema = await window.electronAPI.chat.execQuery('sns', null, "PRAGMA table_info(SnsTimeLine)");
console.log('[SnsPage] SnsTimeLine Schema:', schema);
if (schema.success && schema.rows) {
const columns = schema.rows.map((r: any) => r.name);
console.log('[SnsPage] Available columns:', columns);
}
} catch (e) {
console.error('[SnsPage] Failed to check schema:', e);
}
};
checkSchema();
loadContacts()
}, [])
useEffect(() => {
loadPosts({ reset: true })
}, [selectedUsernames, searchKeyword, jumpTargetDate])
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
// 加载更旧的动态(触底)
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) {
loadPosts({ direction: 'older' })
}
// 加载更新的动态(触顶触发)
// 这里的阈值可以保留,但主要依赖下面的 handleWheel 捕获到顶后的上划
if (scrollTop < 10 && hasNewer && !loading && !loadingNewer) {
loadPosts({ direction: 'newer' })
}
}
// 处理到顶后的手动上滚意图
const handleWheel = (e: React.WheelEvent<HTMLDivElement>) => {
const container = postsContainerRef.current
if (!container) return
// deltaY < 0 表示向上滚scrollTop === 0 表示已经在最顶端
if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) {
console.log('[SnsPage] Wheel-up detected at top, loading newer posts...');
loadPosts({ direction: 'newer' })
}
}
const formatTime = (ts: number) => {
const date = new Date(ts * 1000)
const isCurrentYear = date.getFullYear() === new Date().getFullYear()
return date.toLocaleString('zh-CN', {
year: isCurrentYear ? undefined : 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const toggleUserSelection = (username: string) => {
// 选择联系人时,如果当前有时间跳转,建议清除时间跳转以避免“跳到旧动态”的困惑
// 或者保持原样。根据用户反馈“乱跳”,我们在这里选择:
// 如果用户选择了新的一个人,而之前有时间跳转,我们重置时间跳转到最新。
setJumpTargetDate(undefined);
setSelectedUsernames(prev => {
if (prev.includes(username)) {
return prev.filter(u => u !== username)
} else {
return [...prev, username]
}
})
}
const clearFilters = () => {
setSearchKeyword('')
setSelectedUsernames([])
setJumpTargetDate(undefined)
}
const filteredContacts = contacts.filter(c =>
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
)
return (
<div className="sns-page">
<div className="sns-container">
{/* 侧边栏:过滤与搜索 */}
<aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}>
<div className="sidebar-header">
<div className="title-wrapper">
<Filter size={18} className="title-icon" />
<h3></h3>
</div>
<button className="toggle-btn" onClick={() => setIsSidebarOpen(false)}>
<X size={18} />
</button>
</div>
<div className="filter-content custom-scrollbar">
{/* 1. 搜索分组 (放到最顶上) */}
<div className="filter-card">
<div className="filter-section">
<label><Search size={14} /> </label>
<div className="search-input-wrapper">
<Search size={14} className="input-icon" />
<input
type="text"
placeholder="搜索动态内容..."
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
/>
{searchKeyword && (
<button className="clear-input" onClick={() => setSearchKeyword('')}>
<X size={14} />
</button>
)}
</div>
</div>
</div>
{/* 2. 日期跳转 (放搜索下面) */}
<div className="filter-card jump-date-card">
<div className="filter-section">
<label><Calendar size={14} /> </label>
<button className={`jump-date-btn ${jumpTargetDate ? 'active' : ''}`} onClick={() => setShowJumpDialog(true)}>
<span className="text">
{jumpTargetDate ? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }) : '选择跳转日期...'}
</span>
<Calendar size={14} className="icon" />
</button>
{jumpTargetDate && (
<button className="clear-jump-date-inline" onClick={() => setJumpTargetDate(undefined)}>
</button>
)}
</div>
</div>
{/* 3. 联系人筛选 (放最下面,高度自适应) */}
<div className="filter-card contact-card">
<div className="contact-filter-section">
<div className="section-header">
<label><User size={14} /> </label>
<div className="header-actions">
{selectedUsernames.length > 0 && (
<button className="clear-selection-btn" onClick={() => setSelectedUsernames([])}></button>
)}
{selectedUsernames.length > 0 && (
<span className="selected-count">{selectedUsernames.length}</span>
)}
</div>
</div>
<div className="contact-search">
<Search size={12} className="search-icon" />
<input
type="text"
placeholder="搜索好友..."
value={contactSearch}
onChange={e => setContactSearch(e.target.value)}
/>
{contactSearch && (
<X size={12} className="clear-search-icon" onClick={() => setContactSearch('')} />
)}
</div>
<div className="contact-list custom-scrollbar">
{filteredContacts.map(contact => (
<div
key={contact.username}
className={`contact-item ${selectedUsernames.includes(contact.username) ? 'active' : ''}`}
onClick={() => toggleUserSelection(contact.username)}
>
<div className="avatar-wrapper">
<Avatar src={contact.avatarUrl} name={contact.displayName} size={32} shape="rounded" />
{selectedUsernames.includes(contact.username) && (
<div className="active-badge"></div>
)}
</div>
<span className="contact-name">{contact.displayName}</span>
<div className="check-box">
{selectedUsernames.includes(contact.username) && <div className="inner-check"></div>}
</div>
</div>
))}
{filteredContacts.length === 0 && (
<div className="empty-contacts"></div>
)}
</div>
</div>
</div>
</div>
<div className="sidebar-footer">
<button className="clear-btn" onClick={clearFilters}>
<RefreshCw size={14} />
</button>
</div>
</aside>
<main className="sns-main">
<div className="sns-header">
<div className="header-left">
{!isSidebarOpen && (
<button className="icon-btn sidebar-trigger" onClick={() => setIsSidebarOpen(true)}>
<Filter size={20} />
</button>
)}
<h2></h2>
</div>
<div className="header-right">
<button
onClick={() => {
if (jumpTargetDate) setJumpTargetDate(undefined);
loadPosts({ reset: true });
}}
disabled={loading || loadingNewer}
className="icon-btn refresh-btn"
>
<RefreshCw size={18} className={(loading || loadingNewer) ? 'spinning' : ''} />
</button>
</div>
</div>
<div className="sns-content-wrapper">
<div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
<div className="posts-list">
{loadingNewer && (
<div className="status-indicator loading-newer">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>
)}
{!loadingNewer && hasNewer && (
<div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
</div>
)}
{posts.map((post, index) => {
return (
<div key={post.id} className="sns-post-row">
<div className="sns-post-wrapper">
<div className="sns-post">
<div className="post-header">
<Avatar
src={post.avatarUrl}
name={post.nickname}
size={44}
shape="rounded"
/>
<div className="post-info">
<div className="nickname">{post.nickname}</div>
<div className="time">{formatTime(post.createTime)}</div>
</div>
</div>
<div className="post-body">
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
{post.type === 15 ? (
<div className="post-video-placeholder">
<Play size={20} />
<span></span>
</div>
) : post.media.length > 0 && (
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
{post.media.map((m, idx) => (
<MediaItem key={idx} url={m.url} thumb={m.thumb} onPreview={() => setPreviewImage(m.url)} />
))}
</div>
)}
</div>
{(post.likes.length > 0 || post.comments.length > 0) && (
<div className="post-footer">
{post.likes.length > 0 && (
<div className="likes-section">
<Heart size={14} className="icon" />
<span className="likes-list">
{post.likes.join('、')}
</span>
</div>
)}
{post.comments.length > 0 && (
<div className="comments-section">
{post.comments.map((c, idx) => (
<div key={idx} className="comment-item">
<span className="comment-user">{c.nickname}</span>
{c.refNickname && (
<>
<span className="reply-text"></span>
<span className="comment-user">{c.refNickname}</span>
</>
)}
<span className="comment-separator">: </span>
<span className="comment-content">{c.content}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
)
})}
</div>
{loading && <div className="status-indicator loading-more">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>}
{!hasMore && posts.length > 0 && <div className="status-indicator no-more"></div>}
{!loading && posts.length === 0 && (
<div className="no-results">
<div className="no-results-icon"><Search size={48} /></div>
<p></p>
{(selectedUsernames.length > 0 || searchKeyword) && (
<button onClick={clearFilters} className="reset-inline">
</button>
)}
</div>
)}
</div>
</div>
</main>
</div>
{previewImage && (
<ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} />
)}
<JumpToDateDialog
isOpen={showJumpDialog}
onClose={() => {
setShowJumpDialog(false)
}}
onSelect={(date) => {
setJumpTargetDate(date)
setShowJumpDialog(false)
}}
currentDate={jumpTargetDate || new Date()}
/>
</div>
)
}

View File

@@ -27,7 +27,8 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns'
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns'
} as const
// 获取解密密钥
@@ -306,3 +307,14 @@ export async function getExportDefaultExcelCompactColumns(): Promise<boolean | n
export async function setExportDefaultExcelCompactColumns(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled)
}
// 获取导出默认 TXT 列配置
export async function getExportDefaultTxtColumns(): Promise<string[] | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS)
return Array.isArray(value) ? (value as string[]) : null
}
// 设置导出默认 TXT 列配置
export async function setExportDefaultTxtColumns(columns: string[]): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns)
}

View File

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

View File

@@ -63,7 +63,7 @@ export interface ElectronAPI {
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string
}>
getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => Promise<{
success: boolean;
messages?: Message[];
hasMore?: boolean;
@@ -100,6 +100,7 @@ export interface ElectronAPI {
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
}
image: {
@@ -321,6 +322,24 @@ export interface ElectronAPI {
getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }>
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void
}
sns: {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => Promise<{
success: boolean
timeline?: Array<{
id: string
username: string
nickname: string
avatarUrl?: string
createTime: number
contentDesc: string
type?: number
media: Array<{ url: string; thumb: string }>
likes: Array<string>
comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }>
}>
error?: string
}>
}
}
export interface ExportOptions {
@@ -333,6 +352,7 @@ export interface ExportOptions {
exportEmojis?: boolean
exportVoiceAsText?: boolean
excelCompactColumns?: boolean
txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session'
}