Compare commits

...

64 Commits

Author SHA1 Message Date
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
Forrest
3637864f9a Merge branch 'dev' 2026-01-16 22:26:30 +08:00
Forrest
6eabd707f8 feat(ui): 新增支持缩放、拖拽及键盘控制的图片预览组件并优化样式,更新安装程序支持 VC++ 运行库自动安装,多账号支持列表选择。 2026-01-16 22:16:55 +08:00
Forrest
b96a47fe29 Merge pull request #36 from hicccc77/dev
Dev
2026-01-15 00:34:51 +08:00
cc
b7eb19aad6 fix: 进一步修复头像无法加载的问题;修复了构建脚本错误的配置 2026-01-14 22:49:19 +08:00
cc
2e41a03c96 feat: 所有数据解析完全后台进行以解决页面未响应的问题;优化了头像渲染逻辑以提升渲染速度
fix: 修复了虚拟机上无法索引到wxkey的问题;修复图片密钥扫描的问题;修复年度报告错误;修复了年度报告和数据分析中的发送者错误问题;修复了部分页面偶发的未渲染名称问题;修复了头像偶发渲染失败的问题;修复了部分图片无法解密的问题
2026-01-14 22:43:42 +08:00
cc
3151f79ee7 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-14 19:44:34 +08:00
cc
e7c93ea2f7 fix: 修复一些代码报错; 移除了好友复刻的功能 2026-01-14 19:44:09 +08:00
xuncha
f09ab1bbcc fix: 修复了群头像导出丢失的问题 2026-01-14 13:31:51 +08:00
xuncha
e6a0726b8d Merge work into dev and resolve ChatPage.tsx by accepting work changes 2026-01-13 18:01:16 +08:00
xuncha
cada002587 fix: 修复了企业微信会显示出id的问题 增加了新手教程提示 2026-01-13 17:50:56 +08:00
Forrest
38e87b8cbf feat: 添加LLM模型加载和释放功能,优化LLM推理流程 2026-01-13 00:19:59 +08:00
cc
bd94ba7b1a 测试版本,添加了模拟好友并优化了本地缓存 2026-01-12 23:42:09 +08:00
cc
756ee03aa0 Merge pull request #24 from hicccc77/dev
Merge pull request #23 from hicccc77/main
2026-01-12 22:27:42 +08:00
cc
76aa875085 Merge pull request #23 from hicccc77/main
同步
2026-01-12 22:26:36 +08:00
cc
16fa8510e6 chore: 更新文档 2026-01-12 22:25:21 +08:00
cc
b587e6bd6f Merge pull request #19 from XiiTang/main
fix: 更新getXorKey方法以改进密钥提取逻辑并添加PNG支持
2026-01-12 22:09:19 +08:00
XiiTang
13cc3751b5 feat: 添加通话消息解析功能 2026-01-12 14:38:34 +08:00
XiiTang
ba65c5f3ad feat: 添加年度报告的自定义导出选项 2026-01-12 11:29:06 +08:00
XiiTang
cfd7635323 fix: 更新getXorKey方法以改进密钥提取逻辑并添加PNG支持 2026-01-12 11:04:20 +08:00
xuncha
895249940c Update Telegram badge in README.md 2026-01-12 04:21:55 +08:00
xuncha
6b85d8a5f1 Add Telegram badge to README 2026-01-12 04:19:24 +08:00
Forrest
5c1773efac fix: 更新致谢部分 2026-01-12 00:37:51 +08:00
Forrest
fa783159ff fix: 更新release.yml,移除ignore_labels的默认值以支持自定义标签 2026-01-12 00:24:52 +08:00
Forrest
e85254bf98 feat: 添加联系人信息异步加载功能,优化会话列表展示 2026-01-12 00:12:42 +08:00
cc
e5f57c7359 feat: 优化会话加载速度;优化动画表现;支持中文数据库路径 2026-01-11 23:32:05 +08:00
xuncha
4cbce8c38f Merge pull request #16 from xunchahaha/main
fix:修复了语音解密
2026-01-11 22:58:38 +08:00
xuncha
d111513346 fix:修复了语音解密 2026-01-11 22:57:38 +08:00
xuncha
e2d34fc530 Merge pull request #9 from xunchahaha/main
fix: 导出时无需再去设置里面选择位置
2026-01-10 23:56:03 +08:00
xuncha
a1d11e4132 fix: 导出时无需再去设置里面选择位置 2026-01-10 23:55:21 +08:00
xuncha
ac95c99541 Merge pull request #8 from xunchahaha/main
fix: 修复了导出无法选择时间的问题
2026-01-10 23:44:44 +08:00
xuncha
654eb40740 Merge branch 'main' of https://github.com/xunchahaha/WeFlow 2026-01-10 23:41:20 +08:00
xuncha
bd3e9a63b7 fix: 统一ui风格 修复导出当天的记录为空的问题 2026-01-10 23:41:15 +08:00
xuncha
bc9ef140f5 fix: 简单修复了导出无法选择时间的问题 2026-01-10 23:26:52 +08:00
cc
f864189407 fix: 优化action打包效果 2026-01-10 14:11:01 +08:00
cc
f321c465d5 feat: 减少安装包体积 2026-01-10 14:10:34 +08:00
54 changed files with 9627 additions and 1909 deletions

View File

@@ -25,32 +25,73 @@ 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: Create Changelog Config
shell: bash
run: |
cat <<EOF > changelog_config.json
{
"template": "# ${{ github.ref_name }} 更新日志\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建",
"categories": [
{
"title": "## 新功能",
"filter": { "pattern": "^feat", "flags": "i" }
},
{
"title": "## 修复",
"filter": { "pattern": "^fix", "flags": "i" }
},
{
"title": "## 性能与维护",
"filter": { "pattern": "^(chore|docs|perf|refactor|ci|style|test)", "flags": "i" }
},
{
"title": "## 其他改动",
"filter": { "pattern": ".*", "flags": "i" }
}
],
"ignore_labels": [],
"commitMode": true,
"empty_summary": "## 更新详情\n- 常规代码优化与维护"
}
EOF
- name: Build Changelog - name: Build Changelog
id: build_changelog id: build_changelog
uses: mikepenz/release-changelog-builder-action@v4 uses: mikepenz/release-changelog-builder-action@v5
with: with:
configuration: "changelog_config.json"
outputFile: "release-notes.md" outputFile: "release-notes.md"
configurationJson: |
{
"categories": [
{ "title": "## 🚀 Features", "labels": ["feat", "feature"] },
{ "title": "## 🐛 Fixes", "labels": ["fix", "bug"] },
{ "title": "## 🧰 Maintenance", "labels": ["chore", "refactor", "docs", "perf"] }
],
"template": "# Release Notes\n\n{{CHANGELOG}}"
}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check Changelog Content
shell: bash
run: |
echo "=== RELEASE NOTES START ==="
cat release-notes.md
echo "=== RELEASE NOTES END ==="
- name: Inject Configuration
shell: bash
run: |
npm pkg set build.releaseInfo.releaseNotesFile=release-notes.md
- name: Package and Publish - 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

6
.gitignore vendored
View File

@@ -13,6 +13,7 @@ dist
dist-electron dist-electron
dist-ssr dist-ssr
*.local *.local
test/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
@@ -42,6 +43,10 @@ release
# OS # OS
Thumbs.db Thumbs.db
# Electron dev cache
.electron/
.cache/
# 忽略 Visual Studio 临时文件夹 # 忽略 Visual Studio 临时文件夹
@@ -51,3 +56,4 @@ Thumbs.db
*.aps *.aps
wcdb/ wcdb/
*info

View File

@@ -20,8 +20,8 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
<a href="https://github.com/hicccc77/WeFlow/issues"> <a href="https://github.com/hicccc77/WeFlow/issues">
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues"> <img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
</a> </a>
<a href="https://github.com/hicccc77/WeFlow/blob/main/LICENSE"> <a href="https://t.me/+hn3QzNc4DbA0MzNl">
<img src="https://img.shields.io/github/license/hicccc77/WeFlow?style=flat-square" alt="License"> <img src="https://img.shields.io/badge/Telegram%20交流群-点击加入-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
</a> </a>
</p> </p>
@@ -92,7 +92,7 @@ WeFlow/
## 致谢 ## 致谢
- [miyu](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架 - [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
## Star History ## Star History

View File

@@ -62,6 +62,12 @@ function isThumbnailDat(fileName: string): boolean {
return fileName.includes('.t.dat') || fileName.includes('_t.dat') return fileName.includes('.t.dat') || fileName.includes('_t.dat')
} }
function isHdDat(fileName: string): boolean {
const lower = fileName.toLowerCase()
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
return base.endsWith('_hd') || base.endsWith('_h')
}
function walkForDat( function walkForDat(
root: string, root: string,
datName: string, datName: string,
@@ -101,6 +107,8 @@ function walkForDat(
if (!isLikelyImageDatBase(baseLower)) continue if (!isLikelyImageDatBase(baseLower)) continue
if (!hasXVariant(baseLower)) continue if (!hasXVariant(baseLower)) continue
if (!matchesDatName(lower, datName)) continue if (!matchesDatName(lower, datName)) continue
// 排除高清图片格式 (_hd, _h)
if (isHdDat(lower)) continue
matchedBases.add(baseLower) matchedBases.add(baseLower)
const isThumb = isThumbnailDat(lower) const isThumb = isThumbnailDat(lower)
if (!allowThumbnail && isThumb) continue if (!allowThumbnail && isThumb) continue

View File

@@ -15,6 +15,8 @@ import { groupAnalyticsService } from './services/groupAnalyticsService'
import { annualReportService } from './services/annualReportService' import { annualReportService } from './services/annualReportService'
import { exportService, ExportOptions } from './services/exportService' import { exportService, ExportOptions } from './services/exportService'
import { KeyService } from './services/keyService' import { KeyService } from './services/keyService'
import { voiceTranscribeService } from './services/voiceTranscribeService'
// 配置自动更新 // 配置自动更新
autoUpdater.autoDownload = false autoUpdater.autoDownload = false
@@ -381,6 +383,8 @@ function registerIpcHandlers() {
return true return true
}) })
// 聊天相关 // 聊天相关
ipcMain.handle('chat:connect', async () => { ipcMain.handle('chat:connect', async () => {
return chatService.connect() return chatService.connect()
@@ -390,6 +394,10 @@ function registerIpcHandlers() {
return chatService.getSessions() return chatService.getSessions()
}) })
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => {
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) => {
return chatService.getMessages(sessionId, offset, limit) return chatService.getMessages(sessionId, offset, limit)
}) })
@@ -406,6 +414,10 @@ function registerIpcHandlers() {
return chatService.getContactAvatar(username) return chatService.getContactAvatar(username)
}) })
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
return chatService.getCachedSessionMessages(sessionId)
})
ipcMain.handle('chat:getMyAvatarUrl', async () => { ipcMain.handle('chat:getMyAvatarUrl', async () => {
return chatService.getMyAvatarUrl() return chatService.getMyAvatarUrl()
}) })
@@ -427,14 +439,26 @@ 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) => {
return chatService.getVoiceTranscript(sessionId, msgId, (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('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => { ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
return imageDecryptService.decryptImage(payload) return imageDecryptService.decryptImage(payload)
}) })
@@ -456,8 +480,8 @@ function registerIpcHandlers() {
}) })
// 数据分析相关 // 数据分析相关
ipcMain.handle('analytics:getOverallStatistics', async () => { ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => {
return analyticsService.getOverallStatistics() return analyticsService.getOverallStatistics(force)
}) })
ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => { ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => {
@@ -468,6 +492,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()
@@ -671,9 +739,11 @@ function checkForUpdatesOnStartup() {
app.whenReady().then(() => { app.whenReady().then(() => {
configService = new ConfigService() configService = new ConfigService()
const resourcesPath = app.isPackaged const candidateResources = app.isPackaged
? join(process.resourcesPath, 'resources') ? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources') : join(app.getAppPath(), 'resources')
const fallbackResources = join(process.cwd(), 'resources')
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
const userDataPath = app.getPath('userData') const userDataPath = app.getPath('userData')
wcdbService.setPaths(resourcesPath, userDataPath) wcdbService.setPaths(resourcesPath, userDataPath)
wcdbService.setLogEnabled(configService.get('logEnabled') === true) wcdbService.setLogEnabled(configService.get('logEnabled') === true)

View File

@@ -69,7 +69,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid), ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid),
open: (dbPath: string, hexKey: string, wxid: string) => open: (dbPath: string, hexKey: string, wxid: string) =>
ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid), ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid),
close: () => ipcRenderer.invoke('wcdb:close') close: () => ipcRenderer.invoke('wcdb:close'),
}, },
// 密钥获取 // 密钥获取
@@ -91,6 +92,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
chat: { chat: {
connect: () => ipcRenderer.invoke('chat:connect'), connect: () => ipcRenderer.invoke('chat:connect'),
getSessions: () => ipcRenderer.invoke('chat:getSessions'), getSessions: () => ipcRenderer.invoke('chat:getSessions'),
enrichSessionsContactInfo: (usernames: string[]) =>
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
getMessages: (sessionId: string, offset?: number, limit?: number) => getMessages: (sessionId: string, offset?: number, limit?: number) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit), ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
getLatestMessages: (sessionId: string, limit?: number) => getLatestMessages: (sessionId: string, limit?: number) =>
@@ -99,12 +102,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username), getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'), getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5), downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
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) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId),
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)
}
}, },
// 图片解密 // 图片解密
image: { image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
@@ -134,6 +148,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'),
@@ -161,5 +182,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
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)
},
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')
}
} }
}) })

View File

