Compare commits

...

100 Commits

Author SHA1 Message Date
cc
a215886015 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-25 14:25:27 +08:00
cc
1d9e8aded0 feat: 大幅提升语音解密速度;优化引导页面;优化图片密钥扫描逻辑 2026-01-25 14:25:24 +08:00
cc
b7e31c9cff Merge pull request #93 from xunchahaha/dev
Dev
2026-01-25 13:37:57 +08:00
xuncha
4e9c81a93d feat: 导出页面新增群昵称 备注等选择 2026-01-25 10:45:38 +08:00
xuncha
9181ac5d34 feat: ecxel导出支持群昵称显示 2026-01-25 09:40:00 +08:00
xuncha
3a10aeb23e feat:新增了切换账号的功能 (#89) 2026-01-24 12:43:09 +08:00
xuncha
178f9c4fdc Merge branch 'dev' into dev 2026-01-24 12:42:33 +08:00
xuncha
4d647a9467 feat:新增了切换账号的功能 2026-01-24 12:39:20 +08:00
Forrest
16cbc6adb1 Merge pull request #88 from 5xiao0qing5/main
fix:修复打包后html导出渲染失败
2026-01-24 03:40:22 +08:00
QingXiao
7afb872bff Bug Fix:修复打包后html导出渲染失败
Add bundled fallback CSS for HTML export (fix missing styles in builds)
2026-01-24 01:13:02 +08:00
QingXiao
7df6182e70 Fix html export styles fallback 2026-01-24 01:02:11 +08:00
xuncha
40efb04a36 hh (#87) 2026-01-24 00:39:21 +08:00
cc
3efaed488a Merge pull request #82 from 5xiao0qing5/dev
实现 TXT导出 和 HTML导出
2026-01-24 00:25:34 +08:00
QingXiao
decdbf95f7 Merge pull request #9 from 5xiao0qing5/codex/implement-html-export-feature-1g9o7z 2026-01-24 00:19:37 +08:00
QingXiao
cccc712814 Merge pull request #8 from 5xiao0qing5/codex/format-txt-export-for-messages
Adjust txt/excel export message formatting
2026-01-24 00:17:58 +08:00
QingXiao
135f4819fb Align HTML export parsing and voip placeholders 2026-01-24 00:07:49 +08:00
QingXiao
388923257b Handle more message types in exports 2026-01-23 23:53:33 +08:00
cc
6918e359e8 Merge pull request #86 from hicccc77/dev
Dev
2026-01-23 23:46:34 +08:00
cc
d5b33c7e77 Merge branch 'main' of https://github.com/hicccc77/WeFlow into dev 2026-01-23 23:45:26 +08:00
QingXiao
d37f53e120 Adjust txt/excel export message formatting 2026-01-23 23:37:16 +08:00
cc
26478217e7 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-23 23:33:08 +08:00
cc
a100f4ef97 feat: 一些朋友圈功能的优化实现 2026-01-23 23:33:06 +08:00
QingXiao
91b746dc59 Merge pull request #7 from 5xiao0qing5/codex/implement-html-export-feature-1g9o7z
将 HTML 导出样式移至外部文件并强化 HTML 导出(清理、表情符号、视频)
2026-01-23 22:40:12 +08:00
QingXiao
1817a847de Merge branch 'dev' into codex/implement-html-export-feature-1g9o7z 2026-01-23 22:38:56 +08:00
cc
7e99feae1e Merge pull request #85 from xunchahaha/dev
fix:修复了头像加载失败的问题
2026-01-23 22:34:42 +08:00
QingXiao
2977c45365 Move HTML export styles to CSS file 2026-01-23 22:32:26 +08:00
Forrest
3b363a3efa Merge pull request #84 from xunchahaha/docs/add-3wm-qrcode
hh
2026-01-23 22:28:01 +08:00
xuncha
e2b0bd44d9 hh 2026-01-23 22:15:42 +08:00
QingXiao
cc26860504 实现 HTML 导出功能 2026-01-23 15:06:07 +08:00
QingXiao
54f3e0481f Fix HTML export app messages and emoji rendering 2026-01-23 15:00:43 +08:00
QingXiao
a61371c8ad Refine HTML export layout and theming 2026-01-23 14:48:34 +08:00
QingXiao
fd6d5e4296 Implement HTML chat export 2026-01-23 14:34:40 +08:00
QingXiao
514a617c55 Merge pull request #4 from 5xiao0qing5/codex/add-txt-export-feature-with-configurable-options
完成未实现的 TXT 导出功能
2026-01-23 13:58:56 +08:00
QingXiao
b47007ea0c Add configurable TXT export 2026-01-23 13:52:47 +08:00
xuncha
6436c39c90 Dev (#79)
* fix:尝试修复闪退的问题

* hhhhh

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

* 优化表诉

* 导出优化

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

* 优化表述

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

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

* Bump version from 1.3.1 to 1.3.2

---------

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

* hhhhh
2026-01-22 18:46:22 +08:00
xuncha
0901e08c5c hhhhh 2026-01-22 18:44:42 +08:00
xuncha
503a77c7cf fix:尝试修复闪退的问题 2026-01-22 15:28:00 +08:00
xuncha
0e3ab8e4d6 Merge pull request #72 from hicccc77/dev
Dev
2026-01-21 21:01:38 +08:00
xuncha
4452e4921c fix:修复了导出时媒体导出出错的问题 2026-01-21 20:56:36 +08:00
xuncha
97c1aa582d feat(export): 多会话导出布局选择与无媒体直出
- 多会话媒体导出支持共享/分会话目录
- 无媒体导出时直接输出到目标目录
2026-01-21 19:37:05 +08:00
xuncha
076c008329 fix:优化了设置中下拉菜单的视觉表现 2026-01-21 19:23:11 +08:00
xuncha
21d785dd3c fix:修复了导出时因为头像在后台加载导致的不导出的问题 2026-01-21 19:08:22 +08:00
xuncha
348f6c81bf fix:尝试修复了新手引导闪退的问题 2026-01-21 19:02:14 +08:00
xuncha
d5a2e2bb62 word: 优化了新手引导的提示词 2026-01-21 18:59:54 +08:00
xuncha
2b51e0659e Merge pull request #65 from yunxilyf/main
fix:修复初始化的时候获取微信启动路径出现错误问题
2026-01-21 18:34:25 +08:00
yunxilyf
3efca5e60c fix:修复初始化的时候获取微信启动路径出现错误问题 2026-01-21 09:12:10 +08:00
cc
2f7b917f1c Merge branch 'main' of https://github.com/hicccc77/WeFlow 2026-01-20 22:40:26 +08:00
cc
8623f86505 fix: 修复了一些已知问题 2026-01-20 22:40:21 +08:00
Forrest
dc74641c19 Merge pull request #62 from hicccc77/dev
修复了一些已知问题。
2026-01-20 18:52:42 +08:00
Forrest
db7817cc22 refactor(WcdbCore, SettingsPage): 优化数据库测试的连接处理:恢复测试后活跃连接,拆分设置页配置保存与连接测试逻辑,避免连接干扰。 2026-01-20 18:25:05 +08:00
xuncha
ada0f68182 fix: 修复了一些情况下无法触发语音转文字的功能
Fix exporting voice-to-text for JSON and Excel exports
2026-01-20 02:13:40 +08:00
QingXiao
fe806895f0 Merge pull request #2 from 5xiao0qing5/codex/locate-cause-of-export-speech-to-text-bug-hv6kr5
Add export defaults and compact Excel column mode
2026-01-19 12:39:48 +08:00
QingXiao
da137d0a8f Add export defaults and compact Excel columns 2026-01-19 12:39:06 +08:00
Yeqing Zhang
93ebc3bce3 Merge pull request #1 from 5xiao0qing5/codex/locate-cause-of-export-speech-to-text-bug
Fix exporting voice-to-text for JSON and Excel exports
2026-01-19 12:01:25 +08:00
Yeqing Zhang
9f6e9eb9bc Fix voice transcript export for json and excel 2026-01-19 11:33:28 +08:00
xuncha
996b133a4f Merge branch 'dev' 2026-01-19 01:08:01 +08:00
Forrest
dd2602ea35 fix(ChatService, VideoService): 优化系统消息清理逻辑并移除冗余日志
- 改进 cleanSystemMessage 方法,增强 XML 声明移除和尾部时间戳清理
- 优化正则表达式以更准确地处理 XML/HTML 标签
- 从 VideoService 中移除大量冗余的 console.log 调试日志
- 简化错误处理,使用注释替代冗余日志输出
- 提升代码可读性和性能,减少不必要的日志输出开销
2026-01-18 23:41:55 +08:00
Forrest
e5cf71b7c5 Merge: 解决冲突 - 保留链接消息和视频消息样式,合并 rawContent 和 content 字段 2026-01-18 23:25:28 +08:00
Forrest
f2e4e21010 feat(ChatPage): 新增链接卡片消息渲染(支持解析 XML 并展示标题 / 描述 / 图标),采用 flexbox 优化消息气泡布局,添加文本截断、响应式样式及悬浮效果。 2026-01-18 23:20:26 +08:00
xuncha
240514f1e5 feat: 新增了聊天页面播放视频的功能 2026-01-18 23:19:58 +08:00
Forrest
d4c7e86e05 fix(UI): 修复聊天窗口的气泡宽度问题。 2026-01-18 21:28:55 +08:00
Forrest
2876c7a539 feat(voice-transcribe): 优化语音转写流程并增强数据库缓存机制
- 添加 createTime 参数到语音转写接口,支持更精确的消息定位
- 实现 media.db 列表缓存机制(5分钟TTL),减少重复查询开销
- 添加 media.db 表结构信息缓存,提升数据库操作效率
- 优化语音缓存目录获取逻辑,支持自定义缓存路径配置
- 重构语音数据获取实现,绕过WCDB的buggy getVoiceData方法
- 移除冗余的调试日志,提升代码整洁度
- 删除不再使用的 silk_v3_decoder.exe 文件
- 优化数据库连接流程,后台预热缓存提升响应速度
2026-01-18 17:12:45 +08:00
cc
32cdbece2c fix: 修复同时解密多个语音时可能引起错误解密的问题 2026-01-18 10:49:44 +08:00
cc
6e7e994cc6 fix: 修复构建日志异常 2026-01-18 10:41:00 +08:00
cc
d95040ffaf fix: 修复构建日志异常的问题 2026-01-18 10:35:41 +08:00
cc
129dfbe1b6 fix: 自动构建修复 2026-01-18 10:32:19 +08:00
cc
f8afce6bfa fix: 修复打包配置错误 2026-01-18 10:25:24 +08:00
cc
0423f23b9c fix: 允许相同版本打包 2026-01-18 10:19:43 +08:00
cc
e3655631bb Merge branch 'dev'
合并
2026-01-18 10:13:09 +08:00
cc
945802f772 chore: 优化自动构建流程 2026-01-18 10:10:42 +08:00
cc
be4d9b510d feat: 优化了语音配置页面的效果;新增语音实际波形图显示;新增语音点击跳转进度
fix: 修复了一个可能导致语音解密错乱的问题
2026-01-18 00:01:07 +08:00
Forrest
0853e049c8 feat(voice-transcribe): 新增语音转写语言过滤配置功能(支持用户自定义允许的转写语言),优化模型下载的超时处理与进度日志,提升下载稳健性,同步更新相关 UI 样式。 2026-01-17 19:54:31 +08:00
Forrest
dc12df0fcf fix: 清理导出服务日志并简化whisper接口参数
- 移除exportService中的冗余console日志输出
- 简化whisper API接口,移除downloadModel和getModelStatus的payload参数
- 清理图片、表情、语音导出过程中的调试日志
- 移除数据库查询和媒体处理中的详细日志记录
- 优化代码可读性,减少控制台输出噪音
2026-01-17 16:24:18 +08:00
cc
82ba0344b9 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-17 14:19:34 +08:00
cc
e8babd48b6 feat: 实现语音转文字并支持流式输出;
fix: 修复了语音解密失败的问题
2026-01-17 14:16:54 +08:00
xuncha
7c0ed66dad fix:新增了数据库目录的提醒 2026-01-17 12:41:45 +08:00
xuncha
9402483d87 fix:修复了日期选择的问题 2026-01-17 12:02:17 +08:00
xuncha
650de55202 fix:修复了语音导出 2026-01-17 07:08:23 +08:00
xuncha
af99ab2029 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-17 07:04:59 +08:00
xuncha
87a2675236 fix:修复了图片 表情映射的问题 2026-01-17 07:03:46 +08:00
xuncha
25f1256baa fix:处理了xml消息 2026-01-17 06:36:28 +08:00
xuncha
f83a37e714 fix:修复了备注昵称错误的问题 2026-01-17 06:27:19 +08:00
xuncha
1aecfb369b feat: 增加了导出时对excel的支持 2026-01-17 06:18:11 +08:00
xuncha
436b090e26 Merge pull request #45 from xunchahaha/dev
fix:增加了聊天中语音转文字功能
2026-01-17 05:28:37 +08:00
xuncha
c0f2620542 fix:使其在聊天中变得可用 2026-01-17 05:27:20 +08:00
xuncha
72e2d82158 feat: 尝试增加一下聊天里面的语音转文字功能 2026-01-17 05:14:14 +08:00
xuncha
095c8f0db6 fix:修复了清除缓存功能的缺失 2026-01-17 02:45:10 +08:00
xuncha
afa3e089b1 fix: 自己发的语音也可以解密 2026-01-17 02:33:02 +08:00
Forrest
11969ea2d4 fix(imageDecryptService): 优化 ffmpeg 路径检测(多方案 fallback 并验证有效性)与错误处理(捕获 stderr 并输出详细日志),新增ffmpeg-static依赖。 2026-01-17 02:05:41 +08:00
Forrest
6707be2200 fix: 修复图像解密服务中的潜在错误 2026-01-17 01:52:56 +08:00
Forrest
f97e102dbd fix: jpg解密修复 2026-01-17 01:23:38 +08:00
64 changed files with 12591 additions and 10543 deletions

View File

@@ -25,44 +25,37 @@ jobs:
cache: 'npm' cache: 'npm'
- name: Install Dependencies - name: Install Dependencies
run: npm install run: npm ci
- name: Sync version with tag
shell: bash
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION"
npm version $VERSION --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check - name: Build Frontend & Type Check
run: | run: |
npx tsc npx tsc
npx vite build npx vite build
- name: Build Changelog
id: build_changelog
uses: mikepenz/release-changelog-builder-action@v4
with:
outputFile: "release-notes.md"
configurationJson: |
{
"template": "# v${{ github.ref_name }} 版本发布\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建",
"categories": [
{
"title": "## 新功能",
"filter": { "pattern": "^feat:.*", "flags": "i" }
},
{
"title": "## 修复",
"filter": { "pattern": "^fix:.*", "flags": "i" }
},
{
"title": "## 性能与维护",
"filter": { "pattern": "^(chore|docs|perf|refactor):.*", "flags": "i" }
}
],
"ignore_labels": [],
"commitMode": true,
"empty_summary": "## 更新详情\n- 常规代码优化与维护"
}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Package and Publish - name: Package and Publish
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
npx electron-builder --publish always "-c.releaseInfo.releaseNotesFile=release-notes.md" 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

1
.gitignore vendored
View File

@@ -56,3 +56,4 @@ Thumbs.db
*.aps *.aps
wcdb/ wcdb/
*info

BIN
2wm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

BIN
3wm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -25,9 +25,23 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
</a> </a>
</p> </p>
> [!TIP] > [!TIP]
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/) > 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
> [!TIP]
> 仅支持微信 **4.0** 及以上版本
# 加入微信交流群
> 🎉 扫码加入微信群,与其他 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 +50,9 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
- 导出聊天记录为 HTML 等格式 - 导出聊天记录为 HTML 等格式
- 本地解密与数据库管理 - 本地解密与数据库管理
> [!NOTE]
> ⚠️ 本工具仅适配微信 **4.0 及以上**版本,请确保你的微信版本符合要求
## 快速开始 ## 快速开始
若你只想使用成品版本,可前往 Release 下载并安装。 若你只想使用成品版本,可前往 Release 下载并安装。

View File

@@ -1,6 +1,6 @@
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron' import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
import { join } from 'path' import { join, dirname } from 'path'
import { autoUpdater } from 'electron-updater' import { autoUpdater } from 'electron-updater'
import { readFile, writeFile, mkdir } from 'fs/promises' import { readFile, writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs' import { existsSync } from 'fs'
@@ -13,8 +13,11 @@ import { imagePreloadService } from './services/imagePreloadService'
import { analyticsService } from './services/analyticsService' import { analyticsService } from './services/analyticsService'
import { groupAnalyticsService } from './services/groupAnalyticsService' import { groupAnalyticsService } from './services/groupAnalyticsService'
import { annualReportService } from './services/annualReportService' import { annualReportService } from './services/annualReportService'
import { exportService, ExportOptions } from './services/exportService' import { exportService, ExportOptions, ExportProgress } from './services/exportService'
import { KeyService } from './services/keyService' import { KeyService } from './services/keyService'
import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService'
import { snsService } from './services/snsService'
// 配置自动更新 // 配置自动更新
@@ -26,6 +29,47 @@ const AUTO_UPDATE_ENABLED =
process.env.AUTO_UPDATE_ENABLED === '1' || process.env.AUTO_UPDATE_ENABLED === '1' ||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL) (process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
// 使用白名单过滤 PATH避免被第三方目录中的旧版 VC++ 运行库劫持。
// 仅保留系统目录Windows/System32/SysWOW64和应用自身目录可执行目录、resources
function sanitizePathEnv() {
// 开发模式不做裁剪,避免影响本地工具链
if (process.env.VITE_DEV_SERVER_URL) return
const rawPath = process.env.PATH || process.env.Path
if (!rawPath) return
const sep = process.platform === 'win32' ? ';' : ':'
const parts = rawPath.split(sep).filter(Boolean)
const systemRoot = process.env.SystemRoot || process.env.WINDIR || ''
const safePrefixes = [
systemRoot,
systemRoot ? join(systemRoot, 'System32') : '',
systemRoot ? join(systemRoot, 'SysWOW64') : '',
dirname(process.execPath),
process.resourcesPath,
join(process.resourcesPath || '', 'resources')
].filter(Boolean)
const normalize = (p: string) => p.replace(/\\/g, '/').toLowerCase()
const isSafe = (p: string) => {
const np = normalize(p)
return safePrefixes.some((prefix) => np.startsWith(normalize(prefix)))
}
const filtered = parts.filter(isSafe)
if (filtered.length !== parts.length) {
const removed = parts.filter((p) => !isSafe(p))
console.warn('[WeFlow] 使用白名单裁剪 PATH移除目录:', removed)
const nextPath = filtered.join(sep)
process.env.PATH = nextPath
process.env.Path = nextPath
}
}
// 启动时立即清理 PATH后续创建的 worker 也能继承安全的环境
sanitizePathEnv()
// 单例服务 // 单例服务
let configService: ConfigService | null = null let configService: ConfigService | null = null
@@ -165,10 +209,11 @@ function createOnboardingWindow() {
: join(process.resourcesPath, 'icon.ico') : join(process.resourcesPath, 'icon.ico')
onboardingWindow = new BrowserWindow({ onboardingWindow = new BrowserWindow({
width: 1100, width: 960,
height: 720, height: 680,
minWidth: 900, minWidth: 900,
minHeight: 600, minHeight: 620,
resizable: false,
frame: false, frame: false,
transparent: true, transparent: true,
backgroundColor: '#00000000', backgroundColor: '#00000000',
@@ -199,6 +244,107 @@ function createOnboardingWindow() {
return onboardingWindow return onboardingWindow
} }
/**
* 创建独立的视频播放窗口
* 窗口大小会根据视频比例自动调整
*/
function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHeight?: number) {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
// 获取屏幕尺寸
const { screen } = require('electron')
const primaryDisplay = screen.getPrimaryDisplay()
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
// 计算窗口尺寸,只有标题栏 40px控制栏悬浮
let winWidth = 854
let winHeight = 520
const titleBarHeight = 40
if (videoWidth && videoHeight && videoWidth > 0 && videoHeight > 0) {
const aspectRatio = videoWidth / videoHeight
const maxWidth = Math.floor(screenWidth * 0.85)
const maxHeight = Math.floor(screenHeight * 0.85)
if (aspectRatio >= 1) {
// 横向视频
winWidth = Math.min(videoWidth, maxWidth)
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
if (winHeight > maxHeight) {
winHeight = maxHeight
winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio)
}
} else {
// 竖向视频
const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight)
winHeight = videoDisplayHeight + titleBarHeight
winWidth = Math.floor(videoDisplayHeight * aspectRatio)
if (winWidth < 300) {
winWidth = 300
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
}
}
winWidth = Math.max(winWidth, 360)
winHeight = Math.max(winHeight, 280)
}
const win = new BrowserWindow({
width: winWidth,
height: winHeight,
minWidth: 360,
minHeight: 280,
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: false
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#1a1a1a',
symbolColor: '#ffffff',
height: 40
},
show: false,
backgroundColor: '#000000',
autoHideMenuBar: true
})
win.once('ready-to-show', () => {
win.show()
})
const videoParam = `videoPath=${encodeURIComponent(videoPath)}`
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/video-player-window?${videoParam}`)
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
if (win.webContents.isDevToolsOpened()) {
win.webContents.closeDevTools()
} else {
win.webContents.openDevTools()
}
event.preventDefault()
}
})
} else {
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/video-player-window?${videoParam}`
})
}
return win
}
function showMainWindow() { function showMainWindow() {
shouldShowMain = true shouldShowMain = true
if (mainWindowReady) { if (mainWindowReady) {
@@ -355,6 +501,79 @@ function registerIpcHandlers() {
} }
}) })
// 打开视频播放窗口
ipcMain.handle('window:openVideoPlayerWindow', (_, videoPath: string, videoWidth?: number, videoHeight?: number) => {
createVideoPlayerWindow(videoPath, videoWidth, videoHeight)
})
// 根据视频尺寸调整窗口大小
ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win || !videoWidth || !videoHeight) return
const { screen } = require('electron')
const primaryDisplay = screen.getPrimaryDisplay()
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
// 只有标题栏 40px控制栏悬浮在视频上
const titleBarHeight = 40
const aspectRatio = videoWidth / videoHeight
const maxWidth = Math.floor(screenWidth * 0.85)
const maxHeight = Math.floor(screenHeight * 0.85)
let winWidth: number
let winHeight: number
if (aspectRatio >= 1) {
// 横向视频 - 以宽度为基准
winWidth = Math.min(videoWidth, maxWidth)
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
if (winHeight > maxHeight) {
winHeight = maxHeight
winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio)
}
} else {
// 竖向视频 - 以高度为基准
const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight)
winHeight = videoDisplayHeight + titleBarHeight
winWidth = Math.floor(videoDisplayHeight * aspectRatio)
// 确保宽度不会太窄
if (winWidth < 300) {
winWidth = 300
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
}
}
winWidth = Math.max(winWidth, 360)
winHeight = Math.max(winHeight, 280)
// 调整窗口大小并居中
win.setSize(winWidth, winHeight)
win.center()
})
// 视频相关
ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => {
try {
const result = await videoService.getVideoInfo(videoMd5)
return { success: true, ...result }
} catch (e) {
return { success: false, error: String(e), exists: false }
}
})
ipcMain.handle('video:parseVideoMd5', async (_, content: string) => {
try {
const md5 = videoService.parseVideoMd5(content)
return { success: true, md5 }
} catch (e) {
return { success: false, error: String(e) }
}
})
// 数据库路径相关 // 数据库路径相关
ipcMain.handle('dbpath:autoDetect', async () => { ipcMain.handle('dbpath:autoDetect', async () => {
return dbPathService.autoDetect() return dbPathService.autoDetect()
@@ -397,8 +616,8 @@ function registerIpcHandlers() {
return chatService.enrichSessionsContactInfo(usernames) return chatService.enrichSessionsContactInfo(usernames)
}) })
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => { ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
return chatService.getMessages(sessionId, offset, limit) return chatService.getMessages(sessionId, offset, limit, startTime, endTime, ascending)
}) })
ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => { ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
@@ -438,14 +657,31 @@ function registerIpcHandlers() {
return chatService.getImageData(sessionId, msgId) return chatService.getImageData(sessionId, msgId)
}) })
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string) => { ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => {
return chatService.getVoiceData(sessionId, msgId) return chatService.getVoiceData(sessionId, msgId, createTime, serverId)
})
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
return chatService.resolveVoiceCache(sessionId, msgId)
})
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => {
return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => {
event.sender.send('chat:voiceTranscriptPartial', { msgId, text })
})
}) })
ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => { ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => {
return chatService.getMessageById(sessionId, localId) 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)
})
// 私聊克隆 // 私聊克隆
@@ -461,8 +697,13 @@ function registerIpcHandlers() {
}) })
// 导出相关 // 导出相关
ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => { ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
return exportService.exportSessions(sessionIds, outputDir, options) const onProgress = (progress: ExportProgress) => {
if (!event.sender.isDestroyed()) {
event.sender.send('export:progress', progress)
}
}
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
}) })
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => { ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
@@ -482,6 +723,50 @@ function registerIpcHandlers() {
return analyticsService.getTimeDistribution() return analyticsService.getTimeDistribution()
}) })
// 缓存管理
ipcMain.handle('cache:clearAnalytics', async () => {
return analyticsService.clearCache()
})
ipcMain.handle('cache:clearImages', async () => {
const imageResult = await imageDecryptService.clearCache()
const emojiResult = chatService.clearCaches({ includeMessages: false, includeContacts: false, includeEmojis: true })
const errors = [imageResult, emojiResult]
.filter((result) => !result.success)
.map((result) => result.error)
.filter(Boolean) as string[]
if (errors.length > 0) {
return { success: false, error: errors.join('; ') }
}
return { success: true }
})
ipcMain.handle('cache:clearAll', async () => {
const [analyticsResult, imageResult] = await Promise.all([
analyticsService.clearCache(),
imageDecryptService.clearCache()
])
const chatResult = chatService.clearCaches()
const errors = [analyticsResult, imageResult, chatResult]
.filter((result) => !result.success)
.map((result) => result.error)
.filter(Boolean) as string[]
if (errors.length > 0) {
return { success: false, error: errors.join('; ') }
}
return { success: true }
})
ipcMain.handle('whisper:downloadModel', async (event) => {
return voiceTranscribeService.downloadModel((progress) => {
event.sender.send('whisper:downloadProgress', progress)
})
})
ipcMain.handle('whisper:getModelStatus', async () => {
return voiceTranscribeService.getModelStatus()
})
// 群聊分析相关 // 群聊分析相关
ipcMain.handle('groupAnalytics:getGroupChats', async () => { ipcMain.handle('groupAnalytics:getGroupChats', async () => {
return groupAnalyticsService.getGroupChats() return groupAnalyticsService.getGroupChats()
@@ -702,6 +987,17 @@ app.whenReady().then(() => {
createOnboardingWindow() createOnboardingWindow()
} }
// 解决朋友圈图片无法加载问题(添加 Referer
session.defaultSession.webRequest.onBeforeSendHeaders(
{
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
},
(details, callback) => {
details.requestHeaders['Referer'] = 'https://wx.qq.com/'
callback({ requestHeaders: details.requestHeaders })
}
)
// 启动时检测更新 // 启动时检测更新
checkForUpdatesOnStartup() checkForUpdatesOnStartup()

View File

@@ -53,7 +53,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'), completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'), openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options) setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight)
}, },
// 数据库路径 // 数据库路径
@@ -94,8 +98,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSessions: () => ipcRenderer.invoke('chat:getSessions'), getSessions: () => ipcRenderer.invoke('chat:getSessions'),
enrichSessionsContactInfo: (usernames: string[]) => enrichSessionsContactInfo: (usernames: string[]) =>
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
getMessages: (sessionId: string, offset?: number, limit?: number) => getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit), ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
getLatestMessages: (sessionId: string, limit?: number) => getLatestMessages: (sessionId: string, limit?: number) =>
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit), ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username), getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
@@ -106,7 +110,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
close: () => ipcRenderer.invoke('chat:close'), close: () => ipcRenderer.invoke('chat:close'),
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
getVoiceData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId) getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
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)
}, },
@@ -129,6 +143,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
} }
}, },
// 视频
video: {
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
},
// 数据分析 // 数据分析
analytics: { analytics: {
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'), getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
@@ -140,6 +160,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
} }
}, },
// 缓存管理
cache: {
clearAnalytics: () => ipcRenderer.invoke('cache:clearAnalytics'),
clearImages: () => ipcRenderer.invoke('cache:clearImages'),
clearAll: () => ipcRenderer.invoke('cache:clearAll')
},
// 群聊分析 // 群聊分析
groupAnalytics: { groupAnalytics: {
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'), getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
@@ -166,6 +193,27 @@ contextBridge.exposeInMainWorld('electronAPI', {
exportSessions: (sessionIds: string[], outputDir: string, options: any) => exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
exportSession: (sessionId: string, outputPath: string, options: any) => exportSession: (sessionId: string, outputPath: string, options: any) =>
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options) ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('export:progress')
}
},
whisper: {
downloadModel: () =>
ipcRenderer.invoke('whisper:downloadModel'),
getModelStatus: () =>
ipcRenderer.invoke('whisper:getModelStatus'),
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => {
ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
}
},
// 朋友圈
sns: {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime)
} }
}) })

