Merge pull request #4 from 5xiao0qing5/codex/add-txt-export-feature-with-configurable-options

完成未实现的 TXT 导出功能
This commit is contained in:
QingXiao
2026-01-23 13:58:56 +08:00
committed by GitHub
36 changed files with 3127 additions and 249 deletions

View File

@@ -32,51 +32,20 @@ jobs:
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION"
npm version $VERSION --no-git-tag-version
npm version $VERSION --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
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|ci|style|test).*:.*", "flags": "i" }
}
],
"ignore_labels": [],
"commitMode": true,
"empty_summary": "## 更新详情\n- 常规代码优化与维护"
}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check Changelog Content
- name: Inject Configuration
shell: bash
run: |
echo "=== RELEASE NOTES CONTENT START ==="
cat release-notes.md
echo "=== RELEASE NOTES CONTENT END ==="
npm pkg set build.releaseInfo.releaseNotes=$'仅适配微信 4.0 及以上版本\n\n修复了一些已知问题\n\n详情前往 Telegram 群查看'
- name: Package and Publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx electron-builder --publish always -c.releaseInfo.releaseNotesFile=release-notes.md
npx electron-builder --publish always

BIN
2wm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

View File

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

View File

@@ -1,6 +1,6 @@
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron'
import { Worker } from 'worker_threads'
import { join } from 'path'
import { join, dirname } from 'path'
import { autoUpdater } from 'electron-updater'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
@@ -13,10 +13,11 @@ import { imagePreloadService } from './services/imagePreloadService'
import { analyticsService } from './services/analyticsService'
import { groupAnalyticsService } from './services/groupAnalyticsService'
import { annualReportService } from './services/annualReportService'
import { exportService, ExportOptions } from './services/exportService'
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
import { KeyService } from './services/keyService'
import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService'
import { snsService } from './services/snsService'
// 配置自动更新
@@ -28,6 +29,47 @@ const AUTO_UPDATE_ENABLED =
process.env.AUTO_UPDATE_ENABLED === '1' ||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
// 使用白名单过滤 PATH避免被第三方目录中的旧版 VC++ 运行库劫持。
// 仅保留系统目录Windows/System32/SysWOW64和应用自身目录可执行目录、resources
function sanitizePathEnv() {
// 开发模式不做裁剪,避免影响本地工具链
if (process.env.VITE_DEV_SERVER_URL) return
const rawPath = process.env.PATH || process.env.Path
if (!rawPath) return
const sep = process.platform === 'win32' ? ';' : ':'
const parts = rawPath.split(sep).filter(Boolean)
const systemRoot = process.env.SystemRoot || process.env.WINDIR || ''
const safePrefixes = [
systemRoot,
systemRoot ? join(systemRoot, 'System32') : '',
systemRoot ? join(systemRoot, 'SysWOW64') : '',
dirname(process.execPath),
process.resourcesPath,
join(process.resourcesPath || '', 'resources')
].filter(Boolean)
const normalize = (p: string) => p.replace(/\\/g, '/').toLowerCase()
const isSafe = (p: string) => {
const np = normalize(p)
return safePrefixes.some((prefix) => np.startsWith(normalize(prefix)))
}
const filtered = parts.filter(isSafe)
if (filtered.length !== parts.length) {
const removed = parts.filter((p) => !isSafe(p))
console.warn('[WeFlow] 使用白名单裁剪 PATH移除目录:', removed)
const nextPath = filtered.join(sep)
process.env.PATH = nextPath
process.env.Path = nextPath
}
}
// 启动时立即清理 PATH后续创建的 worker 也能继承安全的环境
sanitizePathEnv()
// 单例服务
let configService: ConfigService | null = null
@@ -573,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) => {
@@ -631,6 +673,10 @@ function registerIpcHandlers() {
return chatService.getMessageById(sessionId, localId)
})
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
})
// 私聊克隆
@@ -646,8 +692,13 @@ function registerIpcHandlers() {
})
// 导出相关
ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => {
return exportService.exportSessions(sessionIds, outputDir, options)
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
const onProgress = (progress: ExportProgress) => {
if (!event.sender.isDestroyed()) {
event.sender.send('export:progress', progress)
}
}
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
})
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
@@ -931,6 +982,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),
@@ -191,7 +191,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
exportSession: (sessionId: string, outputPath: string, options: any) =>
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options)
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('export:progress')
}
},
whisper: {
@@ -203,5 +207,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> {

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

@@ -72,8 +72,21 @@ export interface ExportOptions {
exportEmojis?: boolean
exportVoiceAsText?: boolean
excelCompactColumns?: boolean
txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session'
}
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
{ id: 'index', label: '序号' },
{ id: 'time', label: '时间' },
{ id: 'senderRole', label: '发送者身份' },
{ id: 'messageType', label: '消息类型' },
{ id: 'content', label: '内容' },
{ id: 'senderNickname', label: '发送者昵称' },
{ id: 'senderWxid', label: '发送者微信ID' },
{ id: 'senderRemark', label: '发送者备注' }
]
interface MediaExportItem {
relativePath: string
kind: 'image' | 'voice' | 'emoji'
@@ -83,7 +96,32 @@ export interface ExportProgress {
current: number
total: number
currentSession: string
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
}
// 并发控制:限制同时执行的 Promise 数量
async function parallelLimit<T, R>(
items: T[],
limit: number,
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length)
let currentIndex = 0
async function runNext(): Promise<void> {
while (currentIndex < items.length) {
const index = currentIndex++
results[index] = await fn(items[index], index)
}
}
// 启动 limit 个并发任务
const workers = Array(Math.min(limit, items.length))
.fill(null)
.map(() => runNext())
await Promise.all(workers)
return results
}
class ExportService {
@@ -402,20 +440,32 @@ class ExportService {
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
private normalizeTxtColumns(columns?: string[] | null): string[] {
const fallback = ['index', 'time', 'senderRole', 'messageType', 'content']
const selected = new Set((columns && columns.length > 0 ? columns : fallback).filter(Boolean))
const ordered = TXT_COLUMN_DEFINITIONS.map((col) => col.id).filter((id) => selected.has(id))
return ordered.length > 0 ? ordered : fallback
}
private sanitizeTxtValue(value: string): string {
return value.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim()
}
/**
* 导出媒体文件到指定目录
*/
private async exportMediaForMessage(
msg: any,
sessionId: string,
mediaDir: string,
mediaRootDir: string,
mediaRelativePrefix: string,
options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean }
): Promise<MediaExportItem | null> {
const localType = msg.localType
// 图片消息
if (localType === 3 && options.exportImages) {
const result = await this.exportImage(msg, sessionId, mediaDir)
const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix)
if (result) {
}
return result
@@ -429,13 +479,13 @@ class ExportService {
}
// 否则导出语音文件
if (options.exportVoices) {
return this.exportVoice(msg, sessionId, mediaDir)
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
}
}
// 动画表情
if (localType === 47 && options.exportEmojis) {
const result = await this.exportEmoji(msg, sessionId, mediaDir)
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix)
if (result) {
}
return result
@@ -447,9 +497,14 @@ class ExportService {
/**
* 导出图片文件
*/
private async exportImage(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> {
private async exportImage(
msg: any,
sessionId: string,
mediaRootDir: string,
mediaRelativePrefix: string
): Promise<MediaExportItem | null> {
try {
const imagesDir = path.join(mediaDir, 'media', 'images')
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
if (!fs.existsSync(imagesDir)) {
fs.mkdirSync(imagesDir, { recursive: true })
}
@@ -494,7 +549,7 @@ class ExportService {
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
return {
relativePath: `media/images/${fileName}`,
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image'
}
} else if (sourcePath.startsWith('file://')) {
@@ -512,7 +567,7 @@ class ExportService {
}
return {
relativePath: `media/images/${fileName}`,
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image'
}
}
@@ -526,9 +581,14 @@ class ExportService {
/**
* 导出语音文件
*/
private async exportVoice(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> {
private async exportVoice(
msg: any,
sessionId: string,
mediaRootDir: string,
mediaRelativePrefix: string
): Promise<MediaExportItem | null> {
try {
const voicesDir = path.join(mediaDir, 'media', 'voices')
const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
if (!fs.existsSync(voicesDir)) {
fs.mkdirSync(voicesDir, { recursive: true })
}
@@ -540,7 +600,7 @@ class ExportService {
// 如果已存在则跳过
if (fs.existsSync(destPath)) {
return {
relativePath: `media/voices/${fileName}`,
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
kind: 'voice'
}
}
@@ -556,7 +616,7 @@ class ExportService {
fs.writeFileSync(destPath, wavBuffer)
return {
relativePath: `media/voices/${fileName}`,
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
kind: 'voice'
}
} catch (e) {
@@ -582,9 +642,14 @@ class ExportService {
/**
* 导出表情文件
*/
private async exportEmoji(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> {
private async exportEmoji(
msg: any,
sessionId: string,
mediaRootDir: string,
mediaRelativePrefix: string
): Promise<MediaExportItem | null> {
try {
const emojisDir = path.join(mediaDir, 'media', 'emojis')
const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
if (!fs.existsSync(emojisDir)) {
fs.mkdirSync(emojisDir, { recursive: true })
}
@@ -613,7 +678,7 @@ class ExportService {
// 如果已存在则跳过
if (fs.existsSync(destPath)) {
return {
relativePath: `media/emojis/${fileName}`,
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
kind: 'emoji'
}
}
@@ -621,13 +686,13 @@ class ExportService {
// 下载表情
if (emojiUrl) {
const downloaded = await this.downloadFile(emojiUrl, destPath)
if (downloaded) {
return {
relativePath: `media/emojis/${fileName}`,
kind: 'emoji'
}
} else {
}
if (downloaded) {
return {
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
kind: 'emoji'
}
} else {
}
}
return null
@@ -704,6 +769,22 @@ class ExportService {
return '.jpg'
}
private getMediaLayout(outputPath: string, options: ExportOptions): {
exportMediaEnabled: boolean
mediaRootDir: string
mediaRelativePrefix: string
} {
const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
const outputDir = path.dirname(outputPath)
const outputBaseName = path.basename(outputPath, path.extname(outputPath))
const useSharedMediaLayout = options.sessionLayout === 'shared'
const mediaRelativePrefix = useSharedMediaLayout
? path.posix.join('media', outputBaseName)
: 'media'
return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix }
}
/**
* 下载文件
*/
@@ -1089,7 +1170,7 @@ class ExportService {
}
/**
* 导出单个会话为 ChatLab 格式
* 导出单个会话为 ChatLab 格式(并行优化版本)
*/
async exportSessionToChatLab(
sessionId: string,
@@ -1121,36 +1202,100 @@ class ExportService {
allMessages.sort((a, b) => a.createTime - b.createTime)
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
// ========== 阶段1并行导出媒体文件 ==========
const mediaMessages = exportMediaEnabled
? allMessages.filter(msg => {
const t = msg.localType
return (t === 3 && options.exportImages) || // 图片
(t === 47 && options.exportEmojis) || // 表情
(t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字)
})
: []
const mediaCache = new Map<string, MediaExportItem | null>()
if (mediaMessages.length > 0) {
onProgress?.({
current: 20,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media'
})
// 并行导出媒体,限制 8 个并发
const MEDIA_CONCURRENCY = 8
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
mediaCache.set(mediaKey, mediaItem)
}
})
}
// ========== 阶段2并行语音转文字 ==========
const voiceMessages = options.exportVoiceAsText
? allMessages.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) {
onProgress?.({
current: 40,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice'
})
// 并行转写语音,限制 4 个并发(转写比较耗资源)
const VOICE_CONCURRENCY = 4
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId))
voiceTranscriptMap.set(msg.localId, transcript)
})
}
// ========== 阶段3构建消息列表 ==========
onProgress?.({
current: 50,
current: 60,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
const chatLabMessages: ChatLabMessage[] = []
for (const msg of allMessages) {
const chatLabMessages: ChatLabMessage[] = allMessages.map(msg => {
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
platformId: msg.senderUsername,
accountName: msg.senderUsername,
groupNickname: undefined
}
let content = this.parseMessageContent(msg.content, msg.localType)
// 如果是语音消息且开启了转文字
// 确定消息内容
let content: string | null
if (msg.localType === 34 && options.exportVoiceAsText) {
content = await this.transcribeVoice(sessionId, String(msg.localId))
// 使用预先转写的文字
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
} else {
content = this.parseMessageContent(msg.content, msg.localType)
}
chatLabMessages.push({
return {
sender: msg.senderUsername,
accountName: memberInfo.accountName,
groupNickname: memberInfo.groupNickname,
timestamp: msg.createTime,
type: this.convertMessageType(msg.localType, msg.content),
content: content
})
}
}
})
const avatarMap = options.exportAvatars
? await this.exportAvatars(
@@ -1218,7 +1363,7 @@ class ExportService {
}
/**
* 导出单个会话为详细 JSON 格式(原项目格式)
* 导出单个会话为详细 JSON 格式(原项目格式)- 并行优化版本
*/
async exportSessionToDetailedJson(
sessionId: string,
@@ -1244,16 +1389,89 @@ class ExportService {
})
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages: any[] = []
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
// ========== 阶段1并行导出媒体文件 ==========
const mediaMessages = exportMediaEnabled
? collected.rows.filter(msg => {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
})
: []
const mediaCache = new Map<string, MediaExportItem | null>()
if (mediaMessages.length > 0) {
onProgress?.({
current: 15,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media'
})
const MEDIA_CONCURRENCY = 8
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
mediaCache.set(mediaKey, mediaItem)
}
})
}
// ========== 阶段2并行语音转文字 ==========
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) {
onProgress?.({
current: 35,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice'
})
const VOICE_CONCURRENCY = 4
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId))
voiceTranscriptMap.set(msg.localId, transcript)
})
}
// ========== 阶段3构建消息列表 ==========
onProgress?.({
current: 55,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
const allMessages: any[] = []
for (const msg of collected.rows) {
const senderInfo = await this.getContactInfo(msg.senderUsername)
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
const source = sourceMatch ? sourceMatch[0] : ''
let content = this.parseMessageContent(msg.content, msg.localType)
if (msg.localType === 34 && options.exportVoiceAsText) {
content = await this.transcribeVoice(sessionId, String(msg.localId))
let content: string | null
const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey)
if (mediaItem) {
content = mediaItem.relativePath
} else if (msg.localType === 34 && options.exportVoiceAsText) {
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
} else {
content = this.parseMessageContent(msg.content, msg.localType)
}
allMessages.push({
@@ -1482,23 +1700,33 @@ class ExportService {
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
// 媒体导出设置
const exportMediaEnabled = options.exportImages || options.exportVoices || options.exportEmojis
const sessionDir = path.dirname(outputPath) // 会话目录,用于媒体导出
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
// ========== 并行预处理:媒体文件 ==========
const mediaMessages = exportMediaEnabled
? sortedMessages.filter(msg => {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
})
: []
// 媒体导出缓存
const mediaCache = new Map<string, MediaExportItem | null>()
for (let i = 0; i < sortedMessages.length; i++) {
const msg = sortedMessages[i]
if (mediaMessages.length > 0) {
onProgress?.({
current: 35,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media'
})
// 导出媒体文件
let mediaItem: MediaExportItem | null = null
if (exportMediaEnabled) {
const MEDIA_CONCURRENCY = 8
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}`
if (mediaCache.has(mediaKey)) {
mediaItem = mediaCache.get(mediaKey) || null
} else {
mediaItem = await this.exportMediaForMessage(msg, sessionId, sessionDir, {
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis,
@@ -1506,7 +1734,45 @@ class ExportService {
})
mediaCache.set(mediaKey, mediaItem)
}
}
})
}
// ========== 并行预处理:语音转文字 ==========
const voiceMessages = options.exportVoiceAsText
? sortedMessages.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) {
onProgress?.({
current: 50,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice'
})
const VOICE_CONCURRENCY = 4
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId))
voiceTranscriptMap.set(msg.localId, transcript)
})
}
onProgress?.({
current: 65,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
// ========== 写入 Excel 行 ==========
for (let i = 0; i < sortedMessages.length; i++) {
const msg = sortedMessages[i]
// 从缓存获取媒体信息
const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey) || null
// 确定发送者信息
let senderRole: string
@@ -1555,12 +1821,15 @@ class ExportService {
const row = worksheet.getRow(currentRow)
row.height = 24
// 确定内容:如果有媒体文件导出成功则显示相对路径,否则显示解析后的内容
let contentValue = mediaItem
? mediaItem.relativePath
: (this.parseMessageContent(msg.content, msg.localType) || '')
if (!mediaItem && msg.localType === 34 && options.exportVoiceAsText) {
contentValue = await this.transcribeVoice(sessionId, String(msg.localId))
// 确定内容:优先使用预处理的缓存
let contentValue: string
if (mediaItem) {
contentValue = mediaItem.relativePath
} else if (msg.localType === 34 && options.exportVoiceAsText) {
// 使用预处理的语音转文字结果
contentValue = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
} else {
contentValue = this.parseMessageContent(msg.content, msg.localType) || ''
}
// 调试日志
@@ -1634,6 +1903,197 @@ class ExportService {
}
}
/**
* 导出单个会话为 TXT 格式(默认与 Excel 精简列一致)
*/
async exportSessionToTxt(
sessionId: string,
outputPath: string,
options: ExportOptions,
onProgress?: (progress: ExportProgress) => void
): Promise<{ success: boolean; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid)
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
const mediaMessages = exportMediaEnabled
? sortedMessages.filter(msg => {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
})
: []
const mediaCache = new Map<string, MediaExportItem | null>()
if (mediaMessages.length > 0) {
onProgress?.({
current: 25,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media'
})
const MEDIA_CONCURRENCY = 8
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
mediaCache.set(mediaKey, mediaItem)
}
})
}
const voiceMessages = options.exportVoiceAsText
? sortedMessages.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) {
onProgress?.({
current: 45,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice'
})
const VOICE_CONCURRENCY = 4
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId))
voiceTranscriptMap.set(msg.localId, transcript)
})
}
onProgress?.({
current: 60,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
const columnOrder = this.normalizeTxtColumns(options.txtColumns)
const columnLabelMap = new Map(TXT_COLUMN_DEFINITIONS.map((col) => [col.id, col.label]))
const lines: string[] = []
lines.push(columnOrder.map((id) => columnLabelMap.get(id) || id).join('\t'))
for (let i = 0; i < sortedMessages.length; i++) {
const msg = sortedMessages[i]
const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey) || null
let contentValue: string
if (mediaItem) {
contentValue = mediaItem.relativePath
} else if (msg.localType === 34 && options.exportVoiceAsText) {
contentValue = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
} else {
contentValue = this.parseMessageContent(msg.content, msg.localType) || ''
}
let senderRole: string
let senderWxid: string
let senderNickname: string
let senderRemark = ''
if (msg.isSend) {
senderRole = '我'
senderWxid = cleanedMyWxid
senderNickname = myInfo.displayName || cleanedMyWxid
} else if (isGroup && msg.senderUsername) {
senderWxid = msg.senderUsername
const contactDetail = await wcdbService.getContact(msg.senderUsername)
if (contactDetail.success && contactDetail.contact) {
senderNickname = contactDetail.contact.nickName || msg.senderUsername
senderRemark = contactDetail.contact.remark || ''
senderRole = senderRemark || senderNickname
} else {
senderNickname = msg.senderUsername
senderRole = msg.senderUsername
}
} else {
senderWxid = sessionId
const contactDetail = await wcdbService.getContact(sessionId)
if (contactDetail.success && contactDetail.contact) {
senderNickname = contactDetail.contact.nickName || sessionId
senderRemark = contactDetail.contact.remark || ''
senderRole = senderRemark || senderNickname
} else {
senderNickname = sessionInfo.displayName || sessionId
senderRole = senderNickname
}
}
const values: Record<string, string> = {
index: String(i + 1),
time: this.formatTimestamp(msg.createTime),
senderRole,
senderNickname,
senderWxid,
senderRemark,
messageType: this.getMessageTypeName(msg.localType),
content: contentValue
}
const line = columnOrder
.map((id) => this.sanitizeTxtValue(values[id] ?? ''))
.join('\t')
lines.push(line)
if ((i + 1) % 200 === 0) {
const progress = 60 + Math.floor((i + 1) / sortedMessages.length * 30)
onProgress?.({
current: progress,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
}
}
onProgress?.({
current: 92,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing'
})
fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8')
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete'
})
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
/**
* 批量导出多个会话
*/
@@ -1656,9 +2116,15 @@ class ExportService {
fs.mkdirSync(outputDir, { recursive: true })
}
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]
const sessionInfo = await this.getContactInfo(sessionId)
const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
const sessionLayout = exportMediaEnabled
? (options.sessionLayout ?? 'per-session')
: 'shared'
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]
const sessionInfo = await this.getContactInfo(sessionId)
onProgress?.({
current: i + 1,
@@ -1667,17 +2133,18 @@ class ExportService {
phase: 'exporting'
})
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
const useSessionFolder = sessionLayout === 'per-session'
const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir
// 为每个会话创建单独的文件夹
const sessionDir = path.join(outputDir, safeName)
if (!fs.existsSync(sessionDir)) {
fs.mkdirSync(sessionDir, { recursive: true })
}
if (useSessionFolder && !fs.existsSync(sessionDir)) {
fs.mkdirSync(sessionDir, { recursive: true })
}
let ext = '.json'
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
else if (options.format === 'excel') ext = '.xlsx'
else if (options.format === 'txt') ext = '.txt'
const outputPath = path.join(sessionDir, `${safeName}${ext}`)
let result: { success: boolean; error?: string }
@@ -1687,6 +2154,8 @@ class ExportService {
result = await this.exportSessionToChatLab(sessionId, outputPath, options)
} else if (options.format === 'excel') {
result = await this.exportSessionToExcel(sessionId, outputPath, options)
} else if (options.format === 'txt') {
result = await this.exportSessionToTxt(sessionId, outputPath, options)
} else {
result = { success: false, error: `不支持的格式: ${options.format}` }
}

View File

@@ -33,6 +33,7 @@ export class KeyService {
private ReadProcessMemory: any = null
private MEMORY_BASIC_INFORMATION: any = null
private TerminateProcess: any = null
private QueryFullProcessImageNameW: any = null
// User32
private EnumWindows: any = null
@@ -194,6 +195,7 @@ export class KeyService {
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['HANDLE', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64'])
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
@@ -310,7 +312,46 @@ export class KeyService {
}
}
private async getProcessExecutablePath(pid: number): Promise<string | null> {
if (!this.ensureKernel32()) return null
// 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION
const hProcess = this.OpenProcess(0x1000, false, pid)
if (!hProcess) return null
try {
const sizeBuf = Buffer.alloc(4)
sizeBuf.writeUInt32LE(1024, 0)
const pathBuf = Buffer.alloc(1024 * 2)
const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf)
if (ret) {
const len = sizeBuf.readUInt32LE(0)
return pathBuf.toString('ucs2', 0, len * 2)
}
return null
} catch (e) {
console.error('获取进程路径失败:', e)
return null
} finally {
this.CloseHandle(hProcess)
}
}
private async findWeChatInstallPath(): Promise<string | null> {
// 0. 优先尝试获取正在运行的微信进程路径
try {
const pid = await this.findWeChatPid()
if (pid) {
const runPath = await this.getProcessExecutablePath(pid)
if (runPath && existsSync(runPath)) {
console.log('发现正在运行的微信进程,使用路径:', runPath)
return runPath
}
}
} catch (e) {
console.error('尝试获取运行中微信路径失败:', e)
}
// 1. Registry - Uninstall Keys
const uninstallKeys = [
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
@@ -588,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

@@ -1,6 +1,12 @@
import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
// DLL 初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null
export function getLastDllInitError(): string | null {
return lastDllInitError
}
export class WcdbCore {
private resourcesPath: string | null = null
private userDataPath: string | null = null
@@ -49,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
@@ -110,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')
@@ -208,6 +216,31 @@ export class WcdbCore {
return false
}
// 关键修复:显式预加载依赖库 WCDB.dll 和 SDL2.dll
// Windows 加载器默认不会查找子目录中的依赖,必须先将其加载到内存
// 这可以解决部分用户因为 VC++ 运行时或 DLL 依赖问题导致的闪退
const dllDir = dirname(dllPath)
const wcdbCorePath = join(dllDir, 'WCDB.dll')
if (existsSync(wcdbCorePath)) {
try {
this.koffi.load(wcdbCorePath)
this.writeLog('预加载 WCDB.dll 成功')
} catch (e) {
console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e)
this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`)
}
}
const sdl2Path = join(dllDir, 'SDL2.dll')
if (existsSync(sdl2Path)) {
try {
this.koffi.load(sdl2Path)
this.writeLog('预加载 SDL2.dll 成功')
} catch (e) {
console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e)
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
}
}
this.lib = this.koffi.load(dllPath)
// 定义类型
@@ -354,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) {
@@ -362,9 +402,20 @@ export class WcdbCore {
}
this.initialized = true
lastDllInitError = null
return true
} catch (e) {
console.error('WCDB 初始化异常:', e)
const errorMsg = e instanceof Error ? e.message : String(e)
console.error('WCDB 初始化异常:', errorMsg)
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
lastDllInitError = errorMsg
// 检查是否是常见的 VC++ 运行时缺失错误
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 位版本的应用程序。'
}
return false
}
}
@@ -382,10 +433,18 @@ export class WcdbCore {
return { success: true, sessionCount: 0 }
}
// 记录当前活动连接,用于在测试结束后恢复(避免影响聊天页等正在使用的连接)
const hadActiveConnection = this.handle !== null
const prevPath = this.currentPath
const prevKey = this.currentKey
const prevWxid = this.currentWxid
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) {
return { success: false, error: 'WCDB 初始化失败' }
// 返回更详细的错误信息,帮助用户诊断问题
const detailedError = lastDllInitError || 'WCDB 初始化失败'
return { success: false, error: detailedError }
}
}
@@ -424,8 +483,8 @@ export class WcdbCore {
return { success: false, error: '无效的数据库句柄' }
}
// 测试成功使用 shutdown 清理所有资源(包括测试句柄)
// 这会中断当前活动连接,但 testConnection 本应该是独立测试
// 测试成功使用 shutdown 清理资源(包括测试句柄)
// 注意shutdown 会断开当前活动连接,因此需要在测试后尝试恢复之前的连接
try {
this.wcdbShutdown()
this.handle = null
@@ -437,6 +496,15 @@ export class WcdbCore {
console.error('关闭测试数据库时出错:', closeErr)
}
// 恢复测试前的连接(如果之前有活动连接)
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
try {
await this.open(prevPath, prevKey, prevWxid)
} catch {
// 恢复失败则保持断开,由调用方处理
}
}
return { success: true, sessionCount: 0 }
} catch (e) {
console.error('测试连接异常:', e)
@@ -1329,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

@@ -58,12 +58,24 @@ export class WcdbService {
})
this.worker.on('error', (err) => {
// Worker error
// Worker 发生错误,需要 reject 所有 pending promises
console.error('WCDB Worker 错误:', err)
const errorMsg = err instanceof Error ? err.message : String(err)
for (const [id, p] of this.pending) {
p.reject(new Error(`Worker 错误: ${errorMsg}`))
}
this.pending.clear()
})
this.worker.on('exit', (code) => {
// Worker 退出,需要 reject 所有 pending promises
if (code !== 0) {
// Worker exited with error
console.error('WCDB Worker 异常退出,退出码:', code)
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
for (const [id, p] of this.pending) {
p.reject(new Error(errorMsg))
}
this.pending.clear()
}
this.worker = null
})
@@ -350,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}` }
}

10
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",
@@ -8537,12 +8537,6 @@
"sherpa-onnx-win-x64": "^1.12.23"
}
},
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": {
"optional": true
},
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-linux-arm64": {
"optional": true
},
"node_modules/sherpa-onnx-win-ia32": {
"version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",

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'
@@ -202,10 +203,22 @@ function App() {
}
} else {
console.log('自动连接失败:', result.error)
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
// 其他错误可能需要重新配置
const errorMsg = result.error || ''
if (errorMsg.includes('Visual C++') ||
errorMsg.includes('DLL') ||
errorMsg.includes('Worker') ||
errorMsg.includes('126') ||
errorMsg.includes('模块')) {
console.warn('检测到可能的运行时依赖问题:', errorMsg)
// 不清除配置,让用户安装 VC++ 后重试
}
}
}
} catch (e) {
console.error('自动连接出错:', e)
// 捕获异常但不清除配置,防止循环重新引导
}
}
@@ -324,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 {

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

@@ -602,6 +602,87 @@
}
}
.export-layout-modal {
background: var(--card-bg);
padding: 28px 32px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
text-align: center;
width: min(520px, 90vw);
h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px;
}
.layout-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 20px;
}
.layout-options {
display: grid;
gap: 12px;
}
.layout-option-btn {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 18px;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
text-align: left;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
}
&.primary {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.12);
}
.layout-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.layout-desc {
font-size: 12px;
color: var(--text-tertiary);
}
}
.layout-actions {
margin-top: 18px;
display: flex;
justify-content: center;
}
.layout-cancel-btn {
padding: 8px 20px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
}
}
}
.export-result-modal {
background: var(--card-bg);
padding: 32px 40px;

View File

@@ -22,6 +22,7 @@ interface ExportOptions {
exportEmojis: boolean
exportVoiceAsText: boolean
excelCompactColumns: boolean
txtColumns: string[]
}
interface ExportResult {
@@ -31,7 +32,10 @@ interface ExportResult {
error?: string
}
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())
@@ -44,6 +48,7 @@ function ExportPage() {
const [showDatePicker, setShowDatePicker] = useState(false)
const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true)
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
const [options, setOptions] = useState<ExportOptions>({
format: 'excel',
@@ -58,7 +63,8 @@ function ExportPage() {
exportVoices: true,
exportEmojis: true,
exportVoiceAsText: true,
excelCompactColumns: true
excelCompactColumns: true,
txtColumns: defaultTxtColumns
})
const buildDateRangeFromPreset = (preset: string) => {
@@ -122,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,
@@ -141,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)
@@ -154,6 +164,19 @@ function ExportPage() {
loadExportDefaults()
}, [loadSessions, loadExportPath, loadExportDefaults])
useEffect(() => {
const removeListener = window.electronAPI.export.onProgress?.((payload) => {
setExportProgress({
current: payload.current,
total: payload.total,
currentName: payload.currentSession
})
})
return () => {
removeListener?.()
}
}, [])
useEffect(() => {
if (!searchKeyword.trim()) {
setFilteredSessions(sessions)
@@ -199,7 +222,7 @@ function ExportPage() {
}
}
const startExport = async () => {
const runExport = async (sessionLayout: SessionLayout) => {
if (selectedSessions.size === 0 || !exportFolder) return
setIsExporting(true)
@@ -215,16 +238,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') {
const result = await window.electronAPI.export.exportSessions(
sessionList,
exportFolder,
@@ -232,16 +257,28 @@ 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)
}
}
const startExport = () => {
if (selectedSessions.size === 0 || !exportFolder) return
if (options.exportMedia && selectedSessions.size > 1) {
setShowMediaLayoutPrompt(true)
return
}
const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared'
runExport(layout)
}
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth()
@@ -600,6 +637,43 @@ function ExportPage() {
</div>
</div>
{/* 媒体导出布局选择弹窗 */}
{showMediaLayoutPrompt && (
<div className="export-overlay" onClick={() => setShowMediaLayoutPrompt(false)}>
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
<h3></h3>
<p className="layout-subtitle"></p>
<div className="layout-options">
<button
className="layout-option-btn primary"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('shared')
}}
>
<span className="layout-title"></span>
<span className="layout-desc"> media </span>
</button>
<button
className="layout-option-btn"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('per-session')
}}
>
<span className="layout-title"></span>
<span className="layout-desc"></span>
</button>
</div>
<div className="layout-actions">
<button className="layout-cancel-btn" onClick={() => setShowMediaLayoutPrompt(false)}>
</button>
</div>
</div>
</div>
)}
{/* 导出进度弹窗 */}
{isExporting && (
<div className="export-overlay">

View File

@@ -221,6 +221,100 @@
}
}
.select-field {
position: relative;
margin-bottom: 10px;
}
.select-trigger {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--text-tertiary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.select-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
box-shadow: var(--shadow-md);
z-index: 20;
max-height: 320px;
overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.select-option {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
transition: all 0.15s;
color: var(--text-primary);
font-size: 14px;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
}
.option-label {
font-weight: 500;
}
.option-desc {
font-size: 12px;
color: var(--text-tertiary);
}
.select-option.active .option-desc {
color: var(--primary);
}
.input-with-toggle {
position: relative;
display: flex;
@@ -1096,13 +1190,15 @@
left: 0;
right: 0;
margin-top: 4px;
background: var(--bg-secondary);
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-primary);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
max-height: 200px;
overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.wxid-option {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useAppStore } from '../stores/appStore'
import { useThemeStore, themes } from '../stores/themeStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
@@ -41,6 +41,12 @@ function SettingsPage() {
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
const [showWxidSelect, setShowWxidSelect] = useState(false)
const wxidDropdownRef = useRef<HTMLDivElement>(null)
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('')
const [logEnabled, setLogEnabled] = useState(false)
const [whisperModelName, setWhisperModelName] = useState('base')
@@ -55,6 +61,7 @@ function SettingsPage() {
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultTxtColumns, setExportDefaultTxtColumns] = useState<string[]>(['index', 'time', 'senderRole', 'messageType', 'content'])
const [isLoading, setIsLoadingState] = useState(false)
const [isTesting, setIsTesting] = useState(false)
@@ -85,13 +92,23 @@ function SettingsPage() {
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(e.target as Node)) {
const target = e.target as Node
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(target)) {
setShowWxidSelect(false)
}
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
setShowExportFormatSelect(false)
}
if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) {
setShowExportDateRangeSelect(false)
}
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showWxidSelect])
}, [showWxidSelect, showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
@@ -125,6 +142,8 @@ function SettingsPage() {
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
const savedExportDefaultTxtColumns = await configService.getExportDefaultTxtColumns()
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
if (savedKey) setDecryptKey(savedKey)
if (savedPath) setDbPath(savedPath)
@@ -142,6 +161,11 @@ function SettingsPage() {
setExportDefaultMedia(savedExportDefaultMedia ?? false)
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true)
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
setExportDefaultTxtColumns(
savedExportDefaultTxtColumns && savedExportDefaultTxtColumns.length > 0
? savedExportDefaultTxtColumns
: defaultTxtColumns
)
// 如果语言列表为空,保存默认值
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
@@ -150,6 +174,10 @@ function SettingsPage() {
await configService.setTranscribeLanguages(defaultLanguages)
}
if (!savedExportDefaultTxtColumns || savedExportDefaultTxtColumns.length === 0) {
await configService.setExportDefaultTxtColumns(defaultTxtColumns)
}
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
} catch (e) {
console.error('加载配置失败:', e)
@@ -484,15 +512,8 @@ function SettingsPage() {
await configService.setTranscribeLanguages(transcribeLanguages)
await configService.setOnboardingDone(true)
showMessage('配置保存成功,正在测试连接...', true)
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
if (result.success) {
setDbConnected(true, dbPath)
showMessage('配置保存成功!数据库连接正常', true)
} else {
showMessage(result.error || '数据库连接失败,请检查配置', false)
}
// 保存按钮只负责持久化配置,不做连接测试/重连,避免影响聊天页的活动连接
showMessage('配置保存成功', true)
} catch (e) {
showMessage(`保存配置失败: ${e}`, false)
} finally {
@@ -870,48 +891,124 @@ function SettingsPage() {
</div>
)
const renderExportTab = () => (
const exportFormatOptions = [
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
]
const exportDateRangeOptions = [
{ value: 'today', label: '今天' },
{ value: '7d', label: '最近7天' },
{ value: '30d', label: '最近30天' },
{ value: '90d', label: '最近90天' },
{ value: 'all', label: '全部时间' }
]
const exportExcelColumnOptions = [
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
]
const exportTxtColumnOptions = [
{ value: 'index', label: '序号' },
{ value: 'time', label: '时间' },
{ value: 'senderRole', label: '发送者身份' },
{ value: 'messageType', label: '消息类型' },
{ value: 'content', label: '内容' },
{ value: 'senderNickname', label: '发送者昵称' },
{ value: 'senderWxid', label: '发送者微信ID' },
{ value: 'senderRemark', label: '发送者备注' }
]
const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
return options.find((option) => option.value === value)?.label ?? value
}
const renderExportTab = () => {
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
return (
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<select
value={exportDefaultFormat}
onChange={async (e) => {
const value = e.target.value
setExportDefaultFormat(value)
await configService.setExportDefaultFormat(value)
showMessage('已更新导出格式默认值', true)
}}
>
<option value="excel">Excel</option>
<option value="chatlab">ChatLab</option>
<option value="chatlab-jsonl">ChatLab JSONL</option>
<option value="json">JSON</option>
<option value="html">HTML</option>
<option value="txt">TXT</option>
<option value="sql">PostgreSQL</option>
</select>
<div className="select-field" ref={exportFormatDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`}
onClick={() => {
setShowExportFormatSelect(!showExportFormatSelect)
setShowExportDateRangeSelect(false)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="select-value">{exportFormatLabel}</span>
<ChevronDown size={16} />
</button>
{showExportFormatSelect && (
<div className="select-dropdown">
{exportFormatOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultFormat === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultFormat(option.value)
await configService.setExportDefaultFormat(option.value)
showMessage('已更新导出格式默认值', true)
setShowExportFormatSelect(false)
}}
>
<span className="option-label">{option.label}</span>
{option.desc && <span className="option-desc">{option.desc}</span>}
</button>
))}
</div>
)}
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<select
value={exportDefaultDateRange}
onChange={async (e) => {
const value = e.target.value
setExportDefaultDateRange(value)
await configService.setExportDefaultDateRange(value)
showMessage('已更新默认导出时间范围', true)
}}
>
<option value="today"></option>
<option value="7d">7</option>
<option value="30d">30</option>
<option value="90d">90</option>
<option value="all"></option>
</select>
<div className="select-field" ref={exportDateRangeDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportDateRangeSelect ? 'open' : ''}`}
onClick={() => {
setShowExportDateRangeSelect(!showExportDateRangeSelect)
setShowExportFormatSelect(false)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="select-value">{exportDateRangeLabel}</span>
<ChevronDown size={16} />
</button>
{showExportDateRangeSelect && (
<div className="select-dropdown">
{exportDateRangeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultDateRange === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultDateRange(option.value)
await configService.setExportDefaultDateRange(option.value)
showMessage('已更新默认导出时间范围', true)
setShowExportDateRangeSelect(false)
}}
>
<span className="option-label">{option.label}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="form-group">
@@ -963,21 +1060,80 @@ function SettingsPage() {
<div className="form-group">
<label>Excel </label>
<span className="form-hint"> Excel </span>
<select
value={exportDefaultExcelCompactColumns ? 'compact' : 'full'}
onChange={async (e) => {
const compact = e.target.value === 'compact'
setExportDefaultExcelCompactColumns(compact)
await configService.setExportDefaultExcelCompactColumns(compact)
showMessage(compact ? '已启用精简列' : '已启用完整列', true)
}}
>
<option value="compact"></option>
<option value="full">/ID/</option>
</select>
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setShowExportFormatSelect(false)
setShowExportDateRangeSelect(false)
}}
>
<span className="select-value">{exportExcelColumnsLabel}</span>
<ChevronDown size={16} />
</button>
{showExportExcelColumnsSelect && (
<div className="select-dropdown">
{exportExcelColumnOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
onClick={async () => {
const compact = option.value === 'compact'
setExportDefaultExcelCompactColumns(compact)
await configService.setExportDefaultExcelCompactColumns(compact)
showMessage(compact ? '已启用精简列' : '已启用完整列', true)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="option-label">{option.label}</span>
{option.desc && <span className="option-desc">{option.desc}</span>}
</button>
))}
</div>
)}
</div>
</div>
<div className="form-group">
<label>TXT </label>
<span className="form-hint"> Excel </span>
<div className="language-checkboxes">
{exportTxtColumnOptions.map((column) => {
const checked = exportDefaultTxtColumns.includes(column.value)
return (
<label key={column.value} className="language-checkbox">
<input
type="checkbox"
checked={checked}
onChange={async (e) => {
const enabled = e.target.checked
const nextColumns = enabled
? [...exportDefaultTxtColumns, column.value]
: exportDefaultTxtColumns.filter((value) => value !== column.value)
if (nextColumns.length === 0) {
showMessage('至少选择一个 TXT 导出栏目', false)
return
}
setExportDefaultTxtColumns(nextColumns)
await configService.setExportDefaultTxtColumns(nextColumns)
showMessage('已更新 TXT 导出栏目', true)
}}
/>
<div className="checkbox-custom">
<Check size={14} />
<span>{column.label}</span>
</div>
</label>
)
})}
</div>
</div>
</div>
)
)
}
const renderCacheTab = () => (
<div className="tab-content">
<p className="section-desc"></p>
@@ -1126,4 +1282,3 @@ function SettingsPage() {
}
export default SettingsPage

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

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

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

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

View File

@@ -441,7 +441,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
<FolderOpen size={16} />
</button>
</div>
<div className="field-hint"> xwechat_files </div>
<div className="field-hint">--</div>
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}> --</div>
</div>
)}
@@ -507,7 +507,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
<div className="field-hint"></div>
<div className="field-hint"></div>
<div className="field-hint"><span style={{color: 'red'}}>hook安装成功</span></div>
</div>
)}
@@ -533,7 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button>
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
<div className="field-hint"></div>
<div className="field-hint"></div>
{isFetchingImageKey && <div className="field-hint status-text">...</div>}
</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

@@ -18,6 +18,7 @@ export interface ChatState {
isLoadingMessages: boolean
isLoadingMore: boolean
hasMoreMessages: boolean
hasMoreLater: boolean
// 联系人缓存
contacts: Map<string, Contact>
@@ -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,6 +58,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
isLoadingMessages: false,
isLoadingMore: false,
hasMoreMessages: true,
hasMoreLater: false,
contacts: new Map(),
searchKeyword: '',
@@ -69,7 +72,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
setCurrentSession: (sessionId) => set({
currentSessionId: sessionId,
messages: [],
hasMoreMessages: true
hasMoreMessages: true,
hasMoreLater: false
}),
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
@@ -85,6 +89,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
setLoadingMessages: (loading) => set({ isLoadingMessages: loading }),
setLoadingMore: (loading) => set({ isLoadingMore: loading }),
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
setHasMoreLater: (hasMore) => set({ hasMoreLater: hasMore }),
setContacts: (contacts) => set({
contacts: new Map(contacts.map(c => [c.username, c]))
@@ -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;
@@ -314,12 +314,31 @@ export interface ElectronAPI {
success: boolean
error?: string
}>
onProgress: (callback: (payload: ExportProgress) => void) => () => void
}
whisper: {
downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }>
getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }>
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void
}
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 {
@@ -332,6 +351,15 @@ export interface ExportOptions {
exportEmojis?: boolean
exportVoiceAsText?: boolean
excelCompactColumns?: boolean
txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session'
}
export interface ExportProgress {
current: number
total: number
currentSession: string
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
}
export interface WxidInfo {