@@ -1,5 +1,8 @@
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { join } from 'path'
import { readFile, writeFile, rm } from 'fs/promises'
import { app } from 'electron'
export interface ChatStatistics { export interface ChatStatistics {
totalMessages: number totalMessages: number
@@ -253,15 +256,31 @@ class AnalyticsService {
sessionIds: string[], sessionIds: string[],
beginTimestamp = 0, beginTimestamp = 0,
endTimestamp = 0, endTimestamp = 0,
window?: any window?: any,
force = false
): Promise<{ success: boolean; data?: any; source?: string; error?: string }> { ): Promise<{ success: boolean; data?: any; source?: string; error?: string }> {
const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp) const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp)
if (this.aggregateCache && this.aggregateCache.key === cacheKey) {
if (force) {
if (this.aggregateCache) this.aggregateCache = null
if (this.fallbackAggregateCache) this.fallbackAggregateCache = null
}
if (!force && this.aggregateCache && this.aggregateCache.key === cacheKey) {
if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) { if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) {
return { success: true, data: this.aggregateCache.data, source: 'cache' } return { success: true, data: this.aggregateCache.data, source: 'cache' }
} }
} }
// 尝试从文件加载缓存
if (!force) {
const fileCache = await this.loadCacheFromFile()
if (fileCache && fileCache.key === cacheKey) {
this.aggregateCache = fileCache
return { success: true, data: fileCache.data, source: 'file-cache' }
}
}
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) { if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
return this.aggregatePromise.promise return this.aggregatePromise.promise
} }
@@ -291,7 +310,12 @@ class AnalyticsService {
this.aggregatePromise = { key: cacheKey, promise } this.aggregatePromise = { key: cacheKey, promise }
try { try {
return await promise const result = await promise
// 如果计算成功,同时写入此文件缓存
if (result.success && result.data && result.source !== 'cache') {
this.saveCacheToFile({ key: cacheKey, data: this.aggregateCache?.data, updatedAt: Date.now() })
}
return result
} finally { } finally {
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) { if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
this.aggregatePromise = null this.aggregatePromise = null
@@ -299,6 +323,25 @@ class AnalyticsService {
} }
} }
private getCacheFilePath(): string {
return join(app.getPath('documents'), 'WeFlow', 'analytics_cache.json')
}
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
try {
const raw = await readFile(this.getCacheFilePath(), 'utf-8')
return JSON.parse(raw)
} catch { return null }
}
private async saveCacheToFile(data: any) {
try {
await writeFile(this.getCacheFilePath(), JSON.stringify(data))
} catch (e) {
console.error('保存统计缓存失败:', e)
}
}
private normalizeAggregateSessions( private normalizeAggregateSessions(
sessions: Record<string, any> | undefined, sessions: Record<string, any> | undefined,
idMap: Record<string, string> | undefined idMap: Record<string, string> | undefined
@@ -326,7 +369,7 @@ class AnalyticsService {
void results void results
} }
async getOverallStatistics(): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> { async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
try { try {
const conn = await this.ensureConnected() const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
@@ -340,7 +383,7 @@ class AnalyticsService {
const win = BrowserWindow.getAllWindows()[0] const win = BrowserWindow.getAllWindows()[0]
this.setProgress(win, '正在执行原生数据聚合...', 30) this.setProgress(win, '正在执行原生数据聚合...', 30)
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win) const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win, force)
if (!result.success || !result.data) { if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' } return { success: false, error: result.error || '聚合统计失败' }
@@ -458,8 +501,8 @@ class AnalyticsService {
const d = result.data const d = result.data
// SQLite strftime('%w') 返回 0=Sun, 1=Mon...6=Sat // SQLite strftime('%w') 返回 0=周日, 1=周一...6=周六
// 前端期望 1=Mon...7=Sun // 前端期望 1=周一...7=周日
const weekdayDistribution: Record<number, number> = {} const weekdayDistribution: Record<number, number> = {}
for (const [w, count] of Object.entries(d.weekday)) { for (const [w, count] of Object.entries(d.weekday)) {
const sqliteW = parseInt(w, 10) const sqliteW = parseInt(w, 10)
@@ -485,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

@@ -19,6 +19,12 @@ interface ConfigSchema {
themeId: string themeId: string
language: string language: string
logEnabled: boolean logEnabled: boolean
llmModelPath: string
whisperModelName: string
whisperModelDir: string
whisperDownloadSource: string
autoTranscribeVoice: boolean
transcribeLanguages: string[]
} }
export class ConfigService { export class ConfigService {
@@ -40,7 +46,13 @@ export class ConfigService {
theme: 'system', theme: 'system',
themeId: 'cloud-dancer', themeId: 'cloud-dancer',
language: 'zh-CN', language: 'zh-CN',
logEnabled: false logEnabled: false,
llmModelPath: '',
whisperModelName: 'base',
whisperModelDir: '',
whisperDownloadSource: 'tsinghua',
autoTranscribeVoice: false,
transcribeLanguages: ['zh']
} }
}) })
} }

View File

@@ -0,0 +1,84 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron'
export interface ContactCacheEntry {
displayName?: string
avatarUrl?: string
updatedAt: number
}
export class ContactCacheService {
private readonly cacheFilePath: string
private cache: Record<string, ContactCacheEntry> = {}
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: join(app.getPath('documents'), 'WeFlow')
this.cacheFilePath = join(basePath, 'contacts.json')
this.ensureCacheDir()
this.loadCache()
}
private ensureCacheDir() {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private loadCache() {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
this.cache = parsed
}
} catch (error) {
console.error('ContactCacheService: 载入缓存失败', error)
this.cache = {}
}
}
get(username: string): ContactCacheEntry | undefined {
return this.cache[username]
}
getAllEntries(): Record<string, ContactCacheEntry> {
return { ...this.cache }
}
setEntries(entries: Record<string, ContactCacheEntry>): void {
if (Object.keys(entries).length === 0) return
let changed = false
for (const [username, entry] of Object.entries(entries)) {
const existing = this.cache[username]
if (!existing || entry.updatedAt >= existing.updatedAt) {
this.cache[username] = entry
changed = true
}
}
if (changed) {
this.persist()
}
}
private persist() {
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
} catch (error) {
console.error('ContactCacheService: 保存缓存失败', error)
}
}
clear(): void {
this.cache = {}
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('ContactCacheService: 清理缓存失败', error)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -181,6 +181,8 @@ class GroupAnalyticsService {
} }
} }
async getGroupActiveHours(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupActiveHours; error?: string }> { async getGroupActiveHours(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupActiveHours; error?: string }> {
try { try {
const conn = await this.ensureConnected() const conn = await this.ensureConnected()

View File

@@ -1,13 +1,45 @@
import { app, BrowserWindow } from 'electron' 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 } 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
@@ -32,11 +64,45 @@ export class ImageDecryptService {
private logInfo(message: string, meta?: Record<string, unknown>): void { private logInfo(message: string, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return if (!this.configService.get('logEnabled')) return
const timestamp = new Date().toISOString()
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
// 同时输出到控制台
if (meta) { if (meta) {
console.info(message, meta) console.info(message, meta)
} else { } else {
console.info(message) console.info(message)
} }
// 写入日志文件
this.writeLog(logLine)
}
private logError(message: string, error?: unknown, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return
const timestamp = new Date().toISOString()
const errorStr = error ? ` Error: ${String(error)}` : ''
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logLine = `[${timestamp}] [ImageDecrypt] ERROR: ${message}${errorStr}${metaStr}\n`
// 同时输出到控制台
console.error(message, error, meta)
// 写入日志文件
this.writeLog(logLine)
}
private writeLog(line: string): void {
try {
const logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true })
}
appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' })
} catch (err) {
console.error('写入日志失败:', err)
}
} }
async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> { async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> {
@@ -81,6 +147,7 @@ export class ImageDecryptService {
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate } return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
} }
} }
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: '未找到缓存图片' } return { success: false, error: '未找到缓存图片' }
} }
@@ -120,15 +187,18 @@ export class ImageDecryptService {
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean },
cacheKey: string cacheKey: string
): Promise<DecryptResult> { ): Promise<DecryptResult> {
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
try { try {
const wxid = this.configService.get('myWxid') const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath') const dbPath = this.configService.get('dbPath')
if (!wxid || !dbPath) { if (!wxid || !dbPath) {
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
return { success: false, error: '未配置账号或数据库路径' } return { success: false, error: '未配置账号或数据库路径' }
} }
const accountDir = this.resolveAccountDir(dbPath, wxid) const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) { if (!accountDir) {
this.logError('未找到账号目录', undefined, { dbPath, wxid })
return { success: false, error: '未找到账号目录' } return { success: false, error: '未找到账号目录' }
} }
@@ -142,12 +212,16 @@ export class ImageDecryptService {
// 如果要求高清图但没找到,直接返回提示 // 如果要求高清图但没找到,直接返回提示
if (!datPath && payload.force) { if (!datPath && payload.force) {
this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' } return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' }
} }
if (!datPath) { if (!datPath) {
this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: '未找到图片文件' } return { success: false, error: '未找到图片文件' }
} }
this.logInfo('找到DAT文件', { datPath })
if (!extname(datPath).toLowerCase().includes('dat')) { if (!extname(datPath).toLowerCase().includes('dat')) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
const dataUrl = this.fileToDataUrl(datPath) const dataUrl = this.fileToDataUrl(datPath)
@@ -160,6 +234,7 @@ export class ImageDecryptService {
// 查找已缓存的解密文件 // 查找已缓存的解密文件
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId) const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
if (existing) { if (existing) {
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
const isHd = this.isHdPath(existing) const isHd = this.isHdPath(existing)
// 如果要求高清但找到的是缩略图,继续解密高清图 // 如果要求高清但找到的是缩略图,继续解密高清图
if (!(payload.force && !isHd)) { if (!(payload.force && !isHd)) {
@@ -192,23 +267,45 @@ export class ImageDecryptService {
const aesKeyRaw = this.configService.get('imageAesKey') const aesKeyRaw = this.configService.get('imageAesKey')
const aesKey = this.resolveAesKey(aesKeyRaw) const aesKey = this.resolveAesKey(aesKeyRaw)
const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey) this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!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 })
// 对于 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 }
} catch (e) { } catch (e) {
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
} }
@@ -233,7 +330,7 @@ export class ImageDecryptService {
if (this.isAccountDir(entryPath)) return entryPath if (this.isAccountDir(entryPath)) return entryPath
} }
} }
} catch {} } catch { }
return null return null
} }
@@ -474,7 +571,7 @@ export class ImageDecryptService {
if (!hasUpdate) return if (!hasUpdate) return
this.updateFlags.set(cacheKey, true) this.updateFlags.set(cacheKey, true)
this.emitImageUpdate(payload, cacheKey) this.emitImageUpdate(payload, cacheKey)
}).catch(() => {}) }).catch(() => { })
} }
private looksLikeMd5(value: string): boolean { private looksLikeMd5(value: string): boolean {
@@ -856,6 +953,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}`)
@@ -863,6 +972,8 @@ export class ImageDecryptService {
} }
const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`) const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`)
if (existsSync(thumbPath)) return thumbPath if (existsSync(thumbPath)) return thumbPath
// 允许返回 _hd 格式(因为它有 _hd 变体后缀)
if (!preferHd) { if (!preferHd) {
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`) const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
if (existsSync(hdPath)) return hdPath if (existsSync(hdPath)) return hdPath
@@ -960,8 +1071,9 @@ export class ImageDecryptService {
const lower = entry.toLowerCase() const lower = entry.toLowerCase()
if (!lower.endsWith('.dat')) continue if (!lower.endsWith('.dat')) continue
if (this.isThumbnailDat(lower)) continue if (this.isThumbnailDat(lower)) continue
if (!this.hasXVariant(lower.slice(0, -4))) continue
const baseLower = lower.slice(0, -4) const baseLower = lower.slice(0, -4)
// 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的)
if (!this.hasXVariant(baseLower)) continue
if (this.normalizeDatBase(baseLower) !== target) continue if (this.normalizeDatBase(baseLower) !== target) continue
return join(dirPath, entry) return join(dirPath, entry)
} }
@@ -973,6 +1085,7 @@ export class ImageDecryptService {
if (!lower.endsWith('.dat')) return false if (!lower.endsWith('.dat')) return false
if (this.isThumbnailDat(lower)) return false if (this.isThumbnailDat(lower)) return false
const baseLower = lower.slice(0, -4) const baseLower = lower.slice(0, -4)
// 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的)
return this.hasXVariant(baseLower) return this.hasXVariant(baseLower)
} }
@@ -1332,10 +1445,10 @@ export class ImageDecryptService {
keyCount.set(key, (keyCount.get(key) || 0) + 1) keyCount.set(key, (keyCount.get(key) || 0) + 1)
filesChecked++ filesChecked++
} }
} catch {} } catch { }
} }
} }
} catch {} } catch { }
} }
scanDir(dirPath) scanDir(dirPath)
@@ -1354,6 +1467,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)
@@ -1378,6 +1644,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

