mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 23:35:49 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3efaed488a | ||
|
|
decdbf95f7 | ||
|
|
cccc712814 | ||
|
|
135f4819fb | ||
|
|
388923257b | ||
|
|
6918e359e8 | ||
|
|
d5b33c7e77 | ||
|
|
d37f53e120 | ||
|
|
26478217e7 | ||
|
|
a100f4ef97 | ||
|
|
91b746dc59 | ||
|
|
1817a847de | ||
|
|
7e99feae1e | ||
|
|
2977c45365 | ||
|
|
3b363a3efa | ||
|
|
e2b0bd44d9 | ||
|
|
cc26860504 | ||
|
|
54f3e0481f | ||
|
|
a61371c8ad | ||
|
|
fd6d5e4296 | ||
|
|
514a617c55 | ||
|
|
b47007ea0c | ||
|
|
6436c39c90 | ||
|
|
eb2f90e605 | ||
|
|
bdbb85175a | ||
|
|
a5e1bfe49a | ||
|
|
b3adb54651 | ||
|
|
07e7bce6a9 | ||
|
|
baa90242a6 | ||
|
|
787db0cec2 | ||
|
|
6359118132 | ||
|
|
49614bf6d8 | ||
|
|
0901e08c5c | ||
|
|
503a77c7cf | ||
|
|
0e3ab8e4d6 | ||
|
|
4452e4921c | ||
|
|
97c1aa582d | ||
|
|
076c008329 | ||
|
|
21d785dd3c | ||
|
|
348f6c81bf | ||
|
|
d5a2e2bb62 | ||
|
|
2b51e0659e | ||
|
|
3efca5e60c | ||
|
|
2f7b917f1c | ||
|
|
8623f86505 | ||
|
|
dc74641c19 | ||
|
|
db7817cc22 | ||
|
|
ada0f68182 | ||
|
|
fe806895f0 | ||
|
|
da137d0a8f | ||
|
|
93ebc3bce3 | ||
|
|
9f6e9eb9bc |
68
.github/workflows/release.yml
vendored
68
.github/workflows/release.yml
vendored
@@ -39,59 +39,23 @@ jobs:
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Create Changelog Config
|
||||
shell: bash
|
||||
run: |
|
||||
cat <<EOF > changelog_config.json
|
||||
{
|
||||
"template": "# ${{ 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" }
|
||||
},
|
||||
{
|
||||
"title": "## 其他改动",
|
||||
"filter": { "pattern": ".*", "flags": "i" }
|
||||
}
|
||||
],
|
||||
"ignore_labels": [],
|
||||
"commitMode": true,
|
||||
"empty_summary": "## 更新详情\n- 常规代码优化与维护"
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Build Changelog
|
||||
id: build_changelog
|
||||
uses: mikepenz/release-changelog-builder-action@v5
|
||||
with:
|
||||
configuration: "changelog_config.json"
|
||||
outputFile: "release-notes.md"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check Changelog Content
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== RELEASE NOTES START ==="
|
||||
cat release-notes.md
|
||||
echo "=== RELEASE NOTES END ==="
|
||||
|
||||
- name: Inject Configuration
|
||||
shell: bash
|
||||
run: |
|
||||
npm pkg set build.releaseInfo.releaseNotesFile=release-notes.md
|
||||
|
||||
- name: Package and Publish
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --publish always
|
||||
npx electron-builder --publish always
|
||||
|
||||
- name: Update Release Notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
cat <<EOF > release_notes.md
|
||||
## 更新日志
|
||||
修复了一些已知问题
|
||||
|
||||
## 加入我们的群
|
||||
[点击加入 Telegram 群](https://t.me/+hn3QzNc4DbA0MzNl)
|
||||
EOF
|
||||
|
||||
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
||||
13
README.md
13
README.md
@@ -28,6 +28,16 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
> [!TIP]
|
||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||
|
||||
# 加入微信交流群
|
||||
|
||||
> 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。
|
||||
|
||||
<p align="center">
|
||||
<img src="2wm.png" alt="WeFlow 微信交流群二维码(一群)" width="220" style="margin-right: 16px;">
|
||||
<img src="3wm.png" alt="WeFlow 微信交流群二维码(二群)" width="220">
|
||||
</p>
|
||||
<p align="center">一群满了加二群</p>
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 本地实时查看聊天记录
|
||||
@@ -36,6 +46,9 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
- 导出聊天记录为 HTML 等格式
|
||||
- 本地解密与数据库管理
|
||||
|
||||
> [!NOTE]
|
||||
> ⚠️ 本工具仅适配微信 **4.0 及以上**版本,请确保你的微信版本符合要求
|
||||
|
||||
## 快速开始
|
||||
|
||||
若你只想使用成品版本,可前往 Release 下载并安装。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { join } from 'path'
|
||||
import { join, dirname } from 'path'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
@@ -13,10 +13,11 @@ import { imagePreloadService } from './services/imagePreloadService'
|
||||
import { analyticsService } from './services/analyticsService'
|
||||
import { groupAnalyticsService } from './services/groupAnalyticsService'
|
||||
import { annualReportService } from './services/annualReportService'
|
||||
import { exportService, ExportOptions } from './services/exportService'
|
||||
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||
import { KeyService } from './services/keyService'
|
||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||
import { videoService } from './services/videoService'
|
||||
import { snsService } from './services/snsService'
|
||||
|
||||
|
||||
// 配置自动更新
|
||||
@@ -28,6 +29,47 @@ const AUTO_UPDATE_ENABLED =
|
||||
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
||||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
|
||||
|
||||
// 使用白名单过滤 PATH,避免被第三方目录中的旧版 VC++ 运行库劫持。
|
||||
// 仅保留系统目录(Windows/System32/SysWOW64)和应用自身目录(可执行目录、resources)。
|
||||
function sanitizePathEnv() {
|
||||
// 开发模式不做裁剪,避免影响本地工具链
|
||||
if (process.env.VITE_DEV_SERVER_URL) return
|
||||
|
||||
const rawPath = process.env.PATH || process.env.Path
|
||||
if (!rawPath) return
|
||||
|
||||
const sep = process.platform === 'win32' ? ';' : ':'
|
||||
const parts = rawPath.split(sep).filter(Boolean)
|
||||
|
||||
const systemRoot = process.env.SystemRoot || process.env.WINDIR || ''
|
||||
const safePrefixes = [
|
||||
systemRoot,
|
||||
systemRoot ? join(systemRoot, 'System32') : '',
|
||||
systemRoot ? join(systemRoot, 'SysWOW64') : '',
|
||||
dirname(process.execPath),
|
||||
process.resourcesPath,
|
||||
join(process.resourcesPath || '', 'resources')
|
||||
].filter(Boolean)
|
||||
|
||||
const normalize = (p: string) => p.replace(/\\/g, '/').toLowerCase()
|
||||
const isSafe = (p: string) => {
|
||||
const np = normalize(p)
|
||||
return safePrefixes.some((prefix) => np.startsWith(normalize(prefix)))
|
||||
}
|
||||
|
||||
const filtered = parts.filter(isSafe)
|
||||
if (filtered.length !== parts.length) {
|
||||
const removed = parts.filter((p) => !isSafe(p))
|
||||
console.warn('[WeFlow] 使用白名单裁剪 PATH,移除目录:', removed)
|
||||
const nextPath = filtered.join(sep)
|
||||
process.env.PATH = nextPath
|
||||
process.env.Path = nextPath
|
||||
}
|
||||
}
|
||||
|
||||
// 启动时立即清理 PATH,后续创建的 worker 也能继承安全的环境
|
||||
sanitizePathEnv()
|
||||
|
||||
// 单例服务
|
||||
let configService: ConfigService | null = null
|
||||
|
||||
@@ -573,8 +615,8 @@ function registerIpcHandlers() {
|
||||
return chatService.enrichSessionsContactInfo(usernames)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => {
|
||||
return chatService.getMessages(sessionId, offset, limit)
|
||||
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
|
||||
return chatService.getMessages(sessionId, offset, limit, startTime, endTime, ascending)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
|
||||
@@ -631,6 +673,14 @@ function registerIpcHandlers() {
|
||||
return chatService.getMessageById(sessionId, localId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => {
|
||||
return chatService.execQuery(kind, path, sql)
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
|
||||
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||
})
|
||||
|
||||
// 私聊克隆
|
||||
|
||||
|
||||
@@ -646,8 +696,13 @@ function registerIpcHandlers() {
|
||||
})
|
||||
|
||||
// 导出相关
|
||||
ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||
return exportService.exportSessions(sessionIds, outputDir, options)
|
||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||
const onProgress = (progress: ExportProgress) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('export:progress', progress)
|
||||
}
|
||||
}
|
||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||
@@ -931,6 +986,17 @@ app.whenReady().then(() => {
|
||||
createOnboardingWindow()
|
||||
}
|
||||
|
||||
// 解决朋友圈图片无法加载问题(添加 Referer)
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||
{
|
||||
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
|
||||
},
|
||||
(details, callback) => {
|
||||
details.requestHeaders['Referer'] = 'https://wx.qq.com/'
|
||||
callback({ requestHeaders: details.requestHeaders })
|
||||
}
|
||||
)
|
||||
|
||||
// 启动时检测更新
|
||||
checkForUpdatesOnStartup()
|
||||
|
||||
|
||||
@@ -98,8 +98,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||
enrichSessionsContactInfo: (usernames: string[]) =>
|
||||
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number) =>
|
||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
||||
getLatestMessages: (sessionId: string, limit?: number) =>
|
||||
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
|
||||
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
||||
@@ -118,7 +118,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
|
||||
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
||||
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
||||
}
|
||||
},
|
||||
execQuery: (kind: string, path: string | null, sql: string) =>
|
||||
ipcRenderer.invoke('chat:execQuery', kind, path, sql)
|
||||
},
|
||||
|
||||
|
||||
@@ -191,7 +193,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options)
|
||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
|
||||
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||
}
|
||||
},
|
||||
|
||||
whisper: {
|
||||
@@ -203,5 +209,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
|
||||
}
|
||||
},
|
||||
|
||||
// 朋友圈
|
||||
sns: {
|
||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ const emojiDownloading: Map<string, Promise<string | null>> = new Map()
|
||||
class ChatService {
|
||||
private configService: ConfigService
|
||||
private connected = false
|
||||
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number }> = new Map()
|
||||
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean }> = new Map()
|
||||
private readonly messageBatchDefault = 50
|
||||
private avatarCache: Map<string, ContactCacheEntry>
|
||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||
@@ -326,7 +326,11 @@ class ChatService {
|
||||
// 检查缓存
|
||||
for (const username of usernames) {
|
||||
const cached = this.avatarCache.get(username)
|
||||
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) {
|
||||
// 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取
|
||||
// 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取
|
||||
const isValidAvatar = cached?.avatarUrl &&
|
||||
!cached.avatarUrl.includes('base64,ffd8') // 检测错误的 hex 格式
|
||||
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) {
|
||||
result[username] = {
|
||||
displayName: cached.displayName,
|
||||
avatarUrl: cached.avatarUrl
|
||||
@@ -343,9 +347,17 @@ class ChatService {
|
||||
wcdbService.getAvatarUrls(missing)
|
||||
])
|
||||
|
||||
// 收集没有头像 URL 的用户名
|
||||
const missingAvatars: string[] = []
|
||||
|
||||
for (const username of missing) {
|
||||
const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined
|
||||
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
|
||||
let avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
|
||||
|
||||
// 如果没有头像 URL,记录下来稍后从 head_image.db 获取
|
||||
if (!avatarUrl) {
|
||||
missingAvatars.push(username)
|
||||
}
|
||||
|
||||
const cacheEntry: ContactCacheEntry = {
|
||||
displayName: displayName || username,
|
||||
@@ -357,6 +369,23 @@ class ChatService {
|
||||
this.avatarCache.set(username, cacheEntry)
|
||||
updatedEntries[username] = cacheEntry
|
||||
}
|
||||
|
||||
// 从 head_image.db 获取缺失的头像
|
||||
if (missingAvatars.length > 0) {
|
||||
const headImageAvatars = await this.getAvatarsFromHeadImageDb(missingAvatars)
|
||||
for (const username of missingAvatars) {
|
||||
const avatarUrl = headImageAvatars[username]
|
||||
if (avatarUrl) {
|
||||
result[username].avatarUrl = avatarUrl
|
||||
const cached = this.avatarCache.get(username)
|
||||
if (cached) {
|
||||
cached.avatarUrl = avatarUrl
|
||||
updatedEntries[username] = cached
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updatedEntries).length > 0) {
|
||||
this.contactCacheService.setEntries(updatedEntries)
|
||||
}
|
||||
@@ -368,6 +397,81 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 head_image.db 批量获取头像(转换为 base64 data URL)
|
||||
*/
|
||||
private async getAvatarsFromHeadImageDb(usernames: string[]): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {}
|
||||
if (usernames.length === 0) return result
|
||||
|
||||
try {
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
const wxid = this.configService.get('myWxid')
|
||||
if (!dbPath || !wxid) return result
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return result
|
||||
|
||||
// head_image.db 可能在不同位置
|
||||
const headImageDbPaths = [
|
||||
join(accountDir, 'db_storage', 'head_image', 'head_image.db'),
|
||||
join(accountDir, 'db_storage', 'head_image.db'),
|
||||
join(accountDir, 'head_image.db')
|
||||
]
|
||||
|
||||
let headImageDbPath: string | null = null
|
||||
for (const path of headImageDbPaths) {
|
||||
if (existsSync(path)) {
|
||||
headImageDbPath = path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!headImageDbPath) return result
|
||||
|
||||
// 使用 wcdbService.execQuery 查询加密的 head_image.db
|
||||
for (const username of usernames) {
|
||||
try {
|
||||
const escapedUsername = username.replace(/'/g, "''")
|
||||
const queryResult = await wcdbService.execQuery(
|
||||
'media',
|
||||
headImageDbPath,
|
||||
`SELECT image_buffer FROM head_image WHERE username = '${escapedUsername}' LIMIT 1`
|
||||
)
|
||||
|
||||
if (queryResult.success && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const row = queryResult.rows[0] as any
|
||||
if (row?.image_buffer) {
|
||||
let base64Data: string
|
||||
if (typeof row.image_buffer === 'string') {
|
||||
// WCDB 返回的 BLOB 是十六进制字符串,需要转换为 base64
|
||||
if (row.image_buffer.toLowerCase().startsWith('ffd8')) {
|
||||
const buffer = Buffer.from(row.image_buffer, 'hex')
|
||||
base64Data = buffer.toString('base64')
|
||||
} else {
|
||||
base64Data = row.image_buffer
|
||||
}
|
||||
} else if (Buffer.isBuffer(row.image_buffer)) {
|
||||
base64Data = row.image_buffer.toString('base64')
|
||||
} else if (Array.isArray(row.image_buffer)) {
|
||||
base64Data = Buffer.from(row.image_buffer).toString('base64')
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
result[username] = `data:image/jpeg;base64,${base64Data}`
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 静默处理单个用户的错误
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('从 head_image.db 获取头像失败:', e)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 补充联系人信息(私有方法,保持向后兼容)
|
||||
*/
|
||||
@@ -396,7 +500,10 @@ class ChatService {
|
||||
async getMessages(
|
||||
sessionId: string,
|
||||
offset: number = 0,
|
||||
limit: number = 50
|
||||
limit: number = 50,
|
||||
startTime: number = 0,
|
||||
endTime: number = 0,
|
||||
ascending: boolean = false
|
||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
@@ -411,7 +518,14 @@ class ChatService {
|
||||
// 1. 没有游标状态
|
||||
// 2. offset 为 0 (重新加载会话)
|
||||
// 3. batchSize 改变
|
||||
const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize
|
||||
// 4. startTime 改变
|
||||
// 5. ascending 改变
|
||||
const needNewCursor = !state ||
|
||||
offset === 0 ||
|
||||
state.batchSize !== batchSize ||
|
||||
state.startTime !== startTime ||
|
||||
state.endTime !== endTime ||
|
||||
state.ascending !== ascending
|
||||
|
||||
if (needNewCursor) {
|
||||
// 关闭旧游标
|
||||
@@ -424,13 +538,16 @@ class ChatService {
|
||||
}
|
||||
|
||||
// 创建新游标
|
||||
const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, false, 0, 0)
|
||||
// 注意:WeFlow 数据库中的 create_time 是以秒为单位的
|
||||
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
||||
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
||||
const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
console.error('[ChatService] 打开消息游标失败:', cursorResult.error)
|
||||
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
||||
}
|
||||
|
||||
state = { cursor: cursorResult.cursor, fetched: 0, batchSize }
|
||||
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
|
||||
this.messageCursors.set(sessionId, state)
|
||||
|
||||
// 如果需要跳过消息(offset > 0),逐批获取但不返回
|
||||
@@ -1706,7 +1823,9 @@ class ChatService {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) return null
|
||||
const cached = this.avatarCache.get(username)
|
||||
if (cached && cached.avatarUrl && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) {
|
||||
// 检查缓存是否有效,且头像不是错误的 hex 格式
|
||||
const isValidAvatar = cached?.avatarUrl && !cached.avatarUrl.includes('base64,ffd8')
|
||||
if (cached && isValidAvatar && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) {
|
||||
return { avatarUrl: cached.avatarUrl, displayName: cached.displayName }
|
||||
}
|
||||
|
||||
@@ -2979,10 +3098,26 @@ class ChatService {
|
||||
|
||||
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||
const normalized = dbPath.replace(/[\\\\/]+$/, '')
|
||||
|
||||
// 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件)
|
||||
// 则向上回溯到账号目录
|
||||
if (basename(normalized).toLowerCase() === 'db_storage') {
|
||||
return dirname(normalized)
|
||||
}
|
||||
const dir = dirname(normalized)
|
||||
if (basename(normalized).toLowerCase() === 'db_storage') return dir
|
||||
if (basename(dir).toLowerCase() === 'db_storage') return dirname(dir)
|
||||
return dir // 兜底
|
||||
if (basename(dir).toLowerCase() === 'db_storage') {
|
||||
return dirname(dir)
|
||||
}
|
||||
|
||||
// 否则,dbPath 应该是数据库根目录(如 xwechat_files)
|
||||
// 账号目录应该是 {dbPath}/{wxid}
|
||||
const accountDirWithWxid = join(normalized, wxid)
|
||||
if (existsSync(accountDirWithWxid)) {
|
||||
return accountDirWithWxid
|
||||
}
|
||||
|
||||
// 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录)
|
||||
return normalized
|
||||
}
|
||||
|
||||
private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> {
|
||||
@@ -3249,6 +3384,19 @@ class ChatService {
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
return wcdbService.execQuery(kind, path, sql)
|
||||
} catch (e) {
|
||||
console.error('ChatService: 执行自定义查询失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const chatService = new ChatService()
|
||||
|
||||
@@ -34,6 +34,14 @@ export class ContactCacheService {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
// 清除无效的头像数据(hex 格式而非正确的 base64)
|
||||
for (const key of Object.keys(parsed)) {
|
||||
const entry = parsed[key]
|
||||
if (entry?.avatarUrl && entry.avatarUrl.includes('base64,ffd8')) {
|
||||
// 这是错误的 hex 格式,清除它
|
||||
entry.avatarUrl = undefined
|
||||
}
|
||||
}
|
||||
this.cache = parsed
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
301
electron/services/exportHtml.css
Normal file
301
electron/services/exportHtml.css
Normal file
@@ -0,0 +1,301 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7fb;
|
||||
--card: #ffffff;
|
||||
--text: #1f2a37;
|
||||
--muted: #6b7280;
|
||||
--accent: #4f46e5;
|
||||
--sent: #dbeafe;
|
||||
--received: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
--radius: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1080px;
|
||||
margin: 32px auto 60px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.control label {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.control input,
|
||||
.control select,
|
||||
.control button {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.control button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.control button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message.sent .message-row {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: #eef2ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: min(70%, 720px);
|
||||
background: var(--received);
|
||||
border-radius: 18px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.message.sent .bubble {
|
||||
background: var(--sent);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.inline-emoji {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
vertical-align: text-bottom;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.message-media {
|
||||
border-radius: 14px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.previewable {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.message-media.image,
|
||||
.message-media.emoji {
|
||||
max-height: 260px;
|
||||
object-fit: contain;
|
||||
background: #f1f5f9;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.message-media.emoji {
|
||||
max-height: 160px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.message-media.video {
|
||||
max-height: 360px;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.message-media.audio {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.image-preview.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
max-width: min(90vw, 1200px);
|
||||
max-height: 90vh;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
|
||||
background: #0f172a;
|
||||
transition: transform 0.1s ease;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
body[data-theme="cloud-dancer"] {
|
||||
--accent: #6b8cff;
|
||||
--sent: #e0e7ff;
|
||||
--received: #ffffff;
|
||||
--border: #d8e0f7;
|
||||
--bg: #f6f7fb;
|
||||
}
|
||||
|
||||
body[data-theme="corundum-blue"] {
|
||||
--accent: #2563eb;
|
||||
--sent: #dbeafe;
|
||||
--received: #ffffff;
|
||||
--border: #c7d2fe;
|
||||
--bg: #eef2ff;
|
||||
}
|
||||
|
||||
body[data-theme="kiwi-green"] {
|
||||
--accent: #16a34a;
|
||||
--sent: #dcfce7;
|
||||
--received: #ffffff;
|
||||
--border: #bbf7d0;
|
||||
--bg: #f0fdf4;
|
||||
}
|
||||
|
||||
body[data-theme="spicy-red"] {
|
||||
--accent: #e11d48;
|
||||
--sent: #ffe4e6;
|
||||
--received: #ffffff;
|
||||
--border: #fecdd3;
|
||||
--bg: #fff1f2;
|
||||
}
|
||||
|
||||
body[data-theme="teal-water"] {
|
||||
--accent: #0f766e;
|
||||
--sent: #ccfbf1;
|
||||
--received: #ffffff;
|
||||
--border: #99f6e4;
|
||||
--bg: #f0fdfa;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 40px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@ export class KeyService {
|
||||
private ReadProcessMemory: any = null
|
||||
private MEMORY_BASIC_INFORMATION: any = null
|
||||
private TerminateProcess: any = null
|
||||
private QueryFullProcessImageNameW: any = null
|
||||
|
||||
// User32
|
||||
private EnumWindows: any = null
|
||||
@@ -194,6 +195,7 @@ export class KeyService {
|
||||
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
|
||||
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
|
||||
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
|
||||
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['HANDLE', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
|
||||
this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64'])
|
||||
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
|
||||
|
||||
@@ -310,7 +312,46 @@ export class KeyService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getProcessExecutablePath(pid: number): Promise<string | null> {
|
||||
if (!this.ensureKernel32()) return null
|
||||
// 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION
|
||||
const hProcess = this.OpenProcess(0x1000, false, pid)
|
||||
if (!hProcess) return null
|
||||
|
||||
try {
|
||||
const sizeBuf = Buffer.alloc(4)
|
||||
sizeBuf.writeUInt32LE(1024, 0)
|
||||
const pathBuf = Buffer.alloc(1024 * 2)
|
||||
|
||||
const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf)
|
||||
if (ret) {
|
||||
const len = sizeBuf.readUInt32LE(0)
|
||||
return pathBuf.toString('ucs2', 0, len * 2)
|
||||
}
|
||||
return null
|
||||
} catch (e) {
|
||||
console.error('获取进程路径失败:', e)
|
||||
return null
|
||||
} finally {
|
||||
this.CloseHandle(hProcess)
|
||||
}
|
||||
}
|
||||
|
||||
private async findWeChatInstallPath(): Promise<string | null> {
|
||||
// 0. 优先尝试获取正在运行的微信进程路径
|
||||
try {
|
||||
const pid = await this.findWeChatPid()
|
||||
if (pid) {
|
||||
const runPath = await this.getProcessExecutablePath(pid)
|
||||
if (runPath && existsSync(runPath)) {
|
||||
console.log('发现正在运行的微信进程,使用路径:', runPath)
|
||||
return runPath
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('尝试获取运行中微信路径失败:', e)
|
||||
}
|
||||
|
||||
// 1. Registry - Uninstall Keys
|
||||
const uninstallKeys = [
|
||||
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||
@@ -588,6 +629,11 @@ export class KeyService {
|
||||
if (!ok) {
|
||||
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
||||
if (error) {
|
||||
// 检测权限不足错误 (NTSTATUS 0xC0000022 = STATUS_ACCESS_DENIED)
|
||||
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
|
||||
const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
|
||||
return { success: false, error: friendlyError }
|
||||
}
|
||||
return { success: false, error }
|
||||
}
|
||||
const statusBuffer = Buffer.alloc(256)
|
||||
|
||||
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 { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
|
||||
// DLL 初始化错误信息,用于帮助用户诊断问题
|
||||
let lastDllInitError: string | null = null
|
||||
export function getLastDllInitError(): string | null {
|
||||
return lastDllInitError
|
||||
}
|
||||
|
||||
export class WcdbCore {
|
||||
private resourcesPath: string | null = null
|
||||
private userDataPath: string | null = null
|
||||
@@ -49,6 +55,7 @@ export class WcdbCore {
|
||||
private wcdbGetEmoticonCdnUrl: any = null
|
||||
private wcdbGetDbStatus: any = null
|
||||
private wcdbGetVoiceData: any = null
|
||||
private wcdbGetSnsTimeline: any = null
|
||||
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||
private logTimer: NodeJS.Timeout | null = null
|
||||
@@ -110,7 +117,8 @@ export class WcdbCore {
|
||||
private writeLog(message: string, force = false): void {
|
||||
if (!force && !this.isLogEnabled()) return
|
||||
const line = `[${new Date().toISOString()}] ${message}`
|
||||
// 移除控制台日志,只写入文件
|
||||
// 同时输出到控制台和文件
|
||||
console.log('[WCDB]', message)
|
||||
try {
|
||||
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
||||
const dir = join(base, 'logs')
|
||||
@@ -208,6 +216,31 @@ export class WcdbCore {
|
||||
return false
|
||||
}
|
||||
|
||||
// 关键修复:显式预加载依赖库 WCDB.dll 和 SDL2.dll
|
||||
// Windows 加载器默认不会查找子目录中的依赖,必须先将其加载到内存
|
||||
// 这可以解决部分用户因为 VC++ 运行时或 DLL 依赖问题导致的闪退
|
||||
const dllDir = dirname(dllPath)
|
||||
const wcdbCorePath = join(dllDir, 'WCDB.dll')
|
||||
if (existsSync(wcdbCorePath)) {
|
||||
try {
|
||||
this.koffi.load(wcdbCorePath)
|
||||
this.writeLog('预加载 WCDB.dll 成功')
|
||||
} catch (e) {
|
||||
console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e)
|
||||
this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`)
|
||||
}
|
||||
}
|
||||
const sdl2Path = join(dllDir, 'SDL2.dll')
|
||||
if (existsSync(sdl2Path)) {
|
||||
try {
|
||||
this.koffi.load(sdl2Path)
|
||||
this.writeLog('预加载 SDL2.dll 成功')
|
||||
} catch (e) {
|
||||
console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e)
|
||||
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.lib = this.koffi.load(dllPath)
|
||||
|
||||
// 定义类型
|
||||
@@ -354,6 +387,13 @@ export class WcdbCore {
|
||||
this.wcdbGetVoiceData = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json)
|
||||
try {
|
||||
this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetSnsTimeline = null
|
||||
}
|
||||
|
||||
// 初始化
|
||||
const initResult = this.wcdbInit()
|
||||
if (initResult !== 0) {
|
||||
@@ -362,9 +402,20 @@ export class WcdbCore {
|
||||
}
|
||||
|
||||
this.initialized = true
|
||||
lastDllInitError = null
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('WCDB 初始化异常:', e)
|
||||
const errorMsg = e instanceof Error ? e.message : String(e)
|
||||
console.error('WCDB 初始化异常:', errorMsg)
|
||||
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
|
||||
lastDllInitError = errorMsg
|
||||
// 检查是否是常见的 VC++ 运行时缺失错误
|
||||
if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') ||
|
||||
errorMsg.includes('The specified module could not be found')) {
|
||||
lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。'
|
||||
} else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) {
|
||||
lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。'
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -382,10 +433,18 @@ export class WcdbCore {
|
||||
return { success: true, sessionCount: 0 }
|
||||
}
|
||||
|
||||
// 记录当前活动连接,用于在测试结束后恢复(避免影响聊天页等正在使用的连接)
|
||||
const hadActiveConnection = this.handle !== null
|
||||
const prevPath = this.currentPath
|
||||
const prevKey = this.currentKey
|
||||
const prevWxid = this.currentWxid
|
||||
|
||||
if (!this.initialized) {
|
||||
const initOk = await this.initialize()
|
||||
if (!initOk) {
|
||||
return { success: false, error: 'WCDB 初始化失败' }
|
||||
// 返回更详细的错误信息,帮助用户诊断问题
|
||||
const detailedError = lastDllInitError || 'WCDB 初始化失败'
|
||||
return { success: false, error: detailedError }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,8 +483,8 @@ export class WcdbCore {
|
||||
return { success: false, error: '无效的数据库句柄' }
|
||||
}
|
||||
|
||||
// 测试成功,使用 shutdown 清理所有资源(包括测试句柄)
|
||||
// 这会中断当前活动连接,但 testConnection 本应该是独立测试
|
||||
// 测试成功:使用 shutdown 清理资源(包括测试句柄)
|
||||
// 注意:shutdown 会断开当前活动连接,因此需要在测试后尝试恢复之前的连接
|
||||
try {
|
||||
this.wcdbShutdown()
|
||||
this.handle = null
|
||||
@@ -437,6 +496,15 @@ export class WcdbCore {
|
||||
console.error('关闭测试数据库时出错:', closeErr)
|
||||
}
|
||||
|
||||
// 恢复测试前的连接(如果之前有活动连接)
|
||||
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
|
||||
try {
|
||||
await this.open(prevPath, prevKey, prevWxid)
|
||||
} catch {
|
||||
// 恢复失败则保持断开,由调用方处理
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, sessionCount: 0 }
|
||||
} catch (e) {
|
||||
console.error('测试连接异常:', e)
|
||||
@@ -1329,4 +1397,32 @@ export class WcdbCore {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : ''
|
||||
const result = this.wcdbGetSnsTimeline(
|
||||
this.handle,
|
||||
limit,
|
||||
offset,
|
||||
usernamesJson,
|
||||
keyword || '',
|
||||
startTime || 0,
|
||||
endTime || 0,
|
||||
outPtr
|
||||
)
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
return { success: false, error: `获取朋友圈失败: ${result}` }
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析朋友圈数据失败' }
|
||||
const timeline = JSON.parse(jsonStr)
|
||||
return { success: true, timeline }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,12 +58,24 @@ export class WcdbService {
|
||||
})
|
||||
|
||||
this.worker.on('error', (err) => {
|
||||
// Worker error
|
||||
// Worker 发生错误,需要 reject 所有 pending promises
|
||||
console.error('WCDB Worker 错误:', err)
|
||||
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||
for (const [id, p] of this.pending) {
|
||||
p.reject(new Error(`Worker 错误: ${errorMsg}`))
|
||||
}
|
||||
this.pending.clear()
|
||||
})
|
||||
|
||||
this.worker.on('exit', (code) => {
|
||||
// Worker 退出,需要 reject 所有 pending promises
|
||||
if (code !== 0) {
|
||||
// Worker exited with error
|
||||
console.error('WCDB Worker 异常退出,退出码:', code)
|
||||
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
|
||||
for (const [id, p] of this.pending) {
|
||||
p.reject(new Error(errorMsg))
|
||||
}
|
||||
this.pending.clear()
|
||||
}
|
||||
this.worker = null
|
||||
})
|
||||
@@ -350,6 +362,13 @@ export class WcdbService {
|
||||
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取朋友圈
|
||||
*/
|
||||
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const wcdbService = new WcdbService()
|
||||
|
||||
@@ -116,6 +116,9 @@ if (parentPort) {
|
||||
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
||||
}
|
||||
break
|
||||
case 'getSnsTimeline':
|
||||
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||
break
|
||||
default:
|
||||
result = { success: false, error: `Unknown method: ${type}` }
|
||||
}
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "weflow",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
@@ -8537,12 +8537,6 @@
|
||||
"sherpa-onnx-win-x64": "^1.12.23"
|
||||
}
|
||||
},
|
||||
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": {
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-linux-arm64": {
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/sherpa-onnx-win-ia32": {
|
||||
"version": "1.12.23",
|
||||
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",
|
||||
|
||||
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.2",
|
||||
"description": "WeFlow",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": "cc",
|
||||
@@ -105,6 +105,24 @@
|
||||
"asarUnpack": [
|
||||
"node_modules/silk-wasm/**/*",
|
||||
"node_modules/sherpa-onnx-node/**/*"
|
||||
],
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/msvcp140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/msvcp140_1.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140_1.dll",
|
||||
"to": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
resources/msvcp140.dll
Normal file
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 ExportPage from './pages/ExportPage'
|
||||
import VideoWindow from './pages/VideoWindow'
|
||||
import SnsPage from './pages/SnsPage'
|
||||
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||
@@ -202,10 +203,22 @@ function App() {
|
||||
}
|
||||
} else {
|
||||
console.log('自动连接失败:', result.error)
|
||||
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
|
||||
// 其他错误可能需要重新配置
|
||||
const errorMsg = result.error || ''
|
||||
if (errorMsg.includes('Visual C++') ||
|
||||
errorMsg.includes('DLL') ||
|
||||
errorMsg.includes('Worker') ||
|
||||
errorMsg.includes('126') ||
|
||||
errorMsg.includes('模块')) {
|
||||
console.warn('检测到可能的运行时依赖问题:', errorMsg)
|
||||
// 不清除配置,让用户安装 VC++ 后重试
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('自动连接出错:', e)
|
||||
// 捕获异常但不清除配置,防止循环重新引导
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +337,7 @@ function App() {
|
||||
<Route path="/data-management" element={<DataManagementPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/export" element={<ExportPage />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
</Routes>
|
||||
</RouteGuard>
|
||||
</main>
|
||||
|
||||
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 { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } from 'lucide-react'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture } from 'lucide-react'
|
||||
import './Sidebar.scss'
|
||||
|
||||
function Sidebar() {
|
||||
@@ -34,6 +34,16 @@ function Sidebar() {
|
||||
<span className="nav-label">聊天</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 朋友圈 */}
|
||||
<NavLink
|
||||
to="/sns"
|
||||
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
|
||||
title={collapsed ? '朋友圈' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Aperture size={20} /></span>
|
||||
<span className="nav-label">朋友圈</span>
|
||||
</NavLink>
|
||||
|
||||
|
||||
|
||||
{/* 私聊分析 */}
|
||||
|
||||
@@ -489,8 +489,21 @@
|
||||
}
|
||||
|
||||
.load-more-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
|
||||
&.later {
|
||||
padding: 24px 0 12px;
|
||||
}
|
||||
|
||||
svg {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-chat {
|
||||
@@ -1660,7 +1673,7 @@
|
||||
max-width: 100%;
|
||||
min-width: 0; // 允许收缩
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
|
||||
// 让气泡宽度由内容决定,而不是被父容器撑开
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getEmojiPath } from 'wechat-emojis'
|
||||
import { ImagePreview } from '../components/ImagePreview'
|
||||
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||
import * as configService from '../services/config'
|
||||
import './ChatPage.scss'
|
||||
|
||||
@@ -132,15 +133,25 @@ function ChatPage(_props: ChatPageProps) {
|
||||
setLoadingMessages,
|
||||
setLoadingMore,
|
||||
setHasMoreMessages,
|
||||
hasMoreLater,
|
||||
setHasMoreLater,
|
||||
setSearchKeyword
|
||||
} = useChatStore()
|
||||
|
||||
const messageListRef = useRef<HTMLDivElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const getMessageKey = useCallback((msg: Message): string => {
|
||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
||||
}, [])
|
||||
const initialRevealTimerRef = useRef<number | null>(null)
|
||||
const sessionListRef = useRef<HTMLDivElement>(null)
|
||||
const [currentOffset, setCurrentOffset] = useState(0)
|
||||
const [jumpStartTime, setJumpStartTime] = useState(0)
|
||||
const [jumpEndTime, setJumpEndTime] = useState(0)
|
||||
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
||||
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
|
||||
const [myWxid, setMyWxid] = useState<string | undefined>(undefined)
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
@@ -477,6 +488,9 @@ function ChatPage(_props: ChatPageProps) {
|
||||
|
||||
// 刷新会话列表
|
||||
const handleRefresh = async () => {
|
||||
setJumpStartTime(0)
|
||||
setJumpEndTime(0)
|
||||
setHasMoreLater(false)
|
||||
await loadSessions({ silent: true })
|
||||
}
|
||||
|
||||
@@ -484,6 +498,9 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const [isRefreshingMessages, setIsRefreshingMessages] = useState(false)
|
||||
const handleRefreshMessages = async () => {
|
||||
if (!currentSessionId || isRefreshingMessages) return
|
||||
setJumpStartTime(0)
|
||||
setJumpEndTime(0)
|
||||
setHasMoreLater(false)
|
||||
setIsRefreshingMessages(true)
|
||||
try {
|
||||
// 获取最新消息并增量添加
|
||||
@@ -518,7 +535,7 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
|
||||
// 加载消息
|
||||
const loadMessages = async (sessionId: string, offset = 0) => {
|
||||
const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => {
|
||||
const listEl = messageListRef.current
|
||||
const session = sessionMapRef.current.get(sessionId)
|
||||
const unreadCount = session?.unreadCount ?? 0
|
||||
@@ -535,7 +552,7 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit)
|
||||
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime)
|
||||
if (result.success && result.messages) {
|
||||
if (offset === 0) {
|
||||
setMessages(result.messages)
|
||||
@@ -601,6 +618,14 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
}
|
||||
setHasMoreMessages(result.hasMore ?? false)
|
||||
// 如果是按 endTime 跳转加载,且结果刚好满批,可能后面(更晚)还有消息
|
||||
if (offset === 0) {
|
||||
if (endTime > 0) {
|
||||
setHasMoreLater(true)
|
||||
} else {
|
||||
setHasMoreLater(false)
|
||||
}
|
||||
}
|
||||
setCurrentOffset(offset + result.messages.length)
|
||||
} else if (!result.success) {
|
||||
setConnectionError(result.error || '加载消息失败')
|
||||
@@ -616,12 +641,41 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更晚的消息
|
||||
const loadLaterMessages = useCallback(async () => {
|
||||
if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return
|
||||
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const lastMsg = messages[messages.length - 1]
|
||||
// 从最后一条消息的时间开始往后找
|
||||
const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true)
|
||||
|
||||
if (result.success && result.messages) {
|
||||
// 过滤掉已经在列表中的重复消息
|
||||
const existingKeys = messageKeySetRef.current
|
||||
const newMsgs = result.messages.filter(m => !existingKeys.has(getMessageKey(m)))
|
||||
|
||||
if (newMsgs.length > 0) {
|
||||
appendMessages(newMsgs, false)
|
||||
}
|
||||
setHasMoreLater(result.hasMore ?? false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载后续消息失败:', e)
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [currentSessionId, isLoadingMore, isLoadingMessages, messages, getMessageKey, appendMessages, setHasMoreLater, setLoadingMore])
|
||||
|
||||
// 选择会话
|
||||
const handleSelectSession = (session: ChatSession) => {
|
||||
if (session.username === currentSessionId) return
|
||||
setCurrentSession(session.username)
|
||||
setCurrentOffset(0)
|
||||
loadMessages(session.username, 0)
|
||||
setJumpStartTime(0)
|
||||
setJumpEndTime(0)
|
||||
loadMessages(session.username, 0, 0, 0)
|
||||
// 重置详情面板
|
||||
setSessionDetail(null)
|
||||
if (showDetailPanel) {
|
||||
@@ -678,16 +732,21 @@ function ChatPage(_props: ChatPageProps) {
|
||||
if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) {
|
||||
const threshold = clientHeight * 0.3
|
||||
if (scrollTop < threshold) {
|
||||
loadMessages(currentSessionId, currentOffset)
|
||||
loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载更晚的消息
|
||||
if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) {
|
||||
const threshold = clientHeight * 0.3
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
if (distanceFromBottom < threshold) {
|
||||
loadLaterMessages()
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset, loadMessages])
|
||||
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages])
|
||||
|
||||
const getMessageKey = useCallback((msg: Message): string => {
|
||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
||||
}, [])
|
||||
|
||||
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
|
||||
return (
|
||||
@@ -1102,6 +1161,25 @@ function ChatPage(_props: ChatPageProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="icon-btn jump-to-time-btn"
|
||||
onClick={() => setShowJumpDialog(true)}
|
||||
title="跳转到指定时间"
|
||||
>
|
||||
<Calendar size={18} />
|
||||
</button>
|
||||
<JumpToDateDialog
|
||||
isOpen={showJumpDialog}
|
||||
onClose={() => setShowJumpDialog(false)}
|
||||
onSelect={(date) => {
|
||||
if (!currentSessionId) return
|
||||
const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000)
|
||||
setCurrentOffset(0)
|
||||
setJumpStartTime(0)
|
||||
setJumpEndTime(end)
|
||||
loadMessages(currentSessionId, 0, 0, end)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn refresh-messages-btn"
|
||||
onClick={handleRefreshMessages}
|
||||
@@ -1177,6 +1255,19 @@ function ChatPage(_props: ChatPageProps) {
|
||||
)
|
||||
})}
|
||||
|
||||
{hasMoreLater && (
|
||||
<div className={`load-more-trigger later ${isLoadingMore ? 'loading' : ''}`}>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 size={14} />
|
||||
<span>正在加载后续消息...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>向下滚动查看更新消息</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 回到底部按钮 */}
|
||||
<div className={`scroll-to-bottom ${showScrollToBottom ? 'show' : ''}`} onClick={scrollToBottom}>
|
||||
<ChevronDown size={16} />
|
||||
|
||||
@@ -602,6 +602,87 @@
|
||||
}
|
||||
}
|
||||
|
||||
.export-layout-modal {
|
||||
background: var(--card-bg);
|
||||
padding: 28px 32px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
text-align: center;
|
||||
width: min(520px, 90vw);
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.layout-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.layout-options {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.layout-option-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.layout-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.layout-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.layout-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.layout-cancel-btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export-result-modal {
|
||||
background: var(--card-bg);
|
||||
padding: 32px 40px;
|
||||
@@ -1056,4 +1137,4 @@
|
||||
input:checked + .slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ interface ExportOptions {
|
||||
exportVoices: boolean
|
||||
exportEmojis: boolean
|
||||
exportVoiceAsText: boolean
|
||||
excelCompactColumns: boolean
|
||||
txtColumns: string[]
|
||||
}
|
||||
|
||||
interface ExportResult {
|
||||
@@ -30,7 +32,10 @@ interface ExportResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
type SessionLayout = 'shared' | 'per-session'
|
||||
|
||||
function ExportPage() {
|
||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([])
|
||||
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
|
||||
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
||||
@@ -43,22 +48,44 @@ function ExportPage() {
|
||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||
const [calendarDate, setCalendarDate] = useState(new Date())
|
||||
const [selectingStart, setSelectingStart] = useState(true)
|
||||
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
|
||||
|
||||
const [options, setOptions] = useState<ExportOptions>({
|
||||
format: 'chatlab',
|
||||
format: 'excel',
|
||||
dateRange: {
|
||||
start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
start: new Date(new Date().setHours(0, 0, 0, 0)),
|
||||
end: new Date()
|
||||
},
|
||||
useAllTime: true,
|
||||
useAllTime: false,
|
||||
exportAvatars: true,
|
||||
exportMedia: false,
|
||||
exportImages: true,
|
||||
exportVoices: true,
|
||||
exportEmojis: true,
|
||||
exportVoiceAsText: false
|
||||
exportVoiceAsText: true,
|
||||
excelCompactColumns: true,
|
||||
txtColumns: defaultTxtColumns
|
||||
})
|
||||
|
||||
const buildDateRangeFromPreset = (preset: string) => {
|
||||
const now = new Date()
|
||||
if (preset === 'all') {
|
||||
return { useAllTime: true, dateRange: { start: now, end: now } }
|
||||
}
|
||||
let rangeMs = 0
|
||||
if (preset === '7d') rangeMs = 7 * 24 * 60 * 60 * 1000
|
||||
if (preset === '30d') rangeMs = 30 * 24 * 60 * 60 * 1000
|
||||
if (preset === '90d') rangeMs = 90 * 24 * 60 * 60 * 1000
|
||||
if (preset === 'today' || rangeMs === 0) {
|
||||
const start = new Date(now)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return { useAllTime: false, dateRange: { start, end: now } }
|
||||
}
|
||||
const start = new Date(now.getTime() - rangeMs)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return { useAllTime: false, dateRange: { start, end: now } }
|
||||
}
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
@@ -94,10 +121,61 @@ function ExportPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadExportDefaults = useCallback(async () => {
|
||||
try {
|
||||
const [
|
||||
savedFormat,
|
||||
savedRange,
|
||||
savedMedia,
|
||||
savedVoiceAsText,
|
||||
savedExcelCompactColumns,
|
||||
savedTxtColumns
|
||||
] = await Promise.all([
|
||||
configService.getExportDefaultFormat(),
|
||||
configService.getExportDefaultDateRange(),
|
||||
configService.getExportDefaultMedia(),
|
||||
configService.getExportDefaultVoiceAsText(),
|
||||
configService.getExportDefaultExcelCompactColumns(),
|
||||
configService.getExportDefaultTxtColumns()
|
||||
])
|
||||
|
||||
const preset = savedRange || 'today'
|
||||
const rangeDefaults = buildDateRangeFromPreset(preset)
|
||||
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
|
||||
|
||||
setOptions((prev) => ({
|
||||
...prev,
|
||||
format: (savedFormat as ExportOptions['format']) || 'excel',
|
||||
useAllTime: rangeDefaults.useAllTime,
|
||||
dateRange: rangeDefaults.dateRange,
|
||||
exportMedia: savedMedia ?? false,
|
||||
exportVoiceAsText: savedVoiceAsText ?? true,
|
||||
excelCompactColumns: savedExcelCompactColumns ?? true,
|
||||
txtColumns
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('加载导出默认设置失败:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
loadExportPath()
|
||||
}, [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(() => {
|
||||
if (!searchKeyword.trim()) {
|
||||
@@ -138,13 +216,30 @@ function ExportPage() {
|
||||
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||
}
|
||||
|
||||
const handleFormatChange = (format: ExportOptions['format']) => {
|
||||
setOptions((prev) => {
|
||||
const next = { ...prev, format }
|
||||
if (format === 'html') {
|
||||
return {
|
||||
...next,
|
||||
exportMedia: true,
|
||||
exportImages: true,
|
||||
exportVoices: true,
|
||||
exportEmojis: true,
|
||||
exportVoiceAsText: true
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const openExportFolder = async () => {
|
||||
if (exportFolder) {
|
||||
await window.electronAPI.shell.openPath(exportFolder)
|
||||
}
|
||||
}
|
||||
|
||||
const startExport = async () => {
|
||||
const runExport = async (sessionLayout: SessionLayout) => {
|
||||
if (selectedSessions.size === 0 || !exportFolder) return
|
||||
|
||||
setIsExporting(true)
|
||||
@@ -160,15 +255,18 @@ function ExportPage() {
|
||||
exportImages: options.exportMedia && options.exportImages,
|
||||
exportVoices: options.exportMedia && options.exportVoices,
|
||||
exportEmojis: options.exportMedia && options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia
|
||||
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
|
||||
excelCompactColumns: options.excelCompactColumns,
|
||||
txtColumns: options.txtColumns,
|
||||
sessionLayout,
|
||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||
// 将结束日期设置为当天的 23:59:59,以包含当天的所有消息
|
||||
// 将结束日期设置为当天的 23:59:59,确保包含当天的所有记录
|
||||
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
|
||||
} : null
|
||||
}
|
||||
|
||||
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel') {
|
||||
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html') {
|
||||
const result = await window.electronAPI.export.exportSessions(
|
||||
sessionList,
|
||||
exportFolder,
|
||||
@@ -176,16 +274,28 @@ function ExportPage() {
|
||||
)
|
||||
setExportResult(result)
|
||||
} else {
|
||||
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` })
|
||||
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('导出失败:', e)
|
||||
console.error('导出过程中发生异常:', e)
|
||||
setExportResult({ success: false, error: String(e) })
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startExport = () => {
|
||||
if (selectedSessions.size === 0 || !exportFolder) return
|
||||
|
||||
if (options.exportMedia && selectedSessions.size > 1) {
|
||||
setShowMediaLayoutPrompt(true)
|
||||
return
|
||||
}
|
||||
|
||||
const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared'
|
||||
runExport(layout)
|
||||
}
|
||||
|
||||
const getDaysInMonth = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth()
|
||||
@@ -362,7 +472,7 @@ function ExportPage() {
|
||||
<div
|
||||
key={fmt.value}
|
||||
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
|
||||
onClick={() => setOptions({ ...options, format: fmt.value as any })}
|
||||
onClick={() => handleFormatChange(fmt.value as ExportOptions['format'])}
|
||||
>
|
||||
<fmt.icon size={24} />
|
||||
<span className="format-label">{fmt.label}</span>
|
||||
@@ -544,6 +654,43 @@ function ExportPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 媒体导出布局选择弹窗 */}
|
||||
{showMediaLayoutPrompt && (
|
||||
<div className="export-overlay" onClick={() => setShowMediaLayoutPrompt(false)}>
|
||||
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>导出文件夹布局</h3>
|
||||
<p className="layout-subtitle">检测到同时导出多个会话并包含媒体文件,请选择存放方式:</p>
|
||||
<div className="layout-options">
|
||||
<button
|
||||
className="layout-option-btn primary"
|
||||
onClick={() => {
|
||||
setShowMediaLayoutPrompt(false)
|
||||
runExport('shared')
|
||||
}}
|
||||
>
|
||||
<span className="layout-title">所有会话在同一文件夹</span>
|
||||
<span className="layout-desc">媒体会按会话名归档到 media 子目录</span>
|
||||
</button>
|
||||
<button
|
||||
className="layout-option-btn"
|
||||
onClick={() => {
|
||||
setShowMediaLayoutPrompt(false)
|
||||
runExport('per-session')
|
||||
}}
|
||||
>
|
||||
<span className="layout-title">每个会话一个文件夹</span>
|
||||
<span className="layout-desc">每个会话单独包含导出文件和媒体</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="layout-actions">
|
||||
<button className="layout-cancel-btn" onClick={() => setShowMediaLayoutPrompt(false)}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导出进度弹窗 */}
|
||||
{isExporting && (
|
||||
<div className="export-overlay">
|
||||
|
||||
@@ -221,6 +221,100 @@
|
||||
}
|
||||
}
|
||||
|
||||
.select-field {
|
||||
position: relative;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.select-trigger {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&.open {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.select-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.select-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 20;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.select-option {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.select-option.active .option-desc {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.input-with-toggle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -1096,13 +1190,15 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-secondary);
|
||||
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.wxid-option {
|
||||
@@ -1216,4 +1312,4 @@
|
||||
border-top: 1px solid var(--border-primary);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useThemeStore, themes } from '../stores/themeStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
} from 'lucide-react'
|
||||
import './SettingsPage.scss'
|
||||
|
||||
type SettingsTab = 'appearance' | 'database' | 'whisper' | 'cache' | 'about'
|
||||
type SettingsTab = 'appearance' | 'database' | 'whisper' | 'export' | 'cache' | 'about'
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
{ id: 'database', label: '数据库连接', icon: Database },
|
||||
{ id: 'whisper', label: '语音识别模型', icon: Mic },
|
||||
{ id: 'export', label: '导出', icon: Download },
|
||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||
{ id: 'about', label: '关于', icon: Info }
|
||||
]
|
||||
@@ -40,6 +41,12 @@ function SettingsPage() {
|
||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
||||
const wxidDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
|
||||
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
|
||||
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [cachePath, setCachePath] = useState('')
|
||||
const [logEnabled, setLogEnabled] = useState(false)
|
||||
const [whisperModelName, setWhisperModelName] = useState('base')
|
||||
@@ -49,6 +56,11 @@ function SettingsPage() {
|
||||
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
||||
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
|
||||
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true)
|
||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||
|
||||
const [isLoading, setIsLoadingState] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
@@ -79,13 +91,23 @@ function SettingsPage() {
|
||||
// 点击外部关闭下拉框
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(e.target as Node)) {
|
||||
const target = e.target as Node
|
||||
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(target)) {
|
||||
setShowWxidSelect(false)
|
||||
}
|
||||
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
|
||||
setShowExportFormatSelect(false)
|
||||
}
|
||||
if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) {
|
||||
setShowExportDateRangeSelect(false)
|
||||
}
|
||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showWxidSelect])
|
||||
}, [showWxidSelect, showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
|
||||
|
||||
useEffect(() => {
|
||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
|
||||
@@ -114,6 +136,11 @@ function SettingsPage() {
|
||||
const savedWhisperModelDir = await configService.getWhisperModelDir()
|
||||
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
|
||||
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
|
||||
const savedExportDefaultFormat = await configService.getExportDefaultFormat()
|
||||
const savedExportDefaultDateRange = await configService.getExportDefaultDateRange()
|
||||
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
|
||||
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
|
||||
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
||||
|
||||
if (savedKey) setDecryptKey(savedKey)
|
||||
if (savedPath) setDbPath(savedPath)
|
||||
@@ -126,6 +153,11 @@ function SettingsPage() {
|
||||
setLogEnabled(savedLogEnabled)
|
||||
setAutoTranscribeVoice(savedAutoTranscribe)
|
||||
setTranscribeLanguages(savedTranscribeLanguages)
|
||||
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
|
||||
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
|
||||
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
||||
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true)
|
||||
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
||||
|
||||
// 如果语言列表为空,保存默认值
|
||||
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||
@@ -134,6 +166,7 @@ function SettingsPage() {
|
||||
await configService.setTranscribeLanguages(defaultLanguages)
|
||||
}
|
||||
|
||||
|
||||
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||
} catch (e) {
|
||||
console.error('加载配置失败:', e)
|
||||
@@ -468,15 +501,8 @@ function SettingsPage() {
|
||||
await configService.setTranscribeLanguages(transcribeLanguages)
|
||||
await configService.setOnboardingDone(true)
|
||||
|
||||
showMessage('配置保存成功,正在测试连接...', true)
|
||||
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
|
||||
|
||||
if (result.success) {
|
||||
setDbConnected(true, dbPath)
|
||||
showMessage('配置保存成功!数据库连接正常', true)
|
||||
} else {
|
||||
showMessage(result.error || '数据库连接失败,请检查配置', false)
|
||||
}
|
||||
// 保存按钮只负责持久化配置,不做连接测试/重连,避免影响聊天页的活动连接
|
||||
showMessage('配置保存成功', true)
|
||||
} catch (e) {
|
||||
showMessage(`保存配置失败: ${e}`, false)
|
||||
} finally {
|
||||
@@ -853,6 +879,206 @@ function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
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 getOptionLabel = (options: { value: string; label: string }[], value: string) => {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
const renderExportTab = () => {
|
||||
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
|
||||
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
|
||||
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<label>默认导出格式</label>
|
||||
<span className="form-hint">导出页面默认选中的格式</span>
|
||||
<div className="select-field" ref={exportFormatDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportFormatSelect(!showExportFormatSelect)
|
||||
setShowExportDateRangeSelect(false)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportFormatLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportFormatSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportFormatOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportDefaultFormat === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultFormat(option.value)
|
||||
await configService.setExportDefaultFormat(option.value)
|
||||
showMessage('已更新导出格式默认值', true)
|
||||
setShowExportFormatSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
{option.desc && <span className="option-desc">{option.desc}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>默认导出时间范围</label>
|
||||
<span className="form-hint">控制导出页面的默认时间选择</span>
|
||||
<div className="select-field" ref={exportDateRangeDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportDateRangeSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportDateRangeSelect(!showExportDateRangeSelect)
|
||||
setShowExportFormatSelect(false)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportDateRangeLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportDateRangeSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportDateRangeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportDefaultDateRange === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultDateRange(option.value)
|
||||
await configService.setExportDefaultDateRange(option.value)
|
||||
showMessage('已更新默认导出时间范围', true)
|
||||
setShowExportDateRangeSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>默认导出媒体文件</label>
|
||||
<span className="form-hint">控制图片/语音/表情的默认导出开关</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{exportDefaultMedia ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch" htmlFor="export-default-media">
|
||||
<input
|
||||
id="export-default-media"
|
||||
className="switch-input"
|
||||
type="checkbox"
|
||||
checked={exportDefaultMedia}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setExportDefaultMedia(enabled)
|
||||
await configService.setExportDefaultMedia(enabled)
|
||||
showMessage(enabled ? '已开启默认媒体导出' : '已关闭默认媒体导出', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>默认语音转文字</label>
|
||||
<span className="form-hint">导出时默认将语音转写为文字</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch" htmlFor="export-default-voice-as-text">
|
||||
<input
|
||||
id="export-default-voice-as-text"
|
||||
className="switch-input"
|
||||
type="checkbox"
|
||||
checked={exportDefaultVoiceAsText}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setExportDefaultVoiceAsText(enabled)
|
||||
await configService.setExportDefaultVoiceAsText(enabled)
|
||||
showMessage(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Excel 列显示</label>
|
||||
<span className="form-hint">控制 Excel 导出的列字段</span>
|
||||
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||
setShowExportFormatSelect(false)
|
||||
setShowExportDateRangeSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportExcelColumnsLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportExcelColumnsSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportExcelColumnOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
const compact = option.value === 'compact'
|
||||
setExportDefaultExcelCompactColumns(compact)
|
||||
await configService.setExportDefaultExcelCompactColumns(compact)
|
||||
showMessage(compact ? '已启用精简列' : '已启用完整列', true)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
{option.desc && <span className="option-desc">{option.desc}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const renderCacheTab = () => (
|
||||
<div className="tab-content">
|
||||
<p className="section-desc">管理应用缓存数据</p>
|
||||
@@ -992,6 +1218,7 @@ function SettingsPage() {
|
||||
{activeTab === 'appearance' && renderAppearanceTab()}
|
||||
{activeTab === 'database' && renderDatabaseTab()}
|
||||
{activeTab === 'whisper' && renderWhisperTab()}
|
||||
{activeTab === 'export' && renderExportTab()}
|
||||
{activeTab === 'cache' && renderCacheTab()}
|
||||
{activeTab === 'about' && renderAboutTab()}
|
||||
</div>
|
||||
@@ -1000,5 +1227,3 @@ function SettingsPage() {
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
|
||||
|
||||
|
||||
940
src/pages/SnsPage.scss
Normal file
940
src/pages/SnsPage.scss
Normal file
@@ -0,0 +1,940 @@
|
||||
.sns-page {
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
|
||||
.sns-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sns-sidebar {
|
||||
width: 300px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
|
||||
&.closed {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-primary);
|
||||
|
||||
.title-icon {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.filter-card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 14px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
&.jump-date-card {
|
||||
.jump-date-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.active {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
background: rgba(var(--accent-color-rgb), 0.05);
|
||||
|
||||
.icon {
|
||||
color: var(--accent-color);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-color);
|
||||
background: var(--bg-primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(var(--accent-color-rgb), 0.08);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.icon {
|
||||
opacity: 0.5;
|
||||
transition: all 0.2s;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-jump-date-inline {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
background: rgba(var(--accent-color-rgb), 0.06);
|
||||
border: 1px dashed rgba(var(--accent-color-rgb), 0.3);
|
||||
color: var(--accent-color);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border-style: solid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.contact-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; // 改为 0 以支持 flex 压缩
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 20px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
|
||||
svg {
|
||||
color: var(--accent-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px 10px 10px 36px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: 0 0 0 4px rgba(var(--accent-color-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.clear-input {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-secondary);
|
||||
background: var(--hover-bg);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contact-filter-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.section-header {
|
||||
padding: 16px 16px 1px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.clear-selection-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent-color);
|
||||
background: rgba(var(--accent-color-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-size: 10px;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contact-search {
|
||||
padding: 0 16px 12px 16px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 26px;
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 8px 30px 8px 30px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.clear-search-icon {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-secondary);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contact-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
margin: 0 4px 8px 4px;
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
margin-bottom: 2px;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(var(--accent-color-rgb), 0.08);
|
||||
|
||||
.contact-name {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.check-box {
|
||||
border-color: var(--accent-color);
|
||||
background: var(--accent-color);
|
||||
|
||||
.inner-check {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
.active-badge {
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--accent-color);
|
||||
border: 2px solid var(--bg-secondary);
|
||||
border-radius: 50%;
|
||||
animation: badge-pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.check-box {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
.inner-check {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 1px;
|
||||
background: white;
|
||||
transform: scale(0);
|
||||
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-contacts {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
.clear-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 4px 10px rgba(var(--accent-color-rgb), 0.2);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sns-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--bg-primary);
|
||||
|
||||
.sns-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
height: 64px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 5;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-trigger {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
&.refresh-btn {
|
||||
&:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.sns-content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
.sns-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 0;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
.active-filters-bar {
|
||||
max-width: 680px;
|
||||
margin: 0 auto 24px auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(var(--accent-color-rgb), 0.08);
|
||||
border: 1px solid rgba(var(--accent-color-rgb), 0.2);
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--accent-color);
|
||||
|
||||
.filter-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.clear-chip-btn {
|
||||
background: var(--accent-color);
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-color-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.posts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.sns-post-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.sns-post-wrapper {
|
||||
width: 100%;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.sns-post {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.03);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.post-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
|
||||
.post-info {
|
||||
margin-left: 14px;
|
||||
|
||||
.nickname {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-body {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.post-text {
|
||||
margin-bottom: 14px;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.post-media-grid {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
|
||||
&.media-count-1 {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
.media-item {
|
||||
width: 320px;
|
||||
height: 240px;
|
||||
max-width: 100%;
|
||||
border-radius: 12px;
|
||||
aspect-ratio: auto;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&.media-count-2,
|
||||
&.media-count-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
&.media-count-3,
|
||||
&.media-count-5,
|
||||
&.media-count-6,
|
||||
&.media-count-7,
|
||||
&.media-count-8,
|
||||
&.media-count-9 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.media-item {
|
||||
width: 160px; // 多图模式下项固定大小(或由 grid 控制,但确保有高度)
|
||||
height: 160px;
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.media-error-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-tertiary);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-video-placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba(var(--accent-color-rgb), 0.08);
|
||||
color: var(--accent-color);
|
||||
padding: 10px 18px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(var(--accent-color-rgb), 0.1);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--accent-color-rgb), 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-footer {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 20px;
|
||||
border-left: 8px solid transparent;
|
||||
border-right: 8px solid transparent;
|
||||
border-bottom: 8px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.likes-section {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
color: var(--accent-color);
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-top: 3px;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.likes-list {
|
||||
line-height: 1.6;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.comments-section {
|
||||
.comment-item {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.6;
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comment-user {
|
||||
color: var(--accent-color);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-text {
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
&.loading-more,
|
||||
&.loading-newer {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
&.newer-hint {
|
||||
background: rgba(var(--accent-color-rgb), 0.08);
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
border: 1px dashed rgba(var(--accent-color-rgb), 0.2);
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--accent-color-rgb), 0.15);
|
||||
border-style: solid;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
.no-results-icon {
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.reset-inline {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 15px rgba(var(--accent-color-rgb), 0.3);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(var(--accent-color-rgb), 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes badge-pop {
|
||||
from {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
595
src/pages/SnsPage.tsx
Normal file
595
src/pages/SnsPage.tsx
Normal file
@@ -0,0 +1,595 @@
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon } from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import { ImagePreview } from '../components/ImagePreview'
|
||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||
import './SnsPage.scss'
|
||||
|
||||
interface SnsPost {
|
||||
id: string
|
||||
username: string
|
||||
nickname: string
|
||||
avatarUrl?: string
|
||||
createTime: number
|
||||
contentDesc: string
|
||||
type?: number
|
||||
media: { url: string; thumb: string }[]
|
||||
likes: string[]
|
||||
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
|
||||
}
|
||||
|
||||
const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => {
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`media-item ${error ? 'error' : ''}`}>
|
||||
{!error ? (
|
||||
<img
|
||||
src={thumb || url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onClick={onPreview}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="media-error-placeholder" onClick={onPreview}>
|
||||
<ImageIcon size={24} style={{ opacity: 0.3 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Contact {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export default function SnsPage() {
|
||||
const [posts, setPosts] = useState<SnsPost[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const loadingRef = useRef(false)
|
||||
|
||||
// 筛选与搜索状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
||||
|
||||
// 联系人列表状态
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
const [contactSearch, setContactSearch] = useState('')
|
||||
const [contactsLoading, setContactsLoading] = useState(false)
|
||||
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
||||
const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined)
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
|
||||
const postsContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [hasNewer, setHasNewer] = useState(false)
|
||||
const [loadingNewer, setLoadingNewer] = useState(false)
|
||||
const postsRef = useRef<SnsPost[]>([])
|
||||
const scrollAdjustmentRef = useRef<number>(0)
|
||||
|
||||
// 同步 posts 到 ref 供 loadPosts 使用
|
||||
useEffect(() => {
|
||||
postsRef.current = posts
|
||||
}, [posts])
|
||||
|
||||
// 处理向上加载动态时的滚动位置保持
|
||||
useEffect(() => {
|
||||
if (scrollAdjustmentRef.current !== 0 && postsContainerRef.current) {
|
||||
const container = postsContainerRef.current;
|
||||
const newHeight = container.scrollHeight;
|
||||
const diff = newHeight - scrollAdjustmentRef.current;
|
||||
if (diff > 0) {
|
||||
container.scrollTop += diff;
|
||||
}
|
||||
scrollAdjustmentRef.current = 0;
|
||||
}
|
||||
}, [posts])
|
||||
|
||||
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
|
||||
const { reset = false, direction = 'older' } = options
|
||||
if (loadingRef.current) return
|
||||
|
||||
loadingRef.current = true
|
||||
if (direction === 'newer') setLoadingNewer(true)
|
||||
else setLoading(true)
|
||||
|
||||
try {
|
||||
const limit = 20
|
||||
let startTs: number | undefined = undefined
|
||||
let endTs: number | undefined = undefined
|
||||
|
||||
if (reset) {
|
||||
if (jumpTargetDate) {
|
||||
endTs = Math.floor(jumpTargetDate.getTime() / 1000) + 86399
|
||||
}
|
||||
} else if (direction === 'newer') {
|
||||
const currentPosts = postsRef.current
|
||||
if (currentPosts.length > 0) {
|
||||
const topTs = currentPosts[0].createTime
|
||||
console.log('[SnsPage] Fetching newer posts starts from:', topTs + 1);
|
||||
|
||||
const result = await window.electronAPI.sns.getTimeline(
|
||||
limit,
|
||||
0,
|
||||
selectedUsernames,
|
||||
searchKeyword,
|
||||
topTs + 1,
|
||||
undefined
|
||||
);
|
||||
|
||||
if (result.success && result.timeline && result.timeline.length > 0) {
|
||||
if (postsContainerRef.current) {
|
||||
scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight;
|
||||
}
|
||||
|
||||
const existingIds = new Set(currentPosts.map(p => p.id));
|
||||
const uniqueNewer = result.timeline.filter(p => !existingIds.has(p.id));
|
||||
|
||||
if (uniqueNewer.length > 0) {
|
||||
setPosts(prev => [...uniqueNewer, ...prev]);
|
||||
}
|
||||
setHasNewer(result.timeline.length >= limit);
|
||||
} else {
|
||||
setHasNewer(false);
|
||||
}
|
||||
}
|
||||
setLoadingNewer(false);
|
||||
loadingRef.current = false;
|
||||
return;
|
||||
} else {
|
||||
const currentPosts = postsRef.current
|
||||
if (currentPosts.length > 0) {
|
||||
endTs = currentPosts[currentPosts.length - 1].createTime - 1
|
||||
}
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.sns.getTimeline(
|
||||
limit,
|
||||
0,
|
||||
selectedUsernames,
|
||||
searchKeyword,
|
||||
startTs,
|
||||
endTs
|
||||
)
|
||||
|
||||
if (result.success && result.timeline) {
|
||||
if (reset) {
|
||||
setPosts(result.timeline)
|
||||
setHasMore(result.timeline.length >= limit)
|
||||
|
||||
// 探测上方是否还有新动态(利用 DLL 过滤,而非底层 SQL)
|
||||
const topTs = result.timeline[0]?.createTime || 0;
|
||||
if (topTs > 0) {
|
||||
const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, searchKeyword, topTs + 1, undefined);
|
||||
setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0));
|
||||
} else {
|
||||
setHasNewer(false);
|
||||
}
|
||||
|
||||
if (postsContainerRef.current) {
|
||||
postsContainerRef.current.scrollTop = 0
|
||||
}
|
||||
} else {
|
||||
if (result.timeline.length > 0) {
|
||||
setPosts(prev => [...prev, ...result.timeline!])
|
||||
}
|
||||
if (result.timeline.length < limit) {
|
||||
setHasMore(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load SNS timeline:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingNewer(false)
|
||||
loadingRef.current = false
|
||||
}
|
||||
}, [selectedUsernames, searchKeyword, jumpTargetDate])
|
||||
|
||||
// 获取联系人列表
|
||||
const loadContacts = async () => {
|
||||
setContactsLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getSessions()
|
||||
if (result.success && result.sessions) {
|
||||
const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder'];
|
||||
const initialContacts = result.sessions
|
||||
.filter((s: any) => {
|
||||
if (!s.username) return false;
|
||||
const u = s.username.toLowerCase();
|
||||
if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) return false;
|
||||
if (u.startsWith('gh_')) return false;
|
||||
if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) return false;
|
||||
return true;
|
||||
})
|
||||
.map((s: any) => ({
|
||||
username: s.username,
|
||||
displayName: s.displayName || s.username,
|
||||
avatarUrl: s.avatarUrl
|
||||
}))
|
||||
setContacts(initialContacts)
|
||||
|
||||
const usernames = initialContacts.map(c => c.username)
|
||||
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
|
||||
if (enriched.success && enriched.contacts) {
|
||||
setContacts(prev => prev.map(c => {
|
||||
const extra = enriched.contacts![c.username]
|
||||
if (extra) {
|
||||
return {
|
||||
...c,
|
||||
displayName: extra.displayName || c.displayName,
|
||||
avatarUrl: extra.avatarUrl || c.avatarUrl
|
||||
}
|
||||
}
|
||||
return c
|
||||
}))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load contacts:', error)
|
||||
} finally {
|
||||
setContactsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
const checkSchema = async () => {
|
||||
try {
|
||||
const schema = await window.electronAPI.chat.execQuery('sns', null, "PRAGMA table_info(SnsTimeLine)");
|
||||
console.log('[SnsPage] SnsTimeLine Schema:', schema);
|
||||
if (schema.success && schema.rows) {
|
||||
const columns = schema.rows.map((r: any) => r.name);
|
||||
console.log('[SnsPage] Available columns:', columns);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SnsPage] Failed to check schema:', e);
|
||||
}
|
||||
};
|
||||
checkSchema();
|
||||
loadContacts()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts({ reset: true })
|
||||
}, [selectedUsernames, searchKeyword, jumpTargetDate])
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
|
||||
|
||||
// 加载更旧的动态(触底)
|
||||
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) {
|
||||
loadPosts({ direction: 'older' })
|
||||
}
|
||||
|
||||
// 加载更新的动态(触顶触发)
|
||||
// 这里的阈值可以保留,但主要依赖下面的 handleWheel 捕获到顶后的上划
|
||||
if (scrollTop < 10 && hasNewer && !loading && !loadingNewer) {
|
||||
loadPosts({ direction: 'newer' })
|
||||
}
|
||||
}
|
||||
|
||||
// 处理到顶后的手动上滚意图
|
||||
const handleWheel = (e: React.WheelEvent<HTMLDivElement>) => {
|
||||
const container = postsContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
// deltaY < 0 表示向上滚,scrollTop === 0 表示已经在最顶端
|
||||
if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) {
|
||||
console.log('[SnsPage] Wheel-up detected at top, loading newer posts...');
|
||||
loadPosts({ direction: 'newer' })
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
const date = new Date(ts * 1000)
|
||||
const isCurrentYear = date.getFullYear() === new Date().getFullYear()
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: isCurrentYear ? undefined : 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const toggleUserSelection = (username: string) => {
|
||||
// 选择联系人时,如果当前有时间跳转,建议清除时间跳转以避免“跳到旧动态”的困惑
|
||||
// 或者保持原样。根据用户反馈“乱跳”,我们在这里选择:
|
||||
// 如果用户选择了新的一个人,而之前有时间跳转,我们重置时间跳转到最新。
|
||||
setJumpTargetDate(undefined);
|
||||
|
||||
setSelectedUsernames(prev => {
|
||||
if (prev.includes(username)) {
|
||||
return prev.filter(u => u !== username)
|
||||
} else {
|
||||
return [...prev, username]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchKeyword('')
|
||||
setSelectedUsernames([])
|
||||
setJumpTargetDate(undefined)
|
||||
}
|
||||
|
||||
const filteredContacts = contacts.filter(c =>
|
||||
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
||||
)
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="sns-page">
|
||||
<div className="sns-container">
|
||||
{/* 侧边栏:过滤与搜索 */}
|
||||
<aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="title-wrapper">
|
||||
<Filter size={18} className="title-icon" />
|
||||
<h3>筛选条件</h3>
|
||||
</div>
|
||||
<button className="toggle-btn" onClick={() => setIsSidebarOpen(false)}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="filter-content custom-scrollbar">
|
||||
{/* 1. 搜索分组 (放到最顶上) */}
|
||||
<div className="filter-card">
|
||||
<div className="filter-section">
|
||||
<label><Search size={14} /> 关键词搜索</label>
|
||||
<div className="search-input-wrapper">
|
||||
<Search size={14} className="input-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索动态内容..."
|
||||
value={searchKeyword}
|
||||
onChange={e => setSearchKeyword(e.target.value)}
|
||||
/>
|
||||
{searchKeyword && (
|
||||
<button className="clear-input" onClick={() => setSearchKeyword('')}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 日期跳转 (放搜索下面) */}
|
||||
<div className="filter-card jump-date-card">
|
||||
<div className="filter-section">
|
||||
<label><Calendar size={14} /> 时间跳转</label>
|
||||
<button className={`jump-date-btn ${jumpTargetDate ? 'active' : ''}`} onClick={() => setShowJumpDialog(true)}>
|
||||
<span className="text">
|
||||
{jumpTargetDate ? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }) : '选择跳转日期...'}
|
||||
</span>
|
||||
<Calendar size={14} className="icon" />
|
||||
</button>
|
||||
{jumpTargetDate && (
|
||||
<button className="clear-jump-date-inline" onClick={() => setJumpTargetDate(undefined)}>
|
||||
返回最新动态
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 3. 联系人筛选 (放最下面,高度自适应) */}
|
||||
<div className="filter-card contact-card">
|
||||
<div className="contact-filter-section">
|
||||
<div className="section-header">
|
||||
<label><User size={14} /> 联系人</label>
|
||||
<div className="header-actions">
|
||||
{selectedUsernames.length > 0 && (
|
||||
<button className="clear-selection-btn" onClick={() => setSelectedUsernames([])}>清除</button>
|
||||
)}
|
||||
{selectedUsernames.length > 0 && (
|
||||
<span className="selected-count">{selectedUsernames.length}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="contact-search">
|
||||
<Search size={12} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索好友..."
|
||||
value={contactSearch}
|
||||
onChange={e => setContactSearch(e.target.value)}
|
||||
/>
|
||||
{contactSearch && (
|
||||
<X size={12} className="clear-search-icon" onClick={() => setContactSearch('')} />
|
||||
)}
|
||||
</div>
|
||||
<div className="contact-list custom-scrollbar">
|
||||
{filteredContacts.map(contact => (
|
||||
<div
|
||||
key={contact.username}
|
||||
className={`contact-item ${selectedUsernames.includes(contact.username) ? 'active' : ''}`}
|
||||
onClick={() => toggleUserSelection(contact.username)}
|
||||
>
|
||||
<div className="avatar-wrapper">
|
||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={32} shape="rounded" />
|
||||
{selectedUsernames.includes(contact.username) && (
|
||||
<div className="active-badge"></div>
|
||||
)}
|
||||
</div>
|
||||
<span className="contact-name">{contact.displayName}</span>
|
||||
<div className="check-box">
|
||||
{selectedUsernames.includes(contact.username) && <div className="inner-check"></div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredContacts.length === 0 && (
|
||||
<div className="empty-contacts">无可显示联系人</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button className="clear-btn" onClick={clearFilters}>
|
||||
<RefreshCw size={14} />
|
||||
重置所有筛选
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="sns-main">
|
||||
<div className="sns-header">
|
||||
<div className="header-left">
|
||||
{!isSidebarOpen && (
|
||||
<button className="icon-btn sidebar-trigger" onClick={() => setIsSidebarOpen(true)}>
|
||||
<Filter size={20} />
|
||||
</button>
|
||||
)}
|
||||
<h2>社交动态</h2>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (jumpTargetDate) setJumpTargetDate(undefined);
|
||||
loadPosts({ reset: true });
|
||||
}}
|
||||
disabled={loading || loadingNewer}
|
||||
className="icon-btn refresh-btn"
|
||||
>
|
||||
<RefreshCw size={18} className={(loading || loadingNewer) ? 'spinning' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sns-content-wrapper">
|
||||
<div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
|
||||
<div className="posts-list">
|
||||
{loadingNewer && (
|
||||
<div className="status-indicator loading-newer">
|
||||
<RefreshCw size={16} className="spinning" />
|
||||
<span>正在检查更新的动态...</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingNewer && hasNewer && (
|
||||
<div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
|
||||
查看更新的动态
|
||||
</div>
|
||||
)}
|
||||
{posts.map((post, index) => {
|
||||
return (
|
||||
<div key={post.id} className="sns-post-row">
|
||||
<div className="sns-post-wrapper">
|
||||
<div className="sns-post">
|
||||
<div className="post-header">
|
||||
<Avatar
|
||||
src={post.avatarUrl}
|
||||
name={post.nickname}
|
||||
size={44}
|
||||
shape="rounded"
|
||||
/>
|
||||
<div className="post-info">
|
||||
<div className="nickname">{post.nickname}</div>
|
||||
<div className="time">{formatTime(post.createTime)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="post-body">
|
||||
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
|
||||
|
||||
{post.type === 15 ? (
|
||||
<div className="post-video-placeholder">
|
||||
<Play size={20} />
|
||||
<span>视频动态</span>
|
||||
</div>
|
||||
) : post.media.length > 0 && (
|
||||
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
|
||||
{post.media.map((m, idx) => (
|
||||
<MediaItem key={idx} url={m.url} thumb={m.thumb} onPreview={() => setPreviewImage(m.url)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(post.likes.length > 0 || post.comments.length > 0) && (
|
||||
<div className="post-footer">
|
||||
{post.likes.length > 0 && (
|
||||
<div className="likes-section">
|
||||
<Heart size={14} className="icon" />
|
||||
<span className="likes-list">
|
||||
{post.likes.join('、')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{post.comments.length > 0 && (
|
||||
<div className="comments-section">
|
||||
{post.comments.map((c, idx) => (
|
||||
<div key={idx} className="comment-item">
|
||||
<span className="comment-user">{c.nickname}</span>
|
||||
{c.refNickname && (
|
||||
<>
|
||||
<span className="reply-text">回复</span>
|
||||
<span className="comment-user">{c.refNickname}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="comment-separator">: </span>
|
||||
<span className="comment-content">{c.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{loading && <div className="status-indicator loading-more">
|
||||
<RefreshCw size={16} className="spinning" />
|
||||
<span>正在加载更多...</span>
|
||||
</div>}
|
||||
{!hasMore && posts.length > 0 && <div className="status-indicator no-more">已经到底啦</div>}
|
||||
{!loading && posts.length === 0 && (
|
||||
<div className="no-results">
|
||||
<div className="no-results-icon"><Search size={48} /></div>
|
||||
<p>未找到相关动态</p>
|
||||
{(selectedUsernames.length > 0 || searchKeyword) && (
|
||||
<button onClick={clearFilters} className="reset-inline">
|
||||
重置搜索条件
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{previewImage && (
|
||||
<ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} />
|
||||
)}
|
||||
<JumpToDateDialog
|
||||
isOpen={showJumpDialog}
|
||||
onClose={() => {
|
||||
setShowJumpDialog(false)
|
||||
}}
|
||||
onSelect={(date) => {
|
||||
setJumpTargetDate(date)
|
||||
setShowJumpDialog(false)
|
||||
}}
|
||||
currentDate={jumpTargetDate || new Date()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -441,7 +441,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
<FolderOpen size={16} /> 浏览选择
|
||||
</button>
|
||||
</div>
|
||||
<div className="field-hint">建议选择包含 xwechat_files 的目录</div>
|
||||
<div className="field-hint">请选择微信-设置-存储位置对应的目录</div>
|
||||
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}>⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -507,7 +507,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
|
||||
<div className="field-hint">获取密钥会自动识别最近登录的账号</div>
|
||||
<div className="field-hint">点击自动获取后微信将重新启动,当页面提示可以登录微信了再点击登录</div>
|
||||
<div className="field-hint">点击自动获取后微信将重新启动,当页面提示<span style={{color: 'red'}}>hook安装成功,现在登录微信</span>后再点击登录</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -533,7 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||
</button>
|
||||
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
|
||||
<div className="field-hint">如获取失败,请先打开朋友圈图片再重试</div>
|
||||
<div className="field-hint">请在电脑微信中打开查看几个图片后再点击获取秘钥,如获取失败请重复以上操作</div>
|
||||
{isFetchingImageKey && <div className="field-hint status-text">正在扫描内存,请稍候...</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,7 +22,13 @@ export const CONFIG_KEYS = {
|
||||
WHISPER_MODEL_DIR: 'whisperModelDir',
|
||||
WHISPER_DOWNLOAD_SOURCE: 'whisperDownloadSource',
|
||||
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice',
|
||||
TRANSCRIBE_LANGUAGES: 'transcribeLanguages'
|
||||
TRANSCRIBE_LANGUAGES: 'transcribeLanguages',
|
||||
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
|
||||
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
||||
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
||||
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
|
||||
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
||||
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns'
|
||||
} as const
|
||||
|
||||
// 获取解密密钥
|
||||
@@ -243,3 +249,72 @@ export async function getTranscribeLanguages(): Promise<string[]> {
|
||||
export async function setTranscribeLanguages(languages: string[]): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.TRANSCRIBE_LANGUAGES, languages)
|
||||
}
|
||||
|
||||
// 获取导出默认格式
|
||||
export async function getExportDefaultFormat(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT)
|
||||
return (value as string) || null
|
||||
}
|
||||
|
||||
// 设置导出默认格式
|
||||
export async function setExportDefaultFormat(format: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT, format)
|
||||
}
|
||||
|
||||
// 获取导出默认时间范围
|
||||
export async function getExportDefaultDateRange(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE)
|
||||
return (value as string) || null
|
||||
}
|
||||
|
||||
// 设置导出默认时间范围
|
||||
export async function setExportDefaultDateRange(range: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
|
||||
}
|
||||
|
||||
// 获取导出默认媒体设置
|
||||
export async function getExportDefaultMedia(): Promise<boolean | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
|
||||
if (typeof value === 'boolean') return value
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置导出默认媒体设置
|
||||
export async function setExportDefaultMedia(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, enabled)
|
||||
}
|
||||
|
||||
// 获取导出默认语音转文字
|
||||
export async function getExportDefaultVoiceAsText(): Promise<boolean | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_VOICE_AS_TEXT)
|
||||
if (typeof value === 'boolean') return value
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置导出默认语音转文字
|
||||
export async function setExportDefaultVoiceAsText(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_VOICE_AS_TEXT, enabled)
|
||||
}
|
||||
|
||||
// 获取导出默认 Excel 列模式
|
||||
export async function getExportDefaultExcelCompactColumns(): Promise<boolean | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS)
|
||||
if (typeof value === 'boolean') return value
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置导出默认 Excel 列模式
|
||||
export async function setExportDefaultExcelCompactColumns(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled)
|
||||
}
|
||||
|
||||
// 获取导出默认 TXT 列配置
|
||||
export async function getExportDefaultTxtColumns(): Promise<string[] | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS)
|
||||
return Array.isArray(value) ? (value as string[]) : null
|
||||
}
|
||||
|
||||
// 设置导出默认 TXT 列配置
|
||||
export async function setExportDefaultTxtColumns(columns: string[]): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns)
|
||||
}
|
||||
|
||||
@@ -6,25 +6,26 @@ export interface ChatState {
|
||||
isConnected: boolean
|
||||
isConnecting: boolean
|
||||
connectionError: string | null
|
||||
|
||||
|
||||
// 会话列表
|
||||
sessions: ChatSession[]
|
||||
filteredSessions: ChatSession[]
|
||||
currentSessionId: string | null
|
||||
isLoadingSessions: boolean
|
||||
|
||||
|
||||
// 消息
|
||||
messages: Message[]
|
||||
isLoadingMessages: boolean
|
||||
isLoadingMore: boolean
|
||||
hasMoreMessages: boolean
|
||||
|
||||
hasMoreLater: boolean
|
||||
|
||||
// 联系人缓存
|
||||
contacts: Map<string, Contact>
|
||||
|
||||
|
||||
// 搜索
|
||||
searchKeyword: string
|
||||
|
||||
|
||||
// 操作
|
||||
setConnected: (connected: boolean) => void
|
||||
setConnecting: (connecting: boolean) => void
|
||||
@@ -38,6 +39,7 @@ export interface ChatState {
|
||||
setLoadingMessages: (loading: boolean) => void
|
||||
setLoadingMore: (loading: boolean) => void
|
||||
setHasMoreMessages: (hasMore: boolean) => void
|
||||
setHasMoreLater: (hasMore: boolean) => void
|
||||
setContacts: (contacts: Contact[]) => void
|
||||
addContact: (contact: Contact) => void
|
||||
setSearchKeyword: (keyword: string) => void
|
||||
@@ -56,48 +58,51 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
isLoadingMessages: false,
|
||||
isLoadingMore: false,
|
||||
hasMoreMessages: true,
|
||||
hasMoreLater: false,
|
||||
contacts: new Map(),
|
||||
searchKeyword: '',
|
||||
|
||||
setConnected: (connected) => set({ isConnected: connected }),
|
||||
setConnecting: (connecting) => set({ isConnecting: connecting }),
|
||||
setConnectionError: (error) => set({ connectionError: error }),
|
||||
|
||||
|
||||
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
|
||||
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
|
||||
|
||||
setCurrentSession: (sessionId) => set({
|
||||
|
||||
setCurrentSession: (sessionId) => set({
|
||||
currentSessionId: sessionId,
|
||||
messages: [],
|
||||
hasMoreMessages: true
|
||||
hasMoreMessages: true,
|
||||
hasMoreLater: false
|
||||
}),
|
||||
|
||||
|
||||
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
||||
|
||||
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
|
||||
appendMessages: (newMessages, prepend = false) => set((state) => ({
|
||||
messages: prepend
|
||||
messages: prepend
|
||||
? [...newMessages, ...state.messages]
|
||||
: [...state.messages, ...newMessages]
|
||||
})),
|
||||
|
||||
|
||||
setLoadingMessages: (loading) => set({ isLoadingMessages: loading }),
|
||||
setLoadingMore: (loading) => set({ isLoadingMore: loading }),
|
||||
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
|
||||
|
||||
setContacts: (contacts) => set({
|
||||
contacts: new Map(contacts.map(c => [c.username, c]))
|
||||
setHasMoreLater: (hasMore) => set({ hasMoreLater: hasMore }),
|
||||
|
||||
setContacts: (contacts) => set({
|
||||
contacts: new Map(contacts.map(c => [c.username, c]))
|
||||
}),
|
||||
|
||||
|
||||
addContact: (contact) => set((state) => {
|
||||
const newContacts = new Map(state.contacts)
|
||||
newContacts.set(contact.username, contact)
|
||||
return { contacts: newContacts }
|
||||
}),
|
||||
|
||||
|
||||
setSearchKeyword: (keyword) => set({ searchKeyword: keyword }),
|
||||
|
||||
|
||||
reset: () => set({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
@@ -110,6 +115,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
isLoadingMessages: false,
|
||||
isLoadingMore: false,
|
||||
hasMoreMessages: true,
|
||||
hasMoreLater: false,
|
||||
contacts: new Map(),
|
||||
searchKeyword: ''
|
||||
})
|
||||
|
||||
36
src/types/electron.d.ts
vendored
36
src/types/electron.d.ts
vendored
@@ -63,7 +63,7 @@ export interface ElectronAPI {
|
||||
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
||||
error?: string
|
||||
}>
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => Promise<{
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
hasMore?: boolean;
|
||||
@@ -100,6 +100,7 @@ export interface ElectronAPI {
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
|
||||
}
|
||||
|
||||
image: {
|
||||
@@ -314,12 +315,31 @@ export interface ElectronAPI {
|
||||
success: boolean
|
||||
error?: string
|
||||
}>
|
||||
onProgress: (callback: (payload: ExportProgress) => void) => () => void
|
||||
}
|
||||
whisper: {
|
||||
downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }>
|
||||
getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }>
|
||||
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void
|
||||
}
|
||||
sns: {
|
||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => Promise<{
|
||||
success: boolean
|
||||
timeline?: Array<{
|
||||
id: string
|
||||
username: string
|
||||
nickname: string
|
||||
avatarUrl?: string
|
||||
createTime: number
|
||||
contentDesc: string
|
||||
type?: number
|
||||
media: Array<{ url: string; thumb: string }>
|
||||
likes: Array<string>
|
||||
comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }>
|
||||
}>
|
||||
error?: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
@@ -327,6 +347,20 @@ export interface ExportOptions {
|
||||
dateRange?: { start: number; end: number } | null
|
||||
exportMedia?: boolean
|
||||
exportAvatars?: boolean
|
||||
exportImages?: boolean
|
||||
exportVoices?: boolean
|
||||
exportEmojis?: boolean
|
||||
exportVoiceAsText?: boolean
|
||||
excelCompactColumns?: boolean
|
||||
txtColumns?: string[]
|
||||
sessionLayout?: 'shared' | 'per-session'
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
current: number
|
||||
total: number
|
||||
currentSession: string
|
||||
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
|
||||
}
|
||||
|
||||
export interface WxidInfo {
|
||||
|
||||
Reference in New Issue
Block a user