View File

@@ -1,7 +1,7 @@
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { join } from 'path' import { join } from 'path'
import { readFile, writeFile } from 'fs/promises' import { readFile, writeFile, rm } from 'fs/promises'
import { app } from 'electron' import { app } from 'electron'
export interface ChatStatistics { export interface ChatStatistics {
@@ -324,7 +324,7 @@ class AnalyticsService {
} }
private getCacheFilePath(): string { private getCacheFilePath(): string {
return join(app.getPath('userData'), 'analytics_cache.json') return join(app.getPath('documents'), 'WeFlow', 'analytics_cache.json')
} }
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> { private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
@@ -528,6 +528,18 @@ class AnalyticsService {
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
} }
async clearCache(): Promise<{ success: boolean; error?: string }> {
this.aggregateCache = null
this.fallbackAggregateCache = null
this.aggregatePromise = null
try {
await rm(this.getCacheFilePath(), { force: true })
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
} }
export const analyticsService = new AnalyticsService() export const analyticsService = new AnalyticsService()

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ interface ConfigSchema {
onboardingDone: boolean onboardingDone: boolean
imageXorKey: number imageXorKey: number
imageAesKey: string imageAesKey: string
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
// 缓存相关 // 缓存相关
cachePath: string cachePath: string
@@ -20,6 +21,11 @@ interface ConfigSchema {
language: string language: string
logEnabled: boolean logEnabled: boolean
llmModelPath: string llmModelPath: string
whisperModelName: string
whisperModelDir: string
whisperDownloadSource: string
autoTranscribeVoice: boolean
transcribeLanguages: string[]
} }
export class ConfigService { export class ConfigService {
@@ -35,6 +41,7 @@ export class ConfigService {
onboardingDone: false, onboardingDone: false,
imageXorKey: 0, imageXorKey: 0,
imageAesKey: '', imageAesKey: '',
wxidConfigs: {},
cachePath: '', cachePath: '',
lastOpenedDb: '', lastOpenedDb: '',
lastSession: '', lastSession: '',
@@ -42,7 +49,12 @@ export class ConfigService {
themeId: 'cloud-dancer', themeId: 'cloud-dancer',
language: 'zh-CN', language: 'zh-CN',
logEnabled: false, logEnabled: false,
llmModelPath: '' llmModelPath: '',
whisperModelName: 'base',
whisperModelDir: '',
whisperDownloadSource: 'tsinghua',
autoTranscribeVoice: false,
transcribeLanguages: ['zh']
} }
}) })
} }

View File