@@ -1,9 +1,10 @@
import { app } from 'electron' import { app } from 'electron'
import { join, dirname, basename } from 'path' import { join, dirname, basename } from 'path'
import { existsSync, readdirSync, readFileSync, statSync } from 'fs' import { existsSync, readdirSync, readFileSync, statSync, copyFileSync, mkdirSync } from 'fs'
import { execFile, spawn } from 'child_process' import { execFile, spawn } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import crypto from 'crypto' import crypto from 'crypto'
import os from 'os'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
@@ -57,18 +58,94 @@ export class KeyService {
private readonly ERROR_SUCCESS = 0 private readonly ERROR_SUCCESS = 0
private getDllPath(): string { private getDllPath(): string {
const resourcesPath = app.isPackaged const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources') // 候选路径列表
return join(resourcesPath, 'wx_key.dll') const candidates: string[] = []
// 1. 显式环境变量 (最高优先级)
if (process.env.WX_KEY_DLL_PATH) {
candidates.push(process.env.WX_KEY_DLL_PATH)
}
if (isPackaged) {
// 生产环境: 通常在 resources 目录下,但也可能直接在 resources 根目录
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
} else {
// 开发环境
const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
}
// 检查并返回第一个存在的路径
for (const path of candidates) {
if (existsSync(path)) {
return path
}
}
// 如果都没找到,返回最可能的路径以便报错信息有参考
return candidates[0]
}
// 检查路径是否为 UNC 路径或网络路径
private isNetworkPath(path: string): boolean {
// UNC 路径以 \\ 开头
if (path.startsWith('\\\\')) {
return true
}
// 检查是否为网络映射驱动器简化检测A: 表示驱动器)
// 注意:这是一个启发式检测,更准确的方式需要调用 GetDriveType Windows API
// 但对于大多数 VM 共享场景UNC 路径检测已足够
return false
}
// 将 DLL 复制到本地临时目录
private localizeNetworkDll(originalPath: string): string {
try {
const tempDir = join(os.tmpdir(), 'weflow_dll_cache')
if (!existsSync(tempDir)) {
mkdirSync(tempDir, { recursive: true })
}
const localPath = join(tempDir, 'wx_key.dll')
// 检查是否已经有本地副本,如果有就使用它
if (existsSync(localPath)) {
console.log(`使用已存在的 DLL 本地副本: ${localPath}`)
return localPath
}
console.log(`检测到网络路径 DLL正在复制到本地: ${originalPath} -> ${localPath}`)
copyFileSync(originalPath, localPath)
console.log('DLL 本地化成功')
return localPath
} catch (e) {
console.error('DLL 本地化失败:', e)
// 如果本地化失败,返回原路径
return originalPath
}
} }
private ensureLoaded(): boolean { private ensureLoaded(): boolean {
if (this.initialized) return true if (this.initialized) return true
let dllPath = ''
try { try {
this.koffi = require('koffi') this.koffi = require('koffi')
const dllPath = this.getDllPath() dllPath = this.getDllPath()
if (!existsSync(dllPath)) return false
if (!existsSync(dllPath)) {
console.error(`wx_key.dll 不存在于路径: ${dllPath}`)
return false
}
// 检查是否为网络路径,如果是则本地化
if (this.isNetworkPath(dllPath)) {
console.log('检测到网络路径,将进行本地化处理')
dllPath = this.localizeNetworkDll(dllPath)
}
this.lib = this.koffi.load(dllPath) this.lib = this.koffi.load(dllPath)
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)') this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
@@ -80,7 +157,14 @@ export class KeyService {
this.initialized = true this.initialized = true
return true return true
} catch (e) { } catch (e) {
console.error('加载 wx_key.dll 失败:', e) const errorMsg = e instanceof Error ? e.message : String(e)
const errorStack = e instanceof Error ? e.stack : ''
console.error(`加载 wx_key.dll 失败`)
console.error(` 路径: ${dllPath}`)
console.error(` 错误: ${errorMsg}`)
if (errorStack) {
console.error(` 堆栈: ${errorStack}`)
}
return false return false
} }
} }
@@ -695,33 +779,41 @@ export class KeyService {
} }
private getXorKey(templateFiles: string[]): number | null { private getXorKey(templateFiles: string[]): number | null {
const counts = new Map<string, number>() const counts = new Map<number, number>()
const tailSignatures = [
Buffer.from([0xFF, 0xD9]),
Buffer.from([0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82])
]
for (const file of templateFiles) { for (const file of templateFiles) {
try { try {
const bytes = readFileSync(file) const bytes = readFileSync(file)
if (bytes.length < 2) continue for (const signature of tailSignatures) {
const x = bytes[bytes.length - 2] if (bytes.length < signature.length) continue
const y = bytes[bytes.length - 1] const tail = bytes.subarray(bytes.length - signature.length)
const key = `${x}_${y}` const xorKey = tail[0] ^ signature[0]
counts.set(key, (counts.get(key) ?? 0) + 1) let valid = true
for (let i = 1; i < signature.length; i++) {
if ((tail[i] ^ xorKey) !== signature[i]) {
valid = false
break
}
}
if (valid) {
counts.set(xorKey, (counts.get(xorKey) ?? 0) + 1)
}
}
} catch { } } catch { }
} }
if (!counts.size) return null if (!counts.size) return null
let mostKey = '' let bestKey: number | null = null
let mostCount = 0 let bestCount = 0
for (const [key, count] of counts) { for (const [key, count] of counts) {
if (count > mostCount) { if (count > bestCount) {
mostCount = count bestCount = count
mostKey = key bestKey = key
} }
} }
if (!mostKey) return null return bestKey
const [xStr, yStr] = mostKey.split('_')
const x = Number(xStr)
const y = Number(yStr)
const xorKey = x ^ 0xFF
const check = y ^ 0xD9
return xorKey === check ? xorKey : null
} }
private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null { private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null {
@@ -766,7 +858,17 @@ export class KeyService {
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null) const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
decipher.setAutoPadding(false) decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
return decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff const isJpeg = decrypted.length >= 3 && decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff
const isPng = decrypted.length >= 8 &&
decrypted[0] === 0x89 &&
decrypted[1] === 0x50 &&
decrypted[2] === 0x4e &&
decrypted[3] === 0x47 &&
decrypted[4] === 0x0d &&
decrypted[5] === 0x0a &&
decrypted[6] === 0x1a &&
decrypted[7] === 0x0a
return isJpeg || isPng
} catch { } catch {
return false return false
} }
@@ -813,17 +915,40 @@ export class KeyService {
return buffer.subarray(0, bytesRead[0]) return buffer.subarray(0, bytesRead[0])
} }
private async getAesKeyFromMemory(pid: number, ciphertext: Buffer): Promise<string | null> { private async getAesKeyFromMemory(
pid: number,
ciphertext: Buffer,
onProgress?: (current: number, total: number, message: string) => void
): Promise<string | null> {
if (!this.ensureKernel32()) return null if (!this.ensureKernel32()) return null
const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid) const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid)
if (!hProcess) return null if (!hProcess) return null
try { try {
const regions = this.getMemoryRegions(hProcess) const allRegions = this.getMemoryRegions(hProcess)
const chunkSize = 4 * 1024 * 1024
// 优化1: 只保留小内存区域(< 10MB- 密钥通常在小区域,可大幅减少扫描时间
const filteredRegions = allRegions.filter(([_, size]) => size <= 10 * 1024 * 1024)
// 优化2: 优先级排序 - 按大小升序,先扫描小区域(密钥通常在较小区域)
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 const overlap = 65
for (const [baseAddress, regionSize] of regions) { let currentRegion = 0
if (regionSize > 100 * 1024 * 1024) continue
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 offset = 0
let trailing: Buffer | null = null let trailing: Buffer | null = null
while (offset < regionSize) { while (offset < regionSize) {
@@ -878,6 +1003,9 @@ export class KeyService {
trailing = dataToScan.subarray(start < 0 ? 0 : start) trailing = dataToScan.subarray(start < 0 ? 0 : start)
offset += currentChunkSize offset += currentChunkSize
} }
// 更新已处理字节数
processedBytes += regionSize
} }
return null return null
} finally { } finally {
@@ -915,7 +1043,9 @@ export class KeyService {
if (!pid) return { success: false, error: '未检测到微信进程' } if (!pid) return { success: false, error: '未检测到微信进程' }
onProgress?.('正在扫描内存获取 AES 密钥...') onProgress?.('正在扫描内存获取 AES 密钥...')
const aesKey = await this.getAesKeyFromMemory(pid, ciphertext) const aesKey = await this.getAesKeyFromMemory(pid, ciphertext, (current, total, msg) => {
onProgress?.(`${msg} (${current}/${total})`)
})
if (!aesKey) { if (!aesKey) {
return { return {
success: false, success: false,

View File

@@ -0,0 +1,77 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron'
export interface SessionMessageCacheEntry {
updatedAt: number
messages: any[]
}
export class MessageCacheService {
private readonly cacheFilePath: string
private cache: Record<string, SessionMessageCacheEntry> = {}
private readonly sessionLimit = 150
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: join(app.getPath('documents'), 'WeFlow')
this.cacheFilePath = join(basePath, 'session-messages.json')
this.ensureCacheDir()
this.loadCache()
}
private ensureCacheDir() {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private loadCache() {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
this.cache = parsed
}
} catch (error) {
console.error('MessageCacheService: 载入缓存失败', error)
this.cache = {}
}
}
get(sessionId: string): SessionMessageCacheEntry | undefined {
return this.cache[sessionId]
}
set(sessionId: string, messages: any[]): void {
if (!sessionId) return
const trimmed = messages.length > this.sessionLimit
? messages.slice(-this.sessionLimit)
: messages.slice()
this.cache[sessionId] = {
updatedAt: Date.now(),
messages: trimmed
}
this.persist()
}
private persist() {
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
} catch (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,374 @@
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) => {
if (msg.type === 'partial') {
onPartial?.(msg.text)
} else if (msg.type === 'final') {
finalTranscript = msg.text
resolve({ success: true, transcript: finalTranscript })
worker.terminate()
} else if (msg.type === '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()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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[]>
}

128
electron/wcdbWorker.ts Normal file
View File

@@ -0,0 +1,128 @@
import { parentPort, workerData } from 'worker_threads'
import { WcdbCore } from './services/wcdbCore'
const core = new WcdbCore()
if (parentPort) {
parentPort.on('message', async (msg) => {
const { id, type, payload } = msg
try {
let result: any
switch (type) {
case 'setPaths':
core.setPaths(payload.resourcesPath, payload.userDataPath)
result = { success: true }
break
case 'setLogEnabled':
core.setLogEnabled(payload.enabled)
result = { success: true }
break
case 'testConnection':
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
break
case 'open':
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
break
case 'close':
core.close()
result = { success: true }
break
case 'isConnected':
result = core.isConnected()
break
case 'getSessions':
result = await core.getSessions()
break
case 'getMessages':
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
break
case 'getMessageCount':
result = await core.getMessageCount(payload.sessionId)
break
case 'getDisplayNames':
result = await core.getDisplayNames(payload.usernames)
break
case 'getAvatarUrls':
result = await core.getAvatarUrls(payload.usernames)
break
case 'getGroupMemberCount':
result = await core.getGroupMemberCount(payload.chatroomId)
break
case 'getGroupMemberCounts':
result = await core.getGroupMemberCounts(payload.chatroomIds)
break
case 'getGroupMembers':
result = await core.getGroupMembers(payload.chatroomId)
break
case 'getMessageTables':
result = await core.getMessageTables(payload.sessionId)
break
case 'getMessageTableStats':
result = await core.getMessageTableStats(payload.sessionId)
break
case 'getMessageMeta':
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
break
case 'getContact':
result = await core.getContact(payload.username)
break
case 'getAggregateStats':
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
break
case 'getAvailableYears':
result = await core.getAvailableYears(payload.sessionIds)
break
case 'getAnnualReportStats':
result = await core.getAnnualReportStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
break
case 'getAnnualReportExtras':
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
break
case 'getGroupStats':
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
break
case 'openMessageCursor':
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
break
case 'openMessageCursorLite':
result = await core.openMessageCursorLite(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
break
case 'fetchMessageBatch':
result = await core.fetchMessageBatch(payload.cursor)
break
case 'closeMessageCursor':
result = await core.closeMessageCursor(payload.cursor)
break
case 'execQuery':
result = await core.execQuery(payload.kind, payload.path, payload.sql)
break
case 'getEmoticonCdnUrl':
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
break
case 'listMessageDbs':
result = await core.listMessageDbs()
break
case 'listMediaDbs':
result = await core.listMediaDbs()
break
case 'getMessageById':
result = await core.getMessageById(payload.sessionId, payload.localId)
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
default:
result = { success: false, error: `Unknown method: ${type}` }
}
parentPort!.postMessage({ id, result })
} catch (e) {
parentPort!.postMessage({ id, error: String(e) })
}
})
}

View File

@@ -2,6 +2,7 @@
ManifestDPIAware true ManifestDPIAware true
!include "WordFunc.nsh" !include "WordFunc.nsh"
!include "nsDialogs.nsh"
!macro customInit !macro customInit
; 设置 DPI 感知 ; 设置 DPI 感知
@@ -16,3 +17,49 @@ ManifestDPIAware true
StrCpy $INSTDIR "$INSTDIR\WeFlow" StrCpy $INSTDIR "$INSTDIR\WeFlow"
${EndIf} ${EndIf}
!macroend !macroend
; 安装完成后检测并安装 VC++ Redistributable
!macro customInstall
; 检查 VC++ 2015-2022 x64 是否已安装
ReadRegStr $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
${If} $0 != "1"
; 未安装,显示提示并下载
MessageBox MB_YESNO|MB_ICONQUESTION "检测到系统缺少 Visual C++ 运行库,这可能导致程序无法正常运行。$\n$\n是否立即下载并安装约 24MB" IDYES downloadVC IDNO skipVC
downloadVC:
DetailPrint "正在下载 Visual C++ Redistributable..."
SetOutPath "$TEMP"
; 从微软官方下载 VC++ Redistributable x64
inetc::get /TIMEOUT=30000 /CAPTION "下载 Visual C++ 运行库" /BANNER "正在下载,请稍候..." \
"https://aka.ms/vs/17/release/vc_redist.x64.exe" "$TEMP\vc_redist.x64.exe" /END
Pop $0
${If} $0 == "OK"
DetailPrint "下载完成,正在安装..."
; 使用 ShellExecute 以管理员权限运行
ExecShell "runas" '"$TEMP\vc_redist.x64.exe"' "/install /quiet /norestart" SW_HIDE
; 等待安装完成
Sleep 5000
; 检查是否安装成功
ReadRegStr $1 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
${If} $1 == "1"
DetailPrint "Visual C++ Redistributable 安装成功"
MessageBox MB_OK|MB_ICONINFORMATION "Visual C++ 运行库安装成功!"
${Else}
MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,您可能需要手动安装。"
${EndIf}
Delete "$TEMP\vc_redist.x64.exe"
${Else}
MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n您可以稍后手动下载安装 Visual C++ Redistributable。"
${EndIf}
Goto doneVC
skipVC:
DetailPrint "用户跳过 Visual C++ Redistributable 安装"
doneVC:
${Else}
DetailPrint "Visual C++ Redistributable 已安装"
${EndIf}
!macroend

609
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.0.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "weflow", "name": "weflow",
"version": "1.0.0", "version": "1.2.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
@@ -14,6 +14,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",
@@ -23,6 +25,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"
}, },
@@ -344,6 +348,21 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@derhuerst/http-basic": {
"version": "8.2.4",
"resolved": "https://registry.npmmirror.com/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
"license": "MIT",
"dependencies": {
"caseless": "^0.12.0",
"concat-stream": "^2.0.0",
"http-response-object": "^3.0.1",
"parse-cache-control": "^1.0.1"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@develar/schema-utils": { "node_modules/@develar/schema-utils": {
"version": "2.6.5", "version": "2.6.5",
"resolved": "https://registry.npmmirror.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz", "resolved": "https://registry.npmmirror.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@@ -1124,6 +1143,47 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@fast-csv/format": {
"version": "4.3.5",
"resolved": "https://registry.npmmirror.com/@fast-csv/format/-/format-4.3.5.tgz",
"integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==",
"license": "MIT",
"dependencies": {
"@types/node": "^14.0.1",
"lodash.escaperegexp": "^4.1.2",
"lodash.isboolean": "^3.0.3",
"lodash.isequal": "^4.5.0",
"lodash.isfunction": "^3.0.9",
"lodash.isnil": "^4.0.0"
}
},
"node_modules/@fast-csv/format/node_modules/@types/node": {
"version": "14.18.63",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-14.18.63.tgz",
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
"license": "MIT"
},
"node_modules/@fast-csv/parse": {
"version": "4.3.6",
"resolved": "https://registry.npmmirror.com/@fast-csv/parse/-/parse-4.3.6.tgz",
"integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==",
"license": "MIT",
"dependencies": {
"@types/node": "^14.0.1",
"lodash.escaperegexp": "^4.1.2",
"lodash.groupby": "^4.6.0",
"lodash.isfunction": "^3.0.9",
"lodash.isnil": "^4.0.0",
"lodash.isundefined": "^3.0.1",
"lodash.uniq": "^4.5.0"
}
},
"node_modules/@fast-csv/parse/node_modules/@types/node": {
"version": "14.18.63",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-14.18.63.tgz",
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
"license": "MIT"
},
"node_modules/@gar/promisify": { "node_modules/@gar/promisify": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz", "resolved": "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -3564,9 +3624,7 @@
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmmirror.com/archiver/-/archiver-5.3.2.tgz", "resolved": "https://registry.npmmirror.com/archiver/-/archiver-5.3.2.tgz",
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^2.1.0", "archiver-utils": "^2.1.0",
"async": "^3.2.4", "async": "^3.2.4",
@@ -3584,9 +3642,7 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-2.1.0.tgz", "resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-2.1.0.tgz",
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.1.4", "glob": "^7.1.4",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
@@ -3607,9 +3663,7 @@
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@@ -3624,17 +3678,13 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true, "license": "MIT"
"license": "MIT",
"peer": true
}, },
"node_modules/archiver-utils/node_modules/string_decoder": { "node_modules/archiver-utils/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@@ -3686,7 +3736,6 @@
"version": "3.2.6", "version": "3.2.6",
"resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz", "resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/async-exit-hook": { "node_modules/async-exit-hook": {
@@ -3730,7 +3779,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-arraybuffer": { "node_modules/base64-arraybuffer": {
@@ -3786,6 +3834,28 @@
"node": "20.x || 22.x || 23.x || 24.x || 25.x" "node": "20.x || 22.x || 23.x || 24.x || 25.x"
} }
}, },
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz",
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
"license": "Unlicense",
"engines": {
"node": ">=0.6"
}
},
"node_modules/binary": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/binary/-/binary-0.3.0.tgz",
"integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==",
"license": "MIT",
"dependencies": {
"buffers": "~0.1.1",
"chainsaw": "~0.1.0"
},
"engines": {
"node": "*"
}
},
"node_modules/bindings": { "node_modules/bindings": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz", "resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz",
@@ -3836,7 +3906,6 @@
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@@ -3919,7 +3988,6 @@
"version": "0.2.13", "version": "0.2.13",
"resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "*" "node": "*"
@@ -3929,9 +3997,25 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/buffer-indexof-polyfill": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
"integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/buffers": {
"version": "0.1.1",
"resolved": "https://registry.npmmirror.com/buffers/-/buffers-0.1.1.tgz",
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==",
"engines": {
"node": ">=0.2.0"
}
},
"node_modules/builder-util": { "node_modules/builder-util": {
"version": "25.1.7", "version": "25.1.7",
"resolved": "https://registry.npmmirror.com/builder-util/-/builder-util-25.1.7.tgz", "resolved": "https://registry.npmmirror.com/builder-util/-/builder-util-25.1.7.tgz",
@@ -4188,6 +4272,24 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmmirror.com/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"license": "Apache-2.0"
},
"node_modules/chainsaw": {
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/chainsaw/-/chainsaw-0.1.0.tgz",
"integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==",
"license": "MIT/X11",
"dependencies": {
"traverse": ">=0.3.0 <0.4"
},
"engines": {
"node": "*"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
@@ -4413,9 +4515,7 @@
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz", "resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz",
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"buffer-crc32": "^0.2.13", "buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2", "crc32-stream": "^4.0.2",
@@ -4430,9 +4530,23 @@
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/conf": { "node_modules/conf": {
"version": "14.0.0", "version": "14.0.0",
"resolved": "https://registry.npmmirror.com/conf/-/conf-14.0.0.tgz", "resolved": "https://registry.npmmirror.com/conf/-/conf-14.0.0.tgz",
@@ -4596,9 +4710,7 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz", "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"crc32": "bin/crc32.njs" "crc32": "bin/crc32.njs"
}, },
@@ -4610,9 +4722,7 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmmirror.com/crc32-stream/-/crc32-stream-4.0.3.tgz", "resolved": "https://registry.npmmirror.com/crc32-stream/-/crc32-stream-4.0.3.tgz",
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"crc-32": "^1.2.0", "crc-32": "^1.2.0",
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
@@ -4652,6 +4762,12 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT"
},
"node_modules/debounce-fn": { "node_modules/debounce-fn": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmmirror.com/debounce-fn/-/debounce-fn-6.0.0.tgz", "resolved": "https://registry.npmmirror.com/debounce-fn/-/debounce-fn-6.0.0.tgz",
@@ -4981,6 +5097,45 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmmirror.com/duplexer2/-/duplexer2-0.1.4.tgz",
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
"license": "BSD-3-Clause",
"dependencies": {
"readable-stream": "^2.0.2"
}
},
"node_modules/duplexer2/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/duplexer2/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/duplexer2/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -5355,7 +5510,6 @@
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -5491,6 +5645,26 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/exceljs": {
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/exceljs/-/exceljs-4.4.0.tgz",
"integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==",
"license": "MIT",
"dependencies": {
"archiver": "^5.0.0",
"dayjs": "^1.8.34",
"fast-csv": "^4.3.1",
"jszip": "^3.10.1",
"readable-stream": "^3.6.0",
"saxes": "^5.0.1",
"tmp": "^0.2.0",
"unzipper": "^0.10.11",
"uuid": "^8.3.0"
},
"engines": {
"node": ">=8.3.0"
}
},
"node_modules/expand-template": { "node_modules/expand-template": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz", "resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz",
@@ -5539,6 +5713,19 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/fast-csv": {
"version": "4.3.6",
"resolved": "https://registry.npmmirror.com/fast-csv/-/fast-csv-4.3.6.tgz",
"integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==",
"license": "MIT",
"dependencies": {
"@fast-csv/format": "4.3.5",
"@fast-csv/parse": "4.3.6"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5578,6 +5765,47 @@
"pend": "~1.2.0" "pend": "~1.2.0"
} }
}, },
"node_modules/ffmpeg-static": {
"version": "5.3.0",
"resolved": "https://registry.npmmirror.com/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
"@derhuerst/http-basic": "^8.2.0",
"env-paths": "^2.2.0",
"https-proxy-agent": "^5.0.0",
"progress": "^2.0.3"
},
"engines": {
"node": ">=16"
}
},
"node_modules/ffmpeg-static/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ffmpeg-static/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/file-uri-to-path": { "node_modules/file-uri-to-path": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -5716,7 +5944,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/fsevents": { "node_modules/fsevents": {
@@ -5734,6 +5961,47 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/fstream": {
"version": "1.0.12",
"resolved": "https://registry.npmmirror.com/fstream/-/fstream-1.0.12.tgz",
"integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"graceful-fs": "^4.1.2",
"inherits": "~2.0.0",
"mkdirp": ">=0.5 0",
"rimraf": "2"
},
"engines": {
"node": ">=0.6"
}
},
"node_modules/fstream/node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/fstream/node_modules/rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
@@ -5857,7 +6125,6 @@
"resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported", "deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"fs.realpath": "^1.0.0", "fs.realpath": "^1.0.0",
@@ -5878,7 +6145,6 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
@@ -6109,6 +6375,21 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/http-response-object": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/http-response-object/-/http-response-object-3.0.2.tgz",
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
"license": "MIT",
"dependencies": {
"@types/node": "^10.0.3"
}
},
"node_modules/http-response-object/node_modules/@types/node": {
"version": "10.17.60",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-10.17.60.tgz",
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
"license": "MIT"
},
"node_modules/http2-wrapper": { "node_modules/http2-wrapper": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz", "resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
@@ -6243,7 +6524,6 @@
"resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"once": "^1.3.0", "once": "^1.3.0",
@@ -6582,9 +6862,7 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/lazystream/-/lazystream-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/lazystream/-/lazystream-1.0.1.tgz",
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"readable-stream": "^2.0.5" "readable-stream": "^2.0.5"
}, },
@@ -6596,9 +6874,7 @@
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@@ -6613,17 +6889,13 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true, "license": "MIT"
"license": "MIT",
"peer": true
}, },
"node_modules/lazystream/node_modules/string_decoder": { "node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@@ -6637,6 +6909,12 @@
"immediate": "~3.0.5" "immediate": "~3.0.5"
} }
}, },
"node_modules/listenercount": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/listenercount/-/listenercount-1.0.1.tgz",
"integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==",
"license": "ISC"
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
@@ -6648,17 +6926,13 @@
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"dev": true, "license": "MIT"
"license": "MIT",
"peer": true
}, },
"node_modules/lodash.difference": { "node_modules/lodash.difference": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.difference/-/lodash.difference-4.5.0.tgz", "resolved": "https://registry.npmmirror.com/lodash.difference/-/lodash.difference-4.5.0.tgz",
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
"dev": true, "license": "MIT"
"license": "MIT",
"peer": true
}, },
"node_modules/lodash.escaperegexp": { "node_modules/lodash.escaperegexp": {
"version": "4.1.2", "version": "4.1.2",
@@ -6670,9 +6944,19 @@
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "resolved": "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
"dev": true, "license": "MIT"
"license": "MIT", },
"peer": true "node_modules/lodash.groupby": {
"version": "4.6.0",
"resolved": "https://registry.npmmirror.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz",
"integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
}, },
"node_modules/lodash.isequal": { "node_modules/lodash.isequal": {
"version": "4.5.0", "version": "4.5.0",
@@ -6681,21 +6965,41 @@
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.isfunction": {
"version": "3.0.9",
"resolved": "https://registry.npmmirror.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz",
"integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==",
"license": "MIT"
},
"node_modules/lodash.isnil": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz",
"integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": { "node_modules/lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true, "license": "MIT"
"license": "MIT", },
"peer": true "node_modules/lodash.isundefined": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz",
"integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==",
"license": "MIT"
}, },
"node_modules/lodash.union": { "node_modules/lodash.union": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmmirror.com/lodash.union/-/lodash.union-4.6.0.tgz", "resolved": "https://registry.npmmirror.com/lodash.union/-/lodash.union-4.6.0.tgz",
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
"dev": true, "license": "MIT"
"license": "MIT", },
"peer": true "node_modules/lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
"license": "MIT"
}, },
"node_modules/log-symbols": { "node_modules/log-symbols": {
"version": "4.1.0", "version": "4.1.0",
@@ -7257,9 +7561,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -7406,11 +7708,15 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)" "license": "(MIT AND Zlib)"
}, },
"node_modules/parse-cache-control": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="
},
"node_modules/path-is-absolute": { "node_modules/path-is-absolute": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -7595,7 +7901,6 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", "resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
@@ -7770,9 +8075,7 @@
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmmirror.com/readdir-glob/-/readdir-glob-1.1.3.tgz", "resolved": "https://registry.npmmirror.com/readdir-glob/-/readdir-glob-1.1.3.tgz",
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"minimatch": "^5.1.0" "minimatch": "^5.1.0"
} }
@@ -7781,9 +8084,7 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
@@ -7792,9 +8093,7 @@
"version": "5.1.6", "version": "5.1.6",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.1"
}, },
@@ -8042,6 +8341,18 @@
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/saxes": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/saxes/-/saxes-5.0.1.tgz",
"integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==",
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
@@ -8186,6 +8497,78 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/sherpa-onnx-darwin-arm64": {
"version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-darwin-arm64/-/sherpa-onnx-darwin-arm64-1.12.23.tgz",
"integrity": "sha512-zbjNUUH/IXhjRyRJ9mpcWVOGIVr31a/qXBPsfOYc7U8cgwcq33Vmj2OzoLYWQF6T+puqCAE4nMxFAxJvdZekhg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/sherpa-onnx-linux-x64": {
"version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-linux-x64/-/sherpa-onnx-linux-x64-1.12.23.tgz",
"integrity": "sha512-pUZIdDvPtyRXQDGo9R9MIBf2AFUzfgcGmutoulsEdH3hpK6JteR7Z/5pfrZIIqe/O99djAjEHK4AlwLHC2jiZw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/sherpa-onnx-node": {
"version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-node/-/sherpa-onnx-node-1.12.23.tgz",
"integrity": "sha512-09SRixVSjsajxeCV8Hy9R5J4IHPtw7vNgaIcEokdh/LpU7sY+e12z9uHHIMMMgNiInyGEH74wIwjLXms+W7qRA==",
"license": "Apache-2.0",
"optionalDependencies": {
"sherpa-onnx-darwin-arm64": "^1.12.23",
"sherpa-onnx-darwin-x64": "^1.12.23",
"sherpa-onnx-linux-arm64": "^1.12.23",
"sherpa-onnx-linux-x64": "^1.12.23",
"sherpa-onnx-win-ia32": "^1.12.23",
"sherpa-onnx-win-x64": "^1.12.23"
}
},
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": {
"optional": true
},
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-linux-arm64": {
"optional": true
},
"node_modules/sherpa-onnx-win-ia32": {
"version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",
"integrity": "sha512-MyLsK7r6dd7paglyTgb8UHTXTEFqOzA91u6VDV64Lq8rDGuOFVYioxX7vlwmGe1A9o7VhuOPNaKcRjEPtVDhBQ==",
"cpu": [
"ia32"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/sherpa-onnx-win-x64": {
"version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-x64/-/sherpa-onnx-win-x64-1.12.23.tgz",
"integrity": "sha512-pdHEYMJiYy8+xzH2WkBVS4/hnRwqjY8FaWnjs0NBgQZnPmc/k4M+TAiauTOuFDNK4GPwFQnjwrCGx6jI9AOkOg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz",
@@ -8193,6 +8576,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/silk-wasm": {
"version": "3.7.1",
"resolved": "https://registry.npmmirror.com/silk-wasm/-/silk-wasm-3.7.1.tgz",
"integrity": "sha512-mXPwLRtZxrYV3TZx41jMAeKc80wvmyrcXIcs8HctFxK15Ahz2OJQENYhNgEPeCEOdI6Mbx1NxQsqxzwc3DKerw==",
"license": "MIT",
"engines": {
"node": ">=16.11.0"
}
},
"node_modules/simple-concat": { "node_modules/simple-concat": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz",
@@ -8731,7 +9123,6 @@
"version": "0.2.5", "version": "0.2.5",
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz", "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=14.14" "node": ">=14.14"
@@ -8761,6 +9152,15 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/traverse": {
"version": "0.3.9",
"resolved": "https://registry.npmmirror.com/traverse/-/traverse-0.3.9.tgz",
"integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==",
"license": "MIT/X11",
"engines": {
"node": "*"
}
},
"node_modules/truncate-utf8-bytes": { "node_modules/truncate-utf8-bytes": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
@@ -8801,6 +9201,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
@@ -8870,6 +9276,60 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/unzipper": {
"version": "0.10.14",
"resolved": "https://registry.npmmirror.com/unzipper/-/unzipper-0.10.14.tgz",
"integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==",
"license": "MIT",
"dependencies": {
"big-integer": "^1.6.17",
"binary": "~0.3.0",
"bluebird": "~3.4.1",
"buffer-indexof-polyfill": "~1.0.0",
"duplexer2": "~0.1.4",
"fstream": "^1.0.12",
"graceful-fs": "^4.2.2",
"listenercount": "~1.0.1",
"readable-stream": "~2.3.6",
"setimmediate": "~1.0.4"
}
},
"node_modules/unzipper/node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/unzipper/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/unzipper/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/unzipper/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -8933,6 +9393,15 @@
"base64-arraybuffer": "^1.0.2" "base64-arraybuffer": "^1.0.2"
} }
}, },
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/verror": { "node_modules/verror": {
"version": "1.10.1", "version": "1.10.1",
"resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz", "resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz",
@@ -9181,6 +9650,12 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
@@ -9255,9 +9730,7 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmmirror.com/zip-stream/-/zip-stream-4.1.1.tgz", "resolved": "https://registry.npmmirror.com/zip-stream/-/zip-stream-4.1.1.tgz",
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^3.0.4", "archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2", "compress-commons": "^4.1.2",
@@ -9271,9 +9744,7 @@
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-3.0.4.tgz", "resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-3.0.4.tgz",
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.2.3", "glob": "^7.2.3",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",

