mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
Merge pull request #4 from 5xiao0qing5/codex/add-txt-export-feature-with-configurable-options
完成未实现的 TXT 导出功能
This commit is contained in:
39
.github/workflows/release.yml
vendored
39
.github/workflows/release.yml
vendored
@@ -32,51 +32,20 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=${GITHUB_REF_NAME#v}
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
echo "Syncing package.json version to $VERSION"
|
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
|
- name: Build Frontend & Type Check
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
|
|
||||||
- name: Build Changelog
|
- name: Inject Configuration
|
||||||
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
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "=== RELEASE NOTES CONTENT START ==="
|
npm pkg set build.releaseInfo.releaseNotes=$'仅适配微信 4.0 及以上版本\n\n修复了一些已知问题\n\n详情前往 Telegram 群查看'
|
||||||
cat release-notes.md
|
|
||||||
echo "=== RELEASE NOTES CONTENT END ==="
|
|
||||||
|
|
||||||
- name: Package and Publish
|
- name: Package and Publish
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --publish always -c.releaseInfo.releaseNotesFile=release-notes.md
|
npx electron-builder --publish always
|
||||||
11
README.md
11
README.md
@@ -28,6 +28,14 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||||
|
|
||||||
|
# 加入微信交流群
|
||||||
|
|
||||||
|
> 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="2wm.png" alt="WeFlow 微信交流群二维码" width="220">
|
||||||
|
</p>
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
- 本地实时查看聊天记录
|
- 本地实时查看聊天记录
|
||||||
@@ -36,6 +44,9 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
- 导出聊天记录为 HTML 等格式
|
- 导出聊天记录为 HTML 等格式
|
||||||
- 本地解密与数据库管理
|
- 本地解密与数据库管理
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> ⚠️ 本工具仅适配微信 **4.0 及以上**版本,请确保你的微信版本符合要求
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
若你只想使用成品版本,可前往 Release 下载并安装。
|
若你只想使用成品版本,可前往 Release 下载并安装。
|
||||||
|
|||||||
@@ -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 { Worker } from 'worker_threads'
|
||||||
import { join } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { autoUpdater } from 'electron-updater'
|
import { autoUpdater } from 'electron-updater'
|
||||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
@@ -13,10 +13,11 @@ import { imagePreloadService } from './services/imagePreloadService'
|
|||||||
import { analyticsService } from './services/analyticsService'
|
import { analyticsService } from './services/analyticsService'
|
||||||
import { groupAnalyticsService } from './services/groupAnalyticsService'
|
import { groupAnalyticsService } from './services/groupAnalyticsService'
|
||||||
import { annualReportService } from './services/annualReportService'
|
import { annualReportService } from './services/annualReportService'
|
||||||
import { exportService, ExportOptions } from './services/exportService'
|
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||||
import { KeyService } from './services/keyService'
|
import { KeyService } from './services/keyService'
|
||||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||||
import { videoService } from './services/videoService'
|
import { videoService } from './services/videoService'
|
||||||
|
import { snsService } from './services/snsService'
|
||||||
|
|
||||||
|
|
||||||
// 配置自动更新
|
// 配置自动更新
|
||||||
@@ -28,6 +29,47 @@ const AUTO_UPDATE_ENABLED =
|
|||||||
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
||||||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
|
(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
|
let configService: ConfigService | null = null
|
||||||
|
|
||||||
@@ -573,8 +615,8 @@ function registerIpcHandlers() {
|
|||||||
return chatService.enrichSessionsContactInfo(usernames)
|
return chatService.enrichSessionsContactInfo(usernames)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => {
|
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
|
||||||
return chatService.getMessages(sessionId, offset, limit)
|
return chatService.getMessages(sessionId, offset, limit, startTime, endTime, ascending)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
|
ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
|
||||||
@@ -631,6 +673,10 @@ function registerIpcHandlers() {
|
|||||||
return chatService.getMessageById(sessionId, localId)
|
return chatService.getMessageById(sessionId, localId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
|
||||||
|
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||||
|
})
|
||||||
|
|
||||||
// 私聊克隆
|
// 私聊克隆
|
||||||
|
|
||||||
|
|
||||||
@@ -646,8 +692,13 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 导出相关
|
// 导出相关
|
||||||
ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||||
return exportService.exportSessions(sessionIds, outputDir, options)
|
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) => {
|
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||||
@@ -931,6 +982,17 @@ app.whenReady().then(() => {
|
|||||||
createOnboardingWindow()
|
createOnboardingWindow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解决朋友圈图片无法加载问题(添加 Referer)
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
|
{
|
||||||
|
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
|
||||||
|
},
|
||||||
|
(details, callback) => {
|
||||||
|
details.requestHeaders['Referer'] = 'https://wx.qq.com/'
|
||||||
|
callback({ requestHeaders: details.requestHeaders })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 启动时检测更新
|
// 启动时检测更新
|
||||||
checkForUpdatesOnStartup()
|
checkForUpdatesOnStartup()
|
||||||
|
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||||
enrichSessionsContactInfo: (usernames: string[]) =>
|
enrichSessionsContactInfo: (usernames: string[]) =>
|
||||||
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
||||||
getMessages: (sessionId: string, offset?: number, limit?: number) =>
|
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
||||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
|
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
||||||
getLatestMessages: (sessionId: string, limit?: number) =>
|
getLatestMessages: (sessionId: string, limit?: number) =>
|
||||||
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
|
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
|
||||||
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
||||||
@@ -191,7 +191,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
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: {
|
whisper: {
|
||||||
@@ -203,5 +207,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
|
ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
|
return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 朋友圈
|
||||||
|
sns: {
|
||||||
|
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||||
|
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const emojiDownloading: Map<string, Promise<string | null>> = new Map()
|
|||||||
class ChatService {
|
class ChatService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private connected = false
|
private connected = false
|
||||||
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number }> = new Map()
|
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean }> = new Map()
|
||||||
private readonly messageBatchDefault = 50
|
private readonly messageBatchDefault = 50
|
||||||
private avatarCache: Map<string, ContactCacheEntry>
|
private avatarCache: Map<string, ContactCacheEntry>
|
||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
@@ -326,7 +326,11 @@ class ChatService {
|
|||||||
// 检查缓存
|
// 检查缓存
|
||||||
for (const username of usernames) {
|
for (const username of usernames) {
|
||||||
const cached = this.avatarCache.get(username)
|
const cached = this.avatarCache.get(username)
|
||||||
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) {
|
// 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取
|
||||||
|
// 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取
|
||||||
|
const isValidAvatar = cached?.avatarUrl &&
|
||||||
|
!cached.avatarUrl.includes('base64,ffd8') // 检测错误的 hex 格式
|
||||||
|
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) {
|
||||||
result[username] = {
|
result[username] = {
|
||||||
displayName: cached.displayName,
|
displayName: cached.displayName,
|
||||||
avatarUrl: cached.avatarUrl
|
avatarUrl: cached.avatarUrl
|
||||||
@@ -343,9 +347,17 @@ class ChatService {
|
|||||||
wcdbService.getAvatarUrls(missing)
|
wcdbService.getAvatarUrls(missing)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// 收集没有头像 URL 的用户名
|
||||||
|
const missingAvatars: string[] = []
|
||||||
|
|
||||||
for (const username of missing) {
|
for (const username of missing) {
|
||||||
const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined
|
const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined
|
||||||
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
|
let avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
|
||||||
|
|
||||||
|
// 如果没有头像 URL,记录下来稍后从 head_image.db 获取
|
||||||
|
if (!avatarUrl) {
|
||||||
|
missingAvatars.push(username)
|
||||||
|
}
|
||||||
|
|
||||||
const cacheEntry: ContactCacheEntry = {
|
const cacheEntry: ContactCacheEntry = {
|
||||||
displayName: displayName || username,
|
displayName: displayName || username,
|
||||||
@@ -357,6 +369,23 @@ class ChatService {
|
|||||||
this.avatarCache.set(username, cacheEntry)
|
this.avatarCache.set(username, cacheEntry)
|
||||||
updatedEntries[username] = cacheEntry
|
updatedEntries[username] = cacheEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 head_image.db 获取缺失的头像
|
||||||
|
if (missingAvatars.length > 0) {
|
||||||
|
const headImageAvatars = await this.getAvatarsFromHeadImageDb(missingAvatars)
|
||||||
|
for (const username of missingAvatars) {
|
||||||
|
const avatarUrl = headImageAvatars[username]
|
||||||
|
if (avatarUrl) {
|
||||||
|
result[username].avatarUrl = avatarUrl
|
||||||
|
const cached = this.avatarCache.get(username)
|
||||||
|
if (cached) {
|
||||||
|
cached.avatarUrl = avatarUrl
|
||||||
|
updatedEntries[username] = cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updatedEntries).length > 0) {
|
if (Object.keys(updatedEntries).length > 0) {
|
||||||
this.contactCacheService.setEntries(updatedEntries)
|
this.contactCacheService.setEntries(updatedEntries)
|
||||||
}
|
}
|
||||||
@@ -368,6 +397,81 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 head_image.db 批量获取头像(转换为 base64 data URL)
|
||||||
|
*/
|
||||||
|
private async getAvatarsFromHeadImageDb(usernames: string[]): Promise<Record<string, string>> {
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
if (usernames.length === 0) return result
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbPath = this.configService.get('dbPath')
|
||||||
|
const wxid = this.configService.get('myWxid')
|
||||||
|
if (!dbPath || !wxid) return result
|
||||||
|
|
||||||
|
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||||
|
if (!accountDir) return result
|
||||||
|
|
||||||
|
// head_image.db 可能在不同位置
|
||||||
|
const headImageDbPaths = [
|
||||||
|
join(accountDir, 'db_storage', 'head_image', 'head_image.db'),
|
||||||
|
join(accountDir, 'db_storage', 'head_image.db'),
|
||||||
|
join(accountDir, 'head_image.db')
|
||||||
|
]
|
||||||
|
|
||||||
|
let headImageDbPath: string | null = null
|
||||||
|
for (const path of headImageDbPaths) {
|
||||||
|
if (existsSync(path)) {
|
||||||
|
headImageDbPath = path
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headImageDbPath) return result
|
||||||
|
|
||||||
|
// 使用 wcdbService.execQuery 查询加密的 head_image.db
|
||||||
|
for (const username of usernames) {
|
||||||
|
try {
|
||||||
|
const escapedUsername = username.replace(/'/g, "''")
|
||||||
|
const queryResult = await wcdbService.execQuery(
|
||||||
|
'media',
|
||||||
|
headImageDbPath,
|
||||||
|
`SELECT image_buffer FROM head_image WHERE username = '${escapedUsername}' LIMIT 1`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (queryResult.success && queryResult.rows && queryResult.rows.length > 0) {
|
||||||
|
const row = queryResult.rows[0] as any
|
||||||
|
if (row?.image_buffer) {
|
||||||
|
let base64Data: string
|
||||||
|
if (typeof row.image_buffer === 'string') {
|
||||||
|
// WCDB 返回的 BLOB 是十六进制字符串,需要转换为 base64
|
||||||
|
if (row.image_buffer.toLowerCase().startsWith('ffd8')) {
|
||||||
|
const buffer = Buffer.from(row.image_buffer, 'hex')
|
||||||
|
base64Data = buffer.toString('base64')
|
||||||
|
} else {
|
||||||
|
base64Data = row.image_buffer
|
||||||
|
}
|
||||||
|
} else if (Buffer.isBuffer(row.image_buffer)) {
|
||||||
|
base64Data = row.image_buffer.toString('base64')
|
||||||
|
} else if (Array.isArray(row.image_buffer)) {
|
||||||
|
base64Data = Buffer.from(row.image_buffer).toString('base64')
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[username] = `data:image/jpeg;base64,${base64Data}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 静默处理单个用户的错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('从 head_image.db 获取头像失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 补充联系人信息(私有方法,保持向后兼容)
|
* 补充联系人信息(私有方法,保持向后兼容)
|
||||||
*/
|
*/
|
||||||
@@ -396,7 +500,10 @@ class ChatService {
|
|||||||
async getMessages(
|
async getMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
limit: number = 50
|
limit: number = 50,
|
||||||
|
startTime: number = 0,
|
||||||
|
endTime: number = 0,
|
||||||
|
ascending: boolean = false
|
||||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const connectResult = await this.ensureConnected()
|
const connectResult = await this.ensureConnected()
|
||||||
@@ -411,7 +518,14 @@ class ChatService {
|
|||||||
// 1. 没有游标状态
|
// 1. 没有游标状态
|
||||||
// 2. offset 为 0 (重新加载会话)
|
// 2. offset 为 0 (重新加载会话)
|
||||||
// 3. batchSize 改变
|
// 3. batchSize 改变
|
||||||
const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize
|
// 4. startTime 改变
|
||||||
|
// 5. ascending 改变
|
||||||
|
const needNewCursor = !state ||
|
||||||
|
offset === 0 ||
|
||||||
|
state.batchSize !== batchSize ||
|
||||||
|
state.startTime !== startTime ||
|
||||||
|
state.endTime !== endTime ||
|
||||||
|
state.ascending !== ascending
|
||||||
|
|
||||||
if (needNewCursor) {
|
if (needNewCursor) {
|
||||||
// 关闭旧游标
|
// 关闭旧游标
|
||||||
@@ -424,13 +538,16 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 创建新游标
|
// 创建新游标
|
||||||
const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, false, 0, 0)
|
// 注意:WeFlow 数据库中的 create_time 是以秒为单位的
|
||||||
|
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
||||||
|
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
||||||
|
const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||||
if (!cursorResult.success || !cursorResult.cursor) {
|
if (!cursorResult.success || !cursorResult.cursor) {
|
||||||
console.error('[ChatService] 打开消息游标失败:', cursorResult.error)
|
console.error('[ChatService] 打开消息游标失败:', cursorResult.error)
|
||||||
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
state = { cursor: cursorResult.cursor, fetched: 0, batchSize }
|
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
|
||||||
this.messageCursors.set(sessionId, state)
|
this.messageCursors.set(sessionId, state)
|
||||||
|
|
||||||
// 如果需要跳过消息(offset > 0),逐批获取但不返回
|
// 如果需要跳过消息(offset > 0),逐批获取但不返回
|
||||||
@@ -1706,7 +1823,9 @@ class ChatService {
|
|||||||
const connectResult = await this.ensureConnected()
|
const connectResult = await this.ensureConnected()
|
||||||
if (!connectResult.success) return null
|
if (!connectResult.success) return null
|
||||||
const cached = this.avatarCache.get(username)
|
const cached = this.avatarCache.get(username)
|
||||||
if (cached && cached.avatarUrl && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) {
|
// 检查缓存是否有效,且头像不是错误的 hex 格式
|
||||||
|
const isValidAvatar = cached?.avatarUrl && !cached.avatarUrl.includes('base64,ffd8')
|
||||||
|
if (cached && isValidAvatar && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) {
|
||||||
return { avatarUrl: cached.avatarUrl, displayName: cached.displayName }
|
return { avatarUrl: cached.avatarUrl, displayName: cached.displayName }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2979,10 +3098,26 @@ class ChatService {
|
|||||||
|
|
||||||
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||||
const normalized = dbPath.replace(/[\\\\/]+$/, '')
|
const normalized = dbPath.replace(/[\\\\/]+$/, '')
|
||||||
|
|
||||||
|
// 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件)
|
||||||
|
// 则向上回溯到账号目录
|
||||||
|
if (basename(normalized).toLowerCase() === 'db_storage') {
|
||||||
|
return dirname(normalized)
|
||||||
|
}
|
||||||
const dir = dirname(normalized)
|
const dir = dirname(normalized)
|
||||||
if (basename(normalized).toLowerCase() === 'db_storage') return dir
|
if (basename(dir).toLowerCase() === 'db_storage') {
|
||||||
if (basename(dir).toLowerCase() === 'db_storage') return dirname(dir)
|
return dirname(dir)
|
||||||
return dir // 兜底
|
}
|
||||||
|
|
||||||
|
// 否则,dbPath 应该是数据库根目录(如 xwechat_files)
|
||||||
|
// 账号目录应该是 {dbPath}/{wxid}
|
||||||
|
const accountDirWithWxid = join(normalized, wxid)
|
||||||
|
if (existsSync(accountDirWithWxid)) {
|
||||||
|
return accountDirWithWxid
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录)
|
||||||
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> {
|
private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> {
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ export class ContactCacheService {
|
|||||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
if (parsed && typeof parsed === 'object') {
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
// 清除无效的头像数据(hex 格式而非正确的 base64)
|
||||||
|
for (const key of Object.keys(parsed)) {
|
||||||
|
const entry = parsed[key]
|
||||||
|
if (entry?.avatarUrl && entry.avatarUrl.includes('base64,ffd8')) {
|
||||||
|
// 这是错误的 hex 格式,清除它
|
||||||
|
entry.avatarUrl = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
this.cache = parsed
|
this.cache = parsed
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -72,8 +72,21 @@ export interface ExportOptions {
|
|||||||
exportEmojis?: boolean
|
exportEmojis?: boolean
|
||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
excelCompactColumns?: 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 {
|
interface MediaExportItem {
|
||||||
relativePath: string
|
relativePath: string
|
||||||
kind: 'image' | 'voice' | 'emoji'
|
kind: 'image' | 'voice' | 'emoji'
|
||||||
@@ -83,7 +96,32 @@ export interface ExportProgress {
|
|||||||
current: number
|
current: number
|
||||||
total: number
|
total: number
|
||||||
currentSession: string
|
currentSession: string
|
||||||
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
|
phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并发控制:限制同时执行的 Promise 数量
|
||||||
|
async function parallelLimit<T, R>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T, index: number) => Promise<R>
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results: R[] = new Array(items.length)
|
||||||
|
let currentIndex = 0
|
||||||
|
|
||||||
|
async function runNext(): Promise<void> {
|
||||||
|
while (currentIndex < items.length) {
|
||||||
|
const index = currentIndex++
|
||||||
|
results[index] = await fn(items[index], index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动 limit 个并发任务
|
||||||
|
const workers = Array(Math.min(limit, items.length))
|
||||||
|
.fill(null)
|
||||||
|
.map(() => runNext())
|
||||||
|
|
||||||
|
await Promise.all(workers)
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExportService {
|
class ExportService {
|
||||||
@@ -402,20 +440,32 @@ class ExportService {
|
|||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
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(
|
private async exportMediaForMessage(
|
||||||
msg: any,
|
msg: any,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
mediaDir: string,
|
mediaRootDir: string,
|
||||||
|
mediaRelativePrefix: string,
|
||||||
options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean }
|
options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean }
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
const localType = msg.localType
|
const localType = msg.localType
|
||||||
|
|
||||||
// 图片消息
|
// 图片消息
|
||||||
if (localType === 3 && options.exportImages) {
|
if (localType === 3 && options.exportImages) {
|
||||||
const result = await this.exportImage(msg, sessionId, mediaDir)
|
const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||||
if (result) {
|
if (result) {
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -429,13 +479,13 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
// 否则导出语音文件
|
// 否则导出语音文件
|
||||||
if (options.exportVoices) {
|
if (options.exportVoices) {
|
||||||
return this.exportVoice(msg, sessionId, mediaDir)
|
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 动画表情
|
// 动画表情
|
||||||
if (localType === 47 && options.exportEmojis) {
|
if (localType === 47 && options.exportEmojis) {
|
||||||
const result = await this.exportEmoji(msg, sessionId, mediaDir)
|
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||||
if (result) {
|
if (result) {
|
||||||
}
|
}
|
||||||
return 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 {
|
try {
|
||||||
const imagesDir = path.join(mediaDir, 'media', 'images')
|
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
||||||
if (!fs.existsSync(imagesDir)) {
|
if (!fs.existsSync(imagesDir)) {
|
||||||
fs.mkdirSync(imagesDir, { recursive: true })
|
fs.mkdirSync(imagesDir, { recursive: true })
|
||||||
}
|
}
|
||||||
@@ -494,7 +549,7 @@ class ExportService {
|
|||||||
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
|
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relativePath: `media/images/${fileName}`,
|
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
|
||||||
kind: 'image'
|
kind: 'image'
|
||||||
}
|
}
|
||||||
} else if (sourcePath.startsWith('file://')) {
|
} else if (sourcePath.startsWith('file://')) {
|
||||||
@@ -512,7 +567,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relativePath: `media/images/${fileName}`,
|
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
|
||||||
kind: 'image'
|
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 {
|
try {
|
||||||
const voicesDir = path.join(mediaDir, 'media', 'voices')
|
const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
|
||||||
if (!fs.existsSync(voicesDir)) {
|
if (!fs.existsSync(voicesDir)) {
|
||||||
fs.mkdirSync(voicesDir, { recursive: true })
|
fs.mkdirSync(voicesDir, { recursive: true })
|
||||||
}
|
}
|
||||||
@@ -540,7 +600,7 @@ class ExportService {
|
|||||||
// 如果已存在则跳过
|
// 如果已存在则跳过
|
||||||
if (fs.existsSync(destPath)) {
|
if (fs.existsSync(destPath)) {
|
||||||
return {
|
return {
|
||||||
relativePath: `media/voices/${fileName}`,
|
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
|
||||||
kind: 'voice'
|
kind: 'voice'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,7 +616,7 @@ class ExportService {
|
|||||||
fs.writeFileSync(destPath, wavBuffer)
|
fs.writeFileSync(destPath, wavBuffer)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relativePath: `media/voices/${fileName}`,
|
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
|
||||||
kind: 'voice'
|
kind: 'voice'
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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 {
|
try {
|
||||||
const emojisDir = path.join(mediaDir, 'media', 'emojis')
|
const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
|
||||||
if (!fs.existsSync(emojisDir)) {
|
if (!fs.existsSync(emojisDir)) {
|
||||||
fs.mkdirSync(emojisDir, { recursive: true })
|
fs.mkdirSync(emojisDir, { recursive: true })
|
||||||
}
|
}
|
||||||
@@ -613,7 +678,7 @@ class ExportService {
|
|||||||
// 如果已存在则跳过
|
// 如果已存在则跳过
|
||||||
if (fs.existsSync(destPath)) {
|
if (fs.existsSync(destPath)) {
|
||||||
return {
|
return {
|
||||||
relativePath: `media/emojis/${fileName}`,
|
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
||||||
kind: 'emoji'
|
kind: 'emoji'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,7 +688,7 @@ class ExportService {
|
|||||||
const downloaded = await this.downloadFile(emojiUrl, destPath)
|
const downloaded = await this.downloadFile(emojiUrl, destPath)
|
||||||
if (downloaded) {
|
if (downloaded) {
|
||||||
return {
|
return {
|
||||||
relativePath: `media/emojis/${fileName}`,
|
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
||||||
kind: 'emoji'
|
kind: 'emoji'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -704,6 +769,22 @@ class ExportService {
|
|||||||
return '.jpg'
|
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(
|
async exportSessionToChatLab(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -1121,36 +1202,100 @@ class ExportService {
|
|||||||
|
|
||||||
allMessages.sort((a, b) => a.createTime - b.createTime)
|
allMessages.sort((a, b) => a.createTime - b.createTime)
|
||||||
|
|
||||||
|
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||||
|
|
||||||
|
// ========== 阶段1:并行导出媒体文件 ==========
|
||||||
|
const mediaMessages = exportMediaEnabled
|
||||||
|
? allMessages.filter(msg => {
|
||||||
|
const t = msg.localType
|
||||||
|
return (t === 3 && options.exportImages) || // 图片
|
||||||
|
(t === 47 && options.exportEmojis) || // 表情
|
||||||
|
(t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字)
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||||
|
|
||||||
|
if (mediaMessages.length > 0) {
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
current: 50,
|
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: 60,
|
||||||
total: 100,
|
total: 100,
|
||||||
currentSession: sessionInfo.displayName,
|
currentSession: sessionInfo.displayName,
|
||||||
phase: 'exporting'
|
phase: 'exporting'
|
||||||
})
|
})
|
||||||
|
|
||||||
const chatLabMessages: ChatLabMessage[] = []
|
const chatLabMessages: ChatLabMessage[] = allMessages.map(msg => {
|
||||||
for (const msg of allMessages) {
|
|
||||||
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
||||||
platformId: msg.senderUsername,
|
platformId: msg.senderUsername,
|
||||||
accountName: msg.senderUsername,
|
accountName: msg.senderUsername,
|
||||||
groupNickname: undefined
|
groupNickname: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = this.parseMessageContent(msg.content, msg.localType)
|
// 确定消息内容
|
||||||
// 如果是语音消息且开启了转文字
|
let content: string | null
|
||||||
if (msg.localType === 34 && options.exportVoiceAsText) {
|
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,
|
sender: msg.senderUsername,
|
||||||
accountName: memberInfo.accountName,
|
accountName: memberInfo.accountName,
|
||||||
groupNickname: memberInfo.groupNickname,
|
groupNickname: memberInfo.groupNickname,
|
||||||
timestamp: msg.createTime,
|
timestamp: msg.createTime,
|
||||||
type: this.convertMessageType(msg.localType, msg.content),
|
type: this.convertMessageType(msg.localType, msg.content),
|
||||||
content: content
|
content: content
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const avatarMap = options.exportAvatars
|
const avatarMap = options.exportAvatars
|
||||||
? await this.exportAvatars(
|
? await this.exportAvatars(
|
||||||
@@ -1218,7 +1363,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出单个会话为详细 JSON 格式(原项目格式)
|
* 导出单个会话为详细 JSON 格式(原项目格式)- 并行优化版本
|
||||||
*/
|
*/
|
||||||
async exportSessionToDetailedJson(
|
async exportSessionToDetailedJson(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -1244,16 +1389,89 @@ class ExportService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
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) {
|
for (const msg of collected.rows) {
|
||||||
const senderInfo = await this.getContactInfo(msg.senderUsername)
|
const senderInfo = await this.getContactInfo(msg.senderUsername)
|
||||||
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
|
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
|
||||||
const source = sourceMatch ? sourceMatch[0] : ''
|
const source = sourceMatch ? sourceMatch[0] : ''
|
||||||
|
|
||||||
let content = this.parseMessageContent(msg.content, msg.localType)
|
let content: string | null
|
||||||
if (msg.localType === 34 && options.exportVoiceAsText) {
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
content = await this.transcribeVoice(sessionId, String(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({
|
allMessages.push({
|
||||||
@@ -1482,23 +1700,33 @@ class ExportService {
|
|||||||
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
||||||
|
|
||||||
// 媒体导出设置
|
// 媒体导出设置
|
||||||
const exportMediaEnabled = options.exportImages || options.exportVoices || options.exportEmojis
|
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||||
const sessionDir = path.dirname(outputPath) // 会话目录,用于媒体导出
|
|
||||||
|
// ========== 并行预处理:媒体文件 ==========
|
||||||
|
const mediaMessages = exportMediaEnabled
|
||||||
|
? sortedMessages.filter(msg => {
|
||||||
|
const t = msg.localType
|
||||||
|
return (t === 3 && options.exportImages) ||
|
||||||
|
(t === 47 && options.exportEmojis) ||
|
||||||
|
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
// 媒体导出缓存
|
|
||||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||||
|
|
||||||
for (let i = 0; i < sortedMessages.length; i++) {
|
if (mediaMessages.length > 0) {
|
||||||
const msg = sortedMessages[i]
|
onProgress?.({
|
||||||
|
current: 35,
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'exporting-media'
|
||||||
|
})
|
||||||
|
|
||||||
// 导出媒体文件
|
const MEDIA_CONCURRENCY = 8
|
||||||
let mediaItem: MediaExportItem | null = null
|
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
||||||
if (exportMediaEnabled) {
|
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
if (mediaCache.has(mediaKey)) {
|
if (!mediaCache.has(mediaKey)) {
|
||||||
mediaItem = mediaCache.get(mediaKey) || null
|
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||||
} else {
|
|
||||||
mediaItem = await this.exportMediaForMessage(msg, sessionId, sessionDir, {
|
|
||||||
exportImages: options.exportImages,
|
exportImages: options.exportImages,
|
||||||
exportVoices: options.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
@@ -1506,8 +1734,46 @@ class ExportService {
|
|||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 并行预处理:语音转文字 ==========
|
||||||
|
const voiceMessages = options.exportVoiceAsText
|
||||||
|
? sortedMessages.filter(msg => msg.localType === 34)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const voiceTranscriptMap = new Map<number, string>()
|
||||||
|
|
||||||
|
if (voiceMessages.length > 0) {
|
||||||
|
onProgress?.({
|
||||||
|
current: 50,
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'exporting-voice'
|
||||||
|
})
|
||||||
|
|
||||||
|
const VOICE_CONCURRENCY = 4
|
||||||
|
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||||
|
const transcript = await this.transcribeVoice(sessionId, String(msg.localId))
|
||||||
|
voiceTranscriptMap.set(msg.localId, transcript)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
current: 65,
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'exporting'
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========== 写入 Excel 行 ==========
|
||||||
|
for (let i = 0; i < sortedMessages.length; i++) {
|
||||||
|
const msg = sortedMessages[i]
|
||||||
|
|
||||||
|
// 从缓存获取媒体信息
|
||||||
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
|
const mediaItem = mediaCache.get(mediaKey) || null
|
||||||
|
|
||||||
// 确定发送者信息
|
// 确定发送者信息
|
||||||
let senderRole: string
|
let senderRole: string
|
||||||
let senderWxid: string
|
let senderWxid: string
|
||||||
@@ -1555,12 +1821,15 @@ class ExportService {
|
|||||||
const row = worksheet.getRow(currentRow)
|
const row = worksheet.getRow(currentRow)
|
||||||
row.height = 24
|
row.height = 24
|
||||||
|
|
||||||
// 确定内容:如果有媒体文件导出成功则显示相对路径,否则显示解析后的内容
|
// 确定内容:优先使用预处理的缓存
|
||||||
let contentValue = mediaItem
|
let contentValue: string
|
||||||
? mediaItem.relativePath
|
if (mediaItem) {
|
||||||
: (this.parseMessageContent(msg.content, msg.localType) || '')
|
contentValue = mediaItem.relativePath
|
||||||
if (!mediaItem && msg.localType === 34 && options.exportVoiceAsText) {
|
} else if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||||
contentValue = await this.transcribeVoice(sessionId, String(msg.localId))
|
// 使用预处理的语音转文字结果
|
||||||
|
contentValue = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||||
|
} else {
|
||||||
|
contentValue = this.parseMessageContent(msg.content, msg.localType) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调试日志
|
// 调试日志
|
||||||
@@ -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,6 +2116,12 @@ class ExportService {
|
|||||||
fs.mkdirSync(outputDir, { recursive: true })
|
fs.mkdirSync(outputDir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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++) {
|
for (let i = 0; i < sessionIds.length; i++) {
|
||||||
const sessionId = sessionIds[i]
|
const sessionId = sessionIds[i]
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
@@ -1668,16 +2134,17 @@ class ExportService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
// 为每个会话创建单独的文件夹
|
if (useSessionFolder && !fs.existsSync(sessionDir)) {
|
||||||
const sessionDir = path.join(outputDir, safeName)
|
|
||||||
if (!fs.existsSync(sessionDir)) {
|
|
||||||
fs.mkdirSync(sessionDir, { recursive: true })
|
fs.mkdirSync(sessionDir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
let ext = '.json'
|
let ext = '.json'
|
||||||
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
|
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
|
||||||
else if (options.format === 'excel') ext = '.xlsx'
|
else if (options.format === 'excel') ext = '.xlsx'
|
||||||
|
else if (options.format === 'txt') ext = '.txt'
|
||||||
const outputPath = path.join(sessionDir, `${safeName}${ext}`)
|
const outputPath = path.join(sessionDir, `${safeName}${ext}`)
|
||||||
|
|
||||||
let result: { success: boolean; error?: string }
|
let result: { success: boolean; error?: string }
|
||||||
@@ -1687,6 +2154,8 @@ class ExportService {
|
|||||||
result = await this.exportSessionToChatLab(sessionId, outputPath, options)
|
result = await this.exportSessionToChatLab(sessionId, outputPath, options)
|
||||||
} else if (options.format === 'excel') {
|
} else if (options.format === 'excel') {
|
||||||
result = await this.exportSessionToExcel(sessionId, outputPath, options)
|
result = await this.exportSessionToExcel(sessionId, outputPath, options)
|
||||||
|
} else if (options.format === 'txt') {
|
||||||
|
result = await this.exportSessionToTxt(sessionId, outputPath, options)
|
||||||
} else {
|
} else {
|
||||||
result = { success: false, error: `不支持的格式: ${options.format}` }
|
result = { success: false, error: `不支持的格式: ${options.format}` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export class KeyService {
|
|||||||
private ReadProcessMemory: any = null
|
private ReadProcessMemory: any = null
|
||||||
private MEMORY_BASIC_INFORMATION: any = null
|
private MEMORY_BASIC_INFORMATION: any = null
|
||||||
private TerminateProcess: any = null
|
private TerminateProcess: any = null
|
||||||
|
private QueryFullProcessImageNameW: any = null
|
||||||
|
|
||||||
// User32
|
// User32
|
||||||
private EnumWindows: any = null
|
private EnumWindows: any = null
|
||||||
@@ -194,6 +195,7 @@ export class KeyService {
|
|||||||
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
|
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
|
||||||
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
|
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
|
||||||
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
|
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.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'))])
|
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> {
|
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
|
// 1. Registry - Uninstall Keys
|
||||||
const uninstallKeys = [
|
const uninstallKeys = [
|
||||||
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||||
@@ -588,6 +629,11 @@ export class KeyService {
|
|||||||
if (!ok) {
|
if (!ok) {
|
||||||
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
||||||
if (error) {
|
if (error) {
|
||||||
|
// 检测权限不足错误 (NTSTATUS 0xC0000022 = STATUS_ACCESS_DENIED)
|
||||||
|
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
|
||||||
|
const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
|
||||||
|
return { success: false, error: friendlyError }
|
||||||
|
}
|
||||||
return { success: false, error }
|
return { success: false, error }
|
||||||
}
|
}
|
||||||
const statusBuffer = Buffer.alloc(256)
|
const statusBuffer = Buffer.alloc(256)
|
||||||
|
|||||||
64
electron/services/snsService.ts
Normal file
64
electron/services/snsService.ts
Normal 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()
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { join, dirname, basename } from 'path'
|
import { join, dirname, basename } from 'path'
|
||||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
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 {
|
export class WcdbCore {
|
||||||
private resourcesPath: string | null = null
|
private resourcesPath: string | null = null
|
||||||
private userDataPath: string | null = null
|
private userDataPath: string | null = null
|
||||||
@@ -49,6 +55,7 @@ export class WcdbCore {
|
|||||||
private wcdbGetEmoticonCdnUrl: any = null
|
private wcdbGetEmoticonCdnUrl: any = null
|
||||||
private wcdbGetDbStatus: any = null
|
private wcdbGetDbStatus: any = null
|
||||||
private wcdbGetVoiceData: any = null
|
private wcdbGetVoiceData: any = null
|
||||||
|
private wcdbGetSnsTimeline: any = null
|
||||||
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
private logTimer: NodeJS.Timeout | null = null
|
private logTimer: NodeJS.Timeout | null = null
|
||||||
@@ -110,7 +117,8 @@ export class WcdbCore {
|
|||||||
private writeLog(message: string, force = false): void {
|
private writeLog(message: string, force = false): void {
|
||||||
if (!force && !this.isLogEnabled()) return
|
if (!force && !this.isLogEnabled()) return
|
||||||
const line = `[${new Date().toISOString()}] ${message}`
|
const line = `[${new Date().toISOString()}] ${message}`
|
||||||
// 移除控制台日志,只写入文件
|
// 同时输出到控制台和文件
|
||||||
|
console.log('[WCDB]', message)
|
||||||
try {
|
try {
|
||||||
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
||||||
const dir = join(base, 'logs')
|
const dir = join(base, 'logs')
|
||||||
@@ -208,6 +216,31 @@ export class WcdbCore {
|
|||||||
return false
|
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)
|
this.lib = this.koffi.load(dllPath)
|
||||||
|
|
||||||
// 定义类型
|
// 定义类型
|
||||||
@@ -354,6 +387,13 @@ export class WcdbCore {
|
|||||||
this.wcdbGetVoiceData = null
|
this.wcdbGetVoiceData = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetSnsTimeline = null
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
const initResult = this.wcdbInit()
|
const initResult = this.wcdbInit()
|
||||||
if (initResult !== 0) {
|
if (initResult !== 0) {
|
||||||
@@ -362,9 +402,20 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
|
lastDllInitError = null
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} 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
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,10 +433,18 @@ export class WcdbCore {
|
|||||||
return { success: true, sessionCount: 0 }
|
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) {
|
if (!this.initialized) {
|
||||||
const initOk = await this.initialize()
|
const initOk = await this.initialize()
|
||||||
if (!initOk) {
|
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: '无效的数据库句柄' }
|
return { success: false, error: '无效的数据库句柄' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试成功,使用 shutdown 清理所有资源(包括测试句柄)
|
// 测试成功:使用 shutdown 清理资源(包括测试句柄)
|
||||||
// 这会中断当前活动连接,但 testConnection 本应该是独立测试
|
// 注意:shutdown 会断开当前活动连接,因此需要在测试后尝试恢复之前的连接
|
||||||
try {
|
try {
|
||||||
this.wcdbShutdown()
|
this.wcdbShutdown()
|
||||||
this.handle = null
|
this.handle = null
|
||||||
@@ -437,6 +496,15 @@ export class WcdbCore {
|
|||||||
console.error('关闭测试数据库时出错:', closeErr)
|
console.error('关闭测试数据库时出错:', closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 恢复测试前的连接(如果之前有活动连接)
|
||||||
|
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
|
||||||
|
try {
|
||||||
|
await this.open(prevPath, prevKey, prevWxid)
|
||||||
|
} catch {
|
||||||
|
// 恢复失败则保持断开,由调用方处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, sessionCount: 0 }
|
return { success: true, sessionCount: 0 }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('测试连接异常:', e)
|
console.error('测试连接异常:', e)
|
||||||
@@ -1329,4 +1397,32 @@ export class WcdbCore {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : ''
|
||||||
|
const result = this.wcdbGetSnsTimeline(
|
||||||
|
this.handle,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
usernamesJson,
|
||||||
|
keyword || '',
|
||||||
|
startTime || 0,
|
||||||
|
endTime || 0,
|
||||||
|
outPtr
|
||||||
|
)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `获取朋友圈失败: ${result}` }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: '解析朋友圈数据失败' }
|
||||||
|
const timeline = JSON.parse(jsonStr)
|
||||||
|
return { success: true, timeline }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,12 +58,24 @@ export class WcdbService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.worker.on('error', (err) => {
|
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) => {
|
this.worker.on('exit', (code) => {
|
||||||
|
// Worker 退出,需要 reject 所有 pending promises
|
||||||
if (code !== 0) {
|
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
|
this.worker = null
|
||||||
})
|
})
|
||||||
@@ -350,6 +362,13 @@ export class WcdbService {
|
|||||||
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
|
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取朋友圈
|
||||||
|
*/
|
||||||
|
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||||
|
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const wcdbService = new WcdbService()
|
export const wcdbService = new WcdbService()
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ if (parentPort) {
|
|||||||
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
case 'getSnsTimeline':
|
||||||
|
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
result = { success: false, error: `Unknown method: ${type}` }
|
result = { success: false, error: `Unknown method: ${type}` }
|
||||||
}
|
}
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.2.0",
|
"version": "1.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.2.0",
|
"version": "1.3.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
@@ -8537,12 +8537,6 @@
|
|||||||
"sherpa-onnx-win-x64": "^1.12.23"
|
"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": {
|
"node_modules/sherpa-onnx-win-ia32": {
|
||||||
"version": "1.12.23",
|
"version": "1.12.23",
|
||||||
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",
|
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",
|
||||||
|
|||||||
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.2.0",
|
"version": "1.3.2",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": "cc",
|
"author": "cc",
|
||||||
@@ -105,6 +105,24 @@
|
|||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"node_modules/silk-wasm/**/*",
|
"node_modules/silk-wasm/**/*",
|
||||||
"node_modules/sherpa-onnx-node/**/*"
|
"node_modules/sherpa-onnx-node/**/*"
|
||||||
|
],
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "resources/msvcp140.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/msvcp140_1.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/vcruntime140.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/vcruntime140_1.dll",
|
||||||
|
"to": "."
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BIN
resources/msvcp140.dll
Normal file
BIN
resources/msvcp140.dll
Normal file
Binary file not shown.
BIN
resources/msvcp140_1.dll
Normal file
BIN
resources/msvcp140_1.dll
Normal file
Binary file not shown.
BIN
resources/vcruntime140.dll
Normal file
BIN
resources/vcruntime140.dll
Normal file
Binary file not shown.
BIN
resources/vcruntime140_1.dll
Normal file
BIN
resources/vcruntime140_1.dll
Normal file
Binary file not shown.
Binary file not shown.
14
src/App.tsx
14
src/App.tsx
@@ -16,6 +16,7 @@ import DataManagementPage from './pages/DataManagementPage'
|
|||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import ExportPage from './pages/ExportPage'
|
import ExportPage from './pages/ExportPage'
|
||||||
import VideoWindow from './pages/VideoWindow'
|
import VideoWindow from './pages/VideoWindow'
|
||||||
|
import SnsPage from './pages/SnsPage'
|
||||||
|
|
||||||
import { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||||
@@ -202,10 +203,22 @@ function App() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('自动连接失败:', result.error)
|
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) {
|
} catch (e) {
|
||||||
console.error('自动连接出错:', e)
|
console.error('自动连接出错:', e)
|
||||||
|
// 捕获异常但不清除配置,防止循环重新引导
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +337,7 @@ function App() {
|
|||||||
<Route path="/data-management" element={<DataManagementPage />} />
|
<Route path="/data-management" element={<DataManagementPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/export" element={<ExportPage />} />
|
<Route path="/export" element={<ExportPage />} />
|
||||||
|
<Route path="/sns" element={<SnsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</RouteGuard>
|
</RouteGuard>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
238
src/components/JumpToDateDialog.scss
Normal file
238
src/components/JumpToDateDialog.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/components/JumpToDateDialog.tsx
Normal file
156
src/components/JumpToDateDialog.tsx
Normal 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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } from 'lucide-react'
|
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture } from 'lucide-react'
|
||||||
import './Sidebar.scss'
|
import './Sidebar.scss'
|
||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
@@ -34,6 +34,16 @@ function Sidebar() {
|
|||||||
<span className="nav-label">聊天</span>
|
<span className="nav-label">聊天</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
{/* 朋友圈 */}
|
||||||
|
<NavLink
|
||||||
|
to="/sns"
|
||||||
|
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
|
||||||
|
title={collapsed ? '朋友圈' : undefined}
|
||||||
|
>
|
||||||
|
<span className="nav-icon"><Aperture size={20} /></span>
|
||||||
|
<span className="nav-label">朋友圈</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* 私聊分析 */}
|
{/* 私聊分析 */}
|
||||||
|
|||||||
@@ -489,8 +489,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.load-more-trigger {
|
.load-more-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 0;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
|
|
||||||
|
&.later {
|
||||||
|
padding: 24px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-chat {
|
.empty-chat {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getEmojiPath } from 'wechat-emojis'
|
|||||||
import { ImagePreview } from '../components/ImagePreview'
|
import { ImagePreview } from '../components/ImagePreview'
|
||||||
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||||
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
||||||
|
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import './ChatPage.scss'
|
import './ChatPage.scss'
|
||||||
|
|
||||||
@@ -132,15 +133,25 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
setLoadingMessages,
|
setLoadingMessages,
|
||||||
setLoadingMore,
|
setLoadingMore,
|
||||||
setHasMoreMessages,
|
setHasMoreMessages,
|
||||||
|
hasMoreLater,
|
||||||
|
setHasMoreLater,
|
||||||
setSearchKeyword
|
setSearchKeyword
|
||||||
} = useChatStore()
|
} = useChatStore()
|
||||||
|
|
||||||
const messageListRef = useRef<HTMLDivElement>(null)
|
const messageListRef = useRef<HTMLDivElement>(null)
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const getMessageKey = useCallback((msg: Message): string => {
|
||||||
|
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
||||||
|
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
||||||
|
}, [])
|
||||||
const initialRevealTimerRef = useRef<number | null>(null)
|
const initialRevealTimerRef = useRef<number | null>(null)
|
||||||
const sessionListRef = useRef<HTMLDivElement>(null)
|
const sessionListRef = useRef<HTMLDivElement>(null)
|
||||||
const [currentOffset, setCurrentOffset] = useState(0)
|
const [currentOffset, setCurrentOffset] = useState(0)
|
||||||
|
const [jumpStartTime, setJumpStartTime] = useState(0)
|
||||||
|
const [jumpEndTime, setJumpEndTime] = useState(0)
|
||||||
|
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
||||||
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
|
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
|
||||||
const [myWxid, setMyWxid] = useState<string | undefined>(undefined)
|
const [myWxid, setMyWxid] = useState<string | undefined>(undefined)
|
||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||||
@@ -477,6 +488,9 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
|
|
||||||
// 刷新会话列表
|
// 刷新会话列表
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
|
setJumpStartTime(0)
|
||||||
|
setJumpEndTime(0)
|
||||||
|
setHasMoreLater(false)
|
||||||
await loadSessions({ silent: true })
|
await loadSessions({ silent: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +498,9 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const [isRefreshingMessages, setIsRefreshingMessages] = useState(false)
|
const [isRefreshingMessages, setIsRefreshingMessages] = useState(false)
|
||||||
const handleRefreshMessages = async () => {
|
const handleRefreshMessages = async () => {
|
||||||
if (!currentSessionId || isRefreshingMessages) return
|
if (!currentSessionId || isRefreshingMessages) return
|
||||||
|
setJumpStartTime(0)
|
||||||
|
setJumpEndTime(0)
|
||||||
|
setHasMoreLater(false)
|
||||||
setIsRefreshingMessages(true)
|
setIsRefreshingMessages(true)
|
||||||
try {
|
try {
|
||||||
// 获取最新消息并增量添加
|
// 获取最新消息并增量添加
|
||||||
@@ -518,7 +535,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 加载消息
|
// 加载消息
|
||||||
const loadMessages = async (sessionId: string, offset = 0) => {
|
const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => {
|
||||||
const listEl = messageListRef.current
|
const listEl = messageListRef.current
|
||||||
const session = sessionMapRef.current.get(sessionId)
|
const session = sessionMapRef.current.get(sessionId)
|
||||||
const unreadCount = session?.unreadCount ?? 0
|
const unreadCount = session?.unreadCount ?? 0
|
||||||
@@ -535,7 +552,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit)
|
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime)
|
||||||
if (result.success && result.messages) {
|
if (result.success && result.messages) {
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
setMessages(result.messages)
|
setMessages(result.messages)
|
||||||
@@ -601,6 +618,14 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setHasMoreMessages(result.hasMore ?? false)
|
setHasMoreMessages(result.hasMore ?? false)
|
||||||
|
// 如果是按 endTime 跳转加载,且结果刚好满批,可能后面(更晚)还有消息
|
||||||
|
if (offset === 0) {
|
||||||
|
if (endTime > 0) {
|
||||||
|
setHasMoreLater(true)
|
||||||
|
} else {
|
||||||
|
setHasMoreLater(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
setCurrentOffset(offset + result.messages.length)
|
setCurrentOffset(offset + result.messages.length)
|
||||||
} else if (!result.success) {
|
} else if (!result.success) {
|
||||||
setConnectionError(result.error || '加载消息失败')
|
setConnectionError(result.error || '加载消息失败')
|
||||||
@@ -616,12 +641,41 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载更晚的消息
|
||||||
|
const loadLaterMessages = useCallback(async () => {
|
||||||
|
if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return
|
||||||
|
|
||||||
|
setLoadingMore(true)
|
||||||
|
try {
|
||||||
|
const lastMsg = messages[messages.length - 1]
|
||||||
|
// 从最后一条消息的时间开始往后找
|
||||||
|
const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true)
|
||||||
|
|
||||||
|
if (result.success && result.messages) {
|
||||||
|
// 过滤掉已经在列表中的重复消息
|
||||||
|
const existingKeys = messageKeySetRef.current
|
||||||
|
const newMsgs = result.messages.filter(m => !existingKeys.has(getMessageKey(m)))
|
||||||
|
|
||||||
|
if (newMsgs.length > 0) {
|
||||||
|
appendMessages(newMsgs, false)
|
||||||
|
}
|
||||||
|
setHasMoreLater(result.hasMore ?? false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载后续消息失败:', e)
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false)
|
||||||
|
}
|
||||||
|
}, [currentSessionId, isLoadingMore, isLoadingMessages, messages, getMessageKey, appendMessages, setHasMoreLater, setLoadingMore])
|
||||||
|
|
||||||
// 选择会话
|
// 选择会话
|
||||||
const handleSelectSession = (session: ChatSession) => {
|
const handleSelectSession = (session: ChatSession) => {
|
||||||
if (session.username === currentSessionId) return
|
if (session.username === currentSessionId) return
|
||||||
setCurrentSession(session.username)
|
setCurrentSession(session.username)
|
||||||
setCurrentOffset(0)
|
setCurrentOffset(0)
|
||||||
loadMessages(session.username, 0)
|
setJumpStartTime(0)
|
||||||
|
setJumpEndTime(0)
|
||||||
|
loadMessages(session.username, 0, 0, 0)
|
||||||
// 重置详情面板
|
// 重置详情面板
|
||||||
setSessionDetail(null)
|
setSessionDetail(null)
|
||||||
if (showDetailPanel) {
|
if (showDetailPanel) {
|
||||||
@@ -678,16 +732,21 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) {
|
if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) {
|
||||||
const threshold = clientHeight * 0.3
|
const threshold = clientHeight * 0.3
|
||||||
if (scrollTop < threshold) {
|
if (scrollTop < threshold) {
|
||||||
loadMessages(currentSessionId, currentOffset)
|
loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预加载更晚的消息
|
||||||
|
if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) {
|
||||||
|
const threshold = clientHeight * 0.3
|
||||||
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||||
|
if (distanceFromBottom < threshold) {
|
||||||
|
loadLaterMessages()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset, loadMessages])
|
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages])
|
||||||
|
|
||||||
const getMessageKey = useCallback((msg: Message): string => {
|
|
||||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
|
||||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
|
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
|
||||||
return (
|
return (
|
||||||
@@ -1102,6 +1161,25 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
|
<button
|
||||||
|
className="icon-btn jump-to-time-btn"
|
||||||
|
onClick={() => setShowJumpDialog(true)}
|
||||||
|
title="跳转到指定时间"
|
||||||
|
>
|
||||||
|
<Calendar size={18} />
|
||||||
|
</button>
|
||||||
|
<JumpToDateDialog
|
||||||
|
isOpen={showJumpDialog}
|
||||||
|
onClose={() => setShowJumpDialog(false)}
|
||||||
|
onSelect={(date) => {
|
||||||
|
if (!currentSessionId) return
|
||||||
|
const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000)
|
||||||
|
setCurrentOffset(0)
|
||||||
|
setJumpStartTime(0)
|
||||||
|
setJumpEndTime(end)
|
||||||
|
loadMessages(currentSessionId, 0, 0, end)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
className="icon-btn refresh-messages-btn"
|
className="icon-btn refresh-messages-btn"
|
||||||
onClick={handleRefreshMessages}
|
onClick={handleRefreshMessages}
|
||||||
@@ -1177,6 +1255,19 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{hasMoreLater && (
|
||||||
|
<div className={`load-more-trigger later ${isLoadingMore ? 'loading' : ''}`}>
|
||||||
|
{isLoadingMore ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} />
|
||||||
|
<span>正在加载后续消息...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>向下滚动查看更新消息</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 回到底部按钮 */}
|
{/* 回到底部按钮 */}
|
||||||
<div className={`scroll-to-bottom ${showScrollToBottom ? 'show' : ''}`} onClick={scrollToBottom}>
|
<div className={`scroll-to-bottom ${showScrollToBottom ? 'show' : ''}`} onClick={scrollToBottom}>
|
||||||
<ChevronDown size={16} />
|
<ChevronDown size={16} />
|
||||||
|
|||||||
@@ -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 {
|
.export-result-modal {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
padding: 32px 40px;
|
padding: 32px 40px;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface ExportOptions {
|
|||||||
exportEmojis: boolean
|
exportEmojis: boolean
|
||||||
exportVoiceAsText: boolean
|
exportVoiceAsText: boolean
|
||||||
excelCompactColumns: boolean
|
excelCompactColumns: boolean
|
||||||
|
txtColumns: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExportResult {
|
interface ExportResult {
|
||||||
@@ -31,7 +32,10 @@ interface ExportResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionLayout = 'shared' | 'per-session'
|
||||||
|
|
||||||
function ExportPage() {
|
function ExportPage() {
|
||||||
|
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>([])
|
const [sessions, setSessions] = useState<ChatSession[]>([])
|
||||||
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
|
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
|
||||||
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
||||||
@@ -44,6 +48,7 @@ function ExportPage() {
|
|||||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||||
const [calendarDate, setCalendarDate] = useState(new Date())
|
const [calendarDate, setCalendarDate] = useState(new Date())
|
||||||
const [selectingStart, setSelectingStart] = useState(true)
|
const [selectingStart, setSelectingStart] = useState(true)
|
||||||
|
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
|
||||||
|
|
||||||
const [options, setOptions] = useState<ExportOptions>({
|
const [options, setOptions] = useState<ExportOptions>({
|
||||||
format: 'excel',
|
format: 'excel',
|
||||||
@@ -58,7 +63,8 @@ function ExportPage() {
|
|||||||
exportVoices: true,
|
exportVoices: true,
|
||||||
exportEmojis: true,
|
exportEmojis: true,
|
||||||
exportVoiceAsText: true,
|
exportVoiceAsText: true,
|
||||||
excelCompactColumns: true
|
excelCompactColumns: true,
|
||||||
|
txtColumns: defaultTxtColumns
|
||||||
})
|
})
|
||||||
|
|
||||||
const buildDateRangeFromPreset = (preset: string) => {
|
const buildDateRangeFromPreset = (preset: string) => {
|
||||||
@@ -122,17 +128,20 @@ function ExportPage() {
|
|||||||
savedRange,
|
savedRange,
|
||||||
savedMedia,
|
savedMedia,
|
||||||
savedVoiceAsText,
|
savedVoiceAsText,
|
||||||
savedExcelCompactColumns
|
savedExcelCompactColumns,
|
||||||
|
savedTxtColumns
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
configService.getExportDefaultFormat(),
|
configService.getExportDefaultFormat(),
|
||||||
configService.getExportDefaultDateRange(),
|
configService.getExportDefaultDateRange(),
|
||||||
configService.getExportDefaultMedia(),
|
configService.getExportDefaultMedia(),
|
||||||
configService.getExportDefaultVoiceAsText(),
|
configService.getExportDefaultVoiceAsText(),
|
||||||
configService.getExportDefaultExcelCompactColumns()
|
configService.getExportDefaultExcelCompactColumns(),
|
||||||
|
configService.getExportDefaultTxtColumns()
|
||||||
])
|
])
|
||||||
|
|
||||||
const preset = savedRange || 'today'
|
const preset = savedRange || 'today'
|
||||||
const rangeDefaults = buildDateRangeFromPreset(preset)
|
const rangeDefaults = buildDateRangeFromPreset(preset)
|
||||||
|
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
|
||||||
|
|
||||||
setOptions((prev) => ({
|
setOptions((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -141,7 +150,8 @@ function ExportPage() {
|
|||||||
dateRange: rangeDefaults.dateRange,
|
dateRange: rangeDefaults.dateRange,
|
||||||
exportMedia: savedMedia ?? false,
|
exportMedia: savedMedia ?? false,
|
||||||
exportVoiceAsText: savedVoiceAsText ?? true,
|
exportVoiceAsText: savedVoiceAsText ?? true,
|
||||||
excelCompactColumns: savedExcelCompactColumns ?? true
|
excelCompactColumns: savedExcelCompactColumns ?? true,
|
||||||
|
txtColumns
|
||||||
}))
|
}))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载导出默认设置失败:', e)
|
console.error('加载导出默认设置失败:', e)
|
||||||
@@ -154,6 +164,19 @@ function ExportPage() {
|
|||||||
loadExportDefaults()
|
loadExportDefaults()
|
||||||
}, [loadSessions, loadExportPath, 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(() => {
|
useEffect(() => {
|
||||||
if (!searchKeyword.trim()) {
|
if (!searchKeyword.trim()) {
|
||||||
setFilteredSessions(sessions)
|
setFilteredSessions(sessions)
|
||||||
@@ -199,7 +222,7 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startExport = async () => {
|
const runExport = async (sessionLayout: SessionLayout) => {
|
||||||
if (selectedSessions.size === 0 || !exportFolder) return
|
if (selectedSessions.size === 0 || !exportFolder) return
|
||||||
|
|
||||||
setIsExporting(true)
|
setIsExporting(true)
|
||||||
@@ -215,16 +238,18 @@ function ExportPage() {
|
|||||||
exportImages: options.exportMedia && options.exportImages,
|
exportImages: options.exportMedia && options.exportImages,
|
||||||
exportVoices: options.exportMedia && options.exportVoices,
|
exportVoices: options.exportMedia && options.exportVoices,
|
||||||
exportEmojis: options.exportMedia && options.exportEmojis,
|
exportEmojis: options.exportMedia && options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia
|
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
|
||||||
excelCompactColumns: options.excelCompactColumns,
|
excelCompactColumns: options.excelCompactColumns,
|
||||||
|
txtColumns: options.txtColumns,
|
||||||
|
sessionLayout,
|
||||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||||
// 将结束日期设置为当天的 23:59:59,以包含当天的所有消息
|
// 将结束日期设置为当天的 23:59:59,确保包含当天的所有记录
|
||||||
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
|
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
|
||||||
} : null
|
} : null
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
const result = await window.electronAPI.export.exportSessions(
|
||||||
sessionList,
|
sessionList,
|
||||||
exportFolder,
|
exportFolder,
|
||||||
@@ -232,16 +257,28 @@ function ExportPage() {
|
|||||||
)
|
)
|
||||||
setExportResult(result)
|
setExportResult(result)
|
||||||
} else {
|
} else {
|
||||||
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` })
|
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` })
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('导出失败:', e)
|
console.error('导出过程中发生异常:', e)
|
||||||
setExportResult({ success: false, error: String(e) })
|
setExportResult({ success: false, error: String(e) })
|
||||||
} finally {
|
} finally {
|
||||||
setIsExporting(false)
|
setIsExporting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 getDaysInMonth = (date: Date) => {
|
||||||
const year = date.getFullYear()
|
const year = date.getFullYear()
|
||||||
const month = date.getMonth()
|
const month = date.getMonth()
|
||||||
@@ -600,6 +637,43 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{isExporting && (
|
||||||
<div className="export-overlay">
|
<div className="export-overlay">
|
||||||
|
|||||||
@@ -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 {
|
.input-with-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1096,13 +1190,15 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
margin-top: 4px;
|
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: 1px solid var(--border-primary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxid-option {
|
.wxid-option {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useAppStore } from '../stores/appStore'
|
import { useAppStore } from '../stores/appStore'
|
||||||
import { useThemeStore, themes } from '../stores/themeStore'
|
import { useThemeStore, themes } from '../stores/themeStore'
|
||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
@@ -41,6 +41,12 @@ function SettingsPage() {
|
|||||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||||
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
||||||
const wxidDropdownRef = useRef<HTMLDivElement>(null)
|
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 [cachePath, setCachePath] = useState('')
|
||||||
const [logEnabled, setLogEnabled] = useState(false)
|
const [logEnabled, setLogEnabled] = useState(false)
|
||||||
const [whisperModelName, setWhisperModelName] = useState('base')
|
const [whisperModelName, setWhisperModelName] = useState('base')
|
||||||
@@ -55,6 +61,7 @@ function SettingsPage() {
|
|||||||
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
||||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true)
|
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true)
|
||||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||||
|
const [exportDefaultTxtColumns, setExportDefaultTxtColumns] = useState<string[]>(['index', 'time', 'senderRole', 'messageType', 'content'])
|
||||||
|
|
||||||
const [isLoading, setIsLoadingState] = useState(false)
|
const [isLoading, setIsLoadingState] = useState(false)
|
||||||
const [isTesting, setIsTesting] = useState(false)
|
const [isTesting, setIsTesting] = useState(false)
|
||||||
@@ -85,13 +92,23 @@ function SettingsPage() {
|
|||||||
// 点击外部关闭下拉框
|
// 点击外部关闭下拉框
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
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)
|
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)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [showWxidSelect])
|
}, [showWxidSelect, showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
|
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
|
||||||
@@ -125,6 +142,8 @@ function SettingsPage() {
|
|||||||
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
|
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
|
||||||
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
|
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
|
||||||
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
||||||
|
const savedExportDefaultTxtColumns = await configService.getExportDefaultTxtColumns()
|
||||||
|
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||||
|
|
||||||
if (savedKey) setDecryptKey(savedKey)
|
if (savedKey) setDecryptKey(savedKey)
|
||||||
if (savedPath) setDbPath(savedPath)
|
if (savedPath) setDbPath(savedPath)
|
||||||
@@ -142,6 +161,11 @@ function SettingsPage() {
|
|||||||
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
||||||
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true)
|
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true)
|
||||||
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
||||||
|
setExportDefaultTxtColumns(
|
||||||
|
savedExportDefaultTxtColumns && savedExportDefaultTxtColumns.length > 0
|
||||||
|
? savedExportDefaultTxtColumns
|
||||||
|
: defaultTxtColumns
|
||||||
|
)
|
||||||
|
|
||||||
// 如果语言列表为空,保存默认值
|
// 如果语言列表为空,保存默认值
|
||||||
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||||
@@ -150,6 +174,10 @@ function SettingsPage() {
|
|||||||
await configService.setTranscribeLanguages(defaultLanguages)
|
await configService.setTranscribeLanguages(defaultLanguages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!savedExportDefaultTxtColumns || savedExportDefaultTxtColumns.length === 0) {
|
||||||
|
await configService.setExportDefaultTxtColumns(defaultTxtColumns)
|
||||||
|
}
|
||||||
|
|
||||||
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载配置失败:', e)
|
console.error('加载配置失败:', e)
|
||||||
@@ -484,15 +512,8 @@ function SettingsPage() {
|
|||||||
await configService.setTranscribeLanguages(transcribeLanguages)
|
await configService.setTranscribeLanguages(transcribeLanguages)
|
||||||
await configService.setOnboardingDone(true)
|
await configService.setOnboardingDone(true)
|
||||||
|
|
||||||
showMessage('配置保存成功,正在测试连接...', true)
|
// 保存按钮只负责持久化配置,不做连接测试/重连,避免影响聊天页的活动连接
|
||||||
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
|
showMessage('配置保存成功', true)
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setDbConnected(true, dbPath)
|
|
||||||
showMessage('配置保存成功!数据库连接正常', true)
|
|
||||||
} else {
|
|
||||||
showMessage(result.error || '数据库连接失败,请检查配置', false)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showMessage(`保存配置失败: ${e}`, false)
|
showMessage(`保存配置失败: ${e}`, false)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -870,48 +891,124 @@ function SettingsPage() {
|
|||||||
</div>
|
</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="tab-content">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>默认导出格式</label>
|
<label>默认导出格式</label>
|
||||||
<span className="form-hint">导出页面默认选中的格式</span>
|
<span className="form-hint">导出页面默认选中的格式</span>
|
||||||
<select
|
<div className="select-field" ref={exportFormatDropdownRef}>
|
||||||
value={exportDefaultFormat}
|
<button
|
||||||
onChange={async (e) => {
|
type="button"
|
||||||
const value = e.target.value
|
className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`}
|
||||||
setExportDefaultFormat(value)
|
onClick={() => {
|
||||||
await configService.setExportDefaultFormat(value)
|
setShowExportFormatSelect(!showExportFormatSelect)
|
||||||
showMessage('已更新导出格式默认值', true)
|
setShowExportDateRangeSelect(false)
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="excel">Excel</option>
|
<span className="select-value">{exportFormatLabel}</span>
|
||||||
<option value="chatlab">ChatLab</option>
|
<ChevronDown size={16} />
|
||||||
<option value="chatlab-jsonl">ChatLab JSONL</option>
|
</button>
|
||||||
<option value="json">JSON</option>
|
{showExportFormatSelect && (
|
||||||
<option value="html">HTML</option>
|
<div className="select-dropdown">
|
||||||
<option value="txt">TXT</option>
|
{exportFormatOptions.map((option) => (
|
||||||
<option value="sql">PostgreSQL</option>
|
<button
|
||||||
</select>
|
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>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>默认导出时间范围</label>
|
<label>默认导出时间范围</label>
|
||||||
<span className="form-hint">控制导出页面的默认时间选择</span>
|
<span className="form-hint">控制导出页面的默认时间选择</span>
|
||||||
<select
|
<div className="select-field" ref={exportDateRangeDropdownRef}>
|
||||||
value={exportDefaultDateRange}
|
<button
|
||||||
onChange={async (e) => {
|
type="button"
|
||||||
const value = e.target.value
|
className={`select-trigger ${showExportDateRangeSelect ? 'open' : ''}`}
|
||||||
setExportDefaultDateRange(value)
|
onClick={() => {
|
||||||
await configService.setExportDefaultDateRange(value)
|
setShowExportDateRangeSelect(!showExportDateRangeSelect)
|
||||||
showMessage('已更新默认导出时间范围', true)
|
setShowExportFormatSelect(false)
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="today">今天</option>
|
<span className="select-value">{exportDateRangeLabel}</span>
|
||||||
<option value="7d">最近7天</option>
|
<ChevronDown size={16} />
|
||||||
<option value="30d">最近30天</option>
|
</button>
|
||||||
<option value="90d">最近90天</option>
|
{showExportDateRangeSelect && (
|
||||||
<option value="all">全部时间</option>
|
<div className="select-dropdown">
|
||||||
</select>
|
{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>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -963,21 +1060,80 @@ function SettingsPage() {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Excel 列显示</label>
|
<label>Excel 列显示</label>
|
||||||
<span className="form-hint">控制 Excel 导出的列字段</span>
|
<span className="form-hint">控制 Excel 导出的列字段</span>
|
||||||
<select
|
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
|
||||||
value={exportDefaultExcelCompactColumns ? 'compact' : 'full'}
|
<button
|
||||||
onChange={async (e) => {
|
type="button"
|
||||||
const compact = e.target.value === 'compact'
|
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)
|
setExportDefaultExcelCompactColumns(compact)
|
||||||
await configService.setExportDefaultExcelCompactColumns(compact)
|
await configService.setExportDefaultExcelCompactColumns(compact)
|
||||||
showMessage(compact ? '已启用精简列' : '已启用完整列', true)
|
showMessage(compact ? '已启用精简列' : '已启用完整列', true)
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="compact">精简列(序号、时间、发送者身份、消息类型、内容)</option>
|
<span className="option-label">{option.label}</span>
|
||||||
<option value="full">完整列(含发送者昵称/微信ID/备注)</option>
|
{option.desc && <span className="option-desc">{option.desc}</span>}
|
||||||
</select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
const renderCacheTab = () => (
|
const renderCacheTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<p className="section-desc">管理应用缓存数据</p>
|
<p className="section-desc">管理应用缓存数据</p>
|
||||||
@@ -1126,4 +1282,3 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default SettingsPage
|
export default SettingsPage
|
||||||
|
|
||||||
|
|||||||
579
src/pages/SnsPage.scss
Normal file
579
src/pages/SnsPage.scss
Normal 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
421
src/pages/SnsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -441,7 +441,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
<FolderOpen size={16} /> 浏览选择
|
<FolderOpen size={16} /> 浏览选择
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="field-hint">建议选择包含 xwechat_files 的目录</div>
|
<div className="field-hint">请选择微信-设置-存储位置对应的目录</div>
|
||||||
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}>⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</div>
|
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}>⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -507,7 +507,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
|
|
||||||
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
|
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
|
||||||
<div className="field-hint">获取密钥会自动识别最近登录的账号</div>
|
<div className="field-hint">获取密钥会自动识别最近登录的账号</div>
|
||||||
<div className="field-hint">点击自动获取后微信将重新启动,当页面提示可以登录微信了再点击登录</div>
|
<div className="field-hint">点击自动获取后微信将重新启动,当页面提示<span style={{color: 'red'}}>hook安装成功,现在登录微信</span>后再点击登录</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -533,7 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||||
</button>
|
</button>
|
||||||
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
|
{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>}
|
{isFetchingImageKey && <div className="field-hint status-text">正在扫描内存,请稍候...</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export const CONFIG_KEYS = {
|
|||||||
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
||||||
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
||||||
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
|
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
|
} as const
|
||||||
|
|
||||||
// 获取解密密钥
|
// 获取解密密钥
|
||||||
@@ -306,3 +307,14 @@ export async function getExportDefaultExcelCompactColumns(): Promise<boolean | n
|
|||||||
export async function setExportDefaultExcelCompactColumns(enabled: boolean): Promise<void> {
|
export async function setExportDefaultExcelCompactColumns(enabled: boolean): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface ChatState {
|
|||||||
isLoadingMessages: boolean
|
isLoadingMessages: boolean
|
||||||
isLoadingMore: boolean
|
isLoadingMore: boolean
|
||||||
hasMoreMessages: boolean
|
hasMoreMessages: boolean
|
||||||
|
hasMoreLater: boolean
|
||||||
|
|
||||||
// 联系人缓存
|
// 联系人缓存
|
||||||
contacts: Map<string, Contact>
|
contacts: Map<string, Contact>
|
||||||
@@ -38,6 +39,7 @@ export interface ChatState {
|
|||||||
setLoadingMessages: (loading: boolean) => void
|
setLoadingMessages: (loading: boolean) => void
|
||||||
setLoadingMore: (loading: boolean) => void
|
setLoadingMore: (loading: boolean) => void
|
||||||
setHasMoreMessages: (hasMore: boolean) => void
|
setHasMoreMessages: (hasMore: boolean) => void
|
||||||
|
setHasMoreLater: (hasMore: boolean) => void
|
||||||
setContacts: (contacts: Contact[]) => void
|
setContacts: (contacts: Contact[]) => void
|
||||||
addContact: (contact: Contact) => void
|
addContact: (contact: Contact) => void
|
||||||
setSearchKeyword: (keyword: string) => void
|
setSearchKeyword: (keyword: string) => void
|
||||||
@@ -56,6 +58,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
isLoadingMessages: false,
|
isLoadingMessages: false,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
hasMoreMessages: true,
|
hasMoreMessages: true,
|
||||||
|
hasMoreLater: false,
|
||||||
contacts: new Map(),
|
contacts: new Map(),
|
||||||
searchKeyword: '',
|
searchKeyword: '',
|
||||||
|
|
||||||
@@ -69,7 +72,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
setCurrentSession: (sessionId) => set({
|
setCurrentSession: (sessionId) => set({
|
||||||
currentSessionId: sessionId,
|
currentSessionId: sessionId,
|
||||||
messages: [],
|
messages: [],
|
||||||
hasMoreMessages: true
|
hasMoreMessages: true,
|
||||||
|
hasMoreLater: false
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
||||||
@@ -85,6 +89,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
setLoadingMessages: (loading) => set({ isLoadingMessages: loading }),
|
setLoadingMessages: (loading) => set({ isLoadingMessages: loading }),
|
||||||
setLoadingMore: (loading) => set({ isLoadingMore: loading }),
|
setLoadingMore: (loading) => set({ isLoadingMore: loading }),
|
||||||
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
|
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
|
||||||
|
setHasMoreLater: (hasMore) => set({ hasMoreLater: hasMore }),
|
||||||
|
|
||||||
setContacts: (contacts) => set({
|
setContacts: (contacts) => set({
|
||||||
contacts: new Map(contacts.map(c => [c.username, c]))
|
contacts: new Map(contacts.map(c => [c.username, c]))
|
||||||
@@ -110,6 +115,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
isLoadingMessages: false,
|
isLoadingMessages: false,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
hasMoreMessages: true,
|
hasMoreMessages: true,
|
||||||
|
hasMoreLater: false,
|
||||||
contacts: new Map(),
|
contacts: new Map(),
|
||||||
searchKeyword: ''
|
searchKeyword: ''
|
||||||
})
|
})
|
||||||
|
|||||||
30
src/types/electron.d.ts
vendored
30
src/types/electron.d.ts
vendored
@@ -63,7 +63,7 @@ export interface ElectronAPI {
|
|||||||
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{
|
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
@@ -314,12 +314,31 @@ export interface ElectronAPI {
|
|||||||
success: boolean
|
success: boolean
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
onProgress: (callback: (payload: ExportProgress) => void) => () => void
|
||||||
}
|
}
|
||||||
whisper: {
|
whisper: {
|
||||||
downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }>
|
downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }>
|
||||||
getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }>
|
getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }>
|
||||||
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void
|
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void
|
||||||
}
|
}
|
||||||
|
sns: {
|
||||||
|
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
timeline?: Array<{
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
avatarUrl?: string
|
||||||
|
createTime: number
|
||||||
|
contentDesc: string
|
||||||
|
type?: number
|
||||||
|
media: Array<{ url: string; thumb: string }>
|
||||||
|
likes: Array<string>
|
||||||
|
comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }>
|
||||||
|
}>
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
@@ -332,6 +351,15 @@ export interface ExportOptions {
|
|||||||
exportEmojis?: boolean
|
exportEmojis?: boolean
|
||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
excelCompactColumns?: 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 {
|
export interface WxidInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user