@@ -1,5 +1,5 @@
import { join, dirname } from 'path' import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron' import { app } from 'electron'
export interface ContactCacheEntry { export interface ContactCacheEntry {
@@ -15,7 +15,7 @@ export class ContactCacheService {
constructor(cacheBasePath?: string) { constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0 const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath ? cacheBasePath
: join(app.getPath('userData'), 'WeFlowCache') : join(app.getPath('documents'), 'WeFlow')
this.cacheFilePath = join(basePath, 'contacts.json') this.cacheFilePath = join(basePath, 'contacts.json')
this.ensureCacheDir() this.ensureCacheDir()
this.loadCache() this.loadCache()
@@ -34,6 +34,14 @@ export class ContactCacheService {
const raw = readFileSync(this.cacheFilePath, 'utf8') const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') { if (parsed && typeof parsed === 'object') {
// 清除无效的头像数据hex 格式而非正确的 base64
for (const key of Object.keys(parsed)) {
const entry = parsed[key]
if (entry?.avatarUrl && entry.avatarUrl.includes('base64,ffd8')) {
// 这是错误的 hex 格式,清除它
entry.avatarUrl = undefined
}
}
this.cache = parsed this.cache = parsed
} }
} catch (error) { } catch (error) {
@@ -72,4 +80,13 @@ export class ContactCacheService {
console.error('ContactCacheService: 保存缓存失败', error) console.error('ContactCacheService: 保存缓存失败', error)
} }
} }
clear(): void {
this.cache = {}
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('ContactCacheService: 清理缓存失败', error)
}
}
} }

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,44 @@ import { app, BrowserWindow } from 'electron'
import { basename, dirname, extname, join } from 'path' import { basename, dirname, extname, join } from 'path'
import { pathToFileURL } from 'url' import { pathToFileURL } from 'url'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs' import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
import { writeFile } from 'fs/promises' import { writeFile, rm, readdir } from 'fs/promises'
import crypto from 'crypto' import crypto from 'crypto'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
// 获取 ffmpeg-static 的路径
function getStaticFfmpegPath(): string | null {
try {
// 方法1: 直接 require ffmpeg-static
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ffmpegStatic = require('ffmpeg-static')
if (typeof ffmpegStatic === 'string' && existsSync(ffmpegStatic)) {
return ffmpegStatic
}
// 方法2: 手动构建路径(开发环境)
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
if (existsSync(devPath)) {
return devPath
}
// 方法3: 打包后的路径
if (app.isPackaged) {
const resourcesPath = process.resourcesPath
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
if (existsSync(packedPath)) {
return packedPath
}
}
return null
} catch {
return null
}
}
type DecryptResult = { type DecryptResult = {
success: boolean success: boolean
localPath?: string localPath?: string
@@ -36,14 +68,7 @@ export class ImageDecryptService {
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n` const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
// 同时输出到控制台 // 只写入文件,不输出到控制台
if (meta) {
console.info(message, meta)
} else {
console.info(message)
}
// 写入日志文件
this.writeLog(logLine) this.writeLog(logLine)
} }
@@ -83,7 +108,6 @@ export class ImageDecryptService {
for (const key of cacheKeys) { for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key) const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached)) { if (cached && existsSync(cached) && this.isImageFile(cached)) {
this.logInfo('缓存命中(从Map)', { key, path: cached, isThumb: this.isThumbnailPath(cached) })
const dataUrl = this.fileToDataUrl(cached) const dataUrl = this.fileToDataUrl(cached)
const isThumb = this.isThumbnailPath(cached) const isThumb = this.isThumbnailPath(cached)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
@@ -103,7 +127,6 @@ export class ImageDecryptService {
for (const key of cacheKeys) { for (const key of cacheKeys) {
const existing = this.findCachedOutput(key, false, payload.sessionId) const existing = this.findCachedOutput(key, false, payload.sessionId)
if (existing) { if (existing) {
this.logInfo('缓存命中(文件系统)', { key, path: existing, isThumb: this.isThumbnailPath(existing) })
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing) this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
const dataUrl = this.fileToDataUrl(existing) const dataUrl = this.fileToDataUrl(existing)
const isThumb = this.isThumbnailPath(existing) const isThumb = this.isThumbnailPath(existing)
@@ -238,20 +261,39 @@ export class ImageDecryptService {
const aesKey = this.resolveAesKey(aesKeyRaw) const aesKey = this.resolveAesKey(aesKeyRaw)
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey }) this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey) let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
const ext = this.detectImageExtension(decrypted) || '.jpg' // 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据
const wxgfResult = await this.unwrapWxgf(decrypted)
decrypted = wxgfResult.data
const outputPath = this.getCacheOutputPathFromDat(datPath, ext, payload.sessionId) let ext = this.detectImageExtension(decrypted)
// 如果是 wxgf 格式且没检测到扩展名
if (wxgfResult.isWxgf && !ext) {
ext = '.hevc'
}
const finalExt = ext || '.jpg'
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
await writeFile(outputPath, decrypted) await writeFile(outputPath, decrypted)
this.logInfo('解密成功', { outputPath, size: decrypted.length }) this.logInfo('解密成功', { outputPath, size: decrypted.length })
// 对于 hevc 格式,返回错误提示
if (finalExt === '.hevc') {
return {
success: false,
error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示',
isThumb: this.isThumbnailPath(datPath)
}
}
const isThumb = this.isThumbnailPath(datPath) const isThumb = this.isThumbnailPath(datPath)
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
if (!isThumb) { if (!isThumb) {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
} }
const dataUrl = this.bufferToDataUrl(decrypted, ext) const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
const localPath = dataUrl || this.filePathToUrl(outputPath) const localPath = dataUrl || this.filePathToUrl(outputPath)
this.emitCacheResolved(payload, cacheKey, localPath) this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb } return { success: true, localPath, isThumb }
@@ -904,6 +946,18 @@ export class ImageDecryptService {
extensions: string[], extensions: string[],
preferHd: boolean preferHd: boolean
): string | null { ): string | null {
// 先检查并删除旧的 .hevc 文件ffmpeg 转换失败时遗留的)
const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`)
const hevcHd = join(dirPath, `${normalizedKey}_hd.hevc`)
try {
if (existsSync(hevcThumb)) {
require('fs').unlinkSync(hevcThumb)
}
if (existsSync(hevcHd)) {
require('fs').unlinkSync(hevcHd)
}
} catch { }
for (const ext of extensions) { for (const ext of extensions) {
if (preferHd) { if (preferHd) {
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`) const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
@@ -1406,6 +1460,159 @@ export class ImageDecryptService {
return mostCommonKey return mostCommonKey
} }
/**
* 解包 wxgf 格式
* wxgf 是微信的图片格式,内部使用 HEVC 编码
*/
private async unwrapWxgf(buffer: Buffer): Promise<{ data: Buffer; isWxgf: boolean }> {
// 检查是否是 wxgf 格式 (77 78 67 66 = "wxgf")
if (buffer.length < 20 ||
buffer[0] !== 0x77 || buffer[1] !== 0x78 ||
buffer[2] !== 0x67 || buffer[3] !== 0x66) {
return { data: buffer, isWxgf: false }
}
// 先尝试搜索内嵌的传统图片签名
for (let i = 4; i < Math.min(buffer.length - 12, 4096); i++) {
if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) {
return { data: buffer.subarray(i), isWxgf: false }
}
if (buffer[i] === 0x89 && buffer[i + 1] === 0x50 &&
buffer[i + 2] === 0x4e && buffer[i + 3] === 0x47) {
return { data: buffer.subarray(i), isWxgf: false }
}
}
// 提取 HEVC NALU 裸流
const hevcData = this.extractHevcNalu(buffer)
if (!hevcData || hevcData.length < 100) {
return { data: buffer, isWxgf: true }
}
// 尝试用 ffmpeg 转换
try {
const jpgData = await this.convertHevcToJpg(hevcData)
if (jpgData && jpgData.length > 0) {
return { data: jpgData, isWxgf: false }
}
} catch {
// ffmpeg 转换失败
}
return { data: hevcData, isWxgf: true }
}
/**
* 从 wxgf 数据中提取 HEVC NALU 裸流
*/
private extractHevcNalu(buffer: Buffer): Buffer | null {
const nalUnits: Buffer[] = []
let i = 4
while (i < buffer.length - 4) {
if (buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01) {
let nalStart = i
let nalEnd = buffer.length
for (let j = i + 4; j < buffer.length - 3; j++) {
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00) {
if (buffer[j + 2] === 0x01 ||
(buffer[j + 2] === 0x00 && j + 3 < buffer.length && buffer[j + 3] === 0x01)) {
nalEnd = j
break
}
}
}
const nalUnit = buffer.subarray(nalStart, nalEnd)
if (nalUnit.length > 3) {
nalUnits.push(nalUnit)
}
i = nalEnd
} else {
i++
}
}
if (nalUnits.length === 0) {
for (let j = 4; j < buffer.length - 4; j++) {
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00 &&
buffer[j + 2] === 0x00 && buffer[j + 3] === 0x01) {
return buffer.subarray(j)
}
}
return null
}
return Buffer.concat(nalUnits)
}
/**
* 获取 ffmpeg 可执行文件路径
*/
private getFfmpegPath(): string {
const staticPath = getStaticFfmpegPath()
this.logInfo('ffmpeg 路径检测', { staticPath, exists: staticPath ? existsSync(staticPath) : false })
if (staticPath) {
return staticPath
}
// 回退到系统 ffmpeg
return 'ffmpeg'
}
/**
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
*/
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
const ffmpeg = this.getFfmpegPath()
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
return new Promise((resolve) => {
const { spawn } = require('child_process')
const chunks: Buffer[] = []
const errChunks: Buffer[] = []
const proc = spawn(ffmpeg, [
'-hide_banner',
'-loglevel', 'error',
'-f', 'hevc',
'-i', 'pipe:0',
'-vframes', '1',
'-q:v', '3',
'-f', 'mjpeg',
'pipe:1'
], {
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
proc.on('close', (code: number) => {
if (code === 0 && chunks.length > 0) {
this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length })
resolve(Buffer.concat(chunks))
} else {
const errMsg = Buffer.concat(errChunks).toString()
this.logInfo('ffmpeg 转换失败', { code, error: errMsg })
resolve(null)
}
})
proc.on('error', (err: Error) => {
this.logInfo('ffmpeg 进程错误', { error: err.message })
resolve(null)
})
proc.stdin.write(hevcData)
proc.stdin.end()
})
}
// 保留原有的解密到文件方法(用于兼容) // 保留原有的解密到文件方法(用于兼容)
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> { async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
const version = this.getDatVersion(inputPath) const version = this.getDatVersion(inputPath)
@@ -1430,6 +1637,71 @@ export class ImageDecryptService {
await writeFile(outputPath, decrypted) await writeFile(outputPath, decrypted)
} }
async clearCache(): Promise<{ success: boolean; error?: string }> {
this.resolvedCache.clear()
this.hardlinkCache.clear()
this.pending.clear()
this.updateFlags.clear()
this.cacheIndexed = false
this.cacheIndexing = null
const configured = this.configService.get('cachePath')
const root = configured
? join(configured, 'Images')
: join(app.getPath('documents'), 'WeFlow', 'Images')
try {
if (!existsSync(root)) {
return { success: true }
}
const monthPattern = /^\d{4}-\d{2}$/
const clearFilesInDir = async (dirPath: string): Promise<void> => {
let entries: Array<{ name: string; isDirectory: () => boolean }>
try {
entries = await readdir(dirPath, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const fullPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
await clearFilesInDir(fullPath)
continue
}
try {
await rm(fullPath, { force: true })
} catch { }
}
}
const traverse = async (dirPath: string): Promise<void> => {
let entries: Array<{ name: string; isDirectory: () => boolean }>
try {
entries = await readdir(dirPath, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const fullPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
if (monthPattern.test(entry.name)) {
await clearFilesInDir(fullPath)
} else {
await traverse(fullPath)
}
continue
}
try {
await rm(fullPath, { force: true })
} catch { }
}
}
await traverse(root)
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
} }
export const imageDecryptService = new ImageDecryptService() export const imageDecryptService = new ImageDecryptService()

View File

@@ -33,6 +33,7 @@ export class KeyService {
private ReadProcessMemory: any = null private ReadProcessMemory: any = null
private MEMORY_BASIC_INFORMATION: any = null private MEMORY_BASIC_INFORMATION: any = null
private TerminateProcess: any = null private TerminateProcess: any = null
private QueryFullProcessImageNameW: any = null
// User32 // User32
private EnumWindows: any = null private EnumWindows: any = null
@@ -194,6 +195,7 @@ export class KeyService {
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32']) this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE']) this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32']) this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['HANDLE', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64']) this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64'])
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))]) this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
@@ -310,7 +312,46 @@ export class KeyService {
} }
} }
private async getProcessExecutablePath(pid: number): Promise<string | null> {
if (!this.ensureKernel32()) return null
// 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION
const hProcess = this.OpenProcess(0x1000, false, pid)
if (!hProcess) return null
try {
const sizeBuf = Buffer.alloc(4)
sizeBuf.writeUInt32LE(1024, 0)
const pathBuf = Buffer.alloc(1024 * 2)
const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf)
if (ret) {
const len = sizeBuf.readUInt32LE(0)
return pathBuf.toString('ucs2', 0, len * 2)
}
return null
} catch (e) {
console.error('获取进程路径失败:', e)
return null
} finally {
this.CloseHandle(hProcess)
}
}
private async findWeChatInstallPath(): Promise<string | null> { private async findWeChatInstallPath(): Promise<string | null> {
// 0. 优先尝试获取正在运行的微信进程路径
try {
const pid = await this.findWeChatPid()
if (pid) {
const runPath = await this.getProcessExecutablePath(pid)
if (runPath && existsSync(runPath)) {
console.log('发现正在运行的微信进程,使用路径:', runPath)
return runPath
}
}
} catch (e) {
console.error('尝试获取运行中微信路径失败:', e)
}
// 1. Registry - Uninstall Keys // 1. Registry - Uninstall Keys
const uninstallKeys = [ const uninstallKeys = [
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall', 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
@@ -588,6 +629,11 @@ export class KeyService {
if (!ok) { if (!ok) {
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '' const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
if (error) { if (error) {
// 检测权限不足错误 (NTSTATUS 0xC0000022 = STATUS_ACCESS_DENIED)
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件如360、火绒等\n3. 确保微信没有以管理员权限运行'
return { success: false, error: friendlyError }
}
return { success: false, error } return { success: false, error }
} }
const statusBuffer = Buffer.alloc(256) const statusBuffer = Buffer.alloc(256)
@@ -836,16 +882,17 @@ export class KeyService {
return null return null
} }
private isAlphaNumAscii(byte: number): boolean { private isAlphaNumLower(byte: number): boolean {
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x30 && byte <= 0x39) // 只匹配小写字母 a-z 和数字 0-9AES密钥格式
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x30 && byte <= 0x39)
} }
private isUtf16AsciiKey(buf: Buffer, start: number): boolean { private isUtf16LowerKey(buf: Buffer, start: number): boolean {
if (start + 64 > buf.length) return false if (start + 64 > buf.length) return false
for (let j = 0; j < 32; j++) { for (let j = 0; j < 32; j++) {
const charByte = buf[start + j * 2] const charByte = buf[start + j * 2]
const nullByte = buf[start + j * 2 + 1] const nullByte = buf[start + j * 2 + 1]
if (nullByte !== 0x00 || !this.isAlphaNumAscii(charByte)) { if (nullByte !== 0x00 || !this.isAlphaNumLower(charByte)) {
return false return false
} }
} }
@@ -878,8 +925,6 @@ export class KeyService {
const regions: Array<[number, number]> = [] const regions: Array<[number, number]> = []
const MEM_COMMIT = 0x1000 const MEM_COMMIT = 0x1000
const MEM_PRIVATE = 0x20000 const MEM_PRIVATE = 0x20000
const MEM_MAPPED = 0x40000
const MEM_IMAGE = 0x1000000
const PAGE_NOACCESS = 0x01 const PAGE_NOACCESS = 0x01
const PAGE_GUARD = 0x100 const PAGE_GUARD = 0x100
@@ -894,11 +939,10 @@ export class KeyService {
const protect = info.Protect const protect = info.Protect
const type = info.Type const type = info.Type
const regionSize = Number(info.RegionSize) const regionSize = Number(info.RegionSize)
if (state === MEM_COMMIT && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) { // 只收集已提交的私有内存(大幅减少扫描区域)
if (type === MEM_PRIVATE || type === MEM_MAPPED || type === MEM_IMAGE) { if (state === MEM_COMMIT && type === MEM_PRIVATE && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) {
regions.push([Number(info.BaseAddress), regionSize]) regions.push([Number(info.BaseAddress), regionSize])
} }
}
const nextAddress = address + regionSize const nextAddress = address + regionSize
if (nextAddress <= address) break if (nextAddress <= address) break
@@ -926,87 +970,52 @@ export class KeyService {
try { try {
const allRegions = this.getMemoryRegions(hProcess) const allRegions = this.getMemoryRegions(hProcess)
const totalRegions = allRegions.length
let scannedCount = 0
let skippedCount = 0
// 优化1: 只保留小内存区域(< 10MB- 密钥通常在小区域,可大幅减少扫描时间 for (const [baseAddress, regionSize] of allRegions) {
const filteredRegions = allRegions.filter(([_, size]) => size <= 10 * 1024 * 1024) // 跳过太大的内存区域(> 100MB
if (regionSize > 100 * 1024 * 1024) {
// 优化2: 优先级排序 - 按大小升序,先扫描小区域(密钥通常在较小区域) skippedCount++
const sortedRegions = filteredRegions.sort((a, b) => a[1] - b[1])
// 优化3: 计算总字节数用于精确进度报告
const totalBytes = sortedRegions.reduce((sum, [_, size]) => sum + size, 0)
let processedBytes = 0
// 优化4: 减小分块大小到 1MB参考 wx_key 项目)
const chunkSize = 1 * 1024 * 1024
const overlap = 65
let currentRegion = 0
for (const [baseAddress, regionSize] of sortedRegions) {
currentRegion++
const progress = totalBytes > 0 ? Math.floor((processedBytes / totalBytes) * 100) : 0
onProgress?.(progress, 100, `扫描内存 ${progress}% (${currentRegion}/${sortedRegions.length})`)
// 每个区域都让出主线程确保UI流畅
await new Promise(resolve => setImmediate(resolve))
let offset = 0
let trailing: Buffer | null = null
while (offset < regionSize) {
const remaining = regionSize - offset
const currentChunkSize = remaining > chunkSize ? chunkSize : remaining
const chunk = this.readProcessMemory(hProcess, baseAddress + offset, currentChunkSize)
if (!chunk || !chunk.length) {
offset += currentChunkSize
trailing = null
continue continue
} }
let dataToScan: Buffer scannedCount++
if (trailing && trailing.length) { if (scannedCount % 10 === 0) {
dataToScan = Buffer.concat([trailing, chunk]) onProgress?.(scannedCount, totalRegions, `正在扫描微信内存... (${scannedCount}/${totalRegions})`)
} else { await new Promise(resolve => setImmediate(resolve))
dataToScan = chunk
} }
for (let i = 0; i < dataToScan.length - 34; i++) { const memory = this.readProcessMemory(hProcess, baseAddress, regionSize)
if (this.isAlphaNumAscii(dataToScan[i])) continue if (!memory) continue
// 直接在原始字节中搜索32字节的小写字母数字序列
for (let i = 0; i < memory.length - 34; i++) {
// 检查前导字符(不是小写字母或数字)
if (this.isAlphaNumLower(memory[i])) continue
// 检查接下来32个字节是否都是小写字母或数字
let valid = true let valid = true
for (let j = 1; j <= 32; j++) { for (let j = 1; j <= 32; j++) {
if (!this.isAlphaNumAscii(dataToScan[i + j])) { if (!this.isAlphaNumLower(memory[i + j])) {
valid = false valid = false
break break
} }
} }
if (valid && this.isAlphaNumAscii(dataToScan[i + 33])) { if (!valid) continue
valid = false
// 检查尾部字符(不是小写字母或数字)
if (i + 33 < memory.length && this.isAlphaNumLower(memory[i + 33])) {
continue
} }
if (valid) {
const keyBytes = dataToScan.subarray(i + 1, i + 33) const keyBytes = memory.subarray(i + 1, i + 33)
if (this.verifyKey(ciphertext, keyBytes)) { if (this.verifyKey(ciphertext, keyBytes)) {
return keyBytes.toString('ascii') return keyBytes.toString('ascii')
} }
} }
} }
for (let i = 0; i < dataToScan.length - 65; i++) {
if (!this.isUtf16AsciiKey(dataToScan, i)) continue
const keyBytes = Buffer.alloc(32)
for (let j = 0; j < 32; j++) {
keyBytes[j] = dataToScan[i + j * 2]
}
if (this.verifyKey(ciphertext, keyBytes)) {
return keyBytes.toString('ascii')
}
}
const start = dataToScan.length - overlap
trailing = dataToScan.subarray(start < 0 ? 0 : start)
offset += currentChunkSize
}
// 更新已处理字节数
processedBytes += regionSize
}
return null return null
} finally { } finally {
try { try {

View File

@@ -1,5 +1,5 @@
import { join, dirname } from 'path' import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron' import { app } from 'electron'
export interface SessionMessageCacheEntry { export interface SessionMessageCacheEntry {
@@ -15,7 +15,7 @@ export class MessageCacheService {
constructor(cacheBasePath?: string) { constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0 const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath ? cacheBasePath
: join(app.getPath('userData'), 'WeFlowCache') : join(app.getPath('documents'), 'WeFlow')
this.cacheFilePath = join(basePath, 'session-messages.json') this.cacheFilePath = join(basePath, 'session-messages.json')
this.ensureCacheDir() this.ensureCacheDir()
this.loadCache() this.loadCache()
@@ -65,4 +65,13 @@ export class MessageCacheService {
console.error('MessageCacheService: 保存缓存失败', error) console.error('MessageCacheService: 保存缓存失败', error)
} }
} }
clear(): void {
this.cache = {}
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('MessageCacheService: 清理缓存失败', error)
}
}
} }

View File

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

View File

@@ -0,0 +1,256 @@
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { ConfigService } from './config'
import Database from 'better-sqlite3'
import { wcdbService } from './wcdbService'
export interface VideoInfo {
videoUrl?: string // 视频文件路径(用于 readFile
coverUrl?: string // 封面 data URL
thumbUrl?: string // 缩略图 data URL
exists: boolean
}
class VideoService {
private configService: ConfigService
constructor() {
this.configService = new ConfigService()
}
/**
* 获取数据库根目录
*/
private getDbPath(): string {
return this.configService.get('dbPath') || ''
}
/**
* 获取当前用户的wxid
*/
private getMyWxid(): string {
return this.configService.get('myWxid') || ''
}
/**
* 获取缓存目录(解密后的数据库存放位置)
*/
private getCachePath(): string {
return this.configService.get('cachePath') || ''
}
/**
* 清理 wxid 目录名(去掉后缀)
*/
private cleanWxid(wxid: string): string {
const trimmed = wxid.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
/**
* 从 video_hardlink_info_v4 表查询视频文件名
* 优先使用 cachePath 中解密后的 hardlink.db使用 better-sqlite3
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
*/
private async queryVideoFileName(md5: string): Promise<string | undefined> {
const cachePath = this.getCachePath()
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
if (!wxid) return undefined
// 方法1优先在 cachePath 下查找解密后的 hardlink.db
if (cachePath) {
const cacheDbPaths = [
join(cachePath, cleanedWxid, 'hardlink.db'),
join(cachePath, wxid, 'hardlink.db'),
join(cachePath, 'hardlink.db'),
join(cachePath, 'databases', cleanedWxid, 'hardlink.db'),
join(cachePath, 'databases', wxid, 'hardlink.db')
]
for (const p of cacheDbPaths) {
if (existsSync(p)) {
try {
const db = new Database(p, { readonly: true })
const row = db.prepare(`
SELECT file_name, md5 FROM video_hardlink_info_v4
WHERE md5 = ?
LIMIT 1
`).get(md5) as { file_name: string; md5: string } | undefined
db.close()
if (row?.file_name) {
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
return realMd5
}
} catch (e) {
// Silently fail
}
}
}
}
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) {
const encryptedDbPaths = [
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
]
for (const p of encryptedDbPaths) {
if (existsSync(p)) {
try {
const escapedMd5 = md5.replace(/'/g, "''")
// 用 md5 字段查询,获取 file_name
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
const result = await wcdbService.execQuery('media', p, sql)
if (result.success && result.rows && result.rows.length > 0) {
const row = result.rows[0]
if (row?.file_name) {
// 提取不带扩展名的文件名作为实际视频 MD5
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
return realMd5
}
}
} catch (e) {
}
}
}
}
return undefined
}
/**
* 将文件转换为 data URL
*/
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
try {
if (!existsSync(filePath)) return undefined
const buffer = readFileSync(filePath)
return `data:${mimeType};base64,${buffer.toString('base64')}`
} catch {
return undefined
}
}
/**
* 根据视频MD5获取视频文件信息
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
if (!dbPath || !wxid || !videoMd5) {
return { exists: false }
}
// 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
const videoBaseDir = join(dbPath, wxid, 'msg', 'video')
if (!existsSync(videoBaseDir)) {
return { exists: false }
}
// 遍历年月目录查找视频文件
try {
const allDirs = readdirSync(videoBaseDir)
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
const yearMonthDirs = allDirs
.filter(dir => {
const dirPath = join(videoBaseDir, dir)
return statSync(dirPath).isDirectory()
})
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
// 检查视频文件是否存在
if (existsSync(videoPath)) {
return {
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true
}
}
}
} catch (e) {
console.error('[VideoService] Error searching for video:', e)
}
return { exists: false }
}
/**
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined {
// 打印前500字符看看 XML 结构
if (!content) return undefined
try {
// 提取所有可能的 md5 值进行日志
const allMd5s: string[] = []
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
let match
while ((match = md5Regex.exec(content)) !== null) {
allMd5s.push(`${match[0]}`)
}
// 提取 md5用于查询 hardlink.db
// 注意:不是 rawmd5rawmd5 是另一个值
// 格式: md5="xxx" 或 <md5>xxx</md5>
// 尝试从videomsg标签中提取md5
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMatch) {
return videoMsgMatch[1].toLowerCase()
}
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) {
return attrMatch[1].toLowerCase()
}
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5Match) {
return md5Match[1].toLowerCase()
}
} catch (e) {
console.error('[VideoService] 解析视频MD5失败:', e)
}
return undefined
}
}
export const videoService = new VideoService()

View File

@@ -0,0 +1,377 @@
import { app } from 'electron'
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream } from 'fs'
import { join } from 'path'
import * as https from 'https'
import * as http from 'http'
import { ConfigService } from './config'
// Sherpa-onnx 类型定义
type OfflineRecognizer = any
type OfflineStream = any
type ModelInfo = {
name: string
files: {
model: string
tokens: string
}
sizeBytes: number
sizeLabel: string
}
type DownloadProgress = {
modelName: string
downloadedBytes: number
totalBytes?: number
percent?: number
}
const SENSEVOICE_MODEL: ModelInfo = {
name: 'SenseVoiceSmall',
files: {
model: 'model.int8.onnx',
tokens: 'tokens.txt'
},
sizeBytes: 245_000_000,
sizeLabel: '245 MB'
}
const MODEL_DOWNLOAD_URLS = {
model: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/model.int8.onnx',
tokens: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/tokens.txt'
}
export class VoiceTranscribeService {
private configService = new ConfigService()
private downloadTasks = new Map<string, Promise<{ success: boolean; path?: string; error?: string }>>()
private recognizer: OfflineRecognizer | null = null
private isInitializing = false
private resolveModelDir(): string {
const configured = this.configService.get('whisperModelDir') as string | undefined
if (configured) return configured
return join(app.getPath('documents'), 'WeFlow', 'models', 'sensevoice')
}
private resolveModelPath(fileName: string): string {
return join(this.resolveModelDir(), fileName)
}
/**
* 检查模型状态
*/
async getModelStatus(): Promise<{
success: boolean
exists?: boolean
modelPath?: string
tokensPath?: string
sizeBytes?: number
error?: string
}> {
try {
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
const modelExists = existsSync(modelPath)
const tokensExists = existsSync(tokensPath)
const exists = modelExists && tokensExists
if (!exists) {
return { success: true, exists: false, modelPath, tokensPath }
}
const modelSize = statSync(modelPath).size
const tokensSize = statSync(tokensPath).size
const totalSize = modelSize + tokensSize
return {
success: true,
exists: true,
modelPath,
tokensPath,
sizeBytes: totalSize
}
} catch (error) {
return { success: false, error: String(error) }
}
}
/**
* 下载模型文件
*/
async downloadModel(
onProgress?: (progress: DownloadProgress) => void
): Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }> {
const cacheKey = 'sensevoice'
const pending = this.downloadTasks.get(cacheKey)
if (pending) return pending
const task = (async () => {
try {
const modelDir = this.resolveModelDir()
if (!existsSync(modelDir)) {
mkdirSync(modelDir, { recursive: true })
}
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
// 初始进度
onProgress?.({
modelName: SENSEVOICE_MODEL.name,
downloadedBytes: 0,
totalBytes: SENSEVOICE_MODEL.sizeBytes,
percent: 0
})
// 下载模型文件 (40%)
console.info('[VoiceTranscribe] 开始下载模型文件...')
await this.downloadToFile(
MODEL_DOWNLOAD_URLS.model,
modelPath,
'model',
(downloaded, total) => {
const percent = total ? (downloaded / total) * 40 : undefined
onProgress?.({
modelName: SENSEVOICE_MODEL.name,
downloadedBytes: downloaded,
totalBytes: SENSEVOICE_MODEL.sizeBytes,
percent
})
}
)
// 下载 tokens 文件 (30%)
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
await this.downloadToFile(
MODEL_DOWNLOAD_URLS.tokens,
tokensPath,
'tokens',
(downloaded, total) => {
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
const percent = total ? 40 + (downloaded / total) * 30 : 40
onProgress?.({
modelName: SENSEVOICE_MODEL.name,
downloadedBytes: modelSize + downloaded,
totalBytes: SENSEVOICE_MODEL.sizeBytes,
percent
})
}
)
console.info('[VoiceTranscribe] 模型下载完成')
console.info('[VoiceTranscribe] 所有文件下载完成')
return { success: true, modelPath, tokensPath }
} catch (error) {
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
try {
if (existsSync(modelPath)) unlinkSync(modelPath)
if (existsSync(tokensPath)) unlinkSync(tokensPath)
} catch { }
return { success: false, error: String(error) }
} finally {
this.downloadTasks.delete(cacheKey)
}
})()
this.downloadTasks.set(cacheKey, task)
return task
}
/**
* 转写 WAV 音频数据 (后台 Worker Threads 版本)
*/
async transcribeWavBuffer(
wavData: Buffer,
onPartial?: (text: string) => void,
languages?: string[]
): Promise<{ success: boolean; transcript?: string; error?: string }> {
return new Promise((resolve) => {
try {
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
if (!existsSync(modelPath) || !existsSync(tokensPath)) {
resolve({ success: false, error: '模型文件不存在,请先下载模型' })
return
}
// 获取配置的语言列表,如果没有传入则从配置读取
let supportedLanguages = languages
if (!supportedLanguages || supportedLanguages.length === 0) {
supportedLanguages = this.configService.get('transcribeLanguages')
// 如果配置中也没有或为空,使用默认值
if (!supportedLanguages || supportedLanguages.length === 0) {
supportedLanguages = ['zh', 'yue']
}
}
const { Worker } = require('worker_threads')
// main.js 和 transcribeWorker.js 同在 dist-electron 目录下
const workerPath = join(__dirname, 'transcribeWorker.js')
const worker = new Worker(workerPath, {
workerData: {
modelPath,
tokensPath,
wavData,
sampleRate: 16000,
languages: supportedLanguages
}
})
let finalTranscript = ''
worker.on('message', (msg: any) => {
console.log('[VoiceTranscribe] Worker 消息:', msg)
if (msg.type === 'partial') {
onPartial?.(msg.text)
} else if (msg.type === 'final') {
finalTranscript = msg.text
console.log('[VoiceTranscribe] 最终文本:', finalTranscript)
resolve({ success: true, transcript: finalTranscript })
worker.terminate()
} else if (msg.type === 'error') {
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
resolve({ success: false, error: msg.error })
worker.terminate()
}
})
worker.on('error', (err: Error) => {
resolve({ success: false, error: String(err) })
})
worker.on('exit', (code: number) => {
if (code !== 0) {
console.error(`[VoiceTranscribe] Worker stopped with exit code ${code}`)
resolve({ success: false, error: `Worker exited with code ${code}` })
}
})
} catch (error) {
resolve({ success: false, error: String(error) })
}
})
}
/**
* 下载文件
*/
private downloadToFile(
url: string,
targetPath: string,
fileName: string,
onProgress?: (downloaded: number, total?: number) => void,
remainingRedirects = 5
): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
console.info(`[VoiceTranscribe] 下载 ${fileName}:`, url)
const options = {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
timeout: 30000 // 30秒连接超时
}
const request = protocol.get(url, options, (response) => {
console.info(`[VoiceTranscribe] ${fileName} 响应状态:`, response.statusCode)
// 处理重定向
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) {
if (remainingRedirects <= 0) {
reject(new Error('重定向次数过多'))
return
}
console.info(`[VoiceTranscribe] 重定向到:`, response.headers.location)
this.downloadToFile(response.headers.location, targetPath, fileName, onProgress, remainingRedirects - 1)
.then(resolve)
.catch(reject)
return
}
if (response.statusCode !== 200) {
reject(new Error(`下载失败: HTTP ${response.statusCode}`))
return
}
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
let downloadedBytes = 0
console.info(`[VoiceTranscribe] ${fileName} 文件大小:`, totalBytes ? `${(totalBytes / 1024 / 1024).toFixed(2)} MB` : '未知')
const writer = createWriteStream(targetPath)
// 设置数据接收超时60秒没有数据则超时
let lastDataTime = Date.now()
const dataTimeout = setInterval(() => {
if (Date.now() - lastDataTime > 60000) {
clearInterval(dataTimeout)
response.destroy()
writer.close()
reject(new Error('下载超时60秒内未收到数据'))
}
}, 5000)
response.on('data', (chunk) => {
lastDataTime = Date.now()
downloadedBytes += chunk.length
onProgress?.(downloadedBytes, totalBytes)
})
response.on('error', (error) => {
clearInterval(dataTimeout)
try { writer.close() } catch { }
console.error(`[VoiceTranscribe] ${fileName} 响应错误:`, error)
reject(error)
})
writer.on('error', (error) => {
clearInterval(dataTimeout)
try { writer.close() } catch { }
console.error(`[VoiceTranscribe] ${fileName} 写入错误:`, error)
reject(error)
})
writer.on('finish', () => {
clearInterval(dataTimeout)
writer.close()
console.info(`[VoiceTranscribe] ${fileName} 下载完成:`, targetPath)
resolve()
})
response.pipe(writer)
})
request.on('timeout', () => {
request.destroy()
console.error(`[VoiceTranscribe] ${fileName} 连接超时`)
reject(new Error('连接超时'))
})
request.on('error', (error) => {
console.error(`[VoiceTranscribe] ${fileName} 请求错误:`, error)
reject(error)
})
})
}
/**
* 清理资源
*/
dispose() {
if (this.recognizer) {
try {
// sherpa-onnx 的 recognizer 可能需要手动释放
this.recognizer = null
} catch (error) {
}
}
}
}
export const voiceTranscribeService = new VoiceTranscribeService()

View File

@@ -1,6 +1,12 @@
import { join, dirname, basename } from 'path' import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
// DLL 初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null
export function getLastDllInitError(): string | null {
return lastDllInitError
}
export class WcdbCore { export class WcdbCore {
private resourcesPath: string | null = null private resourcesPath: string | null = null
private userDataPath: string | null = null private userDataPath: string | null = null
@@ -14,6 +20,7 @@ export class WcdbCore {
private currentWxid: string | null = null private currentWxid: string | null = null
// 函数引用 // 函数引用
private wcdbInitProtection: any = null
private wcdbInit: any = null private wcdbInit: any = null
private wcdbShutdown: any = null private wcdbShutdown: any = null
private wcdbOpenAccount: any = null private wcdbOpenAccount: any = null
@@ -48,6 +55,8 @@ export class WcdbCore {
private wcdbGetMessageById: any = null private wcdbGetMessageById: any = null
private wcdbGetEmoticonCdnUrl: any = null private wcdbGetEmoticonCdnUrl: any = null
private wcdbGetDbStatus: 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 avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
private readonly avatarCacheTtlMs = 10 * 60 * 1000 private readonly avatarCacheTtlMs = 10 * 60 * 1000
private logTimer: NodeJS.Timeout | null = null private logTimer: NodeJS.Timeout | null = null
@@ -108,12 +117,14 @@ export class WcdbCore {
private writeLog(message: string, force = false): void { private writeLog(message: string, force = false): void {
if (!force && !this.isLogEnabled()) return if (!force && !this.isLogEnabled()) return
const line = `[${new Date().toISOString()}] ${message}`
// 同时输出到控制台和文件
console.log('[WCDB]', message)
try { try {
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd() const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
const dir = join(base, 'logs') const dir = join(base, 'logs')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
const line = `[${new Date().toISOString()}] ${message}\n` appendFileSync(join(dir, 'wcdb.log'), line + '\n', { encoding: 'utf8' })
appendFileSync(join(dir, 'wcdb.log'), line, { encoding: 'utf8' })
} catch { } } catch { }
} }
@@ -206,8 +217,45 @@ export class WcdbCore {
return false return false
} }
// 关键修复:显式预加载依赖库 WCDB.dll 和 SDL2.dll
// Windows 加载器默认不会查找子目录中的依赖,必须先将其加载到内存
// 这可以解决部分用户因为 VC++ 运行时或 DLL 依赖问题导致的闪退
const dllDir = dirname(dllPath)
const wcdbCorePath = join(dllDir, 'WCDB.dll')
if (existsSync(wcdbCorePath)) {
try {
this.koffi.load(wcdbCorePath)
this.writeLog('预加载 WCDB.dll 成功')
} catch (e) {
console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e)
this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`)
}
}
const sdl2Path = join(dllDir, 'SDL2.dll')
if (existsSync(sdl2Path)) {
try {
this.koffi.load(sdl2Path)
this.writeLog('预加载 SDL2.dll 成功')
} catch (e) {
console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e)
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
}
}
this.lib = this.koffi.load(dllPath) this.lib = this.koffi.load(dllPath)
// InitProtection (Added for security)
try {
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
const protectionOk = this.wcdbInitProtection(dllDir)
if (!protectionOk) {
console.error('Core security check failed')
return false
}
} catch (e) {
console.warn('InitProtection symbol not found:', e)
}
// 定义类型 // 定义类型
// wcdb_status wcdb_init() // wcdb_status wcdb_init()
this.wcdbInit = this.lib.func('int32 wcdb_init()') this.wcdbInit = this.lib.func('int32 wcdb_init()')
@@ -345,6 +393,20 @@ export class WcdbCore {
this.wcdbGetDbStatus = null this.wcdbGetDbStatus = null
} }
// wcdb_status wcdb_get_voice_data(wcdb_handle handle, const char* session_id, int32_t create_time, int32_t local_id, int64_t svr_id, const char* candidates_json, char** out_hex)
try {
this.wcdbGetVoiceData = this.lib.func('int32 wcdb_get_voice_data(int64 handle, const char* sessionId, int32 createTime, int32 localId, int64 svrId, const char* candidatesJson, _Out_ void** outHex)')
} catch {
this.wcdbGetVoiceData = null
}
// wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json)
try {
this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)')
} catch {
this.wcdbGetSnsTimeline = null
}
// 初始化 // 初始化
const initResult = this.wcdbInit() const initResult = this.wcdbInit()
if (initResult !== 0) { if (initResult !== 0) {
@@ -353,9 +415,20 @@ export class WcdbCore {
} }
this.initialized = true this.initialized = true
lastDllInitError = null
return true return true
} catch (e) { } catch (e) {
console.error('WCDB 初始化异常:', e) const errorMsg = e instanceof Error ? e.message : String(e)
console.error('WCDB 初始化异常:', errorMsg)
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
lastDllInitError = errorMsg
// 检查是否是常见的 VC++ 运行时缺失错误
if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') ||
errorMsg.includes('The specified module could not be found')) {
lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。'
} else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) {
lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。'
}
return false return false
} }
} }
@@ -373,10 +446,18 @@ export class WcdbCore {
return { success: true, sessionCount: 0 } return { success: true, sessionCount: 0 }
} }
// 记录当前活动连接,用于在测试结束后恢复(避免影响聊天页等正在使用的连接)
const hadActiveConnection = this.handle !== null
const prevPath = this.currentPath
const prevKey = this.currentKey
const prevWxid = this.currentWxid
if (!this.initialized) { if (!this.initialized) {
const initOk = await this.initialize() const initOk = await this.initialize()
if (!initOk) { if (!initOk) {
return { success: false, error: 'WCDB 初始化失败' } // 返回更详细的错误信息,帮助用户诊断问题
const detailedError = lastDllInitError || 'WCDB 初始化失败'
return { success: false, error: detailedError }
} }
} }
@@ -415,8 +496,8 @@ export class WcdbCore {
return { success: false, error: '无效的数据库句柄' } return { success: false, error: '无效的数据库句柄' }
} }
// 测试成功使用 shutdown 清理所有资源(包括测试句柄) // 测试成功使用 shutdown 清理资源(包括测试句柄)
// 这会中断当前活动连接,但 testConnection 本应该是独立测试 // 注意shutdown 会断开当前活动连接,因此需要在测试后尝试恢复之前的连接
try { try {
this.wcdbShutdown() this.wcdbShutdown()
this.handle = null this.handle = null
@@ -428,6 +509,15 @@ export class WcdbCore {
console.error('关闭测试数据库时出错:', closeErr) console.error('关闭测试数据库时出错:', closeErr)
} }
// 恢复测试前的连接(如果之前有活动连接)
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
try {
await this.open(prevPath, prevKey, prevWxid)
} catch {
// 恢复失败则保持断开,由调用方处理
}
}
return { success: true, sessionCount: 0 } return { success: true, sessionCount: 0 }
} catch (e) { } catch (e) {
console.error('测试连接异常:', e) console.error('测试连接异常:', e)
@@ -611,7 +701,7 @@ export class WcdbCore {
try { try {
this.wcdbSetMyWxid(this.handle, wxid) this.wcdbSetMyWxid(this.handle, wxid)
} catch (e) { } catch (e) {
console.warn('设置 wxid 失败:', e) // 静默失败
} }
} }
if (this.isLogEnabled()) { if (this.isLogEnabled()) {
@@ -790,7 +880,6 @@ export class WcdbCore {
await new Promise(resolve => setImmediate(resolve)) await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) { if (result !== 0 || !outPtr[0]) {
console.warn(`[wcdbCore] getAvatarUrls DLL调用失败: result=${result}, usernames=${toFetch.length}`)
if (Object.keys(resultMap).length > 0) { if (Object.keys(resultMap).length > 0) {
return { success: true, map: resultMap, error: `获取头像失败: ${result}` } return { success: true, map: resultMap, error: `获取头像失败: ${result}` }
} }
@@ -798,25 +887,18 @@ export class WcdbCore {
} }
const jsonStr = this.decodeJsonPtr(outPtr[0]) const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) { if (!jsonStr) {
console.error('[wcdbCore] getAvatarUrls 解析JSON失败')
return { success: false, error: '解析头像失败' } return { success: false, error: '解析头像失败' }
} }
const map = JSON.parse(jsonStr) as Record<string, string> const map = JSON.parse(jsonStr) as Record<string, string>
let successCount = 0
let emptyCount = 0
for (const username of toFetch) { for (const username of toFetch) {
const url = map[username] const url = map[username]
if (url && url.trim()) { if (url && url.trim()) {
resultMap[username] = url resultMap[username] = url
// 只缓存有效的URL // 只缓存有效的URL
this.avatarUrlCache.set(username, { url, updatedAt: now }) this.avatarUrlCache.set(username, { url, updatedAt: now })
successCount++ }
} else {
emptyCount++
// 不缓存空URL,下次可以重新尝试 // 不缓存空URL,下次可以重新尝试
} }
}
console.log(`[wcdbCore] getAvatarUrls 成功: ${successCount}个, 空结果: ${emptyCount}个, 总请求: ${toFetch.length}`)
return { success: true, map: resultMap } return { success: true, map: resultMap }
} catch (e) { } catch (e) {
console.error('[wcdbCore] getAvatarUrls 异常:', e) console.error('[wcdbCore] getAvatarUrls 异常:', e)
@@ -1295,9 +1377,7 @@ export class WcdbCore {
} catch (e) { } catch (e) {
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
} } async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> {
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
try { try {
const outPtr = [null as any] const outPtr = [null as any]
@@ -1313,5 +1393,49 @@ export class WcdbCore {
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
} }
async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetVoiceData) return { success: false, error: '当前 DLL 版本不支持获取语音数据' }
try {
const outPtr = [null as any]
const result = this.wcdbGetVoiceData(this.handle, sessionId, createTime, localId, BigInt(svrId || 0), JSON.stringify(candidates), outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取语音数据失败: ${result}` }
}
const hex = this.decodeJsonPtr(outPtr[0])
if (hex === null) return { success: false, error: '解析语音数据失败' }
return { success: true, hex: hex || undefined }
} catch (e) {
return { success: false, error: String(e) }
}
} }
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
try {
const outPtr = [null as any]
const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : ''
const result = this.wcdbGetSnsTimeline(
this.handle,
limit,
offset,
usernamesJson,
keyword || '',
startTime || 0,
endTime || 0,
outPtr
)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取朋友圈失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析朋友圈数据失败' }
const timeline = JSON.parse(jsonStr)
return { success: true, timeline }
} catch (e) {
return { success: false, error: String(e) }
}
}
}

View File

@@ -58,11 +58,25 @@ export class WcdbService {
}) })
this.worker.on('error', (err) => { this.worker.on('error', (err) => {
// Worker 发生错误,需要 reject 所有 pending promises
console.error('WCDB Worker 错误:', err) console.error('WCDB Worker 错误:', err)
const errorMsg = err instanceof Error ? err.message : String(err)
for (const [id, p] of this.pending) {
p.reject(new Error(`Worker 错误: ${errorMsg}`))
}
this.pending.clear()
}) })
this.worker.on('exit', (code) => { this.worker.on('exit', (code) => {
if (code !== 0) console.error(`WCDB Worker 异常退出,退出码: ${code}`) // Worker 退出,需要 reject 所有 pending promises
if (code !== 0) {
console.error('WCDB Worker 异常退出,退出码:', code)
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
for (const [id, p] of this.pending) {
p.reject(new Error(errorMsg))
}
this.pending.clear()
}
this.worker = null this.worker = null
}) })
@@ -73,7 +87,7 @@ export class WcdbService {
this.setLogEnabled(this.logEnabled) this.setLogEnabled(this.logEnabled)
} catch (e) { } catch (e) {
console.error('创建 WCDB Worker 失败:', e) // Failed to create worker
} }
} }
@@ -97,7 +111,7 @@ export class WcdbService {
setPaths(resourcesPath: string, userDataPath: string): void { setPaths(resourcesPath: string, userDataPath: string): void {
this.resourcesPath = resourcesPath this.resourcesPath = resourcesPath
this.userDataPath = userDataPath this.userDataPath = userDataPath
this.callWorker('setPaths', { resourcesPath, userDataPath }).catch(console.error) this.callWorker('setPaths', { resourcesPath, userDataPath }).catch(() => { })
} }
/** /**
@@ -105,7 +119,7 @@ export class WcdbService {
*/ */
setLogEnabled(enabled: boolean): void { setLogEnabled(enabled: boolean): void {
this.logEnabled = enabled this.logEnabled = enabled
this.callWorker('setLogEnabled', { enabled }).catch(console.error) this.callWorker('setLogEnabled', { enabled }).catch(() => { })
} }
/** /**
@@ -341,6 +355,20 @@ export class WcdbService {
return this.callWorker('getMessageById', { sessionId, localId }) return this.callWorker('getMessageById', { sessionId, localId })
} }
/**
* 获取语音数据
*/
async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
}
/**
* 获取朋友圈
*/
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
}
} }
export const wcdbService = new WcdbService() export const wcdbService = new WcdbService()

View File

@@ -0,0 +1,166 @@
import { parentPort, workerData } from 'worker_threads'
interface WorkerParams {
modelPath: string
tokensPath: string
wavData: Buffer
sampleRate: number
languages?: string[]
}
// 语言标记映射
const LANGUAGE_TAGS: Record<string, string> = {
'zh': '<|zh|>',
'en': '<|en|>',
'ja': '<|ja|>',
'ko': '<|ko|>',
'yue': '<|yue|>' // 粤语
}
// 技术标签识别语言、语速、ITN等需要从最终文本中移除
const TECH_TAGS = [
'<|zh|>', '<|en|>', '<|ja|>', '<|ko|>', '<|yue|>',
'<|nospeech|>', '<|speech|>',
'<|itn|>', '<|wo_itn|>',
'<|NORMAL|>'
]
// 情感与事件标签映射,转换为直观的 Emoji
const RICH_TAG_MAP: Record<string, string> = {
'<|HAPPY|>': '😊',
'<|SAD|>': '😔',
'<|ANGRY|>': '😠',
'<|NEUTRAL|>': '', // 中性情感不特别标记
'<|FEARFUL|>': '😨',
'<|DISGUSTED|>': '🤢',
'<|SURPRISED|>': '😮',
'<|BGM|>': '🎵',
'<|Applause|>': '👏',
'<|Laughter|>': '😂',
'<|Cry|>': '😭',
'<|Cough|>': ' (咳嗽) ',
'<|Sneeze|>': ' (喷嚏) ',
}
/**
* 富文本后处理:移除技术标签,转换识别出的情感和声音事件
*/
function richTranscribePostProcess(text: string): string {
if (!text) return ''
let processed = text
// 1. 转换情感和事件标签
for (const [tag, replacement] of Object.entries(RICH_TAG_MAP)) {
// 使用正则全局替换,不区分大小写以防不同版本差异
const escapedTag = tag.replace(/[|<>]/g, '\\$&')
processed = processed.replace(new RegExp(escapedTag, 'gi'), replacement)
}
// 2. 移除所有剩余的技术标签
for (const tag of TECH_TAGS) {
const escapedTag = tag.replace(/[|<>]/g, '\\$&')
processed = processed.replace(new RegExp(escapedTag, 'gi'), '')
}
// 3. 清理多余空格并返回
return processed.replace(/\s+/g, ' ').trim()
}
// 检查识别结果是否在允许的语言列表中
function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
if (!result || !result.lang) {
// 如果没有语言信息,默认允许(或从文本开头尝试提取)
return true
}
// 如果没有指定语言或语言列表为空,默认允许中文和粤语
if (!allowedLanguages || allowedLanguages.length === 0) {
allowedLanguages = ['zh', 'yue']
}
const langTag = result.lang
console.log('[TranscribeWorker] 检测到语言标记:', langTag)
// 检查是否在允许的语言列表中
for (const lang of allowedLanguages) {
if (LANGUAGE_TAGS[lang] === langTag) {
console.log('[TranscribeWorker] 语言匹配,允许:', lang)
return true
}
}
console.log('[TranscribeWorker] 语言不在白名单中,过滤掉')
return false
}
async function run() {
if (!parentPort) {
return;
}
try {
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
let sherpa: any;
try {
sherpa = require('sherpa-onnx-node');
} catch (requireError) {
parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
return;
}
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams
const wavData = Buffer.from(rawWavData);
// 确保有有效的语言列表,默认只允许中文
let allowedLanguages = languages || ['zh']
if (allowedLanguages.length === 0) {
allowedLanguages = ['zh']
}
console.log('[TranscribeWorker] 使用的语言白名单:', allowedLanguages)
// 1. 初始化识别器 (SenseVoiceSmall)
const recognizerConfig = {
modelConfig: {
senseVoice: {
model: modelPath,
useInverseTextNormalization: 1
},
tokens: tokensPath,
numThreads: 2,
debug: 0
}
}
const recognizer = new sherpa.OfflineRecognizer(recognizerConfig)
// 2. 处理音频数据 (全量识别)
const pcmData = wavData.slice(44)
const samples = new Float32Array(pcmData.length / 2)
for (let i = 0; i < samples.length; i++) {
samples[i] = pcmData.readInt16LE(i * 2) / 32768.0
}
const stream = recognizer.createStream()
stream.acceptWaveform({ sampleRate, samples })
recognizer.decode(stream)
const result = recognizer.getResult(stream)
console.log('[TranscribeWorker] 识别完成 - 结果对象:', JSON.stringify(result, null, 2))
// 3. 检查语言是否在白名单中
if (isLanguageAllowed(result, allowedLanguages)) {
const processedText = richTranscribePostProcess(result.text)
console.log('[TranscribeWorker] 语言匹配,返回文本:', processedText)
parentPort.postMessage({ type: 'final', text: processedText })
} else {
console.log('[TranscribeWorker] 语言不匹配,返回空文本')
parentPort.postMessage({ type: 'final', text: '' })
}
} catch (error) {
parentPort.postMessage({ type: 'error', error: String(error) })
}
}
run();

4
electron/types/sherpa-onnx-node.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module 'sherpa-onnx-node' {
const content: any;
export = content;
}

22
electron/types/whisper-node.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
declare module 'whisper-node' {
export type WhisperSegment = {
start: string
end: string
speech: string
}
export type WhisperOptions = {
modelName?: string
modelPath?: string
whisperOptions?: {
language?: string
gen_file_txt?: boolean
gen_file_subtitle?: boolean
gen_file_vtt?: boolean
word_timestamps?: boolean
timestamp_size?: number
}
}
export default function whisper(filePath: string, options?: WhisperOptions): Promise<WhisperSegment[]>
}

View File

@@ -110,6 +110,15 @@ if (parentPort) {
case 'getMessageById': case 'getMessageById':
result = await core.getMessageById(payload.sessionId, payload.localId) result = await core.getMessageById(payload.sessionId, payload.localId)
break break
case 'getVoiceData':
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
if (!result.success) {
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: default:
result = { success: false, error: `Unknown method: ${type}` } result = { success: false, error: `Unknown method: ${type}` }
} }

9332
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.1.2", "version": "1.4.0",
"description": "WeFlow", "description": "WeFlow",
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"author": "cc", "author": "cc",
"//": "二改不应改变此处的作者与应用信息",
"scripts": { "scripts": {
"postinstall": "echo 'No native modules to rebuild'", "postinstall": "echo 'No native modules to rebuild'",
"rebuild": "echo 'No native modules to rebuild'", "rebuild": "echo 'No native modules to rebuild'",
@@ -19,6 +20,8 @@
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
"electron-store": "^10.0.0", "electron-store": "^10.0.0",
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
"exceljs": "^4.4.0",
"ffmpeg-static": "^5.3.0",
"fzstd": "^0.1.1", "fzstd": "^0.1.1",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jieba-wasm": "^2.2.0", "jieba-wasm": "^2.2.0",
@@ -28,6 +31,8 @@
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2", "wechat-emojis": "^1.0.2",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
@@ -97,6 +102,28 @@
"files": [ "files": [
"dist/**/*", "dist/**/*",
"dist-electron/**/*" "dist-electron/**/*"
],
"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/SDL2.dll Normal file

Binary file not shown.

BIN
resources/msvcp140.dll Normal file

Binary file not shown.

BIN
resources/msvcp140_1.dll Normal file

Binary file not shown.

Binary file not shown.

BIN
resources/vcruntime140.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -15,6 +15,8 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
import DataManagementPage from './pages/DataManagementPage' import DataManagementPage from './pages/DataManagementPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import ExportPage from './pages/ExportPage' import ExportPage from './pages/ExportPage'
import VideoWindow from './pages/VideoWindow'
import SnsPage from './pages/SnsPage'
import { useAppStore } from './stores/appStore' import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId } from './stores/themeStore' import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
@@ -29,6 +31,7 @@ function App() {
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
const isAgreementWindow = location.pathname === '/agreement-window' const isAgreementWindow = location.pathname === '/agreement-window'
const isOnboardingWindow = location.pathname === '/onboarding-window' const isOnboardingWindow = location.pathname === '/onboarding-window'
const isVideoPlayerWindow = location.pathname === '/video-player-window'
const [themeHydrated, setThemeHydrated] = useState(false) const [themeHydrated, setThemeHydrated] = useState(false)
// 协议同意状态 // 协议同意状态
@@ -182,9 +185,15 @@ function App() {
const decryptKey = await configService.getDecryptKey() const decryptKey = await configService.getDecryptKey()
const wxid = await configService.getMyWxid() const wxid = await configService.getMyWxid()
const onboardingDone = await configService.getOnboardingDone() const onboardingDone = await configService.getOnboardingDone()
const wxidConfig = wxid ? await configService.getWxidConfig(wxid) : null
const effectiveDecryptKey = wxidConfig?.decryptKey || decryptKey
if (wxidConfig?.decryptKey && wxidConfig.decryptKey !== decryptKey) {
await configService.setDecryptKey(wxidConfig.decryptKey)
}
// 如果配置完整,自动测试连接 // 如果配置完整,自动测试连接
if (dbPath && decryptKey && wxid) { if (dbPath && effectiveDecryptKey && wxid) {
if (!onboardingDone) { if (!onboardingDone) {
await configService.setOnboardingDone(true) await configService.setOnboardingDone(true)
} }
@@ -200,10 +209,22 @@ function App() {
} }
} else { } else {
console.log('自动连接失败:', result.error) console.log('自动连接失败:', result.error)
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
// 其他错误可能需要重新配置
const errorMsg = result.error || ''
if (errorMsg.includes('Visual C++') ||
errorMsg.includes('DLL') ||
errorMsg.includes('Worker') ||
errorMsg.includes('126') ||
errorMsg.includes('模块')) {
console.warn('检测到可能的运行时依赖问题:', errorMsg)
// 不清除配置,让用户安装 VC++ 后重试
}
} }
} }
} catch (e) { } catch (e) {
console.error('自动连接出错:', e) console.error('自动连接出错:', e)
// 捕获异常但不清除配置,防止循环重新引导
} }
} }
@@ -219,6 +240,11 @@ function App() {
return <WelcomePage standalone /> return <WelcomePage standalone />
} }
// 独立视频播放窗口
if (isVideoPlayerWindow) {
return <VideoWindow />
}
// 主窗口 - 完整布局 // 主窗口 - 完整布局
return ( return (
<div className="app-container"> <div className="app-container">
@@ -317,6 +343,7 @@ function App() {
<Route path="/data-management" element={<DataManagementPage />} /> <Route path="/data-management" element={<DataManagementPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<ExportPage />} /> <Route path="/export" element={<ExportPage />} />
<Route path="/sns" element={<SnsPage />} />
</Routes> </Routes>
</RouteGuard> </RouteGuard>
</main> </main>

View File

@@ -0,0 +1,73 @@
import React, { memo, useEffect, useState, useRef } from 'react'
interface AnimatedStreamingTextProps {
text: string
className?: string
loading?: boolean
}
export const AnimatedStreamingText = memo(({ text, className, loading }: AnimatedStreamingTextProps) => {
const [displayedSegments, setDisplayedSegments] = useState<string[]>([])
const prevTextRef = useRef('')
useEffect(() => {
const currentText = (text || '').trim()
const prevText = prevTextRef.current
if (currentText === prevText) return
if (!currentText.startsWith(prevText) && prevText !== '') {
// 如果不是追加而是全新的文本(比如重新识别),则重置
setDisplayedSegments([currentText])
prevTextRef.current = currentText
return
}
const newPart = currentText.slice(prevText.length)
if (newPart) {
// 将新部分作为单独的段加入,以触发动画
setDisplayedSegments(prev => [...prev, newPart])
}
prevTextRef.current = currentText
}, [text])
// 处理 loading 状态的显示
if (loading && !text) {
return <span className={className}><span className="dot-flashing">...</span></span>
}
return (
<span className={className}>
{displayedSegments.map((segment, index) => (
<span key={index} className="fade-in-text">
{segment}
</span>
))}
<style>{`
.fade-in-text {
animation: premiumFadeIn 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
opacity: 0;
display: inline-block;
filter: blur(4px);
}
@keyframes premiumFadeIn {
from {
opacity: 0;
transform: translateY(4px) scale(0.98);
filter: blur(4px);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
.dot-flashing {
animation: blink 1s infinite;
}
@keyframes blink { 50% { opacity: 0; } }
`}</style>
</span>
)
})
AnimatedStreamingText.displayName = 'AnimatedStreamingText'

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
.sidebar { .sidebar {
width: 200px; width: 220px;
background: var(--bg-secondary); background: var(--bg-secondary);
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
display: flex; display: flex;
@@ -32,14 +32,14 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
padding: 0 8px; padding: 0 12px;
} }
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 10px 16px; padding: 10px 12px;
border-radius: 9999px; border-radius: 9999px;
color: var(--text-secondary); color: var(--text-secondary);
text-decoration: none; text-decoration: none;
@@ -49,7 +49,6 @@
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
font-family: inherit; font-family: inherit;
width: 100%;
&:hover { &:hover {
background: var(--bg-tertiary); background: var(--bg-tertiary);

View File

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

View File

@@ -0,0 +1,262 @@
.voice-transcribe-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease-out;
}
.voice-transcribe-dialog {
background: var(--bg-secondary);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 480px;
animation: slideUp 0.3s ease-out;
overflow: hidden;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.close-button {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--text-secondary);
border-radius: 6px;
transition: all 0.15s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.dialog-content {
padding: 24px;
}
.info-section {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 16px;
.info-icon {
color: var(--primary);
opacity: 0.8;
}
.info-text {
font-size: 15px;
color: var(--text-primary);
margin: 0;
}
.model-info {
width: 100%;
background: var(--bg-tertiary);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
.model-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
.label {
color: var(--text-secondary);
}
.value {
color: var(--text-primary);
font-weight: 500;
}
}
}
}
.download-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px 0;
.download-icon {
.downloading-icon {
color: var(--primary);
animation: bounce 1s ease-in-out infinite;
}
}
.download-text {
font-size: 15px;
color: var(--text-primary);
margin: 0;
}
.progress-bar {
width: 100%;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
.progress-fill {
height: 100%;
background: var(--primary-gradient);
border-radius: 3px;
transition: width 0.3s ease;
}
}
.progress-text {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
font-variant-numeric: tabular-nums;
}
.download-hint {
font-size: 12px;
color: var(--text-tertiary);
margin: 8px 0 0 0;
text-align: center;
}
}
.complete-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px 0;
.complete-icon {
color: #10b981;
}
.complete-text {
font-size: 15px;
color: var(--text-primary);
margin: 0;
}
}
.error-message {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: #ef4444;
font-size: 14px;
margin-top: 16px;
}
.dialog-actions {
display: flex;
gap: 12px;
margin-top: 24px;
button {
flex: 1;
padding: 12px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
&.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
&:hover {
background: var(--bg-hover);
}
}
&.btn-primary {
background: var(--primary);
color: white;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}

View File

@@ -0,0 +1,160 @@
import React, { useState, useEffect } from 'react'
import { Download, X, CheckCircle, AlertCircle } from 'lucide-react'
import './VoiceTranscribeDialog.scss'
interface VoiceTranscribeDialogProps {
onClose: () => void
onDownloadComplete: () => void
}
export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
onClose,
onDownloadComplete
}) => {
const [isDownloading, setIsDownloading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(0)
const [downloadError, setDownloadError] = useState<string | null>(null)
const [isComplete, setIsComplete] = useState(false)
useEffect(() => {
// 监听下载进度
if (!window.electronAPI?.whisper?.onDownloadProgress) {
console.warn('[VoiceTranscribeDialog] whisper API 不可用')
return
}
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload) => {
if (payload.percent !== undefined) {
setDownloadProgress(payload.percent)
}
})
return () => {
removeListener?.()
}
}, [])
const handleDownload = async () => {
if (!window.electronAPI?.whisper?.downloadModel) {
setDownloadError('语音转文字功能不可用')
return
}
setIsDownloading(true)
setDownloadError(null)
setDownloadProgress(0)
try {
const result = await window.electronAPI.whisper.downloadModel()
if (result?.success) {
setIsComplete(true)
setDownloadProgress(100)
// 延迟关闭弹窗并触发转写
setTimeout(() => {
onDownloadComplete()
}, 1000)
} else {
setDownloadError(result?.error || '下载失败')
setIsDownloading(false)
}
} catch (error) {
setDownloadError(String(error))
setIsDownloading(false)
}
}
const handleCancel = () => {
if (!isDownloading && !isComplete) {
onClose()
}
}
return (
<div className="voice-transcribe-dialog-overlay" onClick={handleCancel}>
<div className="voice-transcribe-dialog" onClick={(e) => e.stopPropagation()}>
<div className="dialog-header">
<h3></h3>
{!isDownloading && !isComplete && (
<button className="close-button" onClick={onClose}>
<X size={20} />
</button>
)}
</div>
<div className="dialog-content">
{!isDownloading && !isComplete && (
<>
<div className="info-section">
<AlertCircle size={48} className="info-icon" />
<p className="info-text">
使 AI
</p>
<div className="model-info">
<div className="model-item">
<span className="label"></span>
<span className="value">SenseVoiceSmall</span>
</div>
<div className="model-item">
<span className="label"></span>
<span className="value"> 240 MB</span>
</div>
<div className="model-item">
<span className="label"></span>
<span className="value"></span>
</div>
</div>
</div>
{downloadError && (
<div className="error-message">
<AlertCircle size={16} />
<span>{downloadError}</span>
</div>
)}
<div className="dialog-actions">
<button className="btn-secondary" onClick={onClose}>
</button>
<button className="btn-primary" onClick={handleDownload}>
<Download size={16} />
<span></span>
</button>
</div>
</>
)}
{isDownloading && !isComplete && (
<div className="download-section">
<div className="download-icon">
<Download size={48} className="downloading-icon" />
</div>
<p className="download-text">
{downloadProgress < 1 ? '正在连接服务器...' : '正在下载模型...'}
</p>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${downloadProgress}%` }}
/>
</div>
<p className="progress-text">{downloadProgress.toFixed(1)}%</p>
{downloadProgress < 1 && (
<p className="download-hint"></p>
)}
</div>
)}
{isComplete && (
<div className="complete-section">
<CheckCircle size={48} className="complete-icon" />
<p className="complete-text">...</p>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react' import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
@@ -16,7 +16,7 @@ function AnalyticsPage() {
const themeMode = useThemeStore((state) => state.themeMode) const themeMode = useThemeStore((state) => state.themeMode)
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore() const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore()
const loadData = async (forceRefresh = false) => { const loadData = useCallback(async (forceRefresh = false) => {
if (isLoaded && !forceRefresh) return if (isLoaded && !forceRefresh) return
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
@@ -55,14 +55,22 @@ function AnalyticsPage() {
setIsLoading(false) setIsLoading(false)
if (removeListener) removeListener() if (removeListener) removeListener()
} }
} }, [isLoaded, markLoaded, setRankings, setStatistics, setTimeDistribution])
const location = useLocation() const location = useLocation()
useEffect(() => { useEffect(() => {
const force = location.state?.forceRefresh === true const force = location.state?.forceRefresh === true
loadData(force) loadData(force)
}, [location.state]) }, [location.state, loadData])
useEffect(() => {
const handleChange = () => {
loadData(true)
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadData])
const handleRefresh = () => loadData(true) const handleRefresh = () => loadData(true)

View File

@@ -1,4 +1,4 @@
.chat-page { .chat-page {
display: flex; display: flex;
height: 100%; height: 100%;
gap: 16px; gap: 16px;
@@ -370,9 +370,23 @@
} }
.message-bubble { .message-bubble {
max-width: 65%; display: flex;
gap: 12px;
max-width: 80%;
margin-bottom: 4px;
align-items: flex-start;
.bubble-body {
display: flex;
flex-direction: column;
max-width: 100%;
min-width: 0; // 允许收缩
width: fit-content; // 让气泡宽度由内容决定
}
&.sent { &.sent {
flex-direction: row-reverse;
.bubble-content { .bubble-content {
background: var(--primary-gradient); background: var(--primary-gradient);
color: #fff; color: #fff;
@@ -382,6 +396,10 @@
line-height: 1.5; line-height: 1.5;
box-shadow: 0 2px 10px var(--primary-light); box-shadow: 0 2px 10px var(--primary-light);
} }
.bubble-body {
align-items: flex-end;
}
} }
&.received { &.received {
@@ -395,6 +413,10 @@
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
.bubble-body {
align-items: flex-start;
}
} }
&.system { &.system {
@@ -428,6 +450,11 @@
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 4px; margin-bottom: 4px;
// 防止名字撑开气泡宽度
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.quoted-message { .quoted-message {
@@ -462,8 +489,21 @@
} }
.load-more-trigger { .load-more-trigger {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 0;
color: var(--text-tertiary); color: var(--text-tertiary);
font-size: 12px; font-size: 13px;
&.later {
padding: 24px 0 12px;
}
svg {
animation: spin 1s linear infinite;
}
} }
.empty-chat { .empty-chat {
@@ -790,6 +830,99 @@
} }
// 右侧消息区域 // 右侧消息区域
// ... (previous content) ...
// 链接卡片消息样式
.link-message {
cursor: pointer;
background: var(--card-bg);
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
max-width: 300px;
margin-top: 4px;
&:hover {
background: var(--bg-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.link-header {
display: flex;
align-items: flex-start;
padding: 12px;
gap: 12px;
}
.link-content {
flex: 1;
min-width: 0;
}
.link-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.link-desc {
font-size: 12px;
color: var(--text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
opacity: 0.8;
}
.link-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
background: var(--bg-tertiary);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
svg {
opacity: 0.8;
}
}
}
// 适配发送出去的消息中的链接卡片
.message-bubble.sent .link-message {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
.link-title,
.link-desc {
color: #fff;
}
.link-icon {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
.message-area { .message-area {
flex: 1 1 70%; flex: 1 1 70%;
display: flex; display: flex;
@@ -943,8 +1076,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; gap: 10px;
background: rgba(10, 10, 10, 0.28); background: var(--bg-tertiary);
backdrop-filter: blur(6px);
transition: opacity 200ms ease; transition: opacity 200ms ease;
z-index: 2; z-index: 2;
} }
@@ -1108,6 +1240,14 @@
border-radius: 16px; border-radius: 16px;
} }
} }
// 使发送的语音消息和转文字也使用接收者的样式 (浅色)
&.sent.voice {
.bubble-content {
background: var(--bg-secondary);
color: var(--text-primary);
}
}
} }
.bubble-avatar { .bubble-avatar {
@@ -1303,8 +1443,10 @@
cursor: pointer; cursor: pointer;
} }
.message-bubble.sent .voice-message { .voice-stack {
background: rgba(255, 255, 255, 0.18); display: flex;
flex-direction: column;
gap: 6px;
} }
.voice-play-btn { .voice-play-btn {
@@ -1339,6 +1481,50 @@
} }
} }
.voice-waveform {
flex: 1;
display: flex;
align-items: center;
gap: 2px;
height: 24px;
min-width: 120px;
}
.waveform-bar {
flex: 1;
width: 2px;
background: rgba(0, 0, 0, 0.1);
border-radius: 1px;
transition: transform 0.2s ease, background 0.2s ease;
&.played {
background: var(--primary);
}
}
.message-bubble.sent.voice .waveform-bar {
background: rgba(0, 0, 0, 0.1); // 基色改为透明黑
&.played {
background: var(--primary);
}
}
.voice-wave-placeholder {
display: flex;
align-items: flex-end;
gap: 3px;
height: 18px;
span {
width: 3px;
height: 8px;
border-radius: 2px;
background: var(--text-tertiary);
opacity: 0.6;
}
}
.voice-message.playing .voice-wave span { .voice-message.playing .voice-wave span {
animation: voicePulse 0.9s ease-in-out infinite; animation: voicePulse 0.9s ease-in-out infinite;
} }
@@ -1391,6 +1577,24 @@
color: #d9480f; color: #d9480f;
} }
.voice-transcript {
max-width: 260px;
padding: 8px 12px;
border-radius: 14px;
font-size: 13px;
line-height: 1.5;
background: var(--card-bg);
color: var(--text-primary);
border: 1px solid var(--border-color);
word-break: break-word;
white-space: pre-wrap;
}
.voice-transcript.error {
color: #d9480f;
cursor: pointer;
}
@keyframes voicePulse { @keyframes voicePulse {
0% { 0% {
height: 6px; height: 6px;
@@ -1413,6 +1617,11 @@
font-size: 12px; font-size: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
margin-bottom: 4px; margin-bottom: 4px;
// 防止名字撑开气泡宽度
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
// 引用消息样式 // 引用消息样式
@@ -1461,7 +1670,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-width: 100%; max-width: 100%;
min-width: 0; // 允许收缩
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
// 让气泡宽度由内容决定,而不是被父容器撑开
width: fit-content;
} }
.bubble-content { .bubble-content {
@@ -1848,3 +2061,113 @@
transform: translateX(0); transform: translateX(0);
} }
} }
/* 语音转文字按钮样式 */
.voice-transcribe-btn {
width: 28px;
height: 28px;
padding: 0;
margin-left: 8px;
border: none;
background: var(--primary-light);
border-radius: 50%;
color: var(--primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
&:hover {
background: var(--primary);
color: #fff;
transform: scale(1.05);
}
svg {
width: 14px;
height: 14px;
}
}
// 视频消息样式
.video-thumb-wrapper {
position: relative;
max-width: 300px;
min-width: 200px;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
background: var(--bg-tertiary);
transition: transform 0.2s;
&:hover {
transform: scale(1.02);
.video-play-button {
opacity: 1;
transform: translate(-50%, -50%) scale(1.1);
}
}
.video-thumb {
width: 100%;
height: auto;
display: block;
}
.video-thumb-placeholder {
width: 100%;
aspect-ratio: 16/9;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-hover);
color: var(--text-tertiary);
svg {
width: 32px;
height: 32px;
}
}
.video-play-button {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0.9;
transition: all 0.2s;
color: #fff;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
}
}
.video-placeholder,
.video-loading,
.video-unavailable {
min-width: 120px;
min-height: 80px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
border-radius: 12px;
background: var(--bg-tertiary);
color: var(--text-tertiary);
font-size: 13px;
svg {
width: 24px;
height: 24px;
}
}
.video-loading {
.spin {
animation: spin 1s linear infinite;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,11 @@ function DataManagementPage() {
setWxid(id) setWxid(id)
} }
loadConfig() loadConfig()
const handleChange = () => {
loadConfig()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, []) }, [])
return ( return (

View File

@@ -396,6 +396,99 @@
} }
} }
.select-field {
position: relative;
}
.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: 260px;
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);
}
.media-options { .media-options {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -602,6 +695,87 @@
} }
} }
.export-layout-modal {
background: var(--card-bg);
padding: 28px 32px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
text-align: center;
width: min(520px, 90vw);
h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px;
}
.layout-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 20px;
}
.layout-options {
display: grid;
gap: 12px;
}
.layout-option-btn {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 18px;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
text-align: left;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
}
&.primary {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.12);
}
.layout-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.layout-desc {
font-size: 12px;
color: var(--text-tertiary);
}
}
.layout-actions {
margin-top: 18px;
display: flex;
justify-content: center;
}
.layout-cancel-btn {
padding: 8px 20px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
}
}
}
.export-result-modal { .export-result-modal {
background: var(--card-bg); background: var(--card-bg);
padding: 32px 40px; padding: 32px 40px;
@@ -920,3 +1094,140 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
// 媒体导出选项卡片样式
.setting-subtitle {
font-size: 12px;
color: var(--text-tertiary);
margin: 4px 0 12px 0;
}
.media-options-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
}
.media-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
}
.media-switch-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.media-switch-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.media-switch-desc {
font-size: 11px;
color: var(--text-tertiary);
}
.media-option-divider {
height: 1px;
background: var(--border-color);
margin-left: 16px;
}
.media-checkbox-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
cursor: pointer;
transition: background 0.2s;
&:hover:not(.disabled) {
background: var(--bg-hover);
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
&:disabled {
cursor: not-allowed;
}
}
}
.media-checkbox-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.media-checkbox-title {
font-size: 14px;
color: var(--text-primary);
}
.media-checkbox-desc {
font-size: 11px;
color: var(--text-tertiary);
}
// Switch 开关样式
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary);
transition: 0.3s;
border-radius: 24px;
&::before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
}
input:checked+.slider {
background-color: var(--primary);
}
input:checked+.slider::before {
transform: translateX(20px);
}
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react' import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
import * as configService from '../services/config' import * as configService from '../services/config'
import './ExportPage.scss' import './ExportPage.scss'
@@ -16,6 +16,14 @@ interface ExportOptions {
dateRange: { start: Date; end: Date } | null dateRange: { start: Date; end: Date } | null
useAllTime: boolean useAllTime: boolean
exportAvatars: boolean exportAvatars: boolean
exportMedia: boolean
exportImages: boolean
exportVoices: boolean
exportEmojis: boolean
exportVoiceAsText: boolean
excelCompactColumns: boolean
txtColumns: string[]
displayNamePreference: 'group-nickname' | 'remark' | 'nickname'
} }
interface ExportResult { interface ExportResult {
@@ -25,7 +33,10 @@ interface ExportResult {
error?: string error?: string
} }
type SessionLayout = 'shared' | 'per-session'
function ExportPage() { function ExportPage() {
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const [sessions, setSessions] = useState<ChatSession[]>([]) const [sessions, setSessions] = useState<ChatSession[]>([])
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([]) const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set()) const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
@@ -38,17 +49,47 @@ function ExportPage() {
const [showDatePicker, setShowDatePicker] = useState(false) const [showDatePicker, setShowDatePicker] = useState(false)
const [calendarDate, setCalendarDate] = useState(new Date()) const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true) const [selectingStart, setSelectingStart] = useState(true)
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'chatlab', format: 'excel',
dateRange: { dateRange: {
start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), start: new Date(new Date().setHours(0, 0, 0, 0)),
end: new Date() end: new Date()
}, },
useAllTime: true, useAllTime: false,
exportAvatars: true exportAvatars: true,
exportMedia: false,
exportImages: true,
exportVoices: true,
exportEmojis: true,
exportVoiceAsText: true,
excelCompactColumns: true,
txtColumns: defaultTxtColumns,
displayNamePreference: 'remark'
}) })
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 () => { const loadSessions = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
try { try {
@@ -84,10 +125,84 @@ 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(() => { useEffect(() => {
loadSessions() loadSessions()
loadExportPath() loadExportPath()
}, [loadSessions, loadExportPath]) loadExportDefaults()
}, [loadSessions, loadExportPath, loadExportDefaults])
useEffect(() => {
const handleChange = () => {
setSelectedSessions(new Set())
setSearchKeyword('')
setExportResult(null)
setSessions([])
setFilteredSessions([])
loadSessions()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadSessions])
useEffect(() => {
const removeListener = window.electronAPI.export.onProgress?.((payload) => {
setExportProgress({
current: payload.current,
total: payload.total,
currentName: payload.currentSession
})
})
return () => {
removeListener?.()
}
}, [])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) {
setShowDisplayNameSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showDisplayNameSelect])
useEffect(() => { useEffect(() => {
if (!searchKeyword.trim()) { if (!searchKeyword.trim()) {
@@ -128,13 +243,30 @@ function ExportPage() {
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) 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 () => { const openExportFolder = async () => {
if (exportFolder) { if (exportFolder) {
await window.electronAPI.shell.openPath(exportFolder) await window.electronAPI.shell.openPath(exportFolder)
} }
} }
const startExport = async () => { const runExport = async (sessionLayout: SessionLayout) => {
if (selectedSessions.size === 0 || !exportFolder) return if (selectedSessions.size === 0 || !exportFolder) return
setIsExporting(true) setIsExporting(true)
@@ -146,14 +278,23 @@ function ExportPage() {
const exportOptions = { const exportOptions = {
format: options.format, format: options.format,
exportAvatars: options.exportAvatars, exportAvatars: options.exportAvatars,
exportMedia: options.exportMedia,
exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices,
exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
excelCompactColumns: options.excelCompactColumns,
txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference,
sessionLayout,
dateRange: options.useAllTime ? null : options.dateRange ? { dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000), start: Math.floor(options.dateRange.start.getTime() / 1000),
// 将结束日期设置为当天的 23:59:59,以包含当天的所有消息 // 将结束日期设置为当天的 23:59:59,确保包含当天的所有记录
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
} : null } : null
} }
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json') { 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( const result = await window.electronAPI.export.exportSessions(
sessionList, sessionList,
exportFolder, exportFolder,
@@ -161,16 +302,28 @@ function ExportPage() {
) )
setExportResult(result) setExportResult(result)
} else { } else {
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` }) setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` })
} }
} catch (e) { } catch (e) {
console.error('导出失败:', e) console.error('导出过程中发生异常:', e)
setExportResult({ success: false, error: String(e) }) setExportResult({ success: false, error: String(e) })
} finally { } finally {
setIsExporting(false) setIsExporting(false)
} }
} }
const startExport = () => {
if (selectedSessions.size === 0 || !exportFolder) return
if (options.exportMedia && selectedSessions.size > 1) {
setShowMediaLayoutPrompt(true)
return
}
const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared'
runExport(layout)
}
const getDaysInMonth = (date: Date) => { const getDaysInMonth = (date: Date) => {
const year = date.getFullYear() const year = date.getFullYear()
const month = date.getMonth() const month = date.getMonth()
@@ -203,18 +356,54 @@ function ExportPage() {
const year = calendarDate.getFullYear() const year = calendarDate.getFullYear()
const month = calendarDate.getMonth() const month = calendarDate.getMonth()
const selectedDate = new Date(year, month, day) const selectedDate = new Date(year, month, day)
// 设置时间为当天的开始或结束
selectedDate.setHours(selectingStart ? 0 : 23, selectingStart ? 0 : 59, selectingStart ? 0 : 59, selectingStart ? 0 : 999)
const now = new Date()
// 如果选择的日期晚于当前时间,限制为当前时间
if (selectedDate > now) {
selectedDate.setTime(now.getTime())
}
if (selectingStart) { if (selectingStart) {
// 选择开始日期
const currentEnd = options.dateRange?.end || new Date()
// 如果选择的开始日期晚于结束日期,则同时更新结束日期
if (selectedDate > currentEnd) {
const newEnd = new Date(selectedDate)
newEnd.setHours(23, 59, 59, 999)
// 确保结束日期也不晚于当前时间
if (newEnd > now) {
newEnd.setTime(now.getTime())
}
setOptions({
...options,
dateRange: { start: selectedDate, end: newEnd }
})
} else {
setOptions({ setOptions({
...options, ...options,
dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() } dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() }
}) })
}
setSelectingStart(false) setSelectingStart(false)
} else {
// 选择结束日期
const currentStart = options.dateRange?.start || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
// 如果选择的结束日期早于开始日期,则同时更新开始日期
if (selectedDate < currentStart) {
const newStart = new Date(selectedDate)
newStart.setHours(0, 0, 0, 0)
setOptions({
...options,
dateRange: { start: newStart, end: selectedDate }
})
} else { } else {
setOptions({ setOptions({
...options, ...options,
dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate } dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate }
}) })
}
setSelectingStart(true) setSelectingStart(true)
} }
} }
@@ -228,6 +417,25 @@ function ExportPage() {
{ value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' }, { value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' },
{ value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' } { value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' }
] ]
const displayNameOptions = [
{
value: 'group-nickname',
label: '群昵称优先',
desc: '仅群聊有效,私聊显示备注/昵称'
},
{
value: 'remark',
label: '备注优先',
desc: '有备注显示备注,否则显示昵称'
},
{
value: 'nickname',
label: '微信昵称',
desc: '始终显示微信昵称'
}
]
const displayNameOption = displayNameOptions.find(option => option.value === options.displayNamePreference)
const displayNameLabel = displayNameOption?.label || '备注优先'
return ( return (
<div className="export-page"> <div className="export-page">
@@ -311,7 +519,7 @@ function ExportPage() {
<div <div
key={fmt.value} key={fmt.value}
className={`format-card ${options.format === fmt.value ? 'active' : ''}`} 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} /> <fmt.icon size={24} />
<span className="format-label">{fmt.label}</span> <span className="format-label">{fmt.label}</span>
@@ -342,19 +550,144 @@ function ExportPage() {
</div> </div>
</div> </div>
{/* 发送者名称显示偏好 */}
{(options.format === 'html' || options.format === 'json' || options.format === 'txt') && (
<div className="setting-section"> <div className="setting-section">
<h3></h3> <h3></h3>
<div className="time-options"> <p className="setting-subtitle"></p>
<label className="checkbox-item"> <div className="select-field" ref={displayNameDropdownRef}>
<button
type="button"
className={`select-trigger ${showDisplayNameSelect ? 'open' : ''}`}
onClick={() => setShowDisplayNameSelect(!showDisplayNameSelect)}
>
<span className="select-value">{displayNameLabel}</span>
<ChevronDown size={16} />
</button>
{showDisplayNameSelect && (
<div className="select-dropdown">
{displayNameOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${options.displayNamePreference === option.value ? 'active' : ''}`}
onClick={() => {
setOptions({
...options,
displayNamePreference: option.value as ExportOptions['displayNamePreference']
})
setShowDisplayNameSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
)}
<div className="setting-section">
<h3></h3>
<p className="setting-subtitle">//</p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
<span className="media-switch-title"></span>
<span className="media-switch-desc"></span>
</div>
<label className="switch">
<input
type="checkbox"
checked={options.exportMedia}
onChange={e => setOptions({ ...options, exportMedia: e.target.checked })}
/>
<span className="slider"></span>
</label>
</div>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span>
</div>
<input
type="checkbox"
checked={options.exportImages}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportImages: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"> MP3</span>
</div>
<input
type="checkbox"
checked={options.exportVoices}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportVoices: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className="media-checkbox-row">
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span>
</div>
<input
type="checkbox"
checked={options.exportVoiceAsText}
onChange={e => setOptions({ ...options, exportVoiceAsText: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span>
</div>
<input
type="checkbox"
checked={options.exportEmojis}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportEmojis: e.target.checked })}
/>
</label>
</div>
</div>
<div className="setting-section">
<h3></h3>
<p className="setting-subtitle"></p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
<span className="media-switch-title"></span>
<span className="media-switch-desc"></span>
</div>
<label className="switch">
<input <input
type="checkbox" type="checkbox"
checked={options.exportAvatars} checked={options.exportAvatars}
onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })} onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })}
/> />
<span></span> <span className="slider"></span>
</label> </label>
</div> </div>
</div> </div>
</div>
<div className="setting-section"> <div className="setting-section">
<h3></h3> <h3></h3>
@@ -406,6 +739,43 @@ function ExportPage() {
</div> </div>
</div> </div>
{/* 媒体导出布局选择弹窗 */}
{showMediaLayoutPrompt && (
<div className="export-overlay" onClick={() => setShowMediaLayoutPrompt(false)}>
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
<h3></h3>
<p className="layout-subtitle"></p>
<div className="layout-options">
<button
className="layout-option-btn primary"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('shared')
}}
>
<span className="layout-title"></span>
<span className="layout-desc"> media </span>
</button>
<button
className="layout-option-btn"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('per-session')
}}
>
<span className="layout-title"></span>
<span className="layout-desc"></span>
</button>
</div>
<div className="layout-actions">
<button className="layout-cancel-btn" onClick={() => setShowMediaLayoutPrompt(false)}>
</button>
</div>
</div>
</div>
)}
{/* 导出进度弹窗 */} {/* 导出进度弹窗 */}
{isExporting && ( {isExporting && (
<div className="export-overlay"> <div className="export-overlay">
@@ -462,6 +832,9 @@ function ExportPage() {
<div className="export-overlay" onClick={() => setShowDatePicker(false)}> <div className="export-overlay" onClick={() => setShowDatePicker(false)}>
<div className="date-picker-modal" onClick={e => e.stopPropagation()}> <div className="date-picker-modal" onClick={e => e.stopPropagation()}>
<h3></h3> <h3></h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: '8px 0 16px 0' }}>
</p>
<div className="quick-select"> <div className="quick-select">
<button <button
className="quick-btn" className="quick-btn"
@@ -556,12 +929,16 @@ function ExportPage() {
const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString() const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString()
const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString() const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString()
const isInRange = options.dateRange && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end const isInRange = options.dateRange && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end
const today = new Date()
today.setHours(0, 0, 0, 0)
const isFuture = currentDate > today
return ( return (
<div <div
key={day} key={day}
className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''}`} className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''} ${isFuture ? 'disabled' : ''}`}
onClick={() => handleDateSelect(day)} onClick={() => !isFuture && handleDateSelect(day)}
style={{ cursor: isFuture ? 'not-allowed' : 'pointer', opacity: isFuture ? 0.3 : 1 }}
> >
{day} {day}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react' import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
@@ -56,7 +56,7 @@ function GroupAnalyticsPage() {
useEffect(() => { useEffect(() => {
loadGroups() loadGroups()
}, []) }, [loadGroups])
useEffect(() => { useEffect(() => {
if (searchQuery) { if (searchQuery) {
@@ -93,7 +93,7 @@ function GroupAnalyticsPage() {
} }
}, [dateRangeReady]) }, [dateRangeReady])
const loadGroups = async () => { const loadGroups = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const result = await window.electronAPI.groupAnalytics.getGroupChats() const result = await window.electronAPI.groupAnalytics.getGroupChats()
@@ -106,7 +106,23 @@ function GroupAnalyticsPage() {
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
}, [])
useEffect(() => {
const handleChange = () => {
setGroups([])
setFilteredGroups([])
setSelectedGroup(null)
setSelectedFunction(null)
setMembers([])
setRankings([])
setActiveHours({})
setMediaStats(null)
void loadGroups()
} }
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadGroups])
const handleGroupSelect = (group: GroupChatInfo) => { const handleGroupSelect = (group: GroupChatInfo) => {
if (selectedGroup?.username !== group.username) { if (selectedGroup?.username !== group.username) {

View File

@@ -204,6 +204,117 @@
} }
} }
select {
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);
margin-bottom: 10px;
cursor: pointer;
&:focus {
outline: none;
border-color: var(--primary);
}
}
.select-field {
position: relative;
margin-bottom: 10px;
}
.select-trigger {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--text-tertiary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.select-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
box-shadow: var(--shadow-md);
z-index: 20;
max-height: 320px;
overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.select-option {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
transition: all 0.15s;
color: var(--text-primary);
font-size: 14px;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
}
.option-label {
font-weight: 500;
}
.option-desc {
font-size: 12px;
color: var(--text-tertiary);
}
.select-option.active .option-desc {
color: var(--primary);
}
.input-with-toggle { .input-with-toggle {
position: relative; position: relative;
display: flex; display: flex;
@@ -235,6 +346,184 @@
} }
} }
.whisper-section {
background: color-mix(in srgb, var(--primary) 3%, transparent);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 20px;
margin-top: 24px;
label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.whisper-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.whisper-field {
display: flex;
flex-direction: column;
}
.field-label {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.whisper-status-line {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-secondary);
margin: 12px 0 16px;
padding: 10px 14px;
background: var(--bg-primary);
border-radius: 12px;
border: 1px solid var(--border-color);
.status {
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.status.ok {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.status.warn {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
border: 1px solid rgba(245, 158, 11, 0.2);
}
.path {
flex: 1;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.8;
}
}
.whisper-progress {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-top: 10px;
.progress-bar-container {
display: flex;
align-items: center;
gap: 12px;
}
.progress-bar {
flex: 1;
height: 8px;
background: var(--bg-tertiary);
border-radius: 999px;
overflow: hidden;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary) 0%, var(--primary-hover) 100%);
border-radius: 999px;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0) 100%);
animation: progress-shimmer 2s infinite;
}
}
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
&.percent {
color: var(--primary);
font-weight: 600;
}
}
}
}
.btn-download-model {
width: 100%;
height: 44px;
justify-content: center;
font-size: 15px;
font-weight: 600;
margin-top: 8px;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 20%, transparent);
border: 1px solid rgba(255, 255, 255, 0.1);
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 16px color-mix(in srgb, var(--primary) 30%, transparent);
}
&:active:not(:disabled) {
transform: translateY(0);
}
svg {
transition: transform 0.2s;
}
&:hover svg {
transform: translateY(2px);
}
}
}
@keyframes progress-shimmer {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
.log-toggle-line { .log-toggle-line {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -248,6 +537,72 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.language-checkboxes {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.language-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
position: relative;
input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkbox-custom {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-primary);
border: 1.5px solid var(--border-color);
border-radius: 12px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
svg {
opacity: 0;
transform: scale(0.5);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
}
&:hover .checkbox-custom {
border-color: var(--text-tertiary);
background: var(--bg-tertiary);
color: var(--text-primary);
}
input:checked+.checkbox-custom {
background: color-mix(in srgb, var(--primary) 10%, transparent);
border-color: var(--primary);
color: var(--primary);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 10%, transparent);
svg {
opacity: 1;
transform: scale(1);
}
}
&:active .checkbox-custom {
transform: scale(0.96);
}
}
.switch { .switch {
position: relative; position: relative;
width: 46px; width: 46px;
@@ -331,18 +686,33 @@
.btn-primary { .btn-primary {
background: var(--primary); background: var(--primary);
color: white; color: white;
box-shadow: 0 2px 6px color-mix(in srgb, var(--primary) 15%, transparent);
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: var(--primary-hover); background: var(--primary-hover);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 25%, transparent);
transform: translateY(-1px);
}
&:active:not(:disabled) {
transform: translateY(0);
} }
} }
.btn-secondary { .btn-secondary {
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-primary); color: var(--text-primary);
border: 1px solid var(--border-color);
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: var(--border-color); background: var(--bg-primary);
border-color: var(--text-tertiary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transform: translateY(-1px);
}
&:active:not(:disabled) {
transform: translateY(0);
} }
} }
@@ -395,6 +765,7 @@
opacity: 0; opacity: 0;
transform: translateX(-50%) translateY(-10px); transform: translateX(-50%) translateY(-10px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateX(-50%) translateY(0); transform: translateX(-50%) translateY(0);
@@ -402,9 +773,12 @@
} }
@keyframes pulse { @keyframes pulse {
0%, 100% {
0%,
100% {
opacity: 1; opacity: 1;
} }
50% { 50% {
opacity: 0.6; opacity: 0.6;
} }
@@ -667,8 +1041,13 @@
} }
@keyframes spin { @keyframes spin {
from { transform: rotate(0deg); } from {
to { transform: rotate(360deg); } transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }
@@ -777,7 +1156,6 @@
input { input {
flex: 1; flex: 1;
padding-right: 36px;
} }
} }
@@ -811,13 +1189,15 @@
left: 0; left: 0;
right: 0; right: 0;
margin-top: 4px; margin-top: 4px;
background: var(--bg-secondary); background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-primary); border: 1px solid var(--border-primary);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100; z-index: 100;
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
} }
.wxid-option { .wxid-option {

View File

@@ -1,20 +1,24 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore'
import { useThemeStore, themes } from '../stores/themeStore' import { useThemeStore, themes } from '../stores/themeStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
import { dialog } from '../services/ipc' import { dialog } from '../services/ipc'
import * as configService from '../services/config' import * as configService from '../services/config'
import { import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Save, Plug, Check, Sun, Moon, RotateCcw, Trash2, Save, Plug, Check, Sun, Moon,
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic
} from 'lucide-react' } from 'lucide-react'
import './SettingsPage.scss' import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'database' | 'cache' | 'about' type SettingsTab = 'appearance' | 'database' | 'whisper' | 'export' | 'cache' | 'about'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette }, { id: 'appearance', label: '外观', icon: Palette },
{ id: 'database', label: '数据库连接', icon: Database }, { id: 'database', label: '数据库连接', icon: Database },
{ id: 'whisper', label: '语音识别模型', icon: Mic },
{ id: 'export', label: '导出', icon: Download },
{ id: 'cache', label: '缓存', icon: HardDrive }, { id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'about', label: '关于', icon: Info } { id: 'about', label: '关于', icon: Info }
] ]
@@ -25,8 +29,10 @@ interface WxidOption {
} }
function SettingsPage() { function SettingsPage() {
const { setDbConnected, setLoading, reset } = useAppStore() const { isDbConnected, setDbConnected, setLoading, reset } = useAppStore()
const resetChatStore = useChatStore((state) => state.reset)
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
const [activeTab, setActiveTab] = useState<SettingsTab>('appearance') const [activeTab, setActiveTab] = useState<SettingsTab>('appearance')
const [decryptKey, setDecryptKey] = useState('') const [decryptKey, setDecryptKey] = useState('')
@@ -36,9 +42,26 @@ function SettingsPage() {
const [wxid, setWxid] = useState('') const [wxid, setWxid] = useState('')
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([]) const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
const [showWxidSelect, setShowWxidSelect] = useState(false) const [showWxidSelect, setShowWxidSelect] = useState(false)
const wxidDropdownRef = useRef<HTMLDivElement>(null) const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('') const [cachePath, setCachePath] = useState('')
const [logEnabled, setLogEnabled] = useState(false) const [logEnabled, setLogEnabled] = useState(false)
const [whisperModelName, setWhisperModelName] = useState('base')
const [whisperModelDir, setWhisperModelDir] = useState('')
const [isWhisperDownloading, setIsWhisperDownloading] = useState(false)
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
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 [isLoading, setIsLoadingState] = useState(false)
const [isTesting, setIsTesting] = useState(false) const [isTesting, setIsTesting] = useState(false)
@@ -55,6 +78,11 @@ function SettingsPage() {
const [dbKeyStatus, setDbKeyStatus] = useState('') const [dbKeyStatus, setDbKeyStatus] = useState('')
const [imageKeyStatus, setImageKeyStatus] = useState('') const [imageKeyStatus, setImageKeyStatus] = useState('')
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false) const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
const [isClearingImageCache, setIsClearingImageCache] = useState(false)
const [isClearingAllCache, setIsClearingAllCache] = useState(false)
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
useEffect(() => { useEffect(() => {
loadConfig() loadConfig()
@@ -64,13 +92,20 @@ function SettingsPage() {
// 点击外部关闭下拉框 // 点击外部关闭下拉框
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(e.target as Node)) { const target = e.target as Node
setShowWxidSelect(false) if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
setShowExportFormatSelect(false)
}
if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) {
setShowExportDateRangeSelect(false)
}
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false)
} }
} }
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showWxidSelect]) }, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
@@ -95,16 +130,52 @@ function SettingsPage() {
const savedLogEnabled = await configService.getLogEnabled() const savedLogEnabled = await configService.getLogEnabled()
const savedImageXorKey = await configService.getImageXorKey() const savedImageXorKey = await configService.getImageXorKey()
const savedImageAesKey = await configService.getImageAesKey() const savedImageAesKey = await configService.getImageAesKey()
const savedWhisperModelName = await configService.getWhisperModelName()
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) if (savedPath) setDbPath(savedPath)
if (savedWxid) setWxid(savedWxid) if (savedWxid) setWxid(savedWxid)
if (savedCachePath) setCachePath(savedCachePath) if (savedCachePath) setCachePath(savedCachePath)
if (savedImageXorKey != null) {
setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`) const wxidConfig = savedWxid ? await configService.getWxidConfig(savedWxid) : null
const decryptKeyToUse = wxidConfig?.decryptKey ?? savedKey ?? ''
const imageXorKeyToUse = typeof wxidConfig?.imageXorKey === 'number'
? wxidConfig.imageXorKey
: savedImageXorKey
const imageAesKeyToUse = wxidConfig?.imageAesKey ?? savedImageAesKey ?? ''
setDecryptKey(decryptKeyToUse)
if (typeof imageXorKeyToUse === 'number') {
setImageXorKey(`0x${imageXorKeyToUse.toString(16).toUpperCase().padStart(2, '0')}`)
} else {
setImageXorKey('')
} }
if (savedImageAesKey) setImageAesKey(savedImageAesKey) setImageAesKey(imageAesKeyToUse)
setLogEnabled(savedLogEnabled) 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) {
const defaultLanguages = ['zh']
setTranscribeLanguages(defaultLanguages)
await configService.setTranscribeLanguages(defaultLanguages)
}
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
} catch (e) { } catch (e) {
console.error('加载配置失败:', e) console.error('加载配置失败:', e)
} }
@@ -112,6 +183,21 @@ function SettingsPage() {
const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
try {
const result = await window.electronAPI.whisper?.getModelStatus()
if (result?.success) {
setWhisperModelStatus({
exists: Boolean(result.exists),
modelPath: result.modelPath,
tokensPath: result.tokensPath
})
}
} catch {
setWhisperModelStatus(null)
}
}
const loadAppVersion = async () => { const loadAppVersion = async () => {
try { try {
const version = await window.electronAPI.app.getVersion() const version = await window.electronAPI.app.getVersion()
@@ -129,6 +215,19 @@ function SettingsPage() {
return () => removeListener?.() return () => removeListener?.()
}, []) }, [])
useEffect(() => {
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload) => {
if (typeof payload.percent === 'number') {
setWhisperDownloadProgress(payload.percent)
}
})
return () => removeListener?.()
}, [])
useEffect(() => {
void refreshWhisperStatus(whisperModelDir)
}, [whisperModelDir])
const handleCheckUpdate = async () => { const handleCheckUpdate = async () => {
setIsCheckingUpdate(true) setIsCheckingUpdate(true)
setUpdateInfo(null) setUpdateInfo(null)
@@ -136,9 +235,9 @@ function SettingsPage() {
const result = await window.electronAPI.app.checkForUpdates() const result = await window.electronAPI.app.checkForUpdates()
if (result.hasUpdate) { if (result.hasUpdate) {
setUpdateInfo(result) setUpdateInfo(result)
showMessage(`发现新版${result.version}`, true) showMessage(`发现新版${result.version}`, true)
} else { } else {
showMessage('当前已是最新版', true) showMessage('当前已是最新版', true)
} }
} catch (e) { } catch (e) {
showMessage(`检查更新失败: ${e}`, false) showMessage(`检查更新失败: ${e}`, false)
@@ -164,6 +263,103 @@ function SettingsPage() {
setTimeout(() => setMessage(null), 3000) setTimeout(() => setMessage(null), 3000)
} }
type WxidKeys = {
decryptKey: string
imageXorKey: number | null
imageAesKey: string
}
const formatImageXorKey = (value: number) => `0x${value.toString(16).toUpperCase().padStart(2, '0')}`
const parseImageXorKey = (value: string) => {
if (!value) return null
const parsed = parseInt(value.replace(/^0x/i, ''), 16)
return Number.isNaN(parsed) ? null : parsed
}
const buildKeysFromState = (): WxidKeys => ({
decryptKey: decryptKey || '',
imageXorKey: parseImageXorKey(imageXorKey),
imageAesKey: imageAesKey || ''
})
const buildKeysFromConfig = (wxidConfig: configService.WxidConfig | null): WxidKeys => ({
decryptKey: wxidConfig?.decryptKey || '',
imageXorKey: typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : null,
imageAesKey: wxidConfig?.imageAesKey || ''
})
const applyKeysToState = (keys: WxidKeys) => {
setDecryptKey(keys.decryptKey)
if (typeof keys.imageXorKey === 'number') {
setImageXorKey(formatImageXorKey(keys.imageXorKey))
} else {
setImageXorKey('')
}
setImageAesKey(keys.imageAesKey)
}
const syncKeysToConfig = async (keys: WxidKeys) => {
await configService.setDecryptKey(keys.decryptKey)
await configService.setImageXorKey(typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0)
await configService.setImageAesKey(keys.imageAesKey)
}
const applyWxidSelection = async (
selectedWxid: string,
options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string }
) => {
if (!selectedWxid) return
const currentWxid = wxid
const isSameWxid = currentWxid === selectedWxid
if (currentWxid && currentWxid !== selectedWxid) {
const currentKeys = buildKeysFromState()
await configService.setWxidConfig(currentWxid, {
decryptKey: currentKeys.decryptKey,
imageXorKey: typeof currentKeys.imageXorKey === 'number' ? currentKeys.imageXorKey : 0,
imageAesKey: currentKeys.imageAesKey
})
}
const preferCurrentKeys = options?.preferCurrentKeys ?? false
const keys = preferCurrentKeys
? buildKeysFromState()
: buildKeysFromConfig(await configService.getWxidConfig(selectedWxid))
setWxid(selectedWxid)
applyKeysToState(keys)
await configService.setMyWxid(selectedWxid)
await syncKeysToConfig(keys)
await configService.setWxidConfig(selectedWxid, {
decryptKey: keys.decryptKey,
imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0,
imageAesKey: keys.imageAesKey
})
setShowWxidSelect(false)
if (isDbConnected) {
try {
await window.electronAPI.chat.close()
const result = await window.electronAPI.chat.connect()
setDbConnected(result.success, dbPath || undefined)
if (!result.success && result.error) {
showMessage(result.error, false)
}
} catch (e) {
showMessage(`切换账号后重新连接失败: ${e}`, false)
setDbConnected(false)
}
}
if (!isSameWxid) {
clearAnalyticsStoreCache()
resetChatStore()
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } }))
}
if (options?.showToast ?? true) {
showMessage(options?.toastText || `已选择账号:${selectedWxid}`, true)
}
}
const handleAutoDetectPath = async () => { const handleAutoDetectPath = async () => {
if (isDetectingPath) return if (isDetectingPath) return
setIsDetectingPath(true) setIsDetectingPath(true)
@@ -177,11 +373,10 @@ function SettingsPage() {
const wxids = await window.electronAPI.dbPath.scanWxids(result.path) const wxids = await window.electronAPI.dbPath.scanWxids(result.path)
setWxidOptions(wxids) setWxidOptions(wxids)
if (wxids.length === 1) { if (wxids.length === 1) {
setWxid(wxids[0].wxid) await applyWxidSelection(wxids[0].wxid, {
await configService.setMyWxid(wxids[0].wxid) toastText: `已检测到账号:${wxids[0].wxid}`
showMessage(`已检测到账号:${wxids[0].wxid}`, true) })
} else if (wxids.length > 1) { } else if (wxids.length > 1) {
// 多账号时弹出选择对话框
setShowWxidSelect(true) setShowWxidSelect(true)
} }
} else { } else {
@@ -206,7 +401,10 @@ function SettingsPage() {
} }
} }
const handleScanWxid = async (silent = false) => { const handleScanWxid = async (
silent = false,
options?: { preferCurrentKeys?: boolean; showDialog?: boolean }
) => {
if (!dbPath) { if (!dbPath) {
if (!silent) showMessage('请先选择数据库目录', false) if (!silent) showMessage('请先选择数据库目录', false)
return return
@@ -214,12 +412,14 @@ function SettingsPage() {
try { try {
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
setWxidOptions(wxids) setWxidOptions(wxids)
const allowDialog = options?.showDialog ?? !silent
if (wxids.length === 1) { if (wxids.length === 1) {
setWxid(wxids[0].wxid) await applyWxidSelection(wxids[0].wxid, {
await configService.setMyWxid(wxids[0].wxid) preferCurrentKeys: options?.preferCurrentKeys ?? false,
if (!silent) showMessage(`已检测到账号:${wxids[0].wxid}`, true) showToast: !silent,
} else if (wxids.length > 1) { toastText: `已检测到账号:${wxids[0].wxid}`
// 多账号时弹出选择对话框 })
} else if (wxids.length > 1 && allowDialog) {
setShowWxidSelect(true) setShowWxidSelect(true)
} else { } else {
if (!silent) showMessage('未检测到账号目录,请检查路径', false) if (!silent) showMessage('未检测到账号目录,请检查路径', false)
@@ -230,10 +430,7 @@ function SettingsPage() {
} }
const handleSelectWxid = async (selectedWxid: string) => { const handleSelectWxid = async (selectedWxid: string) => {
setWxid(selectedWxid) await applyWxidSelection(selectedWxid)
await configService.setMyWxid(selectedWxid)
setShowWxidSelect(false)
showMessage(`已选择账号:${selectedWxid}`, true)
} }
const handleSelectCachePath = async () => { const handleSelectCachePath = async () => {
@@ -250,6 +447,51 @@ function SettingsPage() {
const handleSelectWhisperModelDir = async () => {
try {
const result = await dialog.openFile({ title: '选择 Whisper 模型下载目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) {
const dir = result.filePaths[0]
setWhisperModelDir(dir)
await configService.setWhisperModelDir(dir)
showMessage('已选择 Whisper 模型目录', true)
}
} catch (e) {
showMessage('选择目录失败', false)
}
}
const handleWhisperModelChange = async (value: string) => {
setWhisperModelName(value)
setWhisperDownloadProgress(0)
await configService.setWhisperModelName(value)
}
const handleDownloadWhisperModel = async () => {
if (isWhisperDownloading) return
setIsWhisperDownloading(true)
setWhisperDownloadProgress(0)
try {
const result = await window.electronAPI.whisper.downloadModel()
if (result.success) {
setWhisperDownloadProgress(100)
showMessage('SenseVoiceSmall 模型下载完成', true)
await refreshWhisperStatus(whisperModelDir)
} else {
showMessage(result.error || '模型下载失败', false)
}
} catch (e) {
showMessage(`模型下载失败: ${e}`, false)
} finally {
setIsWhisperDownloading(false)
}
}
const handleResetWhisperModelDir = async () => {
setWhisperModelDir('')
await configService.setWhisperModelDir('')
}
const handleAutoGetDbKey = async () => { const handleAutoGetDbKey = async () => {
if (isFetchingDbKey) return if (isFetchingDbKey) return
setIsFetchingDbKey(true) setIsFetchingDbKey(true)
@@ -261,7 +503,7 @@ function SettingsPage() {
setDecryptKey(result.key) setDecryptKey(result.key)
setDbKeyStatus('密钥获取成功') setDbKeyStatus('密钥获取成功')
showMessage('已自动获取解密密钥', true) showMessage('已自动获取解密密钥', true)
await handleScanWxid(true) await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false })
} else { } else {
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
setIsManualStartPrompt(true) setIsManualStartPrompt(true)
@@ -347,30 +589,21 @@ function SettingsPage() {
await configService.setDbPath(dbPath) await configService.setDbPath(dbPath)
await configService.setMyWxid(wxid) await configService.setMyWxid(wxid)
await configService.setCachePath(cachePath) await configService.setCachePath(cachePath)
if (imageXorKey) { const parsedXorKey = parseImageXorKey(imageXorKey)
const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16) await configService.setImageXorKey(typeof parsedXorKey === 'number' ? parsedXorKey : 0)
if (!Number.isNaN(parsed)) { await configService.setImageAesKey(imageAesKey || '')
await configService.setImageXorKey(parsed) await configService.setWxidConfig(wxid, {
} decryptKey,
} else { imageXorKey: typeof parsedXorKey === 'number' ? parsedXorKey : 0,
await configService.setImageXorKey(0) imageAesKey
} })
if (imageAesKey) { await configService.setWhisperModelDir(whisperModelDir)
await configService.setImageAesKey(imageAesKey) await configService.setAutoTranscribeVoice(autoTranscribeVoice)
} else { await configService.setTranscribeLanguages(transcribeLanguages)
await configService.setImageAesKey('')
}
await configService.setOnboardingDone(true) await configService.setOnboardingDone(true)
showMessage('配置保存成功,正在测试连接...', true) // 保存按钮只负责持久化配置,不做连接测试/重连,避免影响聊天页的活动连接
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid) showMessage('配置保存成功', true)
if (result.success) {
setDbConnected(true, dbPath)
showMessage('配置保存成功!数据库连接正常', true)
} else {
showMessage(result.error || '数据库连接失败,请检查配置', false)
}
} catch (e) { } catch (e) {
showMessage(`保存配置失败: ${e}`, false) showMessage(`保存配置失败: ${e}`, false)
} finally { } finally {
@@ -380,7 +613,7 @@ function SettingsPage() {
} }
const handleClearConfig = async () => { const handleClearConfig = async () => {
const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置') const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置')
if (!confirmed) return if (!confirmed) return
setIsLoadingState(true) setIsLoadingState(true)
setLoading(true, '正在清除配置...') setLoading(true, '正在清除配置...')
@@ -395,6 +628,12 @@ function SettingsPage() {
setWxid('') setWxid('')
setCachePath('') setCachePath('')
setLogEnabled(false) setLogEnabled(false)
setAutoTranscribeVoice(false)
setTranscribeLanguages(['zh'])
setWhisperModelDir('')
setWhisperModelStatus(null)
setWhisperDownloadProgress(0)
setIsWhisperDownloading(false)
setDbConnected(false) setDbConnected(false)
await window.electronAPI.window.openOnboardingWindow() await window.electronAPI.window.openOnboardingWindow()
} catch (e) { } catch (e) {
@@ -428,6 +667,59 @@ function SettingsPage() {
} }
} }
const handleClearAnalyticsCache = async () => {
if (isClearingCache) return
setIsClearingAnalyticsCache(true)
try {
const result = await window.electronAPI.cache.clearAnalytics()
if (result.success) {
clearAnalyticsStoreCache()
showMessage('已清除分析缓存', true)
} else {
showMessage(`清除分析缓存失败: ${result.error || '未知错误'}`, false)
}
} catch (e) {
showMessage(`清除分析缓存失败: ${e}`, false)
} finally {
setIsClearingAnalyticsCache(false)
}
}
const handleClearImageCache = async () => {
if (isClearingCache) return
setIsClearingImageCache(true)
try {
const result = await window.electronAPI.cache.clearImages()
if (result.success) {
showMessage('已清除图片缓存', true)
} else {
showMessage(`清除图片缓存失败: ${result.error || '未知错误'}`, false)
}
} catch (e) {
showMessage(`清除图片缓存失败: ${e}`, false)
} finally {
setIsClearingImageCache(false)
}
}
const handleClearAllCache = async () => {
if (isClearingCache) return
setIsClearingAllCache(true)
try {
const result = await window.electronAPI.cache.clearAll()
if (result.success) {
clearAnalyticsStoreCache()
showMessage('已清除所有缓存', true)
} else {
showMessage(`清除所有缓存失败: ${result.error || '未知错误'}`, false)
}
} catch (e) {
showMessage(`清除所有缓存失败: ${e}`, false)
} finally {
setIsClearingAllCache(false)
}
}
const renderAppearanceTab = () => ( const renderAppearanceTab = () => (
<div className="tab-content"> <div className="tab-content">
<div className="theme-mode-toggle"> <div className="theme-mode-toggle">
@@ -484,6 +776,7 @@ function SettingsPage() {
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint">xwechat_files </span> <span className="form-hint">xwechat_files </span>
<span className="form-hint" style={{ color: '#ff6b6b' }}> --</span>
<input type="text" placeholder="例如: C:\Users\xxx\Documents\xwechat_files" value={dbPath} onChange={(e) => setDbPath(e.target.value)} /> <input type="text" placeholder="例如: C:\Users\xxx\Documents\xwechat_files" value={dbPath} onChange={(e) => setDbPath(e.target.value)} />
<div className="btn-row"> <div className="btn-row">
<button className="btn btn-primary" onClick={handleAutoDetectPath} disabled={isDetectingPath}> <button className="btn btn-primary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
@@ -496,37 +789,13 @@ function SettingsPage() {
<div className="form-group"> <div className="form-group">
<label> wxid</label> <label> wxid</label>
<span className="form-hint"></span> <span className="form-hint"></span>
<div className="wxid-input-wrapper" ref={wxidDropdownRef}> <div className="wxid-input-wrapper">
<input <input
type="text" type="text"
placeholder="例如: wxid_xxxxxx" placeholder="例如: wxid_xxxxxx"
value={wxid} value={wxid}
onChange={(e) => setWxid(e.target.value)} onChange={(e) => setWxid(e.target.value)}
/> />
<button
type="button"
className={`wxid-dropdown-btn ${showWxidSelect ? 'open' : ''}`}
onClick={() => wxidOptions.length > 0 ? setShowWxidSelect(!showWxidSelect) : handleScanWxid()}
title={wxidOptions.length > 0 ? "选择已检测到的账号" : "扫描账号"}
>
<ChevronDown size={16} />
</button>
{showWxidSelect && wxidOptions.length > 0 && (
<div className="wxid-dropdown">
{wxidOptions.map((opt) => (
<div
key={opt.wxid}
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
onClick={() => handleSelectWxid(opt.wxid)}
>
<span className="wxid-value">{opt.wxid}</span>
<span className="wxid-time">
{new Date(opt.modifiedTime).toLocaleDateString()}
</span>
</div>
))}
</div>
)}
</div> </div>
<button className="btn btn-secondary btn-sm" onClick={() => handleScanWxid()}><Search size={14} /> wxid</button> <button className="btn btn-secondary btn-sm" onClick={() => handleScanWxid()}><Search size={14} /> wxid</button>
</div> </div>
@@ -548,16 +817,6 @@ function SettingsPage() {
{isFetchingImageKey && <div className="form-hint status-text">...</div>} {isFetchingImageKey && <div className="form-hint status-text">...</div>}
</div> </div>
<div className="form-group">
<label> <span className="optional">()</span></label>
<span className="form-hint">使</span>
<input type="text" placeholder="留空使用默认目录" value={cachePath} onChange={(e) => setCachePath(e.target.value)} />
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> </button>
</div>
</div>
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"> WCDB 便</span> <span className="form-hint"> WCDB 便</span>
@@ -590,16 +849,335 @@ function SettingsPage() {
</div> </div>
</div> </div>
) )
const renderWhisperTab = () => (
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="log-toggle-line">
<span className="log-status">{autoTranscribeVoice ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="auto-transcribe-toggle">
<input
id="auto-transcribe-toggle"
className="switch-input"
type="checkbox"
checked={autoTranscribeVoice}
onChange={async (e) => {
const enabled = e.target.checked
setAutoTranscribeVoice(enabled)
await configService.setAutoTranscribeVoice(enabled)
showMessage(enabled ? '已开启自动转文字' : '已关闭自动转文字', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="language-checkboxes">
{[
{ code: 'zh', name: '中文' },
{ code: 'yue', name: '粤语' },
{ code: 'en', name: '英文' },
{ code: 'ja', name: '日文' },
{ code: 'ko', name: '韩文' }
].map((lang) => (
<label key={lang.code} className="language-checkbox">
<input
type="checkbox"
checked={transcribeLanguages.includes(lang.code)}
onChange={async (e) => {
const checked = e.target.checked
let newLanguages: string[]
if (checked) {
newLanguages = [...transcribeLanguages, lang.code]
} else {
if (transcribeLanguages.length <= 1) {
showMessage('至少需要选择一种语言', false)
return
}
newLanguages = transcribeLanguages.filter(l => l !== lang.code)
}
setTranscribeLanguages(newLanguages)
await configService.setTranscribeLanguages(newLanguages)
showMessage(`${checked ? '添加' : '移除'}${lang.name}`, true)
}}
/>
<div className="checkbox-custom">
<Check size={14} />
<span>{lang.name}</span>
</div>
</label>
))}
</div>
</div>
<div className="form-group whisper-section">
<label> (SenseVoiceSmall)</label>
<span className="form-hint"> Sherpa-onnx/</span>
<span className="form-hint"></span>
<input
type="text"
placeholder="留空使用默认目录"
value={whisperModelDir}
onChange={(e) => setWhisperModelDir(e.target.value)}
onBlur={() => configService.setWhisperModelDir(whisperModelDir)}
/>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary" onClick={handleResetWhisperModelDir}><RotateCcw size={16} /> </button>
</div>
<div className="whisper-status-line">
<span className={`status ${whisperModelStatus?.exists ? 'ok' : 'warn'}`}>
{whisperModelStatus?.exists ? '已下载 (240 MB)' : '未下载 (240 MB)'}
</span>
{whisperModelStatus?.modelPath && <span className="path">{whisperModelStatus.modelPath}</span>}
</div>
{isWhisperDownloading ? (
<div className="whisper-progress">
<div className="progress-info">
<span>...</span>
<span className="percent">{whisperDownloadProgress.toFixed(0)}%</span>
</div>
<div className="progress-bar-container">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${whisperDownloadProgress}%` }} />
</div>
</div>
</div>
) : (
<button className="btn btn-primary btn-download-model" onClick={handleDownloadWhisperModel}>
<Download size={18} />
</button>
)}
</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 = () => ( const renderCacheTab = () => (
<div className="tab-content"> <div className="tab-content">
<p className="section-desc"></p> <p className="section-desc"></p>
<div className="form-group">
<label> <span className="optional">()</span></label>
<span className="form-hint">使</span>
<input type="text" placeholder="留空使用默认目录" value={cachePath} onChange={(e) => setCachePath(e.target.value)} />
<div className="btn-row"> <div className="btn-row">
<button className="btn btn-secondary"><Trash2 size={16} /> </button> <button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary"><Trash2 size={16} /> </button> <button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> </button>
<button className="btn btn-danger"><Trash2 size={16} /> </button> </div>
</div>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}>
<Trash2 size={16} />
</button>
<button className="btn btn-secondary" onClick={handleClearImageCache} disabled={isClearingCache}>
<Trash2 size={16} />
</button>
<button className="btn btn-danger" onClick={handleClearAllCache} disabled={isClearingCache}>
<Trash2 size={16} /> </button>
</div> </div>
<div className="divider" /> <div className="divider" />
<p className="section-desc"></p> <p className="section-desc"></p>
@@ -624,7 +1202,7 @@ function SettingsPage() {
<div className="about-update"> <div className="about-update">
{updateInfo?.hasUpdate ? ( {updateInfo?.hasUpdate ? (
<> <>
<p className="update-hint"> v{updateInfo.version} </p> <p className="update-hint"> v{updateInfo.version} </p>
{isDownloading ? ( {isDownloading ? (
<div className="download-progress"> <div className="download-progress">
<div className="progress-bar"> <div className="progress-bar">
@@ -681,7 +1259,7 @@ function SettingsPage() {
onClick={() => handleSelectWxid(opt.wxid)} onClick={() => handleSelectWxid(opt.wxid)}
> >
<span className="wxid-id">{opt.wxid}</span> <span className="wxid-id">{opt.wxid}</span>
<span className="wxid-date">: {new Date(opt.modifiedTime).toLocaleString()}</span> <span className="wxid-date"> {new Date(opt.modifiedTime).toLocaleString()}</span>
</div> </div>
))} ))}
</div> </div>
@@ -716,6 +1294,8 @@ function SettingsPage() {
<div className="settings-body"> <div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()} {activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'database' && renderDatabaseTab()} {activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'whisper' && renderWhisperTab()}
{activeTab === 'export' && renderExportTab()}
{activeTab === 'cache' && renderCacheTab()} {activeTab === 'cache' && renderCacheTab()}
{activeTab === 'about' && renderAboutTab()} {activeTab === 'about' && renderAboutTab()}
</div> </div>

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

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

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

@@ -0,0 +1,610 @@
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 = useCallback(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()
}, [loadContacts])
useEffect(() => {
const handleChange = () => {
setPosts([])
setHasMore(true)
setHasNewer(false)
setSelectedUsernames([])
setSearchKeyword('')
setJumpTargetDate(null)
loadContacts()
loadPosts({ reset: true })
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadContacts, loadPosts])
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>
)
}

216
src/pages/VideoWindow.scss Normal file
View File

@@ -0,0 +1,216 @@
.video-window-container {
width: 100vw;
height: 100vh;
background-color: #000;
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
.title-bar {
height: 40px;
min-height: 40px;
display: flex;
background: #1a1a1a;
padding-right: 140px;
position: relative;
z-index: 10;
.window-drag-area {
flex: 1;
height: 100%;
-webkit-app-region: drag;
}
}
.video-viewport {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
cursor: pointer;
background: #000;
overflow: hidden;
min-height: 0; // 重要:让 flex 子元素可以收缩
video {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
}
.video-loading-overlay,
.video-error-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: 5;
}
.video-error-overlay {
color: #ff6b6b;
font-size: 14px;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.play-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity 0.2s;
z-index: 4;
svg {
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
}
}
&:hover .play-overlay {
opacity: 1;
}
}
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.4) 60%, transparent);
padding: 40px 16px 12px;
opacity: 0;
transition: opacity 0.25s;
z-index: 6;
.progress-bar {
height: 16px;
display: flex;
align-items: center;
cursor: pointer;
margin-bottom: 8px;
.progress-track {
flex: 1;
height: 3px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
overflow: hidden;
transition: height 0.15s;
.progress-fill {
height: 100%;
background: var(--primary, #4a9eff);
border-radius: 2px;
}
}
&:hover .progress-track {
height: 5px;
}
}
.controls-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.controls-left,
.controls-right {
display: flex;
align-items: center;
gap: 6px;
}
button {
background: transparent;
border: none;
color: #fff;
cursor: pointer;
padding: 6px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.15);
}
}
.time-display {
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
font-variant-numeric: tabular-nums;
margin-left: 4px;
}
.volume-control {
display: flex;
align-items: center;
gap: 4px;
.volume-slider {
width: 60px;
height: 3px;
appearance: none;
-webkit-appearance: none;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 10px;
height: 10px;
background: #fff;
border-radius: 50%;
cursor: pointer;
}
}
}
}
// 鼠标悬停时显示控制栏
&:hover .video-controls {
opacity: 1;
}
// 播放时如果鼠标不动,隐藏控制栏
&.hide-controls .video-controls {
opacity: 0;
}
}
.video-window-empty {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.6);
background-color: #000;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

199
src/pages/VideoWindow.tsx Normal file
View File

@@ -0,0 +1,199 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Play, Pause, Volume2, VolumeX, RotateCcw } from 'lucide-react'
import './VideoWindow.scss'
export default function VideoWindow() {
const [searchParams] = useSearchParams()
const videoPath = searchParams.get('videoPath')
const [isPlaying, setIsPlaying] = useState(false)
const [isMuted, setIsMuted] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [volume, setVolume] = useState(1)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const progressRef = useRef<HTMLDivElement>(null)
// 格式化时间
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
//播放/暂停
const togglePlay = useCallback(() => {
if (!videoRef.current) return
if (isPlaying) {
videoRef.current.pause()
} else {
videoRef.current.play()
}
}, [isPlaying])
// 静音切换
const toggleMute = useCallback(() => {
if (!videoRef.current) return
videoRef.current.muted = !isMuted
setIsMuted(!isMuted)
}, [isMuted])
// 进度条点击
const handleProgressClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!videoRef.current || !progressRef.current) return
e.stopPropagation()
const rect = progressRef.current.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
videoRef.current.currentTime = percent * duration
}, [duration])
// 音量调节
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value)
setVolume(newVolume)
if (videoRef.current) {
videoRef.current.volume = newVolume
setIsMuted(newVolume === 0)
}
}, [])
// 重新播放
const handleReplay = useCallback(() => {
if (!videoRef.current) return
videoRef.current.currentTime = 0
videoRef.current.play()
}, [])
// 快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') window.electronAPI.window.close()
if (e.key === ' ') {
e.preventDefault()
togglePlay()
}
if (e.key === 'm' || e.key === 'M') toggleMute()
if (e.key === 'ArrowLeft' && videoRef.current) {
videoRef.current.currentTime -= 5
}
if (e.key === 'ArrowRight' && videoRef.current) {
videoRef.current.currentTime += 5
}
if (e.key === 'ArrowUp' && videoRef.current) {
videoRef.current.volume = Math.min(1, videoRef.current.volume + 0.1)
setVolume(videoRef.current.volume)
}
if (e.key === 'ArrowDown' && videoRef.current) {
videoRef.current.volume = Math.max(0, videoRef.current.volume - 0.1)
setVolume(videoRef.current.volume)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [togglePlay, toggleMute])
if (!videoPath) {
return (
<div className="video-window-empty">
<span></span>
</div>
)
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="video-window-container">
<div className="title-bar">
<div className="window-drag-area"></div>
</div>
<div className="video-viewport" onClick={togglePlay}>
{isLoading && (
<div className="video-loading-overlay">
<div className="spinner"></div>
</div>
)}
{error && (
<div className="video-error-overlay">
<span>{error}</span>
</div>
)}
<video
ref={videoRef}
src={videoPath}
onLoadedMetadata={(e) => {
const video = e.currentTarget
setDuration(video.duration)
setIsLoading(false)
// 根据视频尺寸调整窗口大小
if (video.videoWidth && video.videoHeight) {
window.electronAPI.window.resizeToFitVideo(video.videoWidth, video.videoHeight)
}
}}
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
onError={() => {
setError('视频加载失败')
setIsLoading(false)
}}
onWaiting={() => setIsLoading(true)}
onCanPlay={() => setIsLoading(false)}
autoPlay
/>
{!isPlaying && !isLoading && !error && (
<div className="play-overlay">
<Play size={64} fill="white" />
</div>
)}
<div className="video-controls" onClick={(e) => e.stopPropagation()}>
<div
className="progress-bar"
ref={progressRef}
onClick={handleProgressClick}
>
<div className="progress-track">
<div className="progress-fill" style={{ width: `${progress}%` }}></div>
</div>
</div>
<div className="controls-row">
<div className="controls-left">
<button onClick={togglePlay} title={isPlaying ? '暂停 (空格)' : '播放 (空格)'}>
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<button onClick={handleReplay} title="重新播放">
<RotateCcw size={16} />
</button>
<span className="time-display">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className="controls-right">
<div className="volume-control">
<button onClick={toggleMute} title={isMuted ? '取消静音 (M)' : '静音 (M)'}>
{isMuted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="volume-slider"
/>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -269,15 +269,14 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
await configService.setDecryptKey(decryptKey) await configService.setDecryptKey(decryptKey)
await configService.setMyWxid(wxid) await configService.setMyWxid(wxid)
await configService.setCachePath(cachePath) await configService.setCachePath(cachePath)
if (imageXorKey) { const parsedXorKey = imageXorKey ? parseInt(imageXorKey.replace(/^0x/i, ''), 16) : null
const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16) await configService.setImageXorKey(typeof parsedXorKey === 'number' && !Number.isNaN(parsedXorKey) ? parsedXorKey : 0)
if (!Number.isNaN(parsed)) { await configService.setImageAesKey(imageAesKey || '')
await configService.setImageXorKey(parsed) await configService.setWxidConfig(wxid, {
} decryptKey,
} imageXorKey: typeof parsedXorKey === 'number' && !Number.isNaN(parsedXorKey) ? parsedXorKey : 0,
if (imageAesKey) { imageAesKey
await configService.setImageAesKey(imageAesKey) })
}
await configService.setOnboardingDone(true) await configService.setOnboardingDone(true)
setDbConnected(true, dbPath) setDbConnected(true, dbPath)
@@ -313,6 +312,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
if (isDbConnected) { if (isDbConnected) {
return ( return (
<div className={rootClassName}> <div className={rootClassName}>
<div className="welcome-container">
{showWindowControls && ( {showWindowControls && (
<div className="window-controls"> <div className="window-controls">
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化"> <button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
@@ -323,21 +323,33 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</button> </button>
</div> </div>
)} )}
<div className="welcome-shell"> <div className="welcome-sidebar">
<div className="welcome-panel"> <div className="sidebar-header">
<div className="panel-header"> <img src="./logo.png" alt="WeFlow" className="sidebar-logo" />
<img src="./logo.png" alt="WeFlow" className="panel-logo" /> <div className="sidebar-brand">
<div> <span className="brand-name">WeFlow</span>
<p className="panel-kicker">WeFlow</p> <span className="brand-tag">Connected</span>
<h1></h1>
</div> </div>
</div> </div>
<div className="panel-note">
<CheckCircle2 size={16} /> <div className="sidebar-spacer" style={{ flex: 1 }} />
<span></span>
<div className="sidebar-footer">
<ShieldCheck size={14} />
<span></span>
</div> </div>
</div>
<div className="welcome-content success-content">
<div className="success-body">
<div className="success-icon">
<CheckCircle2 size={48} />
</div>
<h1 className="success-title"></h1>
<p className="success-desc">使</p>
<button <button
className="btn btn-primary btn-full" className="btn btn-primary btn-large"
onClick={() => { onClick={() => {
if (standalone) { if (standalone) {
setIsClosing(true) setIsClosing(true)
@@ -349,16 +361,18 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
} }
}} }}
> >
<ArrowRight size={18} />
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
) )
} }
return ( return (
<div className={rootClassName}> <div className={rootClassName}>
<div className="welcome-container">
{showWindowControls && ( {showWindowControls && (
<div className="window-controls"> <div className="window-controls">
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化"> <button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
@@ -369,63 +383,54 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</button> </button>
</div> </div>
)} )}
<div className="welcome-shell"> <div className="welcome-sidebar">
<div className="welcome-panel"> <div className="sidebar-header">
<div className="panel-header"> <img src="./logo.png" alt="WeFlow" className="sidebar-logo" />
<img src="./logo.png" alt="WeFlow" className="panel-logo" /> <div className="sidebar-brand">
<div> <span className="brand-name">WeFlow</span>
<p className="panel-kicker"></p> <span className="brand-tag">Setup</span>
<h1>WeFlow </h1>
<p className="panel-subtitle"></p>
</div> </div>
</div> </div>
<div className="step-list">
<div className="sidebar-nav">
{steps.map((step, index) => ( {steps.map((step, index) => (
<div key={step.id} className={`step-item ${index === stepIndex ? 'active' : ''} ${index < stepIndex ? 'done' : ''}`}> <div key={step.id} className={`nav-item ${index === stepIndex ? 'active' : ''} ${index < stepIndex ? 'completed' : ''}`}>
<div className="step-index">{index < stepIndex ? <CheckCircle2 size={14} /> : index + 1}</div> <div className="nav-indicator">
<div> {index < stepIndex ? <CheckCircle2 size={14} /> : <div className="dot" />}
<div className="step-title">{step.title}</div> </div>
<div className="step-desc">{step.desc}</div> <div className="nav-info">
<div className="nav-title">{step.title}</div>
<div className="nav-desc">{step.desc}</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
<div className="panel-foot">
<ShieldCheck size={16} /> <div className="sidebar-footer">
<ShieldCheck size={14} />
<span></span> <span></span>
</div> </div>
</div> </div>
<div className="setup-card"> <div className="welcome-content">
<div className="setup-header"> <div className="content-header">
<div className="setup-icon">
{currentStep.id === 'intro' && <Sparkles size={18} />}
{currentStep.id === 'db' && <Database size={18} />}
{currentStep.id === 'cache' && <HardDrive size={18} />}
{currentStep.id === 'key' && <KeyRound size={18} />}
{currentStep.id === 'image' && <ShieldCheck size={18} />}
</div>
<div> <div>
<h2>{currentStep.title}</h2> <h2>{currentStep.title}</h2>
<p>{currentStep.desc}</p> <p className="header-desc">{currentStep.desc}</p>
</div> </div>
</div> </div>
<div className="content-body">
{currentStep.id === 'intro' && ( {currentStep.id === 'intro' && (
<div className="setup-body"> <div className="intro-block">
<div className="intro-card"> {/* 内容移至底部 */}
<Wand2 size={18} />
<div>
<h3></h3>
<p></p>
</div>
</div>
</div> </div>
)} )}
{currentStep.id === 'db' && ( {currentStep.id === 'db' && (
<div className="setup-body"> <div className="form-group">
<label className="field-label"></label> <label className="field-label"></label>
<div className="input-group">
<input <input
type="text" type="text"
className="field-input" className="field-input"
@@ -433,51 +438,60 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
value={dbPath} value={dbPath}
onChange={(e) => setDbPath(e.target.value)} onChange={(e) => setDbPath(e.target.value)}
/> />
<div className="button-row"> </div>
<div className="action-row">
<button className="btn btn-secondary" onClick={handleAutoDetectPath} disabled={isDetectingPath}> <button className="btn btn-secondary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
<FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'} <FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'}
</button> </button>
<button className="btn btn-primary" onClick={handleSelectPath}> <button className="btn btn-secondary" onClick={handleSelectPath}>
<FolderOpen size={16} /> <FolderOpen size={16} /> ...
</button> </button>
</div> </div>
<div className="field-hint"> xwechat_files </div>
<div className="field-hint">--</div>
<div className="field-hint warning">
</div>
</div> </div>
)} )}
{currentStep.id === 'cache' && ( {currentStep.id === 'cache' && (
<div className="setup-body"> <div className="form-group">
<label className="field-label"></label> <label className="field-label"></label>
<div className="input-group">
<input <input
type="text" type="text"
className="field-input" className="field-input"
placeholder="留空使用默认目录" placeholder="留空使用默认目录"
value={cachePath} value={cachePath}
onChange={(e) => setCachePath(e.target.value)} onChange={(e) => setCachePath(e.target.value)}
/> />
<div className="button-row"> </div>
<button className="btn btn-primary" onClick={handleSelectCachePath}> <div className="action-row">
<FolderOpen size={16} /> <button className="btn btn-secondary" onClick={handleSelectCachePath}>
<FolderOpen size={16} />
</button> </button>
<button className="btn btn-secondary" onClick={() => setCachePath('')}> <button className="btn btn-secondary" onClick={() => setCachePath('')}>
<RotateCcw size={16} /> 使 <RotateCcw size={16} />
</button> </button>
</div> </div>
<div className="field-hint">使</div> <div className="field-hint"></div>
</div> </div>
)} )}
{currentStep.id === 'key' && ( {currentStep.id === 'key' && (
<div className="setup-body"> <div className="form-group">
<label className="field-label"> wxid</label> <label className="field-label"> (Wxid)</label>
<input <input
type="text" type="text"
className="field-input" className="field-input"
placeholder="获取密钥后将自动填充" placeholder="等待获取..."
value={wxid} value={wxid}
readOnly
onChange={(e) => setWxid(e.target.value)} onChange={(e) => setWxid(e.target.value)}
/> />
<label className="field-label"></label>
<label className="field-label mt-4"></label>
<div className="field-with-toggle"> <div className="field-with-toggle">
<input <input
type={showDecryptKey ? 'text' : 'password'} type={showDecryptKey ? 'text' : 'password'}
@@ -487,39 +501,44 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
onChange={(e) => setDecryptKey(e.target.value.trim())} onChange={(e) => setDecryptKey(e.target.value.trim())}
/> />
<button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}> <button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}>
{showDecryptKey ? <EyeOff size={14} /> : <Eye size={14} />} {showDecryptKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button> </button>
</div> </div>
<div className="key-actions">
{isManualStartPrompt ? ( {isManualStartPrompt ? (
<div className="manual-prompt"> <div className="manual-prompt">
<p className="prompt-text"></p> <p></p>
<button className="btn btn-primary" onClick={handleManualConfirm}> <button className="btn btn-primary" onClick={handleManualConfirm}>
</button> </button>
</div> </div>
) : ( ) : (
<button className="btn btn-secondary btn-inline" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}> <button className="btn btn-secondary btn-block" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}>
{isFetchingDbKey ? '获取...' : '自动获取密钥'} {isFetchingDbKey ? '正在获取...' : '自动获取密钥'}
</button> </button>
)} )}
</div>
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>} {dbKeyStatus && <div className="status-message">{dbKeyStatus}</div>}
<div className="field-hint"></div> <div className="field-hint"></div>
<div className="field-hint"></div>
</div> </div>
)} )}
{currentStep.id === 'image' && ( {currentStep.id === 'image' && (
<div className="setup-body"> <div className="form-group">
<div className="grid-2">
<div>
<label className="field-label"> XOR </label> <label className="field-label"> XOR </label>
<input <input
type="text" type="text"
className="field-input" className="field-input"
placeholder="例如0xA4" placeholder="0x..."
value={imageXorKey} value={imageXorKey}
onChange={(e) => setImageXorKey(e.target.value)} onChange={(e) => setImageXorKey(e.target.value)}
/> />
</div>
<div>
<label className="field-label"> AES </label> <label className="field-label"> AES </label>
<input <input
type="text" type="text"
@@ -528,28 +547,40 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
value={imageAesKey} value={imageAesKey}
onChange={(e) => setImageAesKey(e.target.value)} onChange={(e) => setImageAesKey(e.target.value)}
/> />
<button className="btn btn-secondary btn-inline" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}> </div>
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'} </div>
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
{isFetchingImageKey ? '扫描中...' : '自动获取图片密钥'}
</button> </button>
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
<div className="field-hint"></div> {imageKeyStatus && <div className="status-message">{imageKeyStatus}</div>}
{isFetchingImageKey && <div className="field-hint status-text">...</div>} <div className="field-hint"></div>
</div> </div>
)} )}
</div>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
<div className="setup-actions"> {currentStep.id === 'intro' && (
<button className="btn btn-tertiary" onClick={handleBack} disabled={stepIndex === 0}> <div className="intro-footer">
<p></p>
<p>WeFlow 访</p>
</div>
)}
<div className="content-actions">
<button className="btn btn-ghost" onClick={handleBack} disabled={stepIndex === 0}>
<ArrowLeft size={16} /> <ArrowLeft size={16} />
</button> </button>
{stepIndex < steps.length - 1 ? ( {stepIndex < steps.length - 1 ? (
<button className="btn btn-primary" onClick={handleNext} disabled={!canGoNext()}> <button className="btn btn-primary" onClick={handleNext} disabled={!canGoNext()}>
<ArrowRight size={16} /> <ArrowRight size={16} />
</button> </button>
) : ( ) : (
<button className="btn btn-primary" onClick={handleConnect} disabled={isConnecting || !canGoNext()}> <button className="btn btn-primary" onClick={handleConnect} disabled={isConnecting || !canGoNext()}>
{isConnecting ? '连接中...' : '测试并完成'} {isConnecting ? '连接中...' : '完成配置'} <ArrowRight size={16} />
</button> </button>
)} )}
</div> </div>

View File

@@ -6,6 +6,7 @@ export const CONFIG_KEYS = {
DECRYPT_KEY: 'decryptKey', DECRYPT_KEY: 'decryptKey',
DB_PATH: 'dbPath', DB_PATH: 'dbPath',
MY_WXID: 'myWxid', MY_WXID: 'myWxid',
WXID_CONFIGS: 'wxidConfigs',
THEME: 'theme', THEME: 'theme',
THEME_ID: 'themeId', THEME_ID: 'themeId',
LAST_SESSION: 'lastSession', LAST_SESSION: 'lastSession',
@@ -17,9 +18,27 @@ export const CONFIG_KEYS = {
ONBOARDING_DONE: 'onboardingDone', ONBOARDING_DONE: 'onboardingDone',
LLM_MODEL_PATH: 'llmModelPath', LLM_MODEL_PATH: 'llmModelPath',
IMAGE_XOR_KEY: 'imageXorKey', IMAGE_XOR_KEY: 'imageXorKey',
IMAGE_AES_KEY: 'imageAesKey' IMAGE_AES_KEY: 'imageAesKey',
WHISPER_MODEL_NAME: 'whisperModelName',
WHISPER_MODEL_DIR: 'whisperModelDir',
WHISPER_DOWNLOAD_SOURCE: 'whisperDownloadSource',
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice',
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 } as const
export interface WxidConfig {
decryptKey?: string
imageXorKey?: number
imageAesKey?: string
updatedAt?: number
}
// 获取解密密钥 // 获取解密密钥
export async function getDecryptKey(): Promise<string | null> { export async function getDecryptKey(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.DECRYPT_KEY) const value = await config.get(CONFIG_KEYS.DECRYPT_KEY)
@@ -53,6 +72,32 @@ export async function setMyWxid(wxid: string): Promise<void> {
await config.set(CONFIG_KEYS.MY_WXID, wxid) await config.set(CONFIG_KEYS.MY_WXID, wxid)
} }
export async function getWxidConfigs(): Promise<Record<string, WxidConfig>> {
const value = await config.get(CONFIG_KEYS.WXID_CONFIGS)
if (value && typeof value === 'object') {
return value as Record<string, WxidConfig>
}
return {}
}
export async function getWxidConfig(wxid: string): Promise<WxidConfig | null> {
if (!wxid) return null
const configs = await getWxidConfigs()
return configs[wxid] || null
}
export async function setWxidConfig(wxid: string, configValue: WxidConfig): Promise<void> {
if (!wxid) return
const configs = await getWxidConfigs()
const previous = configs[wxid] || {}
configs[wxid] = {
...previous,
...configValue,
updatedAt: Date.now()
}
await config.set(CONFIG_KEYS.WXID_CONFIGS, configs)
}
// 获取主题 // 获取主题
export async function getTheme(): Promise<'light' | 'dark'> { export async function getTheme(): Promise<'light' | 'dark'> {
const value = await config.get(CONFIG_KEYS.THEME) const value = await config.get(CONFIG_KEYS.THEME)
@@ -144,6 +189,39 @@ export async function setLlmModelPath(path: string): Promise<void> {
await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path) await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path)
} }
// 获取 Whisper 模型名称
export async function getWhisperModelName(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.WHISPER_MODEL_NAME)
return (value as string) || null
}
// 设置 Whisper 模型名称
export async function setWhisperModelName(name: string): Promise<void> {
await config.set(CONFIG_KEYS.WHISPER_MODEL_NAME, name)
}
// 获取 Whisper 模型目录
export async function getWhisperModelDir(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.WHISPER_MODEL_DIR)
return (value as string) || null
}
// 设置 Whisper 模型目录
export async function setWhisperModelDir(dir: string): Promise<void> {
await config.set(CONFIG_KEYS.WHISPER_MODEL_DIR, dir)
}
// 获取 Whisper 下载源
export async function getWhisperDownloadSource(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.WHISPER_DOWNLOAD_SOURCE)
return (value as string) || null
}
// 设置 Whisper 下载源
export async function setWhisperDownloadSource(source: string): Promise<void> {
await config.set(CONFIG_KEYS.WHISPER_DOWNLOAD_SOURCE, source)
}
// 清除所有配置 // 清除所有配置
export async function clearConfig(): Promise<void> { export async function clearConfig(): Promise<void> {
await config.clear() await config.clear()
@@ -182,3 +260,95 @@ export async function getOnboardingDone(): Promise<boolean> {
export async function setOnboardingDone(done: boolean): Promise<void> { export async function setOnboardingDone(done: boolean): Promise<void> {
await config.set(CONFIG_KEYS.ONBOARDING_DONE, done) await config.set(CONFIG_KEYS.ONBOARDING_DONE, done)
} }
// 获取自动语音转文字开关
export async function getAutoTranscribeVoice(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AUTO_TRANSCRIBE_VOICE)
return value === true
}
// 设置自动语音转文字开关
export async function setAutoTranscribeVoice(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AUTO_TRANSCRIBE_VOICE, enabled)
}
// 获取语音转文字支持的语言列表
export async function getTranscribeLanguages(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.TRANSCRIBE_LANGUAGES)
// 默认只支持中文
return (value as string[]) || ['zh']
}
// 设置语音转文字支持的语言列表
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)
}

View File

@@ -18,6 +18,7 @@ export interface ChatState {
isLoadingMessages: boolean isLoadingMessages: boolean
isLoadingMore: boolean isLoadingMore: boolean
hasMoreMessages: boolean hasMoreMessages: boolean
hasMoreLater: boolean
// 联系人缓存 // 联系人缓存
contacts: Map<string, Contact> contacts: Map<string, Contact>
@@ -38,6 +39,7 @@ export interface ChatState {
setLoadingMessages: (loading: boolean) => void setLoadingMessages: (loading: boolean) => void
setLoadingMore: (loading: boolean) => void setLoadingMore: (loading: boolean) => void
setHasMoreMessages: (hasMore: boolean) => void setHasMoreMessages: (hasMore: boolean) => void
setHasMoreLater: (hasMore: boolean) => void
setContacts: (contacts: Contact[]) => void setContacts: (contacts: Contact[]) => void
addContact: (contact: Contact) => void addContact: (contact: Contact) => void
setSearchKeyword: (keyword: string) => void setSearchKeyword: (keyword: string) => void
@@ -56,6 +58,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
isLoadingMessages: false, isLoadingMessages: false,
isLoadingMore: false, isLoadingMore: false,
hasMoreMessages: true, hasMoreMessages: true,
hasMoreLater: false,
contacts: new Map(), contacts: new Map(),
searchKeyword: '', searchKeyword: '',
@@ -69,7 +72,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
setCurrentSession: (sessionId) => set({ setCurrentSession: (sessionId) => set({
currentSessionId: sessionId, currentSessionId: sessionId,
messages: [], messages: [],
hasMoreMessages: true hasMoreMessages: true,
hasMoreLater: false
}), }),
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }), setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
@@ -85,6 +89,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
setLoadingMessages: (loading) => set({ isLoadingMessages: loading }), setLoadingMessages: (loading) => set({ isLoadingMessages: loading }),
setLoadingMore: (loading) => set({ isLoadingMore: loading }), setLoadingMore: (loading) => set({ isLoadingMore: loading }),
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }), setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
setHasMoreLater: (hasMore) => set({ hasMoreLater: hasMore }),
setContacts: (contacts) => set({ setContacts: (contacts) => set({
contacts: new Map(contacts.map(c => [c.username, c])) contacts: new Map(contacts.map(c => [c.username, c]))
@@ -110,6 +115,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
isLoadingMessages: false, isLoadingMessages: false,
isLoadingMore: false, isLoadingMore: false,
hasMoreMessages: true, hasMoreMessages: true,
hasMoreLater: false,
contacts: new Map(), contacts: new Map(),
searchKeyword: '' searchKeyword: ''
}) })

View File

@@ -9,6 +9,8 @@ export interface ElectronAPI {
completeOnboarding: () => Promise<boolean> completeOnboarding: () => Promise<boolean>
openOnboardingWindow: () => Promise<boolean> openOnboardingWindow: () => Promise<boolean>
setTitleBarOverlay: (options: { symbolColor: string }) => void setTitleBarOverlay: (options: { symbolColor: string }) => void
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
} }
config: { config: {
get: (key: string) => Promise<unknown> get: (key: string) => Promise<unknown>
@@ -61,7 +63,7 @@ export interface ElectronAPI {
contacts?: Record<string, { displayName?: string; avatarUrl?: string }> contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string error?: string
}> }>
getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{ getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => Promise<{
success: boolean; success: boolean;
messages?: Message[]; messages?: Message[];
hasMore?: boolean; hasMore?: boolean;
@@ -94,7 +96,11 @@ export interface ElectronAPI {
error?: string error?: string
}> }>
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
getVoiceData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
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: { image: {
@@ -104,6 +110,21 @@ export interface ElectronAPI {
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
} }
video: {
getVideoInfo: (videoMd5: string) => Promise<{
success: boolean
exists: boolean
videoUrl?: string
coverUrl?: string
thumbUrl?: string
error?: string
}>
parseVideoMd5: (content: string) => Promise<{
success: boolean
md5?: string
error?: string
}>
}
analytics: { analytics: {
getOverallStatistics: (force?: boolean) => Promise<{ getOverallStatistics: (force?: boolean) => Promise<{
success: boolean success: boolean
@@ -148,6 +169,11 @@ export interface ElectronAPI {
}> }>
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
} }
cache: {
clearAnalytics: () => Promise<{ success: boolean; error?: string }>
clearImages: () => Promise<{ success: boolean; error?: string }>
clearAll: () => Promise<{ success: boolean; error?: string }>
}
groupAnalytics: { groupAnalytics: {
getGroupChats: () => Promise<{ getGroupChats: () => Promise<{
success: boolean success: boolean
@@ -289,6 +315,30 @@ export interface ElectronAPI {
success: boolean success: boolean
error?: string 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
}>
} }
} }
@@ -297,6 +347,21 @@ export interface ExportOptions {
dateRange?: { start: number; end: number } | null dateRange?: { start: number; end: number } | null
exportMedia?: boolean exportMedia?: boolean
exportAvatars?: boolean exportAvatars?: boolean
exportImages?: boolean
exportVoices?: boolean
exportEmojis?: boolean
exportVoiceAsText?: boolean
excelCompactColumns?: boolean
txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session'
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
}
export interface ExportProgress {
current: number
total: number
currentSession: string
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
} }
export interface WxidInfo { export interface WxidInfo {

View File

@@ -33,11 +33,14 @@ export interface Message {
isSend: number | null isSend: number | null
senderUsername: string | null senderUsername: string | null
parsedContent: string parsedContent: string
rawContent?: string // 原始消息内容(保留用于兼容)
content?: string // 原始消息内容XML
imageMd5?: string imageMd5?: string
imageDatName?: string imageDatName?: string
emojiCdnUrl?: string emojiCdnUrl?: string
emojiMd5?: string emojiMd5?: string
voiceDurationSeconds?: number voiceDurationSeconds?: number
videoMd5?: string
// 引用消息 // 引用消息
quotedContent?: string quotedContent?: string
quotedSender?: string quotedSender?: string

View File

@@ -13,6 +13,7 @@
}, },
"include": [ "include": [
"vite.config.ts", "vite.config.ts",
"electron/**/*.ts" "electron/**/*.ts",
"electron/**/*.d.ts"
] ]
} }

View File

@@ -30,7 +30,10 @@ export default defineConfig({
external: [ external: [
'better-sqlite3', 'better-sqlite3',
'koffi', 'koffi',
'fsevents' 'fsevents',
'whisper-node',
'shelljs',
'exceljs'
] ]
} }
} }
@@ -87,6 +90,23 @@ export default defineConfig({
} }
} }
}, },
{
entry: 'electron/transcribeWorker.ts',
vite: {
build: {
outDir: 'dist-electron',
rollupOptions: {
external: [
'sherpa-onnx-node'
],
output: {
entryFileNames: 'transcribeWorker.js',
inlineDynamicImports: true
}
}
}
}
},
{ {
entry: 'electron/preload.ts', entry: 'electron/preload.ts',
onstart(options) { onstart(options) {