View File

@@ -1,16 +1,17 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.0.1", "version": "1.2.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'",
"rebuild": "echo 'No native modules to rebuild'",
"dev": "vite", "dev": "vite",
"build": "tsc && vite build && electron-builder", "build": "tsc && vite build && electron-builder",
"preview": "vite preview", "preview": "vite preview",
"electron:dev": "vite --mode electron", "electron:dev": "vite --mode electron",
"electron:build": "npm run build", "electron:build": "npm run build"
"postinstall": "electron-rebuild"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
@@ -18,6 +19,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",
@@ -27,6 +30,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"
}, },
@@ -64,6 +69,7 @@
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
"differentialPackage": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true, "createDesktopShortcut": true,
"unicode": true, "unicode": true,
@@ -95,6 +101,10 @@
"files": [ "files": [
"dist/**/*", "dist/**/*",
"dist-electron/**/*" "dist-electron/**/*"
],
"asarUnpack": [
"node_modules/silk-wasm/**/*",
"node_modules/sherpa-onnx-node/**/*"
] ]
} }
} }

BIN
resources/SDL2.dll Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -7,6 +7,7 @@ import WelcomePage from './pages/WelcomePage'
import HomePage from './pages/HomePage' import HomePage from './pages/HomePage'
import ChatPage from './pages/ChatPage' import ChatPage from './pages/ChatPage'
import AnalyticsPage from './pages/AnalyticsPage' import AnalyticsPage from './pages/AnalyticsPage'
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
import AnnualReportPage from './pages/AnnualReportPage' import AnnualReportPage from './pages/AnnualReportPage'
import AnnualReportWindow from './pages/AnnualReportWindow' import AnnualReportWindow from './pages/AnnualReportWindow'
import AgreementPage from './pages/AgreementPage' import AgreementPage from './pages/AgreementPage'
@@ -14,6 +15,7 @@ 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 { 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'
import * as configService from './services/config' import * as configService from './services/config'
@@ -307,7 +309,8 @@ function App() {
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} /> <Route path="/home" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} /> <Route path="/chat" element={<ChatPage />} />
<Route path="/analytics" element={<AnalyticsPage />} /> <Route path="/analytics" element={<AnalyticsWelcomePage />} />
<Route path="/analytics/view" element={<AnalyticsPage />} />
<Route path="/group-analytics" element={<GroupAnalyticsPage />} /> <Route path="/group-analytics" element={<GroupAnalyticsPage />} />
<Route path="/annual-report" element={<AnnualReportPage />} /> <Route path="/annual-report" element={<AnnualReportPage />} />
<Route path="/annual-report/view" element={<AnnualReportWindow />} /> <Route path="/annual-report/view" element={<AnnualReportWindow />} />

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,79 @@
.avatar-component {
position: relative;
display: inline-block;
overflow: hidden;
background-color: var(--bg-tertiary, #f5f5f5);
flex-shrink: 0;
border-radius: 4px;
/* Default radius */
&.circle {
border-radius: 50%;
}
&.rounded {
border-radius: 6px;
}
/* Image styling */
img.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.3s ease-in-out;
border-radius: inherit;
&.loaded {
opacity: 1;
}
&.instant {
transition: none !important;
opacity: 1 !important;
}
}
/* Placeholder/Letter styling */
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
color: var(--text-secondary, #666);
background-color: var(--bg-tertiary, #e0e0e0);
font-size: 1.2em;
text-transform: uppercase;
user-select: none;
border-radius: inherit;
}
/* Loading Skeleton */
.avatar-skeleton {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg,
var(--bg-tertiary, #f0f0f0) 25%,
var(--bg-secondary, #e0e0e0) 50%,
var(--bg-tertiary, #f0f0f0) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
z-index: 1;
border-radius: inherit;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
}

129
src/components/Avatar.tsx Normal file
View File

@@ -0,0 +1,129 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { User } from 'lucide-react'
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
import './Avatar.scss'
// 全局缓存已成功加载过的头像 URL用于控制后续是否显示动画
const loadedAvatarCache = new Set<string>()
interface AvatarProps {
src?: string
name?: string
size?: number | string
shape?: 'circle' | 'square' | 'rounded'
className?: string
lazy?: boolean
onClick?: () => void
}
export const Avatar = React.memo(function Avatar({
src,
name,
size = 48,
shape = 'rounded',
className = '',
lazy = true,
onClick
}: AvatarProps) {
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
const [imageLoaded, setImageLoaded] = useState(isCached)
const [imageError, setImageError] = useState(false)
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
const [isInQueue, setIsInQueue] = useState(false)
const imgRef = useRef<HTMLImageElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const getAvatarLetter = (): string => {
if (!name) return '?'
const chars = [...name]
return chars[0] || '?'
}
// Intersection Observer for lazy loading
useEffect(() => {
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isInQueue) {
setIsInQueue(true)
avatarLoadQueue.enqueue(src).then(() => {
setShouldLoad(true)
}).catch(() => {
// 加载失败不要立刻显示错误,让浏览器渲染去报错
setShouldLoad(true)
}).finally(() => {
setIsInQueue(false)
})
observer.disconnect()
}
})
},
{ rootMargin: '100px' }
)
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [src, lazy, shouldLoad, isInQueue, isCached])
// Reset state when src changes
useEffect(() => {
const cached = src ? loadedAvatarCache.has(src) : false
setImageLoaded(cached)
setImageError(false)
if (lazy && !cached) {
setShouldLoad(false)
setIsInQueue(false)
} else {
setShouldLoad(true)
}
}, [src, lazy])
// Check if image is already cached/loaded
useEffect(() => {
if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
setImageLoaded(true)
}
}, [src, shouldLoad])
const style = {
width: typeof size === 'number' ? `${size}px` : size,
height: typeof size === 'number' ? `${size}px` : size,
}
const hasValidUrl = !!src && !imageError && shouldLoad
return (
<div
ref={containerRef}
className={`avatar-component ${shape} ${className}`}
style={style}
onClick={onClick}
>
{hasValidUrl ? (
<>
{!imageLoaded && <div className="avatar-skeleton" />}
<img
ref={imgRef}
src={src}
alt={name || 'avatar'}
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
onLoad={() => {
if (src) loadedAvatarCache.add(src)
setImageLoaded(true)
}}
onError={() => setImageError(true)}
loading={lazy ? "lazy" : "eager"}
/>
</>
) : (
<div className="avatar-placeholder">
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
</div>
)}
</div>
)
})

View File

@@ -0,0 +1,46 @@
.image-preview-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
user-select: none;
}
.preview-image {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
transition: transform 0.15s ease-out;
&.dragging {
transition: none;
}
}
.image-preview-close {
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.3);
background: rgba(0, 0, 0, 0.7);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
backdrop-filter: blur(10px);
&:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.5);
transform: translateX(-50%) scale(1.1);
}
}

View File

@@ -0,0 +1,101 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'
import { X } from 'lucide-react'
import { createPortal } from 'react-dom'
import './ImagePreview.scss'
interface ImagePreviewProps {
src: string
onClose: () => void
}
export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, onClose }) => {
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const dragStart = useRef({ x: 0, y: 0 })
const positionStart = useRef({ x: 0, y: 0 })
const containerRef = useRef<HTMLDivElement>(null)
// 滚轮缩放
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault()
const delta = e.deltaY > 0 ? 0.9 : 1.1
setScale(prev => Math.min(Math.max(prev * delta, 0.5), 5))
}, [])
// 开始拖动
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (scale <= 1) return
e.preventDefault()
setIsDragging(true)
dragStart.current = { x: e.clientX, y: e.clientY }
positionStart.current = { ...position }
}, [scale, position])
// 拖动中
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging) return
const dx = e.clientX - dragStart.current.x
const dy = e.clientY - dragStart.current.y
setPosition({
x: positionStart.current.x + dx,
y: positionStart.current.y + dy
})
}, [isDragging])
// 结束拖动
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
// 双击重置
const handleDoubleClick = useCallback(() => {
setScale(1)
setPosition({ x: 0, y: 0 })
}, [])
// 点击背景关闭
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
if (e.target === containerRef.current) {
onClose()
}
}, [onClose])
// ESC 关闭
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose])
return createPortal(
<div
ref={containerRef}
className="image-preview-overlay"
onClick={handleOverlayClick}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<img
src={src}
alt="图片预览"
className={`preview-image ${isDragging ? 'dragging' : ''}`}
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
draggable={false}
/>
<button className="image-preview-close" onClick={onClose}>
<X size={20} />
</button>
</div>,
document.body
)
}

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 } from 'lucide-react' import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } from 'lucide-react'
import './Sidebar.scss' import './Sidebar.scss'
function Sidebar() { function Sidebar() {
@@ -34,6 +34,8 @@ function Sidebar() {
<span className="nav-label"></span> <span className="nav-label"></span>
</NavLink> </NavLink>
{/* 私聊分析 */} {/* 私聊分析 */}
<NavLink <NavLink
to="/analytics" to="/analytics"

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,10 +1,12 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
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'
import { useAnalyticsStore } from '../stores/analyticsStore' import { useAnalyticsStore } from '../stores/analyticsStore'
import { useThemeStore } from '../stores/themeStore' import { useThemeStore } from '../stores/themeStore'
import './AnalyticsPage.scss' import './AnalyticsPage.scss'
import './DataManagementPage.scss' import './DataManagementPage.scss'
import { Avatar } from '../components/Avatar'
function AnalyticsPage() { function AnalyticsPage() {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
@@ -28,7 +30,7 @@ function AnalyticsPage() {
try { try {
setLoadingStatus('正在统计消息数据...') setLoadingStatus('正在统计消息数据...')
const statsResult = await window.electronAPI.analytics.getOverallStatistics() const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
if (statsResult.success && statsResult.data) { if (statsResult.success && statsResult.data) {
setStatistics(statsResult.data) setStatistics(statsResult.data)
} else { } else {
@@ -55,7 +57,12 @@ function AnalyticsPage() {
} }
} }
useEffect(() => { loadData() }, []) const location = useLocation()
useEffect(() => {
const force = location.state?.forceRefresh === true
loadData(force)
}, [location.state])
const handleRefresh = () => loadData(true) const handleRefresh = () => loadData(true)
@@ -289,7 +296,7 @@ function AnalyticsPage() {
<div key={contact.username} className="ranking-item"> <div key={contact.username} className="ranking-item">
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span> <span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
<div className="contact-avatar"> <div className="contact-avatar">
{contact.avatarUrl ? <img src={contact.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>} <Avatar src={contact.avatarUrl} name={contact.displayName} size={36} />
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>} {index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
</div> </div>
<div className="contact-info"> <div className="contact-info">

View File

@@ -0,0 +1,119 @@
.analytics-welcome-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px;
background: var(--bg-primary);
color: var(--text-primary);
animation: fadeIn 0.4s ease-out;
.welcome-content {
text-align: center;
max-width: 600px;
.icon-wrapper {
width: 80px;
height: 80px;
margin: 0 auto 24px;
background: rgba(7, 193, 96, 0.1);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
color: #07c160;
svg {
width: 40px;
height: 40px;
}
}
h1 {
font-size: 28px;
margin-bottom: 12px;
font-weight: 600;
}
p {
color: var(--text-secondary);
margin-bottom: 40px;
font-size: 16px;
line-height: 1.6;
}
.action-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 20px;
button {
display: flex;
flex-direction: column;
align-items: center;
padding: 30px 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
&:hover:not(:disabled) {
transform: translateY(-2px);
border-color: #07c160;
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.1);
.card-icon {
color: #07c160;
background: rgba(7, 193, 96, 0.1);
}
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
filter: grayscale(100%);
}
.card-icon {
width: 50px;
height: 50px;
border-radius: 12px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: var(--text-secondary);
transition: all 0.2s ease;
}
h3 {
font-size: 18px;
margin-bottom: 8px;
color: var(--text-primary);
}
span {
font-size: 13px;
color: var(--text-tertiary);
}
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,63 @@
import { useNavigate } from 'react-router-dom'
import { BarChart2, History, RefreshCcw } from 'lucide-react'
import { useAnalyticsStore } from '../stores/analyticsStore'
import './AnalyticsWelcomePage.scss'
function AnalyticsWelcomePage() {
const navigate = useNavigate()
// 检查是否有任何缓存数据加载或基本的存储状态表明它已准备好。
// 实际上,如果 store 没有持久化,`isLoaded` 可能会在应用刷新时重置。
// 如果用户点击“加载缓存”但缓存为空AnalyticsPage 的逻辑loadData 不带 force将尝试从后端缓存加载。
// 如果后端缓存也为空,则会重新计算。
// 我们也可以检查 `lastLoadTime` 来显示“上次更新xxx”如果已持久化
const { lastLoadTime } = useAnalyticsStore()
const handleLoadCache = () => {
navigate('/analytics/view')
}
const handleNewAnalysis = () => {
navigate('/analytics/view', { state: { forceRefresh: true } })
}
const formatLastTime = (ts: number | null) => {
if (!ts) return '无记录'
return new Date(ts).toLocaleString()
}
return (
<div className="analytics-welcome-container">
<div className="welcome-content">
<div className="icon-wrapper">
<BarChart2 size={40} />
</div>
<h1></h1>
<p>
WeFlow <br />
</p>
<div className="action-cards">
<button onClick={handleLoadCache}>
<div className="card-icon">
<History size={24} />
</div>
<h3></h3>
<span><br />(: {formatLastTime(lastLoadTime)})</span>
</button>
<button onClick={handleNewAnalysis}>
<div className="card-icon">
<RefreshCcw size={24} />
</div>
<h3></h3>
<span><br />()</span>
</button>
</div>
</div>
</div>
)
}
export default AnalyticsWelcomePage

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Loader2, Download, Image, Check, X } from 'lucide-react' import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react'
import html2canvas from 'html2canvas' import html2canvas from 'html2canvas'
import { useThemeStore } from '../stores/themeStore' import { useThemeStore } from '../stores/themeStore'
import './AnnualReportWindow.scss' import './AnnualReportWindow.scss'
@@ -249,6 +249,7 @@ function AnnualReportWindow() {
const [fabOpen, setFabOpen] = useState(false) const [fabOpen, setFabOpen] = useState(false)
const [loadingProgress, setLoadingProgress] = useState(0) const [loadingProgress, setLoadingProgress] = useState(0)
const [loadingStage, setLoadingStage] = useState('正在初始化...') const [loadingStage, setLoadingStage] = useState('正在初始化...')
const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate')
const { currentTheme, themeMode } = useThemeStore() const { currentTheme, themeMode } = useThemeStore()
@@ -490,7 +491,7 @@ function AnnualReportWindow() {
} }
// 导出整个报告为长图 // 导出整个报告为长图
const exportFullReport = async () => { const exportFullReport = async (filterIds?: Set<string>) => {
if (!containerRef.current) { if (!containerRef.current) {
return return
} }
@@ -516,6 +517,16 @@ function AnnualReportWindow() {
el.style.padding = '40px 0' el.style.padding = '40px 0'
}) })
// 如果有筛选,隐藏未选中的板块
if (filterIds) {
const available = getAvailableSections()
available.forEach(s => {
if (!filterIds.has(s.id) && s.ref.current) {
s.ref.current.style.display = 'none'
}
})
}
// 修复词云导出问题 // 修复词云导出问题
const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement
const wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement> const wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
@@ -584,7 +595,7 @@ function AnnualReportWindow() {
const dataUrl = outputCanvas.toDataURL('image/png') const dataUrl = outputCanvas.toDataURL('image/png')
const link = document.createElement('a') const link = document.createElement('a')
link.download = `${reportData?.year}年度报告.png` link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png`
link.href = dataUrl link.href = dataUrl
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
@@ -607,6 +618,13 @@ function AnnualReportWindow() {
return return
} }
if (exportMode === 'long') {
setShowExportModal(false)
await exportFullReport(selectedSections)
setSelectedSections(new Set())
return
}
setIsExporting(true) setIsExporting(true)
setShowExportModal(false) setShowExportModal(false)
@@ -735,9 +753,12 @@ function AnnualReportWindow() {
{/* 浮动操作按钮 */} {/* 浮动操作按钮 */}
<div className={`fab-container ${fabOpen ? 'open' : ''}`}> <div className={`fab-container ${fabOpen ? 'open' : ''}`}>
<button className="fab-item" onClick={() => { setFabOpen(false); setShowExportModal(true) }} title="分模块导出"> <button className="fab-item" onClick={() => { setFabOpen(false); setExportMode('separate'); setShowExportModal(true) }} title="分模块导出">
<Image size={18} /> <Image size={18} />
</button> </button>
<button className="fab-item" onClick={() => { setFabOpen(false); setExportMode('long'); setShowExportModal(true) }} title="自定义导出长图">
<SlidersHorizontal size={18} />
</button>
<button className="fab-item" onClick={() => { setFabOpen(false); exportFullReport() }} title="导出长图"> <button className="fab-item" onClick={() => { setFabOpen(false); exportFullReport() }} title="导出长图">
<Download size={18} /> <Download size={18} />
</button> </button>
@@ -765,7 +786,7 @@ function AnnualReportWindow() {
<div className="export-overlay" onClick={() => setShowExportModal(false)}> <div className="export-overlay" onClick={() => setShowExportModal(false)}>
<div className="export-modal section-selector" onClick={e => e.stopPropagation()}> <div className="export-modal section-selector" onClick={e => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h3></h3> <h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3>
<button className="close-btn" onClick={() => setShowExportModal(false)}> <button className="close-btn" onClick={() => setShowExportModal(false)}>
<X size={20} /> <X size={20} />
</button> </button>
@@ -793,7 +814,7 @@ function AnnualReportWindow() {
onClick={exportSelectedSections} onClick={exportSelectedSections}
disabled={selectedSections.size === 0} disabled={selectedSections.size === 0}
> >
{selectedSections.size > 0 ? `(${selectedSections.size})` : ''} {exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
</button> </button>
</div> </div>
</div> </div>
@@ -838,7 +859,7 @@ function AnnualReportWindow() {
<span className="hl">{formatNumber(topFriend.sentCount)}</span> · <span className="hl">{formatNumber(topFriend.sentCount)}</span> ·
TA发来 <span className="hl">{formatNumber(topFriend.receivedCount)}</span> TA发来 <span className="hl">{formatNumber(topFriend.receivedCount)}</span>
</p> </p>
<br/> <br />
<p className="hero-desc"> <p className="hero-desc">
</p> </p>

View File

@@ -883,6 +883,23 @@
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
position: relative;
&.loading .message-list {
opacity: 0;
transform: translateY(8px);
pointer-events: none;
}
&.loaded .message-list {
opacity: 1;
transform: translateY(0);
}
&.loaded .loading-overlay {
opacity: 0;
pointer-events: none;
}
} }
.message-list { .message-list {
@@ -898,6 +915,7 @@
background-color: var(--bg-tertiary); background-color: var(--bg-tertiary);
position: relative; position: relative;
-webkit-app-region: no-drag !important; -webkit-app-region: no-drag !important;
transition: opacity 240ms ease, transform 240ms ease;
// 滚动条样式 // 滚动条样式
&::-webkit-scrollbar { &::-webkit-scrollbar {
@@ -918,6 +936,19 @@
} }
} }
.loading-messages.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
background: rgba(10, 10, 10, 0.28);
backdrop-filter: blur(6px);
transition: opacity 200ms ease;
z-index: 2;
}
.message-list * { .message-list * {
-webkit-app-region: no-drag !important; -webkit-app-region: no-drag !important;
} }
@@ -1077,6 +1108,14 @@
border-radius: 16px; border-radius: 16px;
} }
} }
// 使发送的语音消息和转文字也使用接收者的样式 (浅色)
&.sent.voice {
.bubble-content {
background: var(--bg-secondary);
color: var(--text-primary);
}
}
} }
.bubble-avatar { .bubble-avatar {
@@ -1108,6 +1147,7 @@
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
word-break: break-word; word-break: break-word;
white-space: pre-wrap;
} }
// 表情包消息 // 表情包消息
@@ -1259,36 +1299,6 @@
color: var(--text-quaternary); color: var(--text-quaternary);
} }
.image-preview-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
img {
max-width: 88vw;
max-height: 88vh;
border-radius: 12px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
}
}
.image-preview-close {
position: absolute;
top: 20px;
right: 20px;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.6);
color: #fff;
cursor: pointer;
}
// 语音消息 // 语音消息
.voice-message { .voice-message {
display: flex; display: flex;
@@ -1301,8 +1311,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 {
@@ -1337,6 +1349,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;
} }
@@ -1389,15 +1445,35 @@
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;
opacity: 0.5; opacity: 0.5;
} }
50% { 50% {
height: 16px; height: 16px;
opacity: 1; opacity: 1;
} }
100% { 100% {
height: 6px; height: 6px;
opacity: 0.5; opacity: 0.5;
@@ -1432,6 +1508,7 @@
.quoted-text { .quoted-text {
color: var(--text-secondary); color: var(--text-secondary);
white-space: pre-wrap;
} }
} }
@@ -1843,3 +1920,32 @@
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -379,29 +379,21 @@
border-radius: 10px; border-radius: 10px;
font-size: 14px; font-size: 14px;
color: var(--text-primary); color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
}
svg { svg {
color: var(--text-tertiary); color: var(--text-tertiary);
flex-shrink: 0;
} }
span { span {
flex: 1; flex: 1;
} }
.change-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: var(--text-primary);
}
}
} }
.media-options { .media-options {
@@ -471,6 +463,43 @@
margin: 8px 0 0; margin: 8px 0 0;
} }
.select-folder-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
margin-top: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
svg {
color: var(--primary);
}
}
&:active {
transform: scale(0.98);
}
svg {
color: var(--text-secondary);
transition: color 0.2s;
}
}
.export-action { .export-action {
padding: 20px 24px; padding: 20px 24px;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
@@ -649,9 +678,382 @@
} }
} }
} }
.date-picker-modal {
background: var(--card-bg);
padding: 28px 32px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
min-width: 420px;
max-width: 500px;
h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 20px;
}
.quick-select {
display: flex;
gap: 8px;
margin-bottom: 20px;
.quick-btn {
flex: 1;
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
&:active {
transform: scale(0.98);
}
}
}
.date-display {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--bg-secondary);
border-radius: 12px;
margin-bottom: 24px;
.date-display-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(var(--primary-rgb), 0.05);
}
&.active {
background: rgba(var(--primary-rgb), 0.1);
border: 1px solid var(--primary);
}
.date-label {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 500;
}
.date-value {
font-size: 15px;
color: var(--text-primary);
font-weight: 600;
}
}
.date-separator {
font-size: 14px;
color: var(--text-tertiary);
padding: 0 4px;
}
}
.calendar-container {
margin-bottom: 20px;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding: 0 4px;
.calendar-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;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
&:active {
transform: scale(0.95);
}
}
.calendar-month {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 8px;
.calendar-weekday {
text-align: center;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
padding: 8px 0;
}
}
.calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
.calendar-day {
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;
position: relative;
&.empty {
cursor: default;
}
&:not(.empty):hover {
background: var(--bg-hover);
}
&.in-range {
background: rgba(var(--primary-rgb), 0.08);
}
&.start,
&.end {
background: var(--primary);
color: #fff;
font-weight: 600;
&:hover {
background: var(--primary-hover);
}
}
}
}
.date-picker-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
button {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:active {
transform: scale(0.98);
}
}
.cancel-btn {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
&:hover {
background: var(--bg-hover);
}
}
.confirm-btn {
background: var(--primary);
color: #fff;
border: none;
&:hover {
background: var(--primary-hover);
}
}
}
}
} }
@keyframes exportSpin { @keyframes exportSpin {
from { transform: rotate(0deg); } from {
to { transform: rotate(360deg); } transform: rotate(0deg);
}
to {
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,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, 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,11 @@ 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
} }
interface ExportResult { interface ExportResult {
@@ -35,6 +40,9 @@ function ExportPage() {
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' }) const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' })
const [exportResult, setExportResult] = useState<ExportResult | null>(null) const [exportResult, setExportResult] = useState<ExportResult | null>(null)
const [showDatePicker, setShowDatePicker] = useState(false)
const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'chatlab', format: 'chatlab',
@@ -43,7 +51,12 @@ function ExportPage() {
end: new Date() end: new Date()
}, },
useAllTime: true, useAllTime: true,
exportAvatars: true exportAvatars: true,
exportMedia: false,
exportImages: true,
exportVoices: true,
exportEmojis: true,
exportVoiceAsText: false
}) })
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
@@ -143,13 +156,19 @@ 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, // 独立于 exportMedia
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),
end: Math.floor(options.dateRange.end.getTime() / 1000) // 将结束日期设置为当天的 23:59:59,以包含当天的所有消息
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
} : null } : 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') {
const result = await window.electronAPI.export.exportSessions( const result = await window.electronAPI.export.exportSessions(
sessionList, sessionList,
exportFolder, exportFolder,
@@ -167,6 +186,90 @@ function ExportPage() {
} }
} }
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 handleDateSelect = (day: number) => {
const year = calendarDate.getFullYear()
const month = calendarDate.getMonth()
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) {
// 选择开始日期
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({
...options,
dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() }
})
}
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 {
setOptions({
...options,
dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate }
})
}
setSelectingStart(true)
}
}
const formatOptions = [ const formatOptions = [
{ value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' }, { value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' }, { value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' },
@@ -281,30 +384,115 @@ function ExportPage() {
<span></span> <span></span>
</label> </label>
{!options.useAllTime && options.dateRange && ( {!options.useAllTime && options.dateRange && (
<div className="date-range"> <div className="date-range" onClick={() => setShowDatePicker(true)}>
<Calendar size={16} /> <Calendar size={16} />
<span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span> <span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span>
<button className="change-btn">
<ChevronDown size={14} /> <ChevronDown size={14} />
</button>
</div> </div>
)} )}
</div> </div>
</div> </div>
<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="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>
@@ -312,7 +500,26 @@ function ExportPage() {
<FolderOpen size={16} /> <FolderOpen size={16} />
<span>{exportFolder || '未设置'}</span> <span>{exportFolder || '未设置'}</span>
</div> </div>
<p className="path-hint"></p> <button
className="select-folder-btn"
onClick={async () => {
try {
const result = await window.electronAPI.dialog.openFile({
title: '选择导出目录',
properties: ['openDirectory']
})
if (!result.canceled && result.filePaths.length > 0) {
setExportFolder(result.filePaths[0])
await configService.setExportPath(result.filePaths[0])
}
} catch (e) {
console.error('选择目录失败:', e)
}
}}
>
<FolderOpen size={16} />
<span></span>
</button>
</div> </div>
</div> </div>
@@ -387,6 +594,137 @@ function ExportPage() {
</div> </div>
</div> </div>
)} )}
{/* 日期选择弹窗 */}
{showDatePicker && (
<div className="export-overlay" onClick={() => setShowDatePicker(false)}>
<div className="date-picker-modal" onClick={e => e.stopPropagation()}>
<h3></h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: '8px 0 16px 0' }}>
</p>
<div className="quick-select">
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
>
7
</button>
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
>
30
</button>
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 90 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
>
90
</button>
</div>
<div className="date-display">
<div
className={`date-display-item ${selectingStart ? 'active' : ''}`}
onClick={() => setSelectingStart(true)}
>
<span className="date-label"></span>
<span className="date-value">
{options.dateRange?.start.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})}
</span>
</div>
<span className="date-separator"></span>
<div
className={`date-display-item ${!selectingStart ? 'active' : ''}`}
onClick={() => setSelectingStart(false)}
>
<span className="date-label"></span>
<span className="date-value">
{options.dateRange?.end.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})}
</span>
</div>
</div>
<div className="calendar-container">
<div className="calendar-header">
<button
className="calendar-nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
>
<ChevronLeft size={18} />
</button>
<span className="calendar-month">
{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}
</span>
<button
className="calendar-nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
>
<ChevronRight size={18} />
</button>
</div>
<div className="calendar-weekdays">
{['日', '一', '二', '三', '四', '五', '六'].map(day => (
<div key={day} className="calendar-weekday">{day}</div>
))}
</div>
<div className="calendar-days">
{generateCalendar().map((day, index) => {
if (day === null) {
return <div key={`empty-${index}`} className="calendar-day empty" />
}
const currentDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString()
const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString()
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 (
<div
key={day}
className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''} ${isFuture ? 'disabled' : ''}`}
onClick={() => !isFuture && handleDateSelect(day)}
style={{ cursor: isFuture ? 'not-allowed' : 'pointer', opacity: isFuture ? 0.3 : 1 }}
>
{day}
</div>
)
})}
</div>
</div>
<div className="date-picker-actions">
<button className="cancel-btn" onClick={() => setShowDatePicker(false)}>
</button>
<button className="confirm-btn" onClick={() => setShowDatePicker(false)}>
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } 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 ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker' import DateRangePicker from '../components/DateRangePicker'
import './GroupAnalyticsPage.scss' import './GroupAnalyticsPage.scss'
@@ -256,11 +257,7 @@ function GroupAnalyticsPage() {
</button> </button>
<div className="modal-content"> <div className="modal-content">
<div className="member-avatar large"> <div className="member-avatar large">
{selectedMember.avatarUrl ? ( <Avatar src={selectedMember.avatarUrl} name={selectedMember.displayName} size={96} />
<img src={selectedMember.avatarUrl} alt="" />
) : (
<div className="avatar-placeholder"><User size={48} /></div>
)}
</div> </div>
<h3 className="member-display-name">{selectedMember.displayName}</h3> <h3 className="member-display-name">{selectedMember.displayName}</h3>
<div className="member-details"> <div className="member-details">
@@ -334,7 +331,7 @@ function GroupAnalyticsPage() {
onClick={() => handleGroupSelect(group)} onClick={() => handleGroupSelect(group)}
> >
<div className="group-avatar"> <div className="group-avatar">
{group.avatarUrl ? <img src={group.avatarUrl} alt="" /> : <div className="avatar-placeholder"><Users size={20} /></div>} <Avatar src={group.avatarUrl} name={group.displayName} size={44} />
</div> </div>
<div className="group-info"> <div className="group-info">
<span className="group-name">{group.displayName}</span> <span className="group-name">{group.displayName}</span>
@@ -352,7 +349,7 @@ function GroupAnalyticsPage() {
<div className="function-menu"> <div className="function-menu">
<div className="selected-group-info"> <div className="selected-group-info">
<div className="group-avatar large"> <div className="group-avatar large">
{selectedGroup?.avatarUrl ? <img src={selectedGroup.avatarUrl} alt="" /> : <div className="avatar-placeholder"><Users size={40} /></div>} <Avatar src={selectedGroup?.avatarUrl} name={selectedGroup?.displayName} size={80} />
</div> </div>
<h2>{selectedGroup?.displayName}</h2> <h2>{selectedGroup?.displayName}</h2>
<p>{selectedGroup?.memberCount} </p> <p>{selectedGroup?.memberCount} </p>
@@ -424,7 +421,7 @@ function GroupAnalyticsPage() {
{members.map(member => ( {members.map(member => (
<div key={member.username} className="member-card" onClick={() => handleMemberClick(member)}> <div key={member.username} className="member-card" onClick={() => handleMemberClick(member)}>
<div className="member-avatar"> <div className="member-avatar">
{member.avatarUrl ? <img src={member.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>} <Avatar src={member.avatarUrl} name={member.displayName} size={48} />
</div> </div>
<span className="member-name">{member.displayName}</span> <span className="member-name">{member.displayName}</span>
</div> </div>
@@ -437,7 +434,7 @@ function GroupAnalyticsPage() {
<div key={item.member.username} className="ranking-item"> <div key={item.member.username} className="ranking-item">
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span> <span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
<div className="contact-avatar"> <div className="contact-avatar">
{item.member.avatarUrl ? <img src={item.member.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>} <Avatar src={item.member.avatarUrl} name={item.member.displayName} size={40} />
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>} {index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
</div> </div>
<div className="contact-info"> <div className="contact-info">

View File

@@ -204,6 +204,23 @@
} }
} }
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);
}
}
.input-with-toggle { .input-with-toggle {
position: relative; position: relative;
display: flex; display: flex;
@@ -235,6 +252,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 +443,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;
@@ -287,12 +548,12 @@
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.switch-input:checked + .switch-slider { .switch-input:checked+.switch-slider {
background: var(--primary); background: var(--primary);
border-color: var(--primary); border-color: var(--primary);
} }
.switch-input:checked + .switch-slider::before { .switch-input:checked+.switch-slider::before {
transform: translateX(22px); transform: translateX(22px);
background: #ffffff; background: #ffffff;
} }
@@ -331,18 +592,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 +671,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 +679,12 @@
} }
@keyframes pulse { @keyframes pulse {
0%, 100% {
0%,
100% {
opacity: 1; opacity: 1;
} }
50% { 50% {
opacity: 0.6; opacity: 0.6;
} }
@@ -667,8 +947,13 @@
} }
@keyframes spin { @keyframes spin {
from { transform: rotate(0deg); } from {
to { transform: rotate(360deg); } transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }
@@ -767,3 +1052,168 @@
} }
} }
} }
// wxid 输入框下拉
.wxid-input-wrapper {
position: relative;
display: flex;
align-items: center;
input {
flex: 1;
padding-right: 36px;
}
}
.wxid-dropdown-btn {
position: absolute;
right: 8px;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
color: var(--text-primary);
}
&.open {
transform: rotate(180deg);
}
}
.wxid-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
max-height: 200px;
overflow-y: auto;
}
.wxid-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: var(--primary-light);
color: var(--primary);
}
.wxid-value {
font-weight: 500;
font-size: 13px;
}
.wxid-time {
font-size: 11px;
color: var(--text-tertiary);
}
}
// 多账号选择对话框
.wxid-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.wxid-dialog {
background: var(--bg-primary);
border-radius: 16px;
width: 400px;
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
}
.wxid-dialog-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-primary);
h3 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
}
.wxid-dialog-list {
padding: 8px;
max-height: 300px;
overflow-y: auto;
}
.wxid-dialog-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
border-radius: 10px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: var(--primary-light);
.wxid-id {
color: var(--primary);
}
}
.wxid-id {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.wxid-date {
font-size: 12px;
color: var(--text-tertiary);
}
}
.wxid-dialog-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-primary);
display: flex;
justify-content: flex-end;
}

View File

@@ -1,28 +1,35 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import { useThemeStore, themes } from '../stores/themeStore' import { useThemeStore, themes } from '../stores/themeStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
import { 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 Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic
} from 'lucide-react' } from 'lucide-react'
import './SettingsPage.scss' import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'database' | 'export' | 'cache' | 'about' type SettingsTab = 'appearance' | 'database' | 'whisper' | '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: 'export', label: '导出', icon: Download }, { id: 'whisper', label: '语音识别模型', icon: Mic },
{ id: 'cache', label: '缓存', icon: HardDrive }, { id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'about', label: '关于', icon: Info } { id: 'about', label: '关于', icon: Info }
] ]
interface WxidOption {
wxid: string
modifiedTime: number
}
function SettingsPage() { function SettingsPage() {
const { setDbConnected, setLoading, reset } = useAppStore() const { setDbConnected, setLoading, reset } = useAppStore()
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('')
@@ -30,10 +37,18 @@ function SettingsPage() {
const [imageAesKey, setImageAesKey] = useState('') const [imageAesKey, setImageAesKey] = useState('')
const [dbPath, setDbPath] = useState('') const [dbPath, setDbPath] = useState('')
const [wxid, setWxid] = useState('') const [wxid, setWxid] = useState('')
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
const [showWxidSelect, setShowWxidSelect] = useState(false)
const wxidDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('') const [cachePath, setCachePath] = useState('')
const [exportPath, setExportPath] = useState('')
const [defaultExportPath, setDefaultExportPath] = 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 [isLoading, setIsLoadingState] = useState(false) const [isLoading, setIsLoadingState] = useState(false)
const [isTesting, setIsTesting] = useState(false) const [isTesting, setIsTesting] = useState(false)
@@ -50,13 +65,28 @@ 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()
loadDefaultExportPath()
loadAppVersion() loadAppVersion()
}, []) }, [])
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(e.target as Node)) {
setShowWxidSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showWxidSelect])
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
setDbKeyStatus(payload.message) setDbKeyStatus(payload.message)
@@ -80,28 +110,50 @@ 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()
if (savedKey) setDecryptKey(savedKey) 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 (savedExportPath) setExportPath(savedExportPath)
if (savedImageXorKey != null) { if (savedImageXorKey != null) {
setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`) setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`)
} }
if (savedImageAesKey) setImageAesKey(savedImageAesKey) if (savedImageAesKey) setImageAesKey(savedImageAesKey)
setLogEnabled(savedLogEnabled) setLogEnabled(savedLogEnabled)
setAutoTranscribeVoice(savedAutoTranscribe)
setTranscribeLanguages(savedTranscribeLanguages)
// 如果语言列表为空,保存默认值
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)
} }
} }
const loadDefaultExportPath = async () => {
const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
try { try {
const downloadsPath = await window.electronAPI.app.getDownloadsPath() const result = await window.electronAPI.whisper?.getModelStatus()
setDefaultExportPath(downloadsPath) if (result?.success) {
} catch (e) { setWhisperModelStatus({
console.error('获取默认导出路径失败:', e) exists: Boolean(result.exists),
modelPath: result.modelPath,
tokensPath: result.tokensPath
})
}
} catch {
setWhisperModelStatus(null)
} }
} }
@@ -122,6 +174,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)
@@ -129,9 +194,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)
@@ -168,12 +233,14 @@ function SettingsPage() {
showMessage(`自动检测成功:${result.path}`, true) showMessage(`自动检测成功:${result.path}`, true)
const wxids = await window.electronAPI.dbPath.scanWxids(result.path) const wxids = await window.electronAPI.dbPath.scanWxids(result.path)
setWxidOptions(wxids)
if (wxids.length === 1) { if (wxids.length === 1) {
setWxid(wxids[0].wxid) setWxid(wxids[0].wxid)
await configService.setMyWxid(wxids[0].wxid) await configService.setMyWxid(wxids[0].wxid)
showMessage(`已检测到账号:${wxids[0].wxid}`, true) showMessage(`已检测到账号:${wxids[0].wxid}`, true)
} else if (wxids.length > 1) { } else if (wxids.length > 1) {
showMessage(`检测到 ${wxids.length} 个账号,请手动选择`, true) // 多账号时弹出选择对话框
setShowWxidSelect(true)
} }
} else { } else {
showMessage(result.error || '未能自动检测到数据库目录', false) showMessage(result.error || '未能自动检测到数据库目录', false)
@@ -204,12 +271,14 @@ function SettingsPage() {
} }
try { try {
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
setWxidOptions(wxids)
if (wxids.length === 1) { if (wxids.length === 1) {
setWxid(wxids[0].wxid) setWxid(wxids[0].wxid)
await configService.setMyWxid(wxids[0].wxid) await configService.setMyWxid(wxids[0].wxid)
if (!silent) showMessage(`已检测到账号:${wxids[0].wxid}`, true) if (!silent) showMessage(`已检测到账号:${wxids[0].wxid}`, true)
} else if (wxids.length > 1) { } else if (wxids.length > 1) {
if (!silent) showMessage(`检测到 ${wxids.length} 个账号,请手动选择`, true) // 多账号时弹出选择对话框
setShowWxidSelect(true)
} else { } else {
if (!silent) showMessage('未检测到账号目录,请检查路径', false) if (!silent) showMessage('未检测到账号目录,请检查路径', false)
} }
@@ -218,6 +287,13 @@ function SettingsPage() {
} }
} }
const handleSelectWxid = async (selectedWxid: string) => {
setWxid(selectedWxid)
await configService.setMyWxid(selectedWxid)
setShowWxidSelect(false)
showMessage(`已选择账号:${selectedWxid}`, true)
}
const handleSelectCachePath = async () => { const handleSelectCachePath = async () => {
try { try {
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] }) const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
@@ -230,19 +306,53 @@ function SettingsPage() {
} }
} }
const handleSelectExportPath = async () => {
const handleSelectWhisperModelDir = async () => {
try { try {
const result = await dialog.openFile({ title: '选择导出目录', properties: ['openDirectory'] }) const result = await dialog.openFile({ title: '选择 Whisper 模型下载目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) { if (!result.canceled && result.filePaths.length > 0) {
setExportPath(result.filePaths[0]) const dir = result.filePaths[0]
await configService.setExportPath(result.filePaths[0]) setWhisperModelDir(dir)
showMessage('已设置导出目录', true) await configService.setWhisperModelDir(dir)
showMessage('已选择 Whisper 模型目录', true)
} }
} catch (e) { } catch (e) {
showMessage('选择目录失败', false) 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)
@@ -303,16 +413,7 @@ function SettingsPage() {
} }
} }
const handleResetExportPath = async () => {
try {
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
setExportPath(downloadsPath)
await configService.setExportPath(downloadsPath)
showMessage('已恢复为下载目录', true)
} catch (e) {
showMessage('恢复默认失败', false)
}
}
const handleTestConnection = async () => { const handleTestConnection = async () => {
if (!dbPath) { showMessage('请先选择数据库目录', false); return } if (!dbPath) { showMessage('请先选择数据库目录', false); return }
@@ -362,6 +463,9 @@ function SettingsPage() {
} else { } else {
await configService.setImageAesKey('') await configService.setImageAesKey('')
} }
await configService.setWhisperModelDir(whisperModelDir)
await configService.setAutoTranscribeVoice(autoTranscribeVoice)
await configService.setTranscribeLanguages(transcribeLanguages)
await configService.setOnboardingDone(true) await configService.setOnboardingDone(true)
showMessage('配置保存成功,正在测试连接...', true) showMessage('配置保存成功,正在测试连接...', true)
@@ -382,7 +486,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, '正在清除配置...')
@@ -396,8 +500,13 @@ function SettingsPage() {
setDbPath('') setDbPath('')
setWxid('') setWxid('')
setCachePath('') setCachePath('')
setExportPath('')
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) {
@@ -431,6 +540,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">
@@ -487,6 +649,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}>
@@ -499,7 +662,38 @@ 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>
<input type="text" placeholder="例如: wxid_xxxxxx" value={wxid} onChange={(e) => setWxid(e.target.value)} /> <div className="wxid-input-wrapper" ref={wxidDropdownRef}>
<input
type="text"
placeholder="例如: wxid_xxxxxx"
value={wxid}
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>
<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>
@@ -517,16 +711,7 @@ function SettingsPage() {
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'} <Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button> </button>
{imageKeyStatus && <div className="form-hint status-text">{imageKeyStatus}</div>} {imageKeyStatus && <div className="form-hint status-text">{imageKeyStatus}</div>}
</div> {isFetchingImageKey && <div className="form-hint status-text">...</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>
<div className="form-group"> <div className="form-group">
@@ -561,28 +746,135 @@ function SettingsPage() {
</div> </div>
</div> </div>
) )
const renderWhisperTab = () => (
const renderExportTab = () => (
<div className="tab-content"> <div className="tab-content">
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"></span> <span className="form-hint"></span>
<input type="text" placeholder={defaultExportPath || '系统下载目录'} value={exportPath || defaultExportPath} onChange={(e) => setExportPath(e.target.value)} /> <div className="log-toggle-line">
<div className="btn-row"> <span className="log-status">{autoTranscribeVoice ? '已开启' : '已关闭'}</span>
<button className="btn btn-secondary" onClick={handleSelectExportPath}><FolderOpen size={16} /> </button> <label className="switch" htmlFor="auto-transcribe-toggle">
<button className="btn btn-secondary" onClick={handleResetExportPath}><RotateCcw size={16} /> </button> <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> </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> </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>
@@ -607,7 +899,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">
@@ -648,6 +940,33 @@ function SettingsPage() {
<div className="settings-page"> <div className="settings-page">
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>} {message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
{/* 多账号选择对话框 */}
{showWxidSelect && wxidOptions.length > 1 && (
<div className="wxid-dialog-overlay" onClick={() => setShowWxidSelect(false)}>
<div className="wxid-dialog" onClick={(e) => e.stopPropagation()}>
<div className="wxid-dialog-header">
<h3></h3>
<p>使</p>
</div>
<div className="wxid-dialog-list">
{wxidOptions.map((opt) => (
<div
key={opt.wxid}
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
onClick={() => handleSelectWxid(opt.wxid)}
>
<span className="wxid-id">{opt.wxid}</span>
<span className="wxid-date"> {new Date(opt.modifiedTime).toLocaleString()}</span>
</div>
))}
</div>
<div className="wxid-dialog-footer">
<button className="btn btn-secondary" onClick={() => setShowWxidSelect(false)}></button>
</div>
</div>
</div>
)}
<div className="settings-header"> <div className="settings-header">
<h1></h1> <h1></h1>
<div className="settings-actions"> <div className="settings-actions">
@@ -672,7 +991,7 @@ function SettingsPage() {
<div className="settings-body"> <div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()} {activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'database' && renderDatabaseTab()} {activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'export' && renderExportTab()} {activeTab === 'whisper' && renderWhisperTab()}
{activeTab === 'cache' && renderCacheTab()} {activeTab === 'cache' && renderCacheTab()}
{activeTab === 'about' && renderAboutTab()} {activeTab === 'about' && renderAboutTab()}
</div> </div>
@@ -681,3 +1000,5 @@ function SettingsPage() {
} }
export default SettingsPage export default SettingsPage

View File

@@ -442,6 +442,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</button> </button>
</div> </div>
<div className="field-hint"> xwechat_files </div> <div className="field-hint"> xwechat_files </div>
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}> --</div>
</div> </div>
)} )}
@@ -506,6 +507,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>} {dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
<div className="field-hint"></div> <div className="field-hint"></div>
<div className="field-hint"></div>
</div> </div>
)} )}
@@ -532,6 +534,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</button> </button>
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>} {imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
<div className="field-hint"></div> <div className="field-hint"></div>
{isFetchingImageKey && <div className="field-hint status-text">...</div>}
</div> </div>
)} )}

View File

@@ -15,8 +15,14 @@ export const CONFIG_KEYS = {
AGREEMENT_ACCEPTED: 'agreementAccepted', AGREEMENT_ACCEPTED: 'agreementAccepted',
LOG_ENABLED: 'logEnabled', LOG_ENABLED: 'logEnabled',
ONBOARDING_DONE: 'onboardingDone', ONBOARDING_DONE: 'onboardingDone',
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'
} as const } as const
// 获取解密密钥 // 获取解密密钥
@@ -132,6 +138,50 @@ export async function setLogEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled) await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
} }
// 获取 LLM 模型路径
export async function getLlmModelPath(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)
return (value as string) || null
}
// 设置 LLM 模型路径
export async function setLlmModelPath(path: string): Promise<void> {
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()
@@ -170,3 +220,26 @@ 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)
}

View File

@@ -1,4 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface ChatStatistics { interface ChatStatistics {
totalMessages: number totalMessages: number
@@ -49,7 +50,9 @@ interface AnalyticsState {
clearCache: () => void clearCache: () => void
} }
export const useAnalyticsStore = create<AnalyticsState>((set) => ({ export const useAnalyticsStore = create<AnalyticsState>()(
persist(
(set) => ({
statistics: null, statistics: null,
rankings: [], rankings: [],
timeDistribution: null, timeDistribution: null,
@@ -67,4 +70,9 @@ export const useAnalyticsStore = create<AnalyticsState>((set) => ({
isLoaded: false, isLoaded: false,
lastLoadTime: null lastLoadTime: null
}), }),
})) }),
{
name: 'analytics-storage',
}
)
)

View File

@@ -45,6 +45,7 @@ export interface ElectronAPI {
testConnection: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; sessionCount?: number }> testConnection: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; sessionCount?: number }>
open: (dbPath: string, hexKey: string, wxid: string) => Promise<boolean> open: (dbPath: string, hexKey: string, wxid: string) => Promise<boolean>
close: () => Promise<boolean> close: () => Promise<boolean>
} }
key: { key: {
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }> autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
@@ -55,6 +56,11 @@ export interface ElectronAPI {
chat: { chat: {
connect: () => Promise<{ success: boolean; error?: string }> connect: () => Promise<{ success: boolean; error?: string }>
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
enrichSessionsContactInfo: (usernames: string[]) => Promise<{
success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string
}>
getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{ getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{
success: boolean; success: boolean;
messages?: Message[]; messages?: Message[];
@@ -88,8 +94,12 @@ 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) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
} }
image: { image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }> decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
@@ -98,7 +108,7 @@ export interface ElectronAPI {
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
} }
analytics: { analytics: {
getOverallStatistics: () => Promise<{ getOverallStatistics: (force?: boolean) => Promise<{
success: boolean success: boolean
data?: { data?: {
totalMessages: number totalMessages: number
@@ -141,6 +151,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
@@ -283,6 +298,11 @@ export interface ElectronAPI {
error?: string error?: string
}> }>
} }
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
}
} }
export interface ExportOptions { export interface ExportOptions {

View File

@@ -0,0 +1,74 @@
// 全局头像加载队列管理器(限制并发,避免卡顿)
export class AvatarLoadQueue {
private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = []
private loading = new Map<string, Promise<void>>()
private activeCount = 0
private readonly maxConcurrent = 3
private readonly delayBetweenBatches = 10
private static instance: AvatarLoadQueue
public static getInstance(): AvatarLoadQueue {
if (!AvatarLoadQueue.instance) {
AvatarLoadQueue.instance = new AvatarLoadQueue()
}
return AvatarLoadQueue.instance
}
async enqueue(url: string): Promise<void> {
if (!url) return Promise.resolve()
// 核心修复:防止重复并发请求同一个 URL
const existingPromise = this.loading.get(url)
if (existingPromise) {
return existingPromise
}
const loadPromise = new Promise<void>((resolve, reject) => {
this.queue.push({ url, resolve, reject })
this.processQueue()
})
this.loading.set(url, loadPromise)
loadPromise.finally(() => {
this.loading.delete(url)
})
return loadPromise
}
private async processQueue() {
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
return
}
const task = this.queue.shift()
if (!task) return
this.activeCount++
const img = new Image()
img.onload = () => {
this.activeCount--
task.resolve()
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
}
img.onerror = () => {
this.activeCount--
task.reject(new Error(`Failed: ${task.url}`))
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
}
img.src = task.url
this.processQueue()
}
clear() {
this.queue = []
this.loading.clear()
this.activeCount = 0
}
}
export const avatarLoadQueue = AvatarLoadQueue.getInstance()

View File

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

View File

@@ -10,6 +10,14 @@ export default defineConfig({
port: 3000, port: 3000,
strictPort: false // 如果3000被占用自动尝试下一个 strictPort: false // 如果3000被占用自动尝试下一个
}, },
build: {
commonjsOptions: {
ignoreDynamicRequires: true
}
},
optimizeDeps: {
exclude: []
},
plugins: [ plugins: [
react(), react(),
electron([ electron([
@@ -19,7 +27,14 @@ export default defineConfig({
build: { build: {
outDir: 'dist-electron', outDir: 'dist-electron',
rollupOptions: { rollupOptions: {
external: ['better-sqlite3', 'koffi'] external: [
'better-sqlite3',
'koffi',
'fsevents',
'whisper-node',
'shelljs',
'exceljs'
]
} }
} }
} }
@@ -30,7 +45,10 @@ export default defineConfig({
build: { build: {
outDir: 'dist-electron', outDir: 'dist-electron',
rollupOptions: { rollupOptions: {
external: ['koffi'], external: [
'koffi',
'fsevents'
],
output: { output: {
entryFileNames: 'annualReportWorker.js', entryFileNames: 'annualReportWorker.js',
inlineDynamicImports: true inlineDynamicImports: true
@@ -53,6 +71,42 @@ export default defineConfig({
} }
} }
}, },
{
entry: 'electron/wcdbWorker.ts',
vite: {
build: {
outDir: 'dist-electron',
rollupOptions: {
external: [
'better-sqlite3',
'koffi',
'fsevents'
],
output: {
entryFileNames: 'wcdbWorker.js',
inlineDynamicImports: true
}
}
}
}
},
{
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) {