mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 23:35:49 +00:00
Compare commits
129 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b09068f1f7 | ||
|
|
714a9400d5 | ||
|
|
13dd2fca21 | ||
|
|
5d1f834b61 | ||
|
|
3ca86224eb | ||
|
|
f10e974f36 | ||
|
|
76c40e4118 | ||
|
|
5307f55840 | ||
|
|
3405f26d10 | ||
|
|
85d82bfd09 | ||
|
|
e557ee224e | ||
|
|
88544c4a5d | ||
|
|
b66fc32068 | ||
|
|
7ac3c281a3 | ||
|
|
28616493ce | ||
|
|
d68e4fe880 | ||
|
|
1fd676d63e | ||
|
|
9f31ac0529 | ||
|
|
3c32ad5ca8 | ||
|
|
879d84b597 | ||
|
|
ab3551fb91 | ||
|
|
b9d1ea316f | ||
|
|
7762bd37c9 | ||
|
|
2e61902556 | ||
|
|
9e8072c337 | ||
|
|
827e77c9a3 | ||
|
|
3956989b67 | ||
|
|
33d7c243a7 | ||
|
|
a215886015 | ||
|
|
1d9e8aded0 | ||
|
|
b7e31c9cff | ||
|
|
4e9c81a93d | ||
|
|
9181ac5d34 | ||
|
|
3a10aeb23e | ||
|
|
178f9c4fdc | ||
|
|
4d647a9467 | ||
|
|
16cbc6adb1 | ||
|
|
7afb872bff | ||
|
|
7df6182e70 | ||
|
|
40efb04a36 | ||
|
|
1f03d35253 | ||
|
|
3efaed488a | ||
|
|
decdbf95f7 | ||
|
|
cccc712814 | ||
|
|
135f4819fb | ||
|
|
388923257b | ||
|
|
6918e359e8 | ||
|
|
d5b33c7e77 | ||
|
|
d37f53e120 | ||
|
|
26478217e7 | ||
|
|
a100f4ef97 | ||
|
|
91b746dc59 | ||
|
|
1817a847de | ||
|
|
7e99feae1e | ||
|
|
2977c45365 | ||
|
|
3b363a3efa | ||
|
|
e2b0bd44d9 | ||
|
|
cc26860504 | ||
|
|
54f3e0481f | ||
|
|
a61371c8ad | ||
|
|
fd6d5e4296 | ||
|
|
514a617c55 | ||
|
|
b47007ea0c | ||
|
|
6436c39c90 | ||
|
|
eb2f90e605 | ||
|
|
bdbb85175a | ||
|
|
a5e1bfe49a | ||
|
|
b3adb54651 | ||
|
|
07e7bce6a9 | ||
|
|
baa90242a6 | ||
|
|
787db0cec2 | ||
|
|
6359118132 | ||
|
|
49614bf6d8 | ||
|
|
0901e08c5c | ||
|
|
503a77c7cf | ||
|
|
0e3ab8e4d6 | ||
|
|
4452e4921c | ||
|
|
97c1aa582d | ||
|
|
076c008329 | ||
|
|
21d785dd3c | ||
|
|
348f6c81bf | ||
|
|
d5a2e2bb62 | ||
|
|
2b51e0659e | ||
|
|
3efca5e60c | ||
|
|
2f7b917f1c | ||
|
|
8623f86505 | ||
|
|
dc74641c19 | ||
|
|
db7817cc22 | ||
|
|
ada0f68182 | ||
|
|
fe806895f0 | ||
|
|
da137d0a8f | ||
|
|
93ebc3bce3 | ||
|
|
9f6e9eb9bc | ||
|
|
996b133a4f | ||
|
|
dd2602ea35 | ||
|
|
e5cf71b7c5 | ||
|
|
f2e4e21010 | ||
|
|
240514f1e5 | ||
|
|
d4c7e86e05 | ||
|
|
2876c7a539 | ||
|
|
32cdbece2c | ||
|
|
6e7e994cc6 | ||
|
|
d95040ffaf | ||
|
|
129dfbe1b6 | ||
|
|
f8afce6bfa | ||
|
|
0423f23b9c | ||
|
|
e3655631bb | ||
|
|
945802f772 | ||
|
|
be4d9b510d | ||
|
|
0853e049c8 | ||
|
|
dc12df0fcf | ||
|
|
82ba0344b9 | ||
|
|
e8babd48b6 | ||
|
|
7c0ed66dad | ||
|
|
9402483d87 | ||
|
|
650de55202 | ||
|
|
af99ab2029 | ||
|
|
87a2675236 | ||
|
|
25f1256baa | ||
|
|
f83a37e714 | ||
|
|
1aecfb369b | ||
|
|
436b090e26 | ||
|
|
c0f2620542 | ||
|
|
72e2d82158 | ||
|
|
095c8f0db6 | ||
|
|
afa3e089b1 | ||
|
|
11969ea2d4 | ||
|
|
6707be2200 | ||
|
|
f97e102dbd |
55
.github/workflows/release.yml
vendored
55
.github/workflows/release.yml
vendored
@@ -25,44 +25,37 @@ jobs:
|
|||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Sync version with tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
echo "Syncing package.json version to $VERSION"
|
||||||
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
|
|
||||||
- name: Build Changelog
|
|
||||||
id: build_changelog
|
|
||||||
uses: mikepenz/release-changelog-builder-action@v4
|
|
||||||
with:
|
|
||||||
outputFile: "release-notes.md"
|
|
||||||
configurationJson: |
|
|
||||||
{
|
|
||||||
"template": "# v${{ github.ref_name }} 版本发布\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建",
|
|
||||||
"categories": [
|
|
||||||
{
|
|
||||||
"title": "## 新功能",
|
|
||||||
"filter": { "pattern": "^feat:.*", "flags": "i" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "## 修复",
|
|
||||||
"filter": { "pattern": "^fix:.*", "flags": "i" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "## 性能与维护",
|
|
||||||
"filter": { "pattern": "^(chore|docs|perf|refactor):.*", "flags": "i" }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ignore_labels": [],
|
|
||||||
"commitMode": true,
|
|
||||||
"empty_summary": "## 更新详情\n- 常规代码优化与维护"
|
|
||||||
}
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Package and Publish
|
- name: Package and Publish
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --publish always "-c.releaseInfo.releaseNotesFile=release-notes.md"
|
npx electron-builder --publish always
|
||||||
|
|
||||||
|
- name: Update Release Notes
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cat <<EOF > release_notes.md
|
||||||
|
## 更新日志
|
||||||
|
修复了一些已知问题
|
||||||
|
|
||||||
|
## 加入我们的群
|
||||||
|
[点击加入 Telegram 群](https://t.me/+hn3QzNc4DbA0MzNl)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -56,3 +56,4 @@ Thumbs.db
|
|||||||
*.aps
|
*.aps
|
||||||
|
|
||||||
wcdb/
|
wcdb/
|
||||||
|
*info
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -25,9 +25,24 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 仅支持微信 **4.0** 及以上版本
|
||||||
|
|
||||||
|
# 加入微信交流群
|
||||||
|
|
||||||
|
> 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="2wm.png" alt="WeFlow 微信交流群二维码(一群)" width="220" style="margin-right: 16px;">
|
||||||
|
<img src="3wm.png" alt="WeFlow 微信交流群二维码(二群)" width="220" style="margin-right: 16px;">
|
||||||
|
<img src="4wm.jpg" alt="WeFlow 微信交流群二维码(三群)" width="220"
|
||||||
|
</p>
|
||||||
|
<p align="center">扫到哪个算哪个</p>
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
- 本地实时查看聊天记录
|
- 本地实时查看聊天记录
|
||||||
@@ -36,6 +51,9 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
- 导出聊天记录为 HTML 等格式
|
- 导出聊天记录为 HTML 等格式
|
||||||
- 本地解密与数据库管理
|
- 本地解密与数据库管理
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> ⚠️ 本工具仅适配微信 **4.0 及以上**版本,请确保你的微信版本符合要求
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
若你只想使用成品版本,可前往 Release 下载并安装。
|
若你只想使用成品版本,可前往 Release 下载并安装。
|
||||||
|
|||||||
320
electron/main.ts
320
electron/main.ts
@@ -1,6 +1,6 @@
|
|||||||
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
|
import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron'
|
||||||
import { Worker } from 'worker_threads'
|
import { Worker } from 'worker_threads'
|
||||||
import { join } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { autoUpdater } from 'electron-updater'
|
import { autoUpdater } from 'electron-updater'
|
||||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
@@ -13,8 +13,11 @@ import { imagePreloadService } from './services/imagePreloadService'
|
|||||||
import { analyticsService } from './services/analyticsService'
|
import { analyticsService } from './services/analyticsService'
|
||||||
import { groupAnalyticsService } from './services/groupAnalyticsService'
|
import { groupAnalyticsService } from './services/groupAnalyticsService'
|
||||||
import { annualReportService } from './services/annualReportService'
|
import { annualReportService } from './services/annualReportService'
|
||||||
import { exportService, ExportOptions } from './services/exportService'
|
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||||
import { KeyService } from './services/keyService'
|
import { KeyService } from './services/keyService'
|
||||||
|
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||||
|
import { videoService } from './services/videoService'
|
||||||
|
import { snsService } from './services/snsService'
|
||||||
|
|
||||||
|
|
||||||
// 配置自动更新
|
// 配置自动更新
|
||||||
@@ -26,6 +29,47 @@ const AUTO_UPDATE_ENABLED =
|
|||||||
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
||||||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
|
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
|
||||||
|
|
||||||
|
// 使用白名单过滤 PATH,避免被第三方目录中的旧版 VC++ 运行库劫持。
|
||||||
|
// 仅保留系统目录(Windows/System32/SysWOW64)和应用自身目录(可执行目录、resources)。
|
||||||
|
function sanitizePathEnv() {
|
||||||
|
// 开发模式不做裁剪,避免影响本地工具链
|
||||||
|
if (process.env.VITE_DEV_SERVER_URL) return
|
||||||
|
|
||||||
|
const rawPath = process.env.PATH || process.env.Path
|
||||||
|
if (!rawPath) return
|
||||||
|
|
||||||
|
const sep = process.platform === 'win32' ? ';' : ':'
|
||||||
|
const parts = rawPath.split(sep).filter(Boolean)
|
||||||
|
|
||||||
|
const systemRoot = process.env.SystemRoot || process.env.WINDIR || ''
|
||||||
|
const safePrefixes = [
|
||||||
|
systemRoot,
|
||||||
|
systemRoot ? join(systemRoot, 'System32') : '',
|
||||||
|
systemRoot ? join(systemRoot, 'SysWOW64') : '',
|
||||||
|
dirname(process.execPath),
|
||||||
|
process.resourcesPath,
|
||||||
|
join(process.resourcesPath || '', 'resources')
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
const normalize = (p: string) => p.replace(/\\/g, '/').toLowerCase()
|
||||||
|
const isSafe = (p: string) => {
|
||||||
|
const np = normalize(p)
|
||||||
|
return safePrefixes.some((prefix) => np.startsWith(normalize(prefix)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = parts.filter(isSafe)
|
||||||
|
if (filtered.length !== parts.length) {
|
||||||
|
const removed = parts.filter((p) => !isSafe(p))
|
||||||
|
console.warn('[WeFlow] 使用白名单裁剪 PATH,移除目录:', removed)
|
||||||
|
const nextPath = filtered.join(sep)
|
||||||
|
process.env.PATH = nextPath
|
||||||
|
process.env.Path = nextPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动时立即清理 PATH,后续创建的 worker 也能继承安全的环境
|
||||||
|
sanitizePathEnv()
|
||||||
|
|
||||||
// 单例服务
|
// 单例服务
|
||||||
let configService: ConfigService | null = null
|
let configService: ConfigService | null = null
|
||||||
|
|
||||||
@@ -165,10 +209,11 @@ function createOnboardingWindow() {
|
|||||||
: join(process.resourcesPath, 'icon.ico')
|
: join(process.resourcesPath, 'icon.ico')
|
||||||
|
|
||||||
onboardingWindow = new BrowserWindow({
|
onboardingWindow = new BrowserWindow({
|
||||||
width: 1100,
|
width: 960,
|
||||||
height: 720,
|
height: 680,
|
||||||
minWidth: 900,
|
minWidth: 900,
|
||||||
minHeight: 600,
|
minHeight: 620,
|
||||||
|
resizable: false,
|
||||||
frame: false,
|
frame: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
backgroundColor: '#00000000',
|
backgroundColor: '#00000000',
|
||||||
@@ -199,6 +244,107 @@ function createOnboardingWindow() {
|
|||||||
return onboardingWindow
|
return onboardingWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建独立的视频播放窗口
|
||||||
|
* 窗口大小会根据视频比例自动调整
|
||||||
|
*/
|
||||||
|
function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHeight?: number) {
|
||||||
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
|
const iconPath = isDev
|
||||||
|
? join(__dirname, '../public/icon.ico')
|
||||||
|
: join(process.resourcesPath, 'icon.ico')
|
||||||
|
|
||||||
|
// 获取屏幕尺寸
|
||||||
|
const { screen } = require('electron')
|
||||||
|
const primaryDisplay = screen.getPrimaryDisplay()
|
||||||
|
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
|
||||||
|
|
||||||
|
// 计算窗口尺寸,只有标题栏 40px,控制栏悬浮
|
||||||
|
let winWidth = 854
|
||||||
|
let winHeight = 520
|
||||||
|
const titleBarHeight = 40
|
||||||
|
|
||||||
|
if (videoWidth && videoHeight && videoWidth > 0 && videoHeight > 0) {
|
||||||
|
const aspectRatio = videoWidth / videoHeight
|
||||||
|
|
||||||
|
const maxWidth = Math.floor(screenWidth * 0.85)
|
||||||
|
const maxHeight = Math.floor(screenHeight * 0.85)
|
||||||
|
|
||||||
|
if (aspectRatio >= 1) {
|
||||||
|
// 横向视频
|
||||||
|
winWidth = Math.min(videoWidth, maxWidth)
|
||||||
|
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
|
||||||
|
|
||||||
|
if (winHeight > maxHeight) {
|
||||||
|
winHeight = maxHeight
|
||||||
|
winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 竖向视频
|
||||||
|
const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight)
|
||||||
|
winHeight = videoDisplayHeight + titleBarHeight
|
||||||
|
winWidth = Math.floor(videoDisplayHeight * aspectRatio)
|
||||||
|
|
||||||
|
if (winWidth < 300) {
|
||||||
|
winWidth = 300
|
||||||
|
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
winWidth = Math.max(winWidth, 360)
|
||||||
|
winHeight = Math.max(winHeight, 280)
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: winWidth,
|
||||||
|
height: winHeight,
|
||||||
|
minWidth: 360,
|
||||||
|
minHeight: 280,
|
||||||
|
icon: iconPath,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
webSecurity: false
|
||||||
|
},
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
|
titleBarOverlay: {
|
||||||
|
color: '#1a1a1a',
|
||||||
|
symbolColor: '#ffffff',
|
||||||
|
height: 40
|
||||||
|
},
|
||||||
|
show: false,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
autoHideMenuBar: true
|
||||||
|
})
|
||||||
|
|
||||||
|
win.once('ready-to-show', () => {
|
||||||
|
win.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
const videoParam = `videoPath=${encodeURIComponent(videoPath)}`
|
||||||
|
if (process.env.VITE_DEV_SERVER_URL) {
|
||||||
|
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/video-player-window?${videoParam}`)
|
||||||
|
|
||||||
|
win.webContents.on('before-input-event', (event, input) => {
|
||||||
|
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
|
||||||
|
if (win.webContents.isDevToolsOpened()) {
|
||||||
|
win.webContents.closeDevTools()
|
||||||
|
} else {
|
||||||
|
win.webContents.openDevTools()
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
win.loadFile(join(__dirname, '../dist/index.html'), {
|
||||||
|
hash: `/video-player-window?${videoParam}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return win
|
||||||
|
}
|
||||||
|
|
||||||
function showMainWindow() {
|
function showMainWindow() {
|
||||||
shouldShowMain = true
|
shouldShowMain = true
|
||||||
if (mainWindowReady) {
|
if (mainWindowReady) {
|
||||||
@@ -355,6 +501,79 @@ function registerIpcHandlers() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 打开视频播放窗口
|
||||||
|
ipcMain.handle('window:openVideoPlayerWindow', (_, videoPath: string, videoWidth?: number, videoHeight?: number) => {
|
||||||
|
createVideoPlayerWindow(videoPath, videoWidth, videoHeight)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 根据视频尺寸调整窗口大小
|
||||||
|
ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
if (!win || !videoWidth || !videoHeight) return
|
||||||
|
|
||||||
|
const { screen } = require('electron')
|
||||||
|
const primaryDisplay = screen.getPrimaryDisplay()
|
||||||
|
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
|
||||||
|
|
||||||
|
// 只有标题栏 40px,控制栏悬浮在视频上
|
||||||
|
const titleBarHeight = 40
|
||||||
|
const aspectRatio = videoWidth / videoHeight
|
||||||
|
|
||||||
|
const maxWidth = Math.floor(screenWidth * 0.85)
|
||||||
|
const maxHeight = Math.floor(screenHeight * 0.85)
|
||||||
|
|
||||||
|
let winWidth: number
|
||||||
|
let winHeight: number
|
||||||
|
|
||||||
|
if (aspectRatio >= 1) {
|
||||||
|
// 横向视频 - 以宽度为基准
|
||||||
|
winWidth = Math.min(videoWidth, maxWidth)
|
||||||
|
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
|
||||||
|
|
||||||
|
if (winHeight > maxHeight) {
|
||||||
|
winHeight = maxHeight
|
||||||
|
winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 竖向视频 - 以高度为基准
|
||||||
|
const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight)
|
||||||
|
winHeight = videoDisplayHeight + titleBarHeight
|
||||||
|
winWidth = Math.floor(videoDisplayHeight * aspectRatio)
|
||||||
|
|
||||||
|
// 确保宽度不会太窄
|
||||||
|
if (winWidth < 300) {
|
||||||
|
winWidth = 300
|
||||||
|
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
winWidth = Math.max(winWidth, 360)
|
||||||
|
winHeight = Math.max(winHeight, 280)
|
||||||
|
|
||||||
|
// 调整窗口大小并居中
|
||||||
|
win.setSize(winWidth, winHeight)
|
||||||
|
win.center()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 视频相关
|
||||||
|
ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => {
|
||||||
|
try {
|
||||||
|
const result = await videoService.getVideoInfo(videoMd5)
|
||||||
|
return { success: true, ...result }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e), exists: false }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('video:parseVideoMd5', async (_, content: string) => {
|
||||||
|
try {
|
||||||
|
const md5 = videoService.parseVideoMd5(content)
|
||||||
|
return { success: true, md5 }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 数据库路径相关
|
// 数据库路径相关
|
||||||
ipcMain.handle('dbpath:autoDetect', async () => {
|
ipcMain.handle('dbpath:autoDetect', async () => {
|
||||||
return dbPathService.autoDetect()
|
return dbPathService.autoDetect()
|
||||||
@@ -397,8 +616,8 @@ function registerIpcHandlers() {
|
|||||||
return chatService.enrichSessionsContactInfo(usernames)
|
return chatService.enrichSessionsContactInfo(usernames)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => {
|
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
|
||||||
return chatService.getMessages(sessionId, offset, limit)
|
return chatService.getMessages(sessionId, offset, limit, startTime, endTime, ascending)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
|
ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
|
||||||
@@ -438,14 +657,31 @@ function registerIpcHandlers() {
|
|||||||
return chatService.getImageData(sessionId, msgId)
|
return chatService.getImageData(sessionId, msgId)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string) => {
|
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => {
|
||||||
return chatService.getVoiceData(sessionId, msgId)
|
return chatService.getVoiceData(sessionId, msgId, createTime, serverId)
|
||||||
|
})
|
||||||
|
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
|
||||||
|
return chatService.resolveVoiceCache(sessionId, msgId)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => {
|
||||||
|
return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => {
|
||||||
|
event.sender.send('chat:voiceTranscriptPartial', { msgId, text })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => {
|
ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => {
|
||||||
return chatService.getMessageById(sessionId, localId)
|
return chatService.getMessageById(sessionId, localId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => {
|
||||||
|
return chatService.execQuery(kind, path, sql)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
|
||||||
|
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||||
|
})
|
||||||
|
|
||||||
// 私聊克隆
|
// 私聊克隆
|
||||||
|
|
||||||
|
|
||||||
@@ -461,8 +697,13 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 导出相关
|
// 导出相关
|
||||||
ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||||
return exportService.exportSessions(sessionIds, outputDir, options)
|
const onProgress = (progress: ExportProgress) => {
|
||||||
|
if (!event.sender.isDestroyed()) {
|
||||||
|
event.sender.send('export:progress', progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||||
@@ -482,6 +723,50 @@ function registerIpcHandlers() {
|
|||||||
return analyticsService.getTimeDistribution()
|
return analyticsService.getTimeDistribution()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 缓存管理
|
||||||
|
ipcMain.handle('cache:clearAnalytics', async () => {
|
||||||
|
return analyticsService.clearCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('cache:clearImages', async () => {
|
||||||
|
const imageResult = await imageDecryptService.clearCache()
|
||||||
|
const emojiResult = chatService.clearCaches({ includeMessages: false, includeContacts: false, includeEmojis: true })
|
||||||
|
const errors = [imageResult, emojiResult]
|
||||||
|
.filter((result) => !result.success)
|
||||||
|
.map((result) => result.error)
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { success: false, error: errors.join('; ') }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('cache:clearAll', async () => {
|
||||||
|
const [analyticsResult, imageResult] = await Promise.all([
|
||||||
|
analyticsService.clearCache(),
|
||||||
|
imageDecryptService.clearCache()
|
||||||
|
])
|
||||||
|
const chatResult = chatService.clearCaches()
|
||||||
|
const errors = [analyticsResult, imageResult, chatResult]
|
||||||
|
.filter((result) => !result.success)
|
||||||
|
.map((result) => result.error)
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { success: false, error: errors.join('; ') }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('whisper:downloadModel', async (event) => {
|
||||||
|
return voiceTranscribeService.downloadModel((progress) => {
|
||||||
|
event.sender.send('whisper:downloadProgress', progress)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('whisper:getModelStatus', async () => {
|
||||||
|
return voiceTranscribeService.getModelStatus()
|
||||||
|
})
|
||||||
|
|
||||||
// 群聊分析相关
|
// 群聊分析相关
|
||||||
ipcMain.handle('groupAnalytics:getGroupChats', async () => {
|
ipcMain.handle('groupAnalytics:getGroupChats', async () => {
|
||||||
return groupAnalyticsService.getGroupChats()
|
return groupAnalyticsService.getGroupChats()
|
||||||
@@ -702,6 +987,17 @@ app.whenReady().then(() => {
|
|||||||
createOnboardingWindow()
|
createOnboardingWindow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解决朋友圈图片无法加载问题(添加 Referer)
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
|
{
|
||||||
|
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
|
||||||
|
},
|
||||||
|
(details, callback) => {
|
||||||
|
details.requestHeaders['Referer'] = 'https://wx.qq.com/'
|
||||||
|
callback({ requestHeaders: details.requestHeaders })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 启动时检测更新
|
// 启动时检测更新
|
||||||
checkForUpdatesOnStartup()
|
checkForUpdatesOnStartup()
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
||||||
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options)
|
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
|
||||||
|
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
|
||||||
|
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||||
|
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
|
||||||
|
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 数据库路径
|
// 数据库路径
|
||||||
@@ -94,8 +98,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||||
enrichSessionsContactInfo: (usernames: string[]) =>
|
enrichSessionsContactInfo: (usernames: string[]) =>
|
||||||
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
||||||
getMessages: (sessionId: string, offset?: number, limit?: number) =>
|
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
||||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
|
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
||||||
getLatestMessages: (sessionId: string, limit?: number) =>
|
getLatestMessages: (sessionId: string, limit?: number) =>
|
||||||
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
|
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
|
||||||
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
||||||
@@ -106,7 +110,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
close: () => ipcRenderer.invoke('chat:close'),
|
close: () => ipcRenderer.invoke('chat:close'),
|
||||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
||||||
getVoiceData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId)
|
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
||||||
|
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
||||||
|
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
||||||
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||||
|
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
||||||
|
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
|
||||||
|
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
||||||
|
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
||||||
|
},
|
||||||
|
execQuery: (kind: string, path: string | null, sql: string) =>
|
||||||
|
ipcRenderer.invoke('chat:execQuery', kind, path, sql)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
@@ -129,6 +143,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 视频
|
||||||
|
video: {
|
||||||
|
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
|
||||||
|
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||||
|
},
|
||||||
|
|
||||||
// 数据分析
|
// 数据分析
|
||||||
analytics: {
|
analytics: {
|
||||||
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
|
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
|
||||||
@@ -140,6 +160,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 缓存管理
|
||||||
|
cache: {
|
||||||
|
clearAnalytics: () => ipcRenderer.invoke('cache:clearAnalytics'),
|
||||||
|
clearImages: () => ipcRenderer.invoke('cache:clearImages'),
|
||||||
|
clearAll: () => ipcRenderer.invoke('cache:clearAll')
|
||||||
|
},
|
||||||
|
|
||||||
// 群聊分析
|
// 群聊分析
|
||||||
groupAnalytics: {
|
groupAnalytics: {
|
||||||
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
||||||
@@ -166,6 +193,27 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options)
|
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||||
|
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
|
||||||
|
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||||
|
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
whisper: {
|
||||||
|
downloadModel: () =>
|
||||||
|
ipcRenderer.invoke('whisper:downloadModel'),
|
||||||
|
getModelStatus: () =>
|
||||||
|
ipcRenderer.invoke('whisper:getModelStatus'),
|
||||||
|
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => {
|
||||||
|
ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
|
||||||
|
return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 朋友圈
|
||||||
|
sns: {
|
||||||
|
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||||
|
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { readFile, writeFile } from 'fs/promises'
|
import { readFile, writeFile, rm } from 'fs/promises'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
|
||||||
export interface ChatStatistics {
|
export interface ChatStatistics {
|
||||||
@@ -324,7 +324,7 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getCacheFilePath(): string {
|
private getCacheFilePath(): string {
|
||||||
return join(app.getPath('userData'), 'analytics_cache.json')
|
return join(app.getPath('documents'), 'WeFlow', 'analytics_cache.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
|
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
|
||||||
@@ -528,6 +528,18 @@ class AnalyticsService {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
this.aggregateCache = null
|
||||||
|
this.fallbackAggregateCache = null
|
||||||
|
this.aggregatePromise = null
|
||||||
|
try {
|
||||||
|
await rm(this.getCacheFilePath(), { force: true })
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const analyticsService = new AnalyticsService()
|
export const analyticsService = new AnalyticsService()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ interface ConfigSchema {
|
|||||||
onboardingDone: boolean
|
onboardingDone: boolean
|
||||||
imageXorKey: number
|
imageXorKey: number
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
|
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
||||||
|
|
||||||
// 缓存相关
|
// 缓存相关
|
||||||
cachePath: string
|
cachePath: string
|
||||||
@@ -20,6 +21,11 @@ interface ConfigSchema {
|
|||||||
language: string
|
language: string
|
||||||
logEnabled: boolean
|
logEnabled: boolean
|
||||||
llmModelPath: string
|
llmModelPath: string
|
||||||
|
whisperModelName: string
|
||||||
|
whisperModelDir: string
|
||||||
|
whisperDownloadSource: string
|
||||||
|
autoTranscribeVoice: boolean
|
||||||
|
transcribeLanguages: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConfigService {
|
export class ConfigService {
|
||||||
@@ -35,6 +41,7 @@ export class ConfigService {
|
|||||||
onboardingDone: false,
|
onboardingDone: false,
|
||||||
imageXorKey: 0,
|
imageXorKey: 0,
|
||||||
imageAesKey: '',
|
imageAesKey: '',
|
||||||
|
wxidConfigs: {},
|
||||||
cachePath: '',
|
cachePath: '',
|
||||||
lastOpenedDb: '',
|
lastOpenedDb: '',
|
||||||
lastSession: '',
|
lastSession: '',
|
||||||
@@ -42,7 +49,12 @@ export class ConfigService {
|
|||||||
themeId: 'cloud-dancer',
|
themeId: 'cloud-dancer',
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
logEnabled: false,
|
logEnabled: false,
|
||||||
llmModelPath: ''
|
llmModelPath: '',
|
||||||
|
whisperModelName: 'base',
|
||||||
|
whisperModelDir: '',
|
||||||
|
whisperDownloadSource: 'tsinghua',
|
||||||
|
autoTranscribeVoice: false,
|
||||||
|
transcribeLanguages: ['zh']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
|
||||||
export interface ContactCacheEntry {
|
export interface ContactCacheEntry {
|
||||||
@@ -15,7 +15,7 @@ export class ContactCacheService {
|
|||||||
constructor(cacheBasePath?: string) {
|
constructor(cacheBasePath?: string) {
|
||||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
? cacheBasePath
|
? cacheBasePath
|
||||||
: join(app.getPath('userData'), 'WeFlowCache')
|
: join(app.getPath('documents'), 'WeFlow')
|
||||||
this.cacheFilePath = join(basePath, 'contacts.json')
|
this.cacheFilePath = join(basePath, 'contacts.json')
|
||||||
this.ensureCacheDir()
|
this.ensureCacheDir()
|
||||||
this.loadCache()
|
this.loadCache()
|
||||||
@@ -34,6 +34,14 @@ export class ContactCacheService {
|
|||||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
if (parsed && typeof parsed === 'object') {
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
// 清除无效的头像数据(hex 格式而非正确的 base64)
|
||||||
|
for (const key of Object.keys(parsed)) {
|
||||||
|
const entry = parsed[key]
|
||||||
|
if (entry?.avatarUrl && entry.avatarUrl.includes('base64,ffd8')) {
|
||||||
|
// 这是错误的 hex 格式,清除它
|
||||||
|
entry.avatarUrl = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
this.cache = parsed
|
this.cache = parsed
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -72,4 +80,13 @@ export class ContactCacheService {
|
|||||||
console.error('ContactCacheService: 保存缓存失败', error)
|
console.error('ContactCacheService: 保存缓存失败', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache = {}
|
||||||
|
try {
|
||||||
|
rmSync(this.cacheFilePath, { force: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ContactCacheService: 清理缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
301
electron/services/exportHtml.css
Normal file
301
electron/services/exportHtml.css
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f6f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #1f2a37;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--sent: #dbeafe;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
--radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 32px auto 60px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control input,
|
||||||
|
.control select,
|
||||||
|
.control button {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .message-row {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #eef2ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: min(70%, 720px);
|
||||||
|
background: var(--received);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .bubble {
|
||||||
|
background: var(--sent);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-emoji {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media {
|
||||||
|
border-radius: 14px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewable {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.image,
|
||||||
|
.message-media.emoji {
|
||||||
|
max-height: 260px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.emoji {
|
||||||
|
max-height: 160px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.video {
|
||||||
|
max-height: 360px;
|
||||||
|
background: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.audio {
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: min(90vw, 1200px);
|
||||||
|
max-height: 90vh;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
background: #0f172a;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="cloud-dancer"] {
|
||||||
|
--accent: #6b8cff;
|
||||||
|
--sent: #e0e7ff;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #d8e0f7;
|
||||||
|
--bg: #f6f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="corundum-blue"] {
|
||||||
|
--accent: #2563eb;
|
||||||
|
--sent: #dbeafe;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #c7d2fe;
|
||||||
|
--bg: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="kiwi-green"] {
|
||||||
|
--accent: #16a34a;
|
||||||
|
--sent: #dcfce7;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #bbf7d0;
|
||||||
|
--bg: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="spicy-red"] {
|
||||||
|
--accent: #e11d48;
|
||||||
|
--sent: #ffe4e6;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #fecdd3;
|
||||||
|
--bg: #fff1f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="teal-water"] {
|
||||||
|
--accent: #0f766e;
|
||||||
|
--sent: #ccfbf1;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #99f6e4;
|
||||||
|
--bg: #f0fdfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
302
electron/services/exportHtmlStyles.ts
Normal file
302
electron/services/exportHtmlStyles.ts
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
export const EXPORT_HTML_STYLES = `:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f6f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #1f2a37;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--sent: #dbeafe;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
--radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 32px auto 60px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control input,
|
||||||
|
.control select,
|
||||||
|
.control button {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .message-row {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #eef2ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: min(70%, 720px);
|
||||||
|
background: var(--received);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .bubble {
|
||||||
|
background: var(--sent);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-emoji {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media {
|
||||||
|
border-radius: 14px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewable {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.image,
|
||||||
|
.message-media.emoji {
|
||||||
|
max-height: 260px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.emoji {
|
||||||
|
max-height: 160px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.video {
|
||||||
|
max-height: 360px;
|
||||||
|
background: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.audio {
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: min(90vw, 1200px);
|
||||||
|
max-height: 90vh;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
background: #0f172a;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="cloud-dancer"] {
|
||||||
|
--accent: #6b8cff;
|
||||||
|
--sent: #e0e7ff;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #d8e0f7;
|
||||||
|
--bg: #f6f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="corundum-blue"] {
|
||||||
|
--accent: #2563eb;
|
||||||
|
--sent: #dbeafe;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #c7d2fe;
|
||||||
|
--bg: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="kiwi-green"] {
|
||||||
|
--accent: #16a34a;
|
||||||
|
--sent: #dcfce7;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #bbf7d0;
|
||||||
|
--bg: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="spicy-red"] {
|
||||||
|
--accent: #e11d48;
|
||||||
|
--sent: #ffe4e6;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #fecdd3;
|
||||||
|
--bg: #fff1f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="teal-water"] {
|
||||||
|
--accent: #0f766e;
|
||||||
|
--sent: #ccfbf1;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #99f6e4;
|
||||||
|
--bg: #f0fdfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,44 @@ import { app, BrowserWindow } from 'electron'
|
|||||||
import { basename, dirname, extname, join } from 'path'
|
import { basename, dirname, extname, join } from 'path'
|
||||||
import { pathToFileURL } from 'url'
|
import { pathToFileURL } from 'url'
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
||||||
import { writeFile } from 'fs/promises'
|
import { writeFile, rm, readdir } from 'fs/promises'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { Worker } from 'worker_threads'
|
import { Worker } from 'worker_threads'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
|
// 获取 ffmpeg-static 的路径
|
||||||
|
function getStaticFfmpegPath(): string | null {
|
||||||
|
try {
|
||||||
|
// 方法1: 直接 require ffmpeg-static
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const ffmpegStatic = require('ffmpeg-static')
|
||||||
|
|
||||||
|
if (typeof ffmpegStatic === 'string' && existsSync(ffmpegStatic)) {
|
||||||
|
return ffmpegStatic
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法2: 手动构建路径(开发环境)
|
||||||
|
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||||
|
if (existsSync(devPath)) {
|
||||||
|
return devPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法3: 打包后的路径
|
||||||
|
if (app.isPackaged) {
|
||||||
|
const resourcesPath = process.resourcesPath
|
||||||
|
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||||
|
if (existsSync(packedPath)) {
|
||||||
|
return packedPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type DecryptResult = {
|
type DecryptResult = {
|
||||||
success: boolean
|
success: boolean
|
||||||
localPath?: string
|
localPath?: string
|
||||||
@@ -36,14 +68,7 @@ export class ImageDecryptService {
|
|||||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||||
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
|
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
|
||||||
|
|
||||||
// 同时输出到控制台
|
// 只写入文件,不输出到控制台
|
||||||
if (meta) {
|
|
||||||
console.info(message, meta)
|
|
||||||
} else {
|
|
||||||
console.info(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入日志文件
|
|
||||||
this.writeLog(logLine)
|
this.writeLog(logLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +108,6 @@ export class ImageDecryptService {
|
|||||||
for (const key of cacheKeys) {
|
for (const key of cacheKeys) {
|
||||||
const cached = this.resolvedCache.get(key)
|
const cached = this.resolvedCache.get(key)
|
||||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||||
this.logInfo('缓存命中(从Map)', { key, path: cached, isThumb: this.isThumbnailPath(cached) })
|
|
||||||
const dataUrl = this.fileToDataUrl(cached)
|
const dataUrl = this.fileToDataUrl(cached)
|
||||||
const isThumb = this.isThumbnailPath(cached)
|
const isThumb = this.isThumbnailPath(cached)
|
||||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||||
@@ -103,7 +127,6 @@ export class ImageDecryptService {
|
|||||||
for (const key of cacheKeys) {
|
for (const key of cacheKeys) {
|
||||||
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.logInfo('缓存命中(文件系统)', { key, path: existing, isThumb: this.isThumbnailPath(existing) })
|
|
||||||
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
||||||
const dataUrl = this.fileToDataUrl(existing)
|
const dataUrl = this.fileToDataUrl(existing)
|
||||||
const isThumb = this.isThumbnailPath(existing)
|
const isThumb = this.isThumbnailPath(existing)
|
||||||
@@ -238,20 +261,39 @@ export class ImageDecryptService {
|
|||||||
const aesKey = this.resolveAesKey(aesKeyRaw)
|
const aesKey = this.resolveAesKey(aesKeyRaw)
|
||||||
|
|
||||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
||||||
const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
|
let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
|
||||||
|
|
||||||
const ext = this.detectImageExtension(decrypted) || '.jpg'
|
// 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据
|
||||||
|
const wxgfResult = await this.unwrapWxgf(decrypted)
|
||||||
|
decrypted = wxgfResult.data
|
||||||
|
|
||||||
const outputPath = this.getCacheOutputPathFromDat(datPath, ext, payload.sessionId)
|
let ext = this.detectImageExtension(decrypted)
|
||||||
|
|
||||||
|
// 如果是 wxgf 格式且没检测到扩展名
|
||||||
|
if (wxgfResult.isWxgf && !ext) {
|
||||||
|
ext = '.hevc'
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalExt = ext || '.jpg'
|
||||||
|
|
||||||
|
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
|
||||||
await writeFile(outputPath, decrypted)
|
await writeFile(outputPath, decrypted)
|
||||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||||
|
|
||||||
|
// 对于 hevc 格式,返回错误提示
|
||||||
|
if (finalExt === '.hevc') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示',
|
||||||
|
isThumb: this.isThumbnailPath(datPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
const isThumb = this.isThumbnailPath(datPath)
|
const isThumb = this.isThumbnailPath(datPath)
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||||
if (!isThumb) {
|
if (!isThumb) {
|
||||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||||
}
|
}
|
||||||
const dataUrl = this.bufferToDataUrl(decrypted, ext)
|
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
|
||||||
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||||
return { success: true, localPath, isThumb }
|
return { success: true, localPath, isThumb }
|
||||||
@@ -904,6 +946,18 @@ export class ImageDecryptService {
|
|||||||
extensions: string[],
|
extensions: string[],
|
||||||
preferHd: boolean
|
preferHd: boolean
|
||||||
): string | null {
|
): string | null {
|
||||||
|
// 先检查并删除旧的 .hevc 文件(ffmpeg 转换失败时遗留的)
|
||||||
|
const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`)
|
||||||
|
const hevcHd = join(dirPath, `${normalizedKey}_hd.hevc`)
|
||||||
|
try {
|
||||||
|
if (existsSync(hevcThumb)) {
|
||||||
|
require('fs').unlinkSync(hevcThumb)
|
||||||
|
}
|
||||||
|
if (existsSync(hevcHd)) {
|
||||||
|
require('fs').unlinkSync(hevcHd)
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
for (const ext of extensions) {
|
for (const ext of extensions) {
|
||||||
if (preferHd) {
|
if (preferHd) {
|
||||||
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
||||||
@@ -1406,6 +1460,159 @@ export class ImageDecryptService {
|
|||||||
return mostCommonKey
|
return mostCommonKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解包 wxgf 格式
|
||||||
|
* wxgf 是微信的图片格式,内部使用 HEVC 编码
|
||||||
|
*/
|
||||||
|
private async unwrapWxgf(buffer: Buffer): Promise<{ data: Buffer; isWxgf: boolean }> {
|
||||||
|
// 检查是否是 wxgf 格式 (77 78 67 66 = "wxgf")
|
||||||
|
if (buffer.length < 20 ||
|
||||||
|
buffer[0] !== 0x77 || buffer[1] !== 0x78 ||
|
||||||
|
buffer[2] !== 0x67 || buffer[3] !== 0x66) {
|
||||||
|
return { data: buffer, isWxgf: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先尝试搜索内嵌的传统图片签名
|
||||||
|
for (let i = 4; i < Math.min(buffer.length - 12, 4096); i++) {
|
||||||
|
if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) {
|
||||||
|
return { data: buffer.subarray(i), isWxgf: false }
|
||||||
|
}
|
||||||
|
if (buffer[i] === 0x89 && buffer[i + 1] === 0x50 &&
|
||||||
|
buffer[i + 2] === 0x4e && buffer[i + 3] === 0x47) {
|
||||||
|
return { data: buffer.subarray(i), isWxgf: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 HEVC NALU 裸流
|
||||||
|
const hevcData = this.extractHevcNalu(buffer)
|
||||||
|
if (!hevcData || hevcData.length < 100) {
|
||||||
|
return { data: buffer, isWxgf: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试用 ffmpeg 转换
|
||||||
|
try {
|
||||||
|
const jpgData = await this.convertHevcToJpg(hevcData)
|
||||||
|
if (jpgData && jpgData.length > 0) {
|
||||||
|
return { data: jpgData, isWxgf: false }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ffmpeg 转换失败
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: hevcData, isWxgf: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 wxgf 数据中提取 HEVC NALU 裸流
|
||||||
|
*/
|
||||||
|
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
||||||
|
const nalUnits: Buffer[] = []
|
||||||
|
let i = 4
|
||||||
|
|
||||||
|
while (i < buffer.length - 4) {
|
||||||
|
if (buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
|
||||||
|
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01) {
|
||||||
|
let nalStart = i
|
||||||
|
let nalEnd = buffer.length
|
||||||
|
|
||||||
|
for (let j = i + 4; j < buffer.length - 3; j++) {
|
||||||
|
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00) {
|
||||||
|
if (buffer[j + 2] === 0x01 ||
|
||||||
|
(buffer[j + 2] === 0x00 && j + 3 < buffer.length && buffer[j + 3] === 0x01)) {
|
||||||
|
nalEnd = j
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nalUnit = buffer.subarray(nalStart, nalEnd)
|
||||||
|
if (nalUnit.length > 3) {
|
||||||
|
nalUnits.push(nalUnit)
|
||||||
|
}
|
||||||
|
i = nalEnd
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nalUnits.length === 0) {
|
||||||
|
for (let j = 4; j < buffer.length - 4; j++) {
|
||||||
|
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00 &&
|
||||||
|
buffer[j + 2] === 0x00 && buffer[j + 3] === 0x01) {
|
||||||
|
return buffer.subarray(j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(nalUnits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 ffmpeg 可执行文件路径
|
||||||
|
*/
|
||||||
|
private getFfmpegPath(): string {
|
||||||
|
const staticPath = getStaticFfmpegPath()
|
||||||
|
this.logInfo('ffmpeg 路径检测', { staticPath, exists: staticPath ? existsSync(staticPath) : false })
|
||||||
|
|
||||||
|
if (staticPath) {
|
||||||
|
return staticPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到系统 ffmpeg
|
||||||
|
return 'ffmpeg'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
|
||||||
|
*/
|
||||||
|
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
||||||
|
const ffmpeg = this.getFfmpegPath()
|
||||||
|
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const { spawn } = require('child_process')
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
const errChunks: Buffer[] = []
|
||||||
|
|
||||||
|
const proc = spawn(ffmpeg, [
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel', 'error',
|
||||||
|
'-f', 'hevc',
|
||||||
|
'-i', 'pipe:0',
|
||||||
|
'-vframes', '1',
|
||||||
|
'-q:v', '3',
|
||||||
|
'-f', 'mjpeg',
|
||||||
|
'pipe:1'
|
||||||
|
], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||||
|
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
||||||
|
|
||||||
|
proc.on('close', (code: number) => {
|
||||||
|
if (code === 0 && chunks.length > 0) {
|
||||||
|
this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length })
|
||||||
|
resolve(Buffer.concat(chunks))
|
||||||
|
} else {
|
||||||
|
const errMsg = Buffer.concat(errChunks).toString()
|
||||||
|
this.logInfo('ffmpeg 转换失败', { code, error: errMsg })
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.on('error', (err: Error) => {
|
||||||
|
this.logInfo('ffmpeg 进程错误', { error: err.message })
|
||||||
|
resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.stdin.write(hevcData)
|
||||||
|
proc.stdin.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 保留原有的解密到文件方法(用于兼容)
|
// 保留原有的解密到文件方法(用于兼容)
|
||||||
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
|
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
|
||||||
const version = this.getDatVersion(inputPath)
|
const version = this.getDatVersion(inputPath)
|
||||||
@@ -1430,6 +1637,71 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
await writeFile(outputPath, decrypted)
|
await writeFile(outputPath, decrypted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
this.resolvedCache.clear()
|
||||||
|
this.hardlinkCache.clear()
|
||||||
|
this.pending.clear()
|
||||||
|
this.updateFlags.clear()
|
||||||
|
this.cacheIndexed = false
|
||||||
|
this.cacheIndexing = null
|
||||||
|
|
||||||
|
const configured = this.configService.get('cachePath')
|
||||||
|
const root = configured
|
||||||
|
? join(configured, 'Images')
|
||||||
|
: join(app.getPath('documents'), 'WeFlow', 'Images')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!existsSync(root)) {
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
const monthPattern = /^\d{4}-\d{2}$/
|
||||||
|
const clearFilesInDir = async (dirPath: string): Promise<void> => {
|
||||||
|
let entries: Array<{ name: string; isDirectory: () => boolean }>
|
||||||
|
try {
|
||||||
|
entries = await readdir(dirPath, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(dirPath, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await clearFilesInDir(fullPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await rm(fullPath, { force: true })
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const traverse = async (dirPath: string): Promise<void> => {
|
||||||
|
let entries: Array<{ name: string; isDirectory: () => boolean }>
|
||||||
|
try {
|
||||||
|
entries = await readdir(dirPath, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(dirPath, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (monthPattern.test(entry.name)) {
|
||||||
|
await clearFilesInDir(fullPath)
|
||||||
|
} else {
|
||||||
|
await traverse(fullPath)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await rm(fullPath, { force: true })
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await traverse(root)
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const imageDecryptService = new ImageDecryptService()
|
export const imageDecryptService = new ImageDecryptService()
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export class KeyService {
|
|||||||
private ReadProcessMemory: any = null
|
private ReadProcessMemory: any = null
|
||||||
private MEMORY_BASIC_INFORMATION: any = null
|
private MEMORY_BASIC_INFORMATION: any = null
|
||||||
private TerminateProcess: any = null
|
private TerminateProcess: any = null
|
||||||
|
private QueryFullProcessImageNameW: any = null
|
||||||
|
|
||||||
// User32
|
// User32
|
||||||
private EnumWindows: any = null
|
private EnumWindows: any = null
|
||||||
@@ -194,6 +195,7 @@ export class KeyService {
|
|||||||
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
|
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
|
||||||
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
|
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
|
||||||
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
|
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
|
||||||
|
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['HANDLE', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
|
||||||
this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64'])
|
this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64'])
|
||||||
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
|
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
|
||||||
|
|
||||||
@@ -310,7 +312,46 @@ export class KeyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getProcessExecutablePath(pid: number): Promise<string | null> {
|
||||||
|
if (!this.ensureKernel32()) return null
|
||||||
|
// 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION
|
||||||
|
const hProcess = this.OpenProcess(0x1000, false, pid)
|
||||||
|
if (!hProcess) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sizeBuf = Buffer.alloc(4)
|
||||||
|
sizeBuf.writeUInt32LE(1024, 0)
|
||||||
|
const pathBuf = Buffer.alloc(1024 * 2)
|
||||||
|
|
||||||
|
const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf)
|
||||||
|
if (ret) {
|
||||||
|
const len = sizeBuf.readUInt32LE(0)
|
||||||
|
return pathBuf.toString('ucs2', 0, len * 2)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取进程路径失败:', e)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
this.CloseHandle(hProcess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async findWeChatInstallPath(): Promise<string | null> {
|
private async findWeChatInstallPath(): Promise<string | null> {
|
||||||
|
// 0. 优先尝试获取正在运行的微信进程路径
|
||||||
|
try {
|
||||||
|
const pid = await this.findWeChatPid()
|
||||||
|
if (pid) {
|
||||||
|
const runPath = await this.getProcessExecutablePath(pid)
|
||||||
|
if (runPath && existsSync(runPath)) {
|
||||||
|
console.log('发现正在运行的微信进程,使用路径:', runPath)
|
||||||
|
return runPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('尝试获取运行中微信路径失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Registry - Uninstall Keys
|
// 1. Registry - Uninstall Keys
|
||||||
const uninstallKeys = [
|
const uninstallKeys = [
|
||||||
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||||
@@ -588,6 +629,11 @@ export class KeyService {
|
|||||||
if (!ok) {
|
if (!ok) {
|
||||||
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
||||||
if (error) {
|
if (error) {
|
||||||
|
// 检测权限不足错误 (NTSTATUS 0xC0000022 = STATUS_ACCESS_DENIED)
|
||||||
|
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
|
||||||
|
const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
|
||||||
|
return { success: false, error: friendlyError }
|
||||||
|
}
|
||||||
return { success: false, error }
|
return { success: false, error }
|
||||||
}
|
}
|
||||||
const statusBuffer = Buffer.alloc(256)
|
const statusBuffer = Buffer.alloc(256)
|
||||||
@@ -836,16 +882,17 @@ export class KeyService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private isAlphaNumAscii(byte: number): boolean {
|
private isAlphaNumLower(byte: number): boolean {
|
||||||
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x30 && byte <= 0x39)
|
// 只匹配小写字母 a-z 和数字 0-9(AES密钥格式)
|
||||||
|
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x30 && byte <= 0x39)
|
||||||
}
|
}
|
||||||
|
|
||||||
private isUtf16AsciiKey(buf: Buffer, start: number): boolean {
|
private isUtf16LowerKey(buf: Buffer, start: number): boolean {
|
||||||
if (start + 64 > buf.length) return false
|
if (start + 64 > buf.length) return false
|
||||||
for (let j = 0; j < 32; j++) {
|
for (let j = 0; j < 32; j++) {
|
||||||
const charByte = buf[start + j * 2]
|
const charByte = buf[start + j * 2]
|
||||||
const nullByte = buf[start + j * 2 + 1]
|
const nullByte = buf[start + j * 2 + 1]
|
||||||
if (nullByte !== 0x00 || !this.isAlphaNumAscii(charByte)) {
|
if (nullByte !== 0x00 || !this.isAlphaNumLower(charByte)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -878,8 +925,6 @@ export class KeyService {
|
|||||||
const regions: Array<[number, number]> = []
|
const regions: Array<[number, number]> = []
|
||||||
const MEM_COMMIT = 0x1000
|
const MEM_COMMIT = 0x1000
|
||||||
const MEM_PRIVATE = 0x20000
|
const MEM_PRIVATE = 0x20000
|
||||||
const MEM_MAPPED = 0x40000
|
|
||||||
const MEM_IMAGE = 0x1000000
|
|
||||||
const PAGE_NOACCESS = 0x01
|
const PAGE_NOACCESS = 0x01
|
||||||
const PAGE_GUARD = 0x100
|
const PAGE_GUARD = 0x100
|
||||||
|
|
||||||
@@ -894,10 +939,9 @@ export class KeyService {
|
|||||||
const protect = info.Protect
|
const protect = info.Protect
|
||||||
const type = info.Type
|
const type = info.Type
|
||||||
const regionSize = Number(info.RegionSize)
|
const regionSize = Number(info.RegionSize)
|
||||||
if (state === MEM_COMMIT && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) {
|
// 只收集已提交的私有内存(大幅减少扫描区域)
|
||||||
if (type === MEM_PRIVATE || type === MEM_MAPPED || type === MEM_IMAGE) {
|
if (state === MEM_COMMIT && type === MEM_PRIVATE && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) {
|
||||||
regions.push([Number(info.BaseAddress), regionSize])
|
regions.push([Number(info.BaseAddress), regionSize])
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAddress = address + regionSize
|
const nextAddress = address + regionSize
|
||||||
@@ -926,86 +970,51 @@ export class KeyService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const allRegions = this.getMemoryRegions(hProcess)
|
const allRegions = this.getMemoryRegions(hProcess)
|
||||||
|
const totalRegions = allRegions.length
|
||||||
|
let scannedCount = 0
|
||||||
|
let skippedCount = 0
|
||||||
|
|
||||||
// 优化1: 只保留小内存区域(< 10MB)- 密钥通常在小区域,可大幅减少扫描时间
|
for (const [baseAddress, regionSize] of allRegions) {
|
||||||
const filteredRegions = allRegions.filter(([_, size]) => size <= 10 * 1024 * 1024)
|
// 跳过太大的内存区域(> 100MB)
|
||||||
|
if (regionSize > 100 * 1024 * 1024) {
|
||||||
|
skippedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 优化2: 优先级排序 - 按大小升序,先扫描小区域(密钥通常在较小区域)
|
scannedCount++
|
||||||
const sortedRegions = filteredRegions.sort((a, b) => a[1] - b[1])
|
if (scannedCount % 10 === 0) {
|
||||||
|
onProgress?.(scannedCount, totalRegions, `正在扫描微信内存... (${scannedCount}/${totalRegions})`)
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
}
|
||||||
|
|
||||||
// 优化3: 计算总字节数用于精确进度报告
|
const memory = this.readProcessMemory(hProcess, baseAddress, regionSize)
|
||||||
const totalBytes = sortedRegions.reduce((sum, [_, size]) => sum + size, 0)
|
if (!memory) continue
|
||||||
let processedBytes = 0
|
|
||||||
|
|
||||||
// 优化4: 减小分块大小到 1MB(参考 wx_key 项目)
|
// 直接在原始字节中搜索32字节的小写字母数字序列
|
||||||
const chunkSize = 1 * 1024 * 1024
|
for (let i = 0; i < memory.length - 34; i++) {
|
||||||
const overlap = 65
|
// 检查前导字符(不是小写字母或数字)
|
||||||
let currentRegion = 0
|
if (this.isAlphaNumLower(memory[i])) continue
|
||||||
|
|
||||||
for (const [baseAddress, regionSize] of sortedRegions) {
|
// 检查接下来32个字节是否都是小写字母或数字
|
||||||
currentRegion++
|
let valid = true
|
||||||
const progress = totalBytes > 0 ? Math.floor((processedBytes / totalBytes) * 100) : 0
|
for (let j = 1; j <= 32; j++) {
|
||||||
onProgress?.(progress, 100, `扫描内存 ${progress}% (${currentRegion}/${sortedRegions.length})`)
|
if (!this.isAlphaNumLower(memory[i + j])) {
|
||||||
|
valid = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!valid) continue
|
||||||
|
|
||||||
// 每个区域都让出主线程,确保UI流畅
|
// 检查尾部字符(不是小写字母或数字)
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
if (i + 33 < memory.length && this.isAlphaNumLower(memory[i + 33])) {
|
||||||
let offset = 0
|
|
||||||
let trailing: Buffer | null = null
|
|
||||||
while (offset < regionSize) {
|
|
||||||
const remaining = regionSize - offset
|
|
||||||
const currentChunkSize = remaining > chunkSize ? chunkSize : remaining
|
|
||||||
const chunk = this.readProcessMemory(hProcess, baseAddress + offset, currentChunkSize)
|
|
||||||
if (!chunk || !chunk.length) {
|
|
||||||
offset += currentChunkSize
|
|
||||||
trailing = null
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let dataToScan: Buffer
|
const keyBytes = memory.subarray(i + 1, i + 33)
|
||||||
if (trailing && trailing.length) {
|
if (this.verifyKey(ciphertext, keyBytes)) {
|
||||||
dataToScan = Buffer.concat([trailing, chunk])
|
return keyBytes.toString('ascii')
|
||||||
} else {
|
|
||||||
dataToScan = chunk
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < dataToScan.length - 34; i++) {
|
|
||||||
if (this.isAlphaNumAscii(dataToScan[i])) continue
|
|
||||||
let valid = true
|
|
||||||
for (let j = 1; j <= 32; j++) {
|
|
||||||
if (!this.isAlphaNumAscii(dataToScan[i + j])) {
|
|
||||||
valid = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (valid && this.isAlphaNumAscii(dataToScan[i + 33])) {
|
|
||||||
valid = false
|
|
||||||
}
|
|
||||||
if (valid) {
|
|
||||||
const keyBytes = dataToScan.subarray(i + 1, i + 33)
|
|
||||||
if (this.verifyKey(ciphertext, keyBytes)) {
|
|
||||||
return keyBytes.toString('ascii')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < dataToScan.length - 65; i++) {
|
|
||||||
if (!this.isUtf16AsciiKey(dataToScan, i)) continue
|
|
||||||
const keyBytes = Buffer.alloc(32)
|
|
||||||
for (let j = 0; j < 32; j++) {
|
|
||||||
keyBytes[j] = dataToScan[i + j * 2]
|
|
||||||
}
|
|
||||||
if (this.verifyKey(ciphertext, keyBytes)) {
|
|
||||||
return keyBytes.toString('ascii')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = dataToScan.length - overlap
|
|
||||||
trailing = dataToScan.subarray(start < 0 ? 0 : start)
|
|
||||||
offset += currentChunkSize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新已处理字节数
|
|
||||||
processedBytes += regionSize
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
|
||||||
export interface SessionMessageCacheEntry {
|
export interface SessionMessageCacheEntry {
|
||||||
@@ -15,7 +15,7 @@ export class MessageCacheService {
|
|||||||
constructor(cacheBasePath?: string) {
|
constructor(cacheBasePath?: string) {
|
||||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
? cacheBasePath
|
? cacheBasePath
|
||||||
: join(app.getPath('userData'), 'WeFlowCache')
|
: join(app.getPath('documents'), 'WeFlow')
|
||||||
this.cacheFilePath = join(basePath, 'session-messages.json')
|
this.cacheFilePath = join(basePath, 'session-messages.json')
|
||||||
this.ensureCacheDir()
|
this.ensureCacheDir()
|
||||||
this.loadCache()
|
this.loadCache()
|
||||||
@@ -65,4 +65,13 @@ export class MessageCacheService {
|
|||||||
console.error('MessageCacheService: 保存缓存失败', error)
|
console.error('MessageCacheService: 保存缓存失败', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache = {}
|
||||||
|
try {
|
||||||
|
rmSync(this.cacheFilePath, { force: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MessageCacheService: 清理缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
electron/services/snsService.ts
Normal file
64
electron/services/snsService.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
import { ContactCacheService } from './contactCacheService'
|
||||||
|
|
||||||
|
export interface SnsPost {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
avatarUrl?: string
|
||||||
|
createTime: number
|
||||||
|
contentDesc: string
|
||||||
|
type?: number
|
||||||
|
media: { url: string; thumb: string }[]
|
||||||
|
likes: string[]
|
||||||
|
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnsService {
|
||||||
|
private contactCache: ContactCacheService
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const config = new ConfigService()
|
||||||
|
this.contactCache = new ContactCacheService(config.get('cachePath') as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> {
|
||||||
|
console.log('[SnsService] getTimeline called with:', { limit, offset, usernames, keyword, startTime, endTime })
|
||||||
|
|
||||||
|
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||||
|
|
||||||
|
console.log('[SnsService] getSnsTimeline result:', {
|
||||||
|
success: result.success,
|
||||||
|
timelineCount: result.timeline?.length,
|
||||||
|
error: result.error
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success && result.timeline) {
|
||||||
|
const enrichedTimeline = result.timeline.map((post: any) => {
|
||||||
|
const contact = this.contactCache.get(post.username)
|
||||||
|
|
||||||
|
// 修复媒体 URL,如果是 http 则尝试用 https (虽然 qpic 可能不支持强制 https,但通常支持)
|
||||||
|
const fixedMedia = post.media.map((m: any) => ({
|
||||||
|
url: m.url.replace('http://', 'https://'),
|
||||||
|
thumb: m.thumb.replace('http://', 'https://')
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
...post,
|
||||||
|
avatarUrl: contact?.avatarUrl,
|
||||||
|
nickname: post.nickname || contact?.displayName || post.username,
|
||||||
|
media: fixedMedia
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[SnsService] Returning enriched timeline with', enrichedTimeline.length, 'posts')
|
||||||
|
return { ...result, timeline: enrichedTimeline }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[SnsService] Returning result:', result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const snsService = new SnsService()
|
||||||
256
electron/services/videoService.ts
Normal file
256
electron/services/videoService.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { join } from 'path'
|
||||||
|
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
|
export interface VideoInfo {
|
||||||
|
videoUrl?: string // 视频文件路径(用于 readFile)
|
||||||
|
coverUrl?: string // 封面 data URL
|
||||||
|
thumbUrl?: string // 缩略图 data URL
|
||||||
|
exists: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class VideoService {
|
||||||
|
private configService: ConfigService
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.configService = new ConfigService()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据库根目录
|
||||||
|
*/
|
||||||
|
private getDbPath(): string {
|
||||||
|
return this.configService.get('dbPath') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户的wxid
|
||||||
|
*/
|
||||||
|
private getMyWxid(): string {
|
||||||
|
return this.configService.get('myWxid') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存目录(解密后的数据库存放位置)
|
||||||
|
*/
|
||||||
|
private getCachePath(): string {
|
||||||
|
return this.configService.get('cachePath') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 wxid 目录名(去掉后缀)
|
||||||
|
*/
|
||||||
|
private cleanWxid(wxid: string): string {
|
||||||
|
const trimmed = wxid.trim()
|
||||||
|
if (!trimmed) return trimmed
|
||||||
|
|
||||||
|
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||||
|
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||||
|
if (match) return match[1]
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
|
if (suffixMatch) return suffixMatch[1]
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 video_hardlink_info_v4 表查询视频文件名
|
||||||
|
* 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3)
|
||||||
|
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||||
|
*/
|
||||||
|
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
||||||
|
const cachePath = this.getCachePath()
|
||||||
|
const dbPath = this.getDbPath()
|
||||||
|
const wxid = this.getMyWxid()
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
|
||||||
|
if (!wxid) return undefined
|
||||||
|
|
||||||
|
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
||||||
|
if (cachePath) {
|
||||||
|
const cacheDbPaths = [
|
||||||
|
join(cachePath, cleanedWxid, 'hardlink.db'),
|
||||||
|
join(cachePath, wxid, 'hardlink.db'),
|
||||||
|
join(cachePath, 'hardlink.db'),
|
||||||
|
join(cachePath, 'databases', cleanedWxid, 'hardlink.db'),
|
||||||
|
join(cachePath, 'databases', wxid, 'hardlink.db')
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const p of cacheDbPaths) {
|
||||||
|
if (existsSync(p)) {
|
||||||
|
try {
|
||||||
|
const db = new Database(p, { readonly: true })
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT file_name, md5 FROM video_hardlink_info_v4
|
||||||
|
WHERE md5 = ?
|
||||||
|
LIMIT 1
|
||||||
|
`).get(md5) as { file_name: string; md5: string } | undefined
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if (row?.file_name) {
|
||||||
|
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
||||||
|
return realMd5
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||||
|
if (dbPath) {
|
||||||
|
const encryptedDbPaths = [
|
||||||
|
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
|
||||||
|
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const p of encryptedDbPaths) {
|
||||||
|
if (existsSync(p)) {
|
||||||
|
try {
|
||||||
|
const escapedMd5 = md5.replace(/'/g, "''")
|
||||||
|
|
||||||
|
// 用 md5 字段查询,获取 file_name
|
||||||
|
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
||||||
|
|
||||||
|
const result = await wcdbService.execQuery('media', p, sql)
|
||||||
|
|
||||||
|
if (result.success && result.rows && result.rows.length > 0) {
|
||||||
|
const row = result.rows[0]
|
||||||
|
if (row?.file_name) {
|
||||||
|
// 提取不带扩展名的文件名作为实际视频 MD5
|
||||||
|
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
||||||
|
return realMd5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文件转换为 data URL
|
||||||
|
*/
|
||||||
|
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
|
||||||
|
try {
|
||||||
|
if (!existsSync(filePath)) return undefined
|
||||||
|
const buffer = readFileSync(filePath)
|
||||||
|
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据视频MD5获取视频文件信息
|
||||||
|
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||||
|
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||||
|
*/
|
||||||
|
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
||||||
|
|
||||||
|
const dbPath = this.getDbPath()
|
||||||
|
const wxid = this.getMyWxid()
|
||||||
|
|
||||||
|
if (!dbPath || !wxid || !videoMd5) {
|
||||||
|
return { exists: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先尝试从数据库查询真正的视频文件名
|
||||||
|
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
||||||
|
|
||||||
|
const videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
||||||
|
|
||||||
|
if (!existsSync(videoBaseDir)) {
|
||||||
|
return { exists: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历年月目录查找视频文件
|
||||||
|
try {
|
||||||
|
const allDirs = readdirSync(videoBaseDir)
|
||||||
|
|
||||||
|
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
|
||||||
|
const yearMonthDirs = allDirs
|
||||||
|
.filter(dir => {
|
||||||
|
const dirPath = join(videoBaseDir, dir)
|
||||||
|
return statSync(dirPath).isDirectory()
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
|
||||||
|
|
||||||
|
for (const yearMonth of yearMonthDirs) {
|
||||||
|
const dirPath = join(videoBaseDir, yearMonth)
|
||||||
|
|
||||||
|
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
||||||
|
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
|
||||||
|
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
|
||||||
|
|
||||||
|
// 检查视频文件是否存在
|
||||||
|
if (existsSync(videoPath)) {
|
||||||
|
return {
|
||||||
|
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
|
||||||
|
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||||
|
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[VideoService] Error searching for video:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { exists: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据消息内容解析视频MD5
|
||||||
|
*/
|
||||||
|
parseVideoMd5(content: string): string | undefined {
|
||||||
|
|
||||||
|
// 打印前500字符看看 XML 结构
|
||||||
|
|
||||||
|
if (!content) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 提取所有可能的 md5 值进行日志
|
||||||
|
const allMd5s: string[] = []
|
||||||
|
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
|
||||||
|
let match
|
||||||
|
while ((match = md5Regex.exec(content)) !== null) {
|
||||||
|
allMd5s.push(`${match[0]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 md5(用于查询 hardlink.db)
|
||||||
|
// 注意:不是 rawmd5,rawmd5 是另一个值
|
||||||
|
// 格式: md5="xxx" 或 <md5>xxx</md5>
|
||||||
|
|
||||||
|
// 尝试从videomsg标签中提取md5
|
||||||
|
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (videoMsgMatch) {
|
||||||
|
return videoMsgMatch[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (attrMatch) {
|
||||||
|
return attrMatch[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||||
|
if (md5Match) {
|
||||||
|
return md5Match[1].toLowerCase()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[VideoService] 解析视频MD5失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoService = new VideoService()
|
||||||
377
electron/services/voiceTranscribeService.ts
Normal file
377
electron/services/voiceTranscribeService.ts
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import * as https from 'https'
|
||||||
|
import * as http from 'http'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
|
// Sherpa-onnx 类型定义
|
||||||
|
type OfflineRecognizer = any
|
||||||
|
type OfflineStream = any
|
||||||
|
|
||||||
|
type ModelInfo = {
|
||||||
|
name: string
|
||||||
|
files: {
|
||||||
|
model: string
|
||||||
|
tokens: string
|
||||||
|
}
|
||||||
|
sizeBytes: number
|
||||||
|
sizeLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadProgress = {
|
||||||
|
modelName: string
|
||||||
|
downloadedBytes: number
|
||||||
|
totalBytes?: number
|
||||||
|
percent?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const SENSEVOICE_MODEL: ModelInfo = {
|
||||||
|
name: 'SenseVoiceSmall',
|
||||||
|
files: {
|
||||||
|
model: 'model.int8.onnx',
|
||||||
|
tokens: 'tokens.txt'
|
||||||
|
},
|
||||||
|
sizeBytes: 245_000_000,
|
||||||
|
sizeLabel: '245 MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODEL_DOWNLOAD_URLS = {
|
||||||
|
model: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/model.int8.onnx',
|
||||||
|
tokens: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/tokens.txt'
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VoiceTranscribeService {
|
||||||
|
private configService = new ConfigService()
|
||||||
|
private downloadTasks = new Map<string, Promise<{ success: boolean; path?: string; error?: string }>>()
|
||||||
|
private recognizer: OfflineRecognizer | null = null
|
||||||
|
private isInitializing = false
|
||||||
|
|
||||||
|
private resolveModelDir(): string {
|
||||||
|
const configured = this.configService.get('whisperModelDir') as string | undefined
|
||||||
|
if (configured) return configured
|
||||||
|
return join(app.getPath('documents'), 'WeFlow', 'models', 'sensevoice')
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveModelPath(fileName: string): string {
|
||||||
|
return join(this.resolveModelDir(), fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查模型状态
|
||||||
|
*/
|
||||||
|
async getModelStatus(): Promise<{
|
||||||
|
success: boolean
|
||||||
|
exists?: boolean
|
||||||
|
modelPath?: string
|
||||||
|
tokensPath?: string
|
||||||
|
sizeBytes?: number
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||||
|
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||||
|
const modelExists = existsSync(modelPath)
|
||||||
|
const tokensExists = existsSync(tokensPath)
|
||||||
|
const exists = modelExists && tokensExists
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
return { success: true, exists: false, modelPath, tokensPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelSize = statSync(modelPath).size
|
||||||
|
const tokensSize = statSync(tokensPath).size
|
||||||
|
const totalSize = modelSize + tokensSize
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
exists: true,
|
||||||
|
modelPath,
|
||||||
|
tokensPath,
|
||||||
|
sizeBytes: totalSize
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载模型文件
|
||||||
|
*/
|
||||||
|
async downloadModel(
|
||||||
|
onProgress?: (progress: DownloadProgress) => void
|
||||||
|
): Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }> {
|
||||||
|
const cacheKey = 'sensevoice'
|
||||||
|
const pending = this.downloadTasks.get(cacheKey)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const task = (async () => {
|
||||||
|
try {
|
||||||
|
const modelDir = this.resolveModelDir()
|
||||||
|
if (!existsSync(modelDir)) {
|
||||||
|
mkdirSync(modelDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||||
|
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||||
|
|
||||||
|
// 初始进度
|
||||||
|
onProgress?.({
|
||||||
|
modelName: SENSEVOICE_MODEL.name,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||||
|
percent: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 下载模型文件 (40%)
|
||||||
|
console.info('[VoiceTranscribe] 开始下载模型文件...')
|
||||||
|
await this.downloadToFile(
|
||||||
|
MODEL_DOWNLOAD_URLS.model,
|
||||||
|
modelPath,
|
||||||
|
'model',
|
||||||
|
(downloaded, total) => {
|
||||||
|
const percent = total ? (downloaded / total) * 40 : undefined
|
||||||
|
onProgress?.({
|
||||||
|
modelName: SENSEVOICE_MODEL.name,
|
||||||
|
downloadedBytes: downloaded,
|
||||||
|
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||||
|
percent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 下载 tokens 文件 (30%)
|
||||||
|
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
|
||||||
|
await this.downloadToFile(
|
||||||
|
MODEL_DOWNLOAD_URLS.tokens,
|
||||||
|
tokensPath,
|
||||||
|
'tokens',
|
||||||
|
(downloaded, total) => {
|
||||||
|
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
|
||||||
|
const percent = total ? 40 + (downloaded / total) * 30 : 40
|
||||||
|
onProgress?.({
|
||||||
|
modelName: SENSEVOICE_MODEL.name,
|
||||||
|
downloadedBytes: modelSize + downloaded,
|
||||||
|
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||||
|
percent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
console.info('[VoiceTranscribe] 模型下载完成')
|
||||||
|
|
||||||
|
console.info('[VoiceTranscribe] 所有文件下载完成')
|
||||||
|
return { success: true, modelPath, tokensPath }
|
||||||
|
} catch (error) {
|
||||||
|
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||||
|
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||||
|
try {
|
||||||
|
if (existsSync(modelPath)) unlinkSync(modelPath)
|
||||||
|
if (existsSync(tokensPath)) unlinkSync(tokensPath)
|
||||||
|
} catch { }
|
||||||
|
return { success: false, error: String(error) }
|
||||||
|
} finally {
|
||||||
|
this.downloadTasks.delete(cacheKey)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
this.downloadTasks.set(cacheKey, task)
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转写 WAV 音频数据 (后台 Worker Threads 版本)
|
||||||
|
*/
|
||||||
|
async transcribeWavBuffer(
|
||||||
|
wavData: Buffer,
|
||||||
|
onPartial?: (text: string) => void,
|
||||||
|
languages?: string[]
|
||||||
|
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||||
|
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||||
|
|
||||||
|
if (!existsSync(modelPath) || !existsSync(tokensPath)) {
|
||||||
|
resolve({ success: false, error: '模型文件不存在,请先下载模型' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置的语言列表,如果没有传入则从配置读取
|
||||||
|
let supportedLanguages = languages
|
||||||
|
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||||
|
supportedLanguages = this.configService.get('transcribeLanguages')
|
||||||
|
// 如果配置中也没有或为空,使用默认值
|
||||||
|
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||||
|
supportedLanguages = ['zh', 'yue']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Worker } = require('worker_threads')
|
||||||
|
// main.js 和 transcribeWorker.js 同在 dist-electron 目录下
|
||||||
|
const workerPath = join(__dirname, 'transcribeWorker.js')
|
||||||
|
|
||||||
|
const worker = new Worker(workerPath, {
|
||||||
|
workerData: {
|
||||||
|
modelPath,
|
||||||
|
tokensPath,
|
||||||
|
wavData,
|
||||||
|
sampleRate: 16000,
|
||||||
|
languages: supportedLanguages
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let finalTranscript = ''
|
||||||
|
|
||||||
|
worker.on('message', (msg: any) => {
|
||||||
|
console.log('[VoiceTranscribe] Worker 消息:', msg)
|
||||||
|
if (msg.type === 'partial') {
|
||||||
|
onPartial?.(msg.text)
|
||||||
|
} else if (msg.type === 'final') {
|
||||||
|
finalTranscript = msg.text
|
||||||
|
console.log('[VoiceTranscribe] 最终文本:', finalTranscript)
|
||||||
|
resolve({ success: true, transcript: finalTranscript })
|
||||||
|
worker.terminate()
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
|
||||||
|
resolve({ success: false, error: msg.error })
|
||||||
|
worker.terminate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
worker.on('error', (err: Error) => {
|
||||||
|
resolve({ success: false, error: String(err) })
|
||||||
|
})
|
||||||
|
|
||||||
|
worker.on('exit', (code: number) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.error(`[VoiceTranscribe] Worker stopped with exit code ${code}`)
|
||||||
|
resolve({ success: false, error: `Worker exited with code ${code}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
resolve({ success: false, error: String(error) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件
|
||||||
|
*/
|
||||||
|
private downloadToFile(
|
||||||
|
url: string,
|
||||||
|
targetPath: string,
|
||||||
|
fileName: string,
|
||||||
|
onProgress?: (downloaded: number, total?: number) => void,
|
||||||
|
remainingRedirects = 5
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const protocol = url.startsWith('https') ? https : http
|
||||||
|
console.info(`[VoiceTranscribe] 下载 ${fileName}:`, url)
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
},
|
||||||
|
timeout: 30000 // 30秒连接超时
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = protocol.get(url, options, (response) => {
|
||||||
|
console.info(`[VoiceTranscribe] ${fileName} 响应状态:`, response.statusCode)
|
||||||
|
|
||||||
|
// 处理重定向
|
||||||
|
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) {
|
||||||
|
if (remainingRedirects <= 0) {
|
||||||
|
reject(new Error('重定向次数过多'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.info(`[VoiceTranscribe] 重定向到:`, response.headers.location)
|
||||||
|
this.downloadToFile(response.headers.location, targetPath, fileName, onProgress, remainingRedirects - 1)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
reject(new Error(`下载失败: HTTP ${response.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
|
||||||
|
let downloadedBytes = 0
|
||||||
|
|
||||||
|
console.info(`[VoiceTranscribe] ${fileName} 文件大小:`, totalBytes ? `${(totalBytes / 1024 / 1024).toFixed(2)} MB` : '未知')
|
||||||
|
|
||||||
|
const writer = createWriteStream(targetPath)
|
||||||
|
|
||||||
|
// 设置数据接收超时(60秒没有数据则超时)
|
||||||
|
let lastDataTime = Date.now()
|
||||||
|
const dataTimeout = setInterval(() => {
|
||||||
|
if (Date.now() - lastDataTime > 60000) {
|
||||||
|
clearInterval(dataTimeout)
|
||||||
|
response.destroy()
|
||||||
|
writer.close()
|
||||||
|
reject(new Error('下载超时:60秒内未收到数据'))
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
lastDataTime = Date.now()
|
||||||
|
downloadedBytes += chunk.length
|
||||||
|
onProgress?.(downloadedBytes, totalBytes)
|
||||||
|
})
|
||||||
|
|
||||||
|
response.on('error', (error) => {
|
||||||
|
clearInterval(dataTimeout)
|
||||||
|
try { writer.close() } catch { }
|
||||||
|
console.error(`[VoiceTranscribe] ${fileName} 响应错误:`, error)
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
writer.on('error', (error) => {
|
||||||
|
clearInterval(dataTimeout)
|
||||||
|
try { writer.close() } catch { }
|
||||||
|
console.error(`[VoiceTranscribe] ${fileName} 写入错误:`, error)
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
writer.on('finish', () => {
|
||||||
|
clearInterval(dataTimeout)
|
||||||
|
writer.close()
|
||||||
|
console.info(`[VoiceTranscribe] ${fileName} 下载完成:`, targetPath)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
response.pipe(writer)
|
||||||
|
})
|
||||||
|
|
||||||
|
request.on('timeout', () => {
|
||||||
|
request.destroy()
|
||||||
|
console.error(`[VoiceTranscribe] ${fileName} 连接超时`)
|
||||||
|
reject(new Error('连接超时'))
|
||||||
|
})
|
||||||
|
|
||||||
|
request.on('error', (error) => {
|
||||||
|
console.error(`[VoiceTranscribe] ${fileName} 请求错误:`, error)
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理资源
|
||||||
|
*/
|
||||||
|
dispose() {
|
||||||
|
if (this.recognizer) {
|
||||||
|
try {
|
||||||
|
// sherpa-onnx 的 recognizer 可能需要手动释放
|
||||||
|
this.recognizer = null
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const voiceTranscribeService = new VoiceTranscribeService()
|
||||||
|
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { join, dirname, basename } from 'path'
|
import { join, dirname, basename } from 'path'
|
||||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
|
|
||||||
|
// DLL 初始化错误信息,用于帮助用户诊断问题
|
||||||
|
let lastDllInitError: string | null = null
|
||||||
|
export function getLastDllInitError(): string | null {
|
||||||
|
return lastDllInitError
|
||||||
|
}
|
||||||
|
|
||||||
export class WcdbCore {
|
export class WcdbCore {
|
||||||
private resourcesPath: string | null = null
|
private resourcesPath: string | null = null
|
||||||
private userDataPath: string | null = null
|
private userDataPath: string | null = null
|
||||||
@@ -14,6 +20,7 @@ export class WcdbCore {
|
|||||||
private currentWxid: string | null = null
|
private currentWxid: string | null = null
|
||||||
|
|
||||||
// 函数引用
|
// 函数引用
|
||||||
|
private wcdbInitProtection: any = null
|
||||||
private wcdbInit: any = null
|
private wcdbInit: any = null
|
||||||
private wcdbShutdown: any = null
|
private wcdbShutdown: any = null
|
||||||
private wcdbOpenAccount: any = null
|
private wcdbOpenAccount: any = null
|
||||||
@@ -48,6 +55,8 @@ export class WcdbCore {
|
|||||||
private wcdbGetMessageById: any = null
|
private wcdbGetMessageById: any = null
|
||||||
private wcdbGetEmoticonCdnUrl: any = null
|
private wcdbGetEmoticonCdnUrl: any = null
|
||||||
private wcdbGetDbStatus: any = null
|
private wcdbGetDbStatus: any = null
|
||||||
|
private wcdbGetVoiceData: any = null
|
||||||
|
private wcdbGetSnsTimeline: any = null
|
||||||
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
private logTimer: NodeJS.Timeout | null = null
|
private logTimer: NodeJS.Timeout | null = null
|
||||||
@@ -108,12 +117,14 @@ export class WcdbCore {
|
|||||||
|
|
||||||
private writeLog(message: string, force = false): void {
|
private writeLog(message: string, force = false): void {
|
||||||
if (!force && !this.isLogEnabled()) return
|
if (!force && !this.isLogEnabled()) return
|
||||||
|
const line = `[${new Date().toISOString()}] ${message}`
|
||||||
|
// 同时输出到控制台和文件
|
||||||
|
console.log('[WCDB]', message)
|
||||||
try {
|
try {
|
||||||
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
||||||
const dir = join(base, 'logs')
|
const dir = join(base, 'logs')
|
||||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
const line = `[${new Date().toISOString()}] ${message}\n`
|
appendFileSync(join(dir, 'wcdb.log'), line + '\n', { encoding: 'utf8' })
|
||||||
appendFileSync(join(dir, 'wcdb.log'), line, { encoding: 'utf8' })
|
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +217,45 @@ export class WcdbCore {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关键修复:显式预加载依赖库 WCDB.dll 和 SDL2.dll
|
||||||
|
// Windows 加载器默认不会查找子目录中的依赖,必须先将其加载到内存
|
||||||
|
// 这可以解决部分用户因为 VC++ 运行时或 DLL 依赖问题导致的闪退
|
||||||
|
const dllDir = dirname(dllPath)
|
||||||
|
const wcdbCorePath = join(dllDir, 'WCDB.dll')
|
||||||
|
if (existsSync(wcdbCorePath)) {
|
||||||
|
try {
|
||||||
|
this.koffi.load(wcdbCorePath)
|
||||||
|
this.writeLog('预加载 WCDB.dll 成功')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e)
|
||||||
|
this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sdl2Path = join(dllDir, 'SDL2.dll')
|
||||||
|
if (existsSync(sdl2Path)) {
|
||||||
|
try {
|
||||||
|
this.koffi.load(sdl2Path)
|
||||||
|
this.writeLog('预加载 SDL2.dll 成功')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e)
|
||||||
|
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.lib = this.koffi.load(dllPath)
|
this.lib = this.koffi.load(dllPath)
|
||||||
|
|
||||||
|
// InitProtection (Added for security)
|
||||||
|
try {
|
||||||
|
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
|
||||||
|
const protectionOk = this.wcdbInitProtection(dllDir)
|
||||||
|
if (!protectionOk) {
|
||||||
|
console.error('Core security check failed')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('InitProtection symbol not found:', e)
|
||||||
|
}
|
||||||
|
|
||||||
// 定义类型
|
// 定义类型
|
||||||
// wcdb_status wcdb_init()
|
// wcdb_status wcdb_init()
|
||||||
this.wcdbInit = this.lib.func('int32 wcdb_init()')
|
this.wcdbInit = this.lib.func('int32 wcdb_init()')
|
||||||
@@ -345,6 +393,20 @@ export class WcdbCore {
|
|||||||
this.wcdbGetDbStatus = null
|
this.wcdbGetDbStatus = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_voice_data(wcdb_handle handle, const char* session_id, int32_t create_time, int32_t local_id, int64_t svr_id, const char* candidates_json, char** out_hex)
|
||||||
|
try {
|
||||||
|
this.wcdbGetVoiceData = this.lib.func('int32 wcdb_get_voice_data(int64 handle, const char* sessionId, int32 createTime, int32 localId, int64 svrId, const char* candidatesJson, _Out_ void** outHex)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetVoiceData = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetSnsTimeline = null
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
const initResult = this.wcdbInit()
|
const initResult = this.wcdbInit()
|
||||||
if (initResult !== 0) {
|
if (initResult !== 0) {
|
||||||
@@ -353,9 +415,20 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
|
lastDllInitError = null
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('WCDB 初始化异常:', e)
|
const errorMsg = e instanceof Error ? e.message : String(e)
|
||||||
|
console.error('WCDB 初始化异常:', errorMsg)
|
||||||
|
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
|
||||||
|
lastDllInitError = errorMsg
|
||||||
|
// 检查是否是常见的 VC++ 运行时缺失错误
|
||||||
|
if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') ||
|
||||||
|
errorMsg.includes('The specified module could not be found')) {
|
||||||
|
lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。'
|
||||||
|
} else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) {
|
||||||
|
lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。'
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,10 +446,18 @@ export class WcdbCore {
|
|||||||
return { success: true, sessionCount: 0 }
|
return { success: true, sessionCount: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录当前活动连接,用于在测试结束后恢复(避免影响聊天页等正在使用的连接)
|
||||||
|
const hadActiveConnection = this.handle !== null
|
||||||
|
const prevPath = this.currentPath
|
||||||
|
const prevKey = this.currentKey
|
||||||
|
const prevWxid = this.currentWxid
|
||||||
|
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
const initOk = await this.initialize()
|
const initOk = await this.initialize()
|
||||||
if (!initOk) {
|
if (!initOk) {
|
||||||
return { success: false, error: 'WCDB 初始化失败' }
|
// 返回更详细的错误信息,帮助用户诊断问题
|
||||||
|
const detailedError = lastDllInitError || 'WCDB 初始化失败'
|
||||||
|
return { success: false, error: detailedError }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,8 +496,8 @@ export class WcdbCore {
|
|||||||
return { success: false, error: '无效的数据库句柄' }
|
return { success: false, error: '无效的数据库句柄' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试成功,使用 shutdown 清理所有资源(包括测试句柄)
|
// 测试成功:使用 shutdown 清理资源(包括测试句柄)
|
||||||
// 这会中断当前活动连接,但 testConnection 本应该是独立测试
|
// 注意:shutdown 会断开当前活动连接,因此需要在测试后尝试恢复之前的连接
|
||||||
try {
|
try {
|
||||||
this.wcdbShutdown()
|
this.wcdbShutdown()
|
||||||
this.handle = null
|
this.handle = null
|
||||||
@@ -428,6 +509,15 @@ export class WcdbCore {
|
|||||||
console.error('关闭测试数据库时出错:', closeErr)
|
console.error('关闭测试数据库时出错:', closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 恢复测试前的连接(如果之前有活动连接)
|
||||||
|
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
|
||||||
|
try {
|
||||||
|
await this.open(prevPath, prevKey, prevWxid)
|
||||||
|
} catch {
|
||||||
|
// 恢复失败则保持断开,由调用方处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, sessionCount: 0 }
|
return { success: true, sessionCount: 0 }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('测试连接异常:', e)
|
console.error('测试连接异常:', e)
|
||||||
@@ -611,7 +701,7 @@ export class WcdbCore {
|
|||||||
try {
|
try {
|
||||||
this.wcdbSetMyWxid(this.handle, wxid)
|
this.wcdbSetMyWxid(this.handle, wxid)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('设置 wxid 失败:', e)
|
// 静默失败
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.isLogEnabled()) {
|
if (this.isLogEnabled()) {
|
||||||
@@ -790,7 +880,6 @@ export class WcdbCore {
|
|||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
|
||||||
if (result !== 0 || !outPtr[0]) {
|
if (result !== 0 || !outPtr[0]) {
|
||||||
console.warn(`[wcdbCore] getAvatarUrls DLL调用失败: result=${result}, usernames=${toFetch.length}`)
|
|
||||||
if (Object.keys(resultMap).length > 0) {
|
if (Object.keys(resultMap).length > 0) {
|
||||||
return { success: true, map: resultMap, error: `获取头像失败: ${result}` }
|
return { success: true, map: resultMap, error: `获取头像失败: ${result}` }
|
||||||
}
|
}
|
||||||
@@ -798,25 +887,18 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
if (!jsonStr) {
|
if (!jsonStr) {
|
||||||
console.error('[wcdbCore] getAvatarUrls 解析JSON失败')
|
|
||||||
return { success: false, error: '解析头像失败' }
|
return { success: false, error: '解析头像失败' }
|
||||||
}
|
}
|
||||||
const map = JSON.parse(jsonStr) as Record<string, string>
|
const map = JSON.parse(jsonStr) as Record<string, string>
|
||||||
let successCount = 0
|
|
||||||
let emptyCount = 0
|
|
||||||
for (const username of toFetch) {
|
for (const username of toFetch) {
|
||||||
const url = map[username]
|
const url = map[username]
|
||||||
if (url && url.trim()) {
|
if (url && url.trim()) {
|
||||||
resultMap[username] = url
|
resultMap[username] = url
|
||||||
// 只缓存有效的URL
|
// 只缓存有效的URL
|
||||||
this.avatarUrlCache.set(username, { url, updatedAt: now })
|
this.avatarUrlCache.set(username, { url, updatedAt: now })
|
||||||
successCount++
|
|
||||||
} else {
|
|
||||||
emptyCount++
|
|
||||||
// 不缓存空URL,下次可以重新尝试
|
|
||||||
}
|
}
|
||||||
|
// 不缓存空URL,下次可以重新尝试
|
||||||
}
|
}
|
||||||
console.log(`[wcdbCore] getAvatarUrls 成功: ${successCount}个, 空结果: ${emptyCount}个, 总请求: ${toFetch.length}`)
|
|
||||||
return { success: true, map: resultMap }
|
return { success: true, map: resultMap }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[wcdbCore] getAvatarUrls 异常:', e)
|
console.error('[wcdbCore] getAvatarUrls 异常:', e)
|
||||||
@@ -1295,9 +1377,7 @@ export class WcdbCore {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
} async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> {
|
||||||
|
|
||||||
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> {
|
|
||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
try {
|
try {
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
@@ -1313,5 +1393,49 @@ export class WcdbCore {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbGetVoiceData) return { success: false, error: '当前 DLL 版本不支持获取语音数据' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetVoiceData(this.handle, sessionId, createTime, localId, BigInt(svrId || 0), JSON.stringify(candidates), outPtr)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `获取语音数据失败: ${result}` }
|
||||||
|
}
|
||||||
|
const hex = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (hex === null) return { success: false, error: '解析语音数据失败' }
|
||||||
|
return { success: true, hex: hex || undefined }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : ''
|
||||||
|
const result = this.wcdbGetSnsTimeline(
|
||||||
|
this.handle,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
usernamesJson,
|
||||||
|
keyword || '',
|
||||||
|
startTime || 0,
|
||||||
|
endTime || 0,
|
||||||
|
outPtr
|
||||||
|
)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `获取朋友圈失败: ${result}` }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: '解析朋友圈数据失败' }
|
||||||
|
const timeline = JSON.parse(jsonStr)
|
||||||
|
return { success: true, timeline }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,11 +58,25 @@ export class WcdbService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.worker.on('error', (err) => {
|
this.worker.on('error', (err) => {
|
||||||
|
// Worker 发生错误,需要 reject 所有 pending promises
|
||||||
console.error('WCDB Worker 错误:', err)
|
console.error('WCDB Worker 错误:', err)
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||||
|
for (const [id, p] of this.pending) {
|
||||||
|
p.reject(new Error(`Worker 错误: ${errorMsg}`))
|
||||||
|
}
|
||||||
|
this.pending.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.worker.on('exit', (code) => {
|
this.worker.on('exit', (code) => {
|
||||||
if (code !== 0) console.error(`WCDB Worker 异常退出,退出码: ${code}`)
|
// Worker 退出,需要 reject 所有 pending promises
|
||||||
|
if (code !== 0) {
|
||||||
|
console.error('WCDB Worker 异常退出,退出码:', code)
|
||||||
|
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
|
||||||
|
for (const [id, p] of this.pending) {
|
||||||
|
p.reject(new Error(errorMsg))
|
||||||
|
}
|
||||||
|
this.pending.clear()
|
||||||
|
}
|
||||||
this.worker = null
|
this.worker = null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -73,7 +87,7 @@ export class WcdbService {
|
|||||||
this.setLogEnabled(this.logEnabled)
|
this.setLogEnabled(this.logEnabled)
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('创建 WCDB Worker 失败:', e)
|
// Failed to create worker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +111,7 @@ export class WcdbService {
|
|||||||
setPaths(resourcesPath: string, userDataPath: string): void {
|
setPaths(resourcesPath: string, userDataPath: string): void {
|
||||||
this.resourcesPath = resourcesPath
|
this.resourcesPath = resourcesPath
|
||||||
this.userDataPath = userDataPath
|
this.userDataPath = userDataPath
|
||||||
this.callWorker('setPaths', { resourcesPath, userDataPath }).catch(console.error)
|
this.callWorker('setPaths', { resourcesPath, userDataPath }).catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,7 +119,7 @@ export class WcdbService {
|
|||||||
*/
|
*/
|
||||||
setLogEnabled(enabled: boolean): void {
|
setLogEnabled(enabled: boolean): void {
|
||||||
this.logEnabled = enabled
|
this.logEnabled = enabled
|
||||||
this.callWorker('setLogEnabled', { enabled }).catch(console.error)
|
this.callWorker('setLogEnabled', { enabled }).catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -341,6 +355,20 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageById', { sessionId, localId })
|
return this.callWorker('getMessageById', { sessionId, localId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取语音数据
|
||||||
|
*/
|
||||||
|
async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
|
||||||
|
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取朋友圈
|
||||||
|
*/
|
||||||
|
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||||
|
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const wcdbService = new WcdbService()
|
export const wcdbService = new WcdbService()
|
||||||
|
|||||||
166
electron/transcribeWorker.ts
Normal file
166
electron/transcribeWorker.ts
Normal 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
4
electron/types/sherpa-onnx-node.d.ts
vendored
Normal 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
22
electron/types/whisper-node.d.ts
vendored
Normal 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[]>
|
||||||
|
}
|
||||||
@@ -110,6 +110,15 @@ if (parentPort) {
|
|||||||
case 'getMessageById':
|
case 'getMessageById':
|
||||||
result = await core.getMessageById(payload.sessionId, payload.localId)
|
result = await core.getMessageById(payload.sessionId, payload.localId)
|
||||||
break
|
break
|
||||||
|
case 'getVoiceData':
|
||||||
|
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'getSnsTimeline':
|
||||||
|
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
result = { success: false, error: `Unknown method: ${type}` }
|
result = { success: false, error: `Unknown method: ${type}` }
|
||||||
}
|
}
|
||||||
|
|||||||
609
package-lock.json
generated
609
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.0.4",
|
"version": "1.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.0.4",
|
"version": "1.4.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",
|
||||||
|
|||||||
29
package.json
29
package.json
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.1.2",
|
"version": "1.4.1",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": "cc",
|
"author": "cc",
|
||||||
|
"//": "二改不应改变此处的作者与应用信息",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "echo 'No native modules to rebuild'",
|
"postinstall": "echo 'No native modules to rebuild'",
|
||||||
"rebuild": "echo 'No native modules to rebuild'",
|
"rebuild": "echo 'No native modules to rebuild'",
|
||||||
@@ -19,6 +20,8 @@
|
|||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^10.0.0",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
"fzstd": "^0.1.1",
|
"fzstd": "^0.1.1",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jieba-wasm": "^2.2.0",
|
"jieba-wasm": "^2.2.0",
|
||||||
@@ -28,6 +31,8 @@
|
|||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
"sherpa-onnx-node": "^1.10.38",
|
||||||
|
"silk-wasm": "^3.7.1",
|
||||||
"wechat-emojis": "^1.0.2",
|
"wechat-emojis": "^1.0.2",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
@@ -97,6 +102,28 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"dist-electron/**/*"
|
"dist-electron/**/*"
|
||||||
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"node_modules/silk-wasm/**/*",
|
||||||
|
"node_modules/sherpa-onnx-node/**/*"
|
||||||
|
],
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "resources/msvcp140.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/msvcp140_1.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/vcruntime140.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/vcruntime140_1.dll",
|
||||||
|
"to": "."
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BIN
resources/SDL2.dll
Normal file
BIN
resources/SDL2.dll
Normal file
Binary file not shown.
BIN
resources/msvcp140.dll
Normal file
BIN
resources/msvcp140.dll
Normal file
Binary file not shown.
BIN
resources/msvcp140_1.dll
Normal file
BIN
resources/msvcp140_1.dll
Normal file
Binary file not shown.
Binary file not shown.
BIN
resources/vcruntime140.dll
Normal file
BIN
resources/vcruntime140.dll
Normal file
Binary file not shown.
BIN
resources/vcruntime140_1.dll
Normal file
BIN
resources/vcruntime140_1.dll
Normal file
Binary file not shown.
Binary file not shown.
29
src/App.tsx
29
src/App.tsx
@@ -15,6 +15,8 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
|||||||
import DataManagementPage from './pages/DataManagementPage'
|
import DataManagementPage from './pages/DataManagementPage'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import ExportPage from './pages/ExportPage'
|
import ExportPage from './pages/ExportPage'
|
||||||
|
import VideoWindow from './pages/VideoWindow'
|
||||||
|
import SnsPage from './pages/SnsPage'
|
||||||
|
|
||||||
import { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||||
@@ -29,6 +31,7 @@ function App() {
|
|||||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||||
const isAgreementWindow = location.pathname === '/agreement-window'
|
const isAgreementWindow = location.pathname === '/agreement-window'
|
||||||
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
||||||
|
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
||||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||||
|
|
||||||
// 协议同意状态
|
// 协议同意状态
|
||||||
@@ -182,9 +185,15 @@ function App() {
|
|||||||
const decryptKey = await configService.getDecryptKey()
|
const decryptKey = await configService.getDecryptKey()
|
||||||
const wxid = await configService.getMyWxid()
|
const wxid = await configService.getMyWxid()
|
||||||
const onboardingDone = await configService.getOnboardingDone()
|
const onboardingDone = await configService.getOnboardingDone()
|
||||||
|
const wxidConfig = wxid ? await configService.getWxidConfig(wxid) : null
|
||||||
|
const effectiveDecryptKey = wxidConfig?.decryptKey || decryptKey
|
||||||
|
|
||||||
|
if (wxidConfig?.decryptKey && wxidConfig.decryptKey !== decryptKey) {
|
||||||
|
await configService.setDecryptKey(wxidConfig.decryptKey)
|
||||||
|
}
|
||||||
|
|
||||||
// 如果配置完整,自动测试连接
|
// 如果配置完整,自动测试连接
|
||||||
if (dbPath && decryptKey && wxid) {
|
if (dbPath && effectiveDecryptKey && wxid) {
|
||||||
if (!onboardingDone) {
|
if (!onboardingDone) {
|
||||||
await configService.setOnboardingDone(true)
|
await configService.setOnboardingDone(true)
|
||||||
}
|
}
|
||||||
@@ -200,10 +209,22 @@ function App() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('自动连接失败:', result.error)
|
console.log('自动连接失败:', result.error)
|
||||||
|
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
|
||||||
|
// 其他错误可能需要重新配置
|
||||||
|
const errorMsg = result.error || ''
|
||||||
|
if (errorMsg.includes('Visual C++') ||
|
||||||
|
errorMsg.includes('DLL') ||
|
||||||
|
errorMsg.includes('Worker') ||
|
||||||
|
errorMsg.includes('126') ||
|
||||||
|
errorMsg.includes('模块')) {
|
||||||
|
console.warn('检测到可能的运行时依赖问题:', errorMsg)
|
||||||
|
// 不清除配置,让用户安装 VC++ 后重试
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('自动连接出错:', e)
|
console.error('自动连接出错:', e)
|
||||||
|
// 捕获异常但不清除配置,防止循环重新引导
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +240,11 @@ function App() {
|
|||||||
return <WelcomePage standalone />
|
return <WelcomePage standalone />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 独立视频播放窗口
|
||||||
|
if (isVideoPlayerWindow) {
|
||||||
|
return <VideoWindow />
|
||||||
|
}
|
||||||
|
|
||||||
// 主窗口 - 完整布局
|
// 主窗口 - 完整布局
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
@@ -317,6 +343,7 @@ function App() {
|
|||||||
<Route path="/data-management" element={<DataManagementPage />} />
|
<Route path="/data-management" element={<DataManagementPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/export" element={<ExportPage />} />
|
<Route path="/export" element={<ExportPage />} />
|
||||||
|
<Route path="/sns" element={<SnsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</RouteGuard>
|
</RouteGuard>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
73
src/components/AnimatedStreamingText.tsx
Normal file
73
src/components/AnimatedStreamingText.tsx
Normal 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'
|
||||||
238
src/components/JumpToDateDialog.scss
Normal file
238
src/components/JumpToDateDialog.scss
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
.jump-date-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-modal {
|
||||||
|
background: var(--card-bg);
|
||||||
|
width: 340px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-header {
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
.title-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-view {
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.calendar-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.current-month {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
.weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.weekday {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.days {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.day-cell {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.empty):hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.today:not(.selected) {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 20px 16px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/components/JumpToDateDialog.tsx
Normal file
156
src/components/JumpToDateDialog.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'
|
||||||
|
import './JumpToDateDialog.scss'
|
||||||
|
|
||||||
|
interface JumpToDateDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSelect: (date: Date) => void
|
||||||
|
currentDate?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
currentDate = new Date()
|
||||||
|
}) => {
|
||||||
|
const [calendarDate, setCalendarDate] = useState(new Date(currentDate))
|
||||||
|
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const getDaysInMonth = (date: Date) => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = date.getMonth()
|
||||||
|
return new Date(year, month + 1, 0).getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFirstDayOfMonth = (date: Date) => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = date.getMonth()
|
||||||
|
return new Date(year, month, 1).getDay()
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateCalendar = () => {
|
||||||
|
const daysInMonth = getDaysInMonth(calendarDate)
|
||||||
|
const firstDay = getFirstDayOfMonth(calendarDate)
|
||||||
|
const days: (number | null)[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < firstDay; i++) {
|
||||||
|
days.push(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
|
days.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDateClick = (day: number) => {
|
||||||
|
const newDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
|
||||||
|
setSelectedDate(newDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onSelect(selectedDate)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isToday = (day: number) => {
|
||||||
|
const today = new Date()
|
||||||
|
return day === today.getDate() &&
|
||||||
|
calendarDate.getMonth() === today.getMonth() &&
|
||||||
|
calendarDate.getFullYear() === today.getFullYear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelected = (day: number) => {
|
||||||
|
return day === selectedDate.getDate() &&
|
||||||
|
calendarDate.getMonth() === selectedDate.getMonth() &&
|
||||||
|
calendarDate.getFullYear() === selectedDate.getFullYear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
|
const days = generateCalendar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="jump-date-overlay" onClick={onClose}>
|
||||||
|
<div className="jump-date-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="jump-date-header">
|
||||||
|
<div className="title-area">
|
||||||
|
<CalendarIcon size={18} />
|
||||||
|
<h3>跳转到日期</h3>
|
||||||
|
</div>
|
||||||
|
<button className="close-btn" onClick={onClose}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="calendar-view">
|
||||||
|
<div className="calendar-nav">
|
||||||
|
<button
|
||||||
|
className="nav-btn"
|
||||||
|
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<span className="current-month">
|
||||||
|
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="nav-btn"
|
||||||
|
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||||
|
>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="calendar-grid">
|
||||||
|
<div className="weekdays">
|
||||||
|
{weekdays.map(d => <div key={d} className="weekday">{d}</div>)}
|
||||||
|
</div>
|
||||||
|
<div className="days">
|
||||||
|
{days.map((day, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`day-cell ${day === null ? 'empty' : ''} ${day !== null && isSelected(day) ? 'selected' : ''} ${day !== null && isToday(day) ? 'today' : ''}`}
|
||||||
|
onClick={() => day !== null && handleDateClick(day)}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="quick-options">
|
||||||
|
<button onClick={() => {
|
||||||
|
const d = new Date()
|
||||||
|
setSelectedDate(d)
|
||||||
|
setCalendarDate(new Date(d))
|
||||||
|
}}>今天</button>
|
||||||
|
<button onClick={() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() - 7)
|
||||||
|
setSelectedDate(d)
|
||||||
|
setCalendarDate(new Date(d))
|
||||||
|
}}>一周前</button>
|
||||||
|
<button onClick={() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setMonth(d.getMonth() - 1)
|
||||||
|
setSelectedDate(d)
|
||||||
|
setCalendarDate(new Date(d))
|
||||||
|
}}>一月前</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dialog-footer">
|
||||||
|
<button className="cancel-btn" onClick={onClose}>取消</button>
|
||||||
|
<button className="confirm-btn" onClick={handleConfirm}>跳转</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JumpToDateDialog
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
width: 200px;
|
width: 220px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
transition: width 0.25s ease;
|
transition: width 0.25s ease;
|
||||||
|
|
||||||
&.collapsed {
|
&.collapsed {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
|
|
||||||
.nav-menu,
|
.nav-menu,
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-label {
|
.nav-label {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -32,14 +32,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 0 8px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 16px;
|
padding: 10px 12px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -49,13 +49,12 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: white;
|
||||||
@@ -99,9 +98,9 @@
|
|||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } from 'lucide-react'
|
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture } from 'lucide-react'
|
||||||
import './Sidebar.scss'
|
import './Sidebar.scss'
|
||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
@@ -34,6 +34,16 @@ function Sidebar() {
|
|||||||
<span className="nav-label">聊天</span>
|
<span className="nav-label">聊天</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
{/* 朋友圈 */}
|
||||||
|
<NavLink
|
||||||
|
to="/sns"
|
||||||
|
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
|
||||||
|
title={collapsed ? '朋友圈' : undefined}
|
||||||
|
>
|
||||||
|
<span className="nav-icon"><Aperture size={20} /></span>
|
||||||
|
<span className="nav-label">朋友圈</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* 私聊分析 */}
|
{/* 私聊分析 */}
|
||||||
|
|||||||
262
src/components/VoiceTranscribeDialog.scss
Normal file
262
src/components/VoiceTranscribeDialog.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/components/VoiceTranscribeDialog.tsx
Normal file
160
src/components/VoiceTranscribeDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
|
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
|
||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
@@ -16,7 +16,7 @@ function AnalyticsPage() {
|
|||||||
|
|
||||||
const themeMode = useThemeStore((state) => state.themeMode)
|
const themeMode = useThemeStore((state) => state.themeMode)
|
||||||
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore()
|
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore()
|
||||||
const loadData = async (forceRefresh = false) => {
|
const loadData = useCallback(async (forceRefresh = false) => {
|
||||||
if (isLoaded && !forceRefresh) return
|
if (isLoaded && !forceRefresh) return
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -55,14 +55,22 @@ function AnalyticsPage() {
|
|||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
if (removeListener) removeListener()
|
if (removeListener) removeListener()
|
||||||
}
|
}
|
||||||
}
|
}, [isLoaded, markLoaded, setRankings, setStatistics, setTimeDistribution])
|
||||||
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const force = location.state?.forceRefresh === true
|
const force = location.state?.forceRefresh === true
|
||||||
loadData(force)
|
loadData(force)
|
||||||
}, [location.state])
|
}, [location.state, loadData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleChange = () => {
|
||||||
|
loadData(true)
|
||||||
|
}
|
||||||
|
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||||
|
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
const handleRefresh = () => loadData(true)
|
const handleRefresh = () => loadData(true)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.chat-page {
|
.chat-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -370,9 +370,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble {
|
.message-bubble {
|
||||||
max-width: 65%;
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 80%;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.bubble-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0; // 允许收缩
|
||||||
|
width: fit-content; // 让气泡宽度由内容决定
|
||||||
|
}
|
||||||
|
|
||||||
&.sent {
|
&.sent {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
.bubble-content {
|
.bubble-content {
|
||||||
background: var(--primary-gradient);
|
background: var(--primary-gradient);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -382,6 +396,10 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
box-shadow: 0 2px 10px var(--primary-light);
|
box-shadow: 0 2px 10px var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bubble-body {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.received {
|
&.received {
|
||||||
@@ -395,6 +413,10 @@
|
|||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bubble-body {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.system {
|
&.system {
|
||||||
@@ -428,6 +450,11 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
// 防止名字撑开气泡宽度
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quoted-message {
|
.quoted-message {
|
||||||
@@ -462,8 +489,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.load-more-trigger {
|
.load-more-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 0;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
|
|
||||||
|
&.later {
|
||||||
|
padding: 24px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-chat {
|
.empty-chat {
|
||||||
@@ -790,6 +830,99 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 右侧消息区域
|
// 右侧消息区域
|
||||||
|
// ... (previous content) ...
|
||||||
|
|
||||||
|
// 链接卡片消息样式
|
||||||
|
.link-message {
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
max-width: 300px;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.4;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 适配发送出去的消息中的链接卡片
|
||||||
|
.message-bubble.sent .link-message {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
|
.link-title,
|
||||||
|
.link-desc {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-icon {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.message-area {
|
.message-area {
|
||||||
flex: 1 1 70%;
|
flex: 1 1 70%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -943,8 +1076,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
background: rgba(10, 10, 10, 0.28);
|
background: var(--bg-tertiary);
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
transition: opacity 200ms ease;
|
transition: opacity 200ms ease;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
@@ -1108,6 +1240,14 @@
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使发送的语音消息和转文字也使用接收者的样式 (浅色)
|
||||||
|
&.sent.voice {
|
||||||
|
.bubble-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-avatar {
|
.bubble-avatar {
|
||||||
@@ -1303,8 +1443,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble.sent .voice-message {
|
.voice-stack {
|
||||||
background: rgba(255, 255, 255, 0.18);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-play-btn {
|
.voice-play-btn {
|
||||||
@@ -1339,6 +1481,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voice-waveform {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-bar {
|
||||||
|
flex: 1;
|
||||||
|
width: 2px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: transform 0.2s ease, background 0.2s ease;
|
||||||
|
|
||||||
|
&.played {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble.sent.voice .waveform-bar {
|
||||||
|
background: rgba(0, 0, 0, 0.1); // 基色改为透明黑
|
||||||
|
|
||||||
|
&.played {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-wave-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 3px;
|
||||||
|
height: 18px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
width: 3px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.voice-message.playing .voice-wave span {
|
.voice-message.playing .voice-wave span {
|
||||||
animation: voicePulse 0.9s ease-in-out infinite;
|
animation: voicePulse 0.9s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -1391,6 +1577,24 @@
|
|||||||
color: #d9480f;
|
color: #d9480f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voice-transcript {
|
||||||
|
max-width: 260px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-transcript.error {
|
||||||
|
color: #d9480f;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes voicePulse {
|
@keyframes voicePulse {
|
||||||
0% {
|
0% {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -1413,6 +1617,11 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
// 防止名字撑开气泡宽度
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 引用消息样式
|
// 引用消息样式
|
||||||
@@ -1461,7 +1670,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
min-width: 0; // 允许收缩
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
|
||||||
|
// 让气泡宽度由内容决定,而不是被父容器撑开
|
||||||
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-content {
|
.bubble-content {
|
||||||
@@ -1847,4 +2060,114 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 语音转文字按钮样式 */
|
||||||
|
.voice-transcribe-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频消息样式
|
||||||
|
.video-thumb-wrapper {
|
||||||
|
position: relative;
|
||||||
|
max-width: 300px;
|
||||||
|
min-width: 200px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
|
||||||
|
.video-play-button {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-thumb-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-play-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: #fff;
|
||||||
|
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-placeholder,
|
||||||
|
.video-loading,
|
||||||
|
.video-unavailable {
|
||||||
|
min-width: 120px;
|
||||||
|
min-height: 80px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-loading {
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,11 @@ function DataManagementPage() {
|
|||||||
setWxid(id)
|
setWxid(id)
|
||||||
}
|
}
|
||||||
loadConfig()
|
loadConfig()
|
||||||
|
const handleChange = () => {
|
||||||
|
loadConfig()
|
||||||
|
}
|
||||||
|
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||||
|
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -396,6 +396,99 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.select-field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 20;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option.active .option-desc {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.media-options {
|
.media-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -602,6 +695,87 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-layout-modal {
|
||||||
|
background: var(--card-bg);
|
||||||
|
padding: 28px 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||||
|
text-align: center;
|
||||||
|
width: min(520px, 90vw);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-option-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-actions {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-cancel-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.export-result-modal {
|
.export-result-modal {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
padding: 32px 40px;
|
padding: 32px 40px;
|
||||||
@@ -919,4 +1093,141 @@
|
|||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 媒体导出选项卡片样式
|
||||||
|
.setting-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 4px 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-options-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-switch-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-switch-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-switch-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-option-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-checkbox-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-checkbox-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-checkbox-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch 开关样式
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 24px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked+.slider {
|
||||||
|
background-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked+.slider::before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
|
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import './ExportPage.scss'
|
import './ExportPage.scss'
|
||||||
@@ -16,6 +16,14 @@ interface ExportOptions {
|
|||||||
dateRange: { start: Date; end: Date } | null
|
dateRange: { start: Date; end: Date } | null
|
||||||
useAllTime: boolean
|
useAllTime: boolean
|
||||||
exportAvatars: boolean
|
exportAvatars: boolean
|
||||||
|
exportMedia: boolean
|
||||||
|
exportImages: boolean
|
||||||
|
exportVoices: boolean
|
||||||
|
exportEmojis: boolean
|
||||||
|
exportVoiceAsText: boolean
|
||||||
|
excelCompactColumns: boolean
|
||||||
|
txtColumns: string[]
|
||||||
|
displayNamePreference: 'group-nickname' | 'remark' | 'nickname'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExportResult {
|
interface ExportResult {
|
||||||
@@ -25,7 +33,10 @@ interface ExportResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionLayout = 'shared' | 'per-session'
|
||||||
|
|
||||||
function ExportPage() {
|
function ExportPage() {
|
||||||
|
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>([])
|
const [sessions, setSessions] = useState<ChatSession[]>([])
|
||||||
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
|
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
|
||||||
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
||||||
@@ -38,17 +49,47 @@ function ExportPage() {
|
|||||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||||
const [calendarDate, setCalendarDate] = useState(new Date())
|
const [calendarDate, setCalendarDate] = useState(new Date())
|
||||||
const [selectingStart, setSelectingStart] = useState(true)
|
const [selectingStart, setSelectingStart] = useState(true)
|
||||||
|
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
|
||||||
|
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
|
||||||
|
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const [options, setOptions] = useState<ExportOptions>({
|
const [options, setOptions] = useState<ExportOptions>({
|
||||||
format: 'chatlab',
|
format: 'excel',
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
start: new Date(new Date().setHours(0, 0, 0, 0)),
|
||||||
end: new Date()
|
end: new Date()
|
||||||
},
|
},
|
||||||
useAllTime: true,
|
useAllTime: false,
|
||||||
exportAvatars: true
|
exportAvatars: true,
|
||||||
|
exportMedia: false,
|
||||||
|
exportImages: true,
|
||||||
|
exportVoices: true,
|
||||||
|
exportEmojis: true,
|
||||||
|
exportVoiceAsText: true,
|
||||||
|
excelCompactColumns: true,
|
||||||
|
txtColumns: defaultTxtColumns,
|
||||||
|
displayNamePreference: 'remark'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const buildDateRangeFromPreset = (preset: string) => {
|
||||||
|
const now = new Date()
|
||||||
|
if (preset === 'all') {
|
||||||
|
return { useAllTime: true, dateRange: { start: now, end: now } }
|
||||||
|
}
|
||||||
|
let rangeMs = 0
|
||||||
|
if (preset === '7d') rangeMs = 7 * 24 * 60 * 60 * 1000
|
||||||
|
if (preset === '30d') rangeMs = 30 * 24 * 60 * 60 * 1000
|
||||||
|
if (preset === '90d') rangeMs = 90 * 24 * 60 * 60 * 1000
|
||||||
|
if (preset === 'today' || rangeMs === 0) {
|
||||||
|
const start = new Date(now)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
return { useAllTime: false, dateRange: { start, end: now } }
|
||||||
|
}
|
||||||
|
const start = new Date(now.getTime() - rangeMs)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
return { useAllTime: false, dateRange: { start, end: now } }
|
||||||
|
}
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -84,10 +125,84 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const loadExportDefaults = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
savedFormat,
|
||||||
|
savedRange,
|
||||||
|
savedMedia,
|
||||||
|
savedVoiceAsText,
|
||||||
|
savedExcelCompactColumns,
|
||||||
|
savedTxtColumns
|
||||||
|
] = await Promise.all([
|
||||||
|
configService.getExportDefaultFormat(),
|
||||||
|
configService.getExportDefaultDateRange(),
|
||||||
|
configService.getExportDefaultMedia(),
|
||||||
|
configService.getExportDefaultVoiceAsText(),
|
||||||
|
configService.getExportDefaultExcelCompactColumns(),
|
||||||
|
configService.getExportDefaultTxtColumns()
|
||||||
|
])
|
||||||
|
|
||||||
|
const preset = savedRange || 'today'
|
||||||
|
const rangeDefaults = buildDateRangeFromPreset(preset)
|
||||||
|
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
|
||||||
|
|
||||||
|
setOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
format: (savedFormat as ExportOptions['format']) || 'excel',
|
||||||
|
useAllTime: rangeDefaults.useAllTime,
|
||||||
|
dateRange: rangeDefaults.dateRange,
|
||||||
|
exportMedia: savedMedia ?? false,
|
||||||
|
exportVoiceAsText: savedVoiceAsText ?? true,
|
||||||
|
excelCompactColumns: savedExcelCompactColumns ?? true,
|
||||||
|
txtColumns
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载导出默认设置失败:', e)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSessions()
|
loadSessions()
|
||||||
loadExportPath()
|
loadExportPath()
|
||||||
}, [loadSessions, loadExportPath])
|
loadExportDefaults()
|
||||||
|
}, [loadSessions, loadExportPath, loadExportDefaults])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleChange = () => {
|
||||||
|
setSelectedSessions(new Set())
|
||||||
|
setSearchKeyword('')
|
||||||
|
setExportResult(null)
|
||||||
|
setSessions([])
|
||||||
|
setFilteredSessions([])
|
||||||
|
loadSessions()
|
||||||
|
}
|
||||||
|
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||||
|
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||||
|
}, [loadSessions])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const removeListener = window.electronAPI.export.onProgress?.((payload) => {
|
||||||
|
setExportProgress({
|
||||||
|
current: payload.current,
|
||||||
|
total: payload.total,
|
||||||
|
currentName: payload.currentSession
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
removeListener?.()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) {
|
||||||
|
setShowDisplayNameSelect(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [showDisplayNameSelect])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchKeyword.trim()) {
|
if (!searchKeyword.trim()) {
|
||||||
@@ -128,13 +243,30 @@ function ExportPage() {
|
|||||||
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFormatChange = (format: ExportOptions['format']) => {
|
||||||
|
setOptions((prev) => {
|
||||||
|
const next = { ...prev, format }
|
||||||
|
if (format === 'html') {
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
exportMedia: true,
|
||||||
|
exportImages: true,
|
||||||
|
exportVoices: true,
|
||||||
|
exportEmojis: true,
|
||||||
|
exportVoiceAsText: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const openExportFolder = async () => {
|
const openExportFolder = async () => {
|
||||||
if (exportFolder) {
|
if (exportFolder) {
|
||||||
await window.electronAPI.shell.openPath(exportFolder)
|
await window.electronAPI.shell.openPath(exportFolder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startExport = async () => {
|
const runExport = async (sessionLayout: SessionLayout) => {
|
||||||
if (selectedSessions.size === 0 || !exportFolder) return
|
if (selectedSessions.size === 0 || !exportFolder) return
|
||||||
|
|
||||||
setIsExporting(true)
|
setIsExporting(true)
|
||||||
@@ -146,14 +278,23 @@ function ExportPage() {
|
|||||||
const exportOptions = {
|
const exportOptions = {
|
||||||
format: options.format,
|
format: options.format,
|
||||||
exportAvatars: options.exportAvatars,
|
exportAvatars: options.exportAvatars,
|
||||||
|
exportMedia: options.exportMedia,
|
||||||
|
exportImages: options.exportMedia && options.exportImages,
|
||||||
|
exportVoices: options.exportMedia && options.exportVoices,
|
||||||
|
exportEmojis: options.exportMedia && options.exportEmojis,
|
||||||
|
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
|
||||||
|
excelCompactColumns: options.excelCompactColumns,
|
||||||
|
txtColumns: options.txtColumns,
|
||||||
|
displayNamePreference: options.displayNamePreference,
|
||||||
|
sessionLayout,
|
||||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||||
// 将结束日期设置为当天的 23:59:59,以包含当天的所有消息
|
// 将结束日期设置为当天的 23:59:59,确保包含当天的所有记录
|
||||||
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
|
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
|
||||||
} : null
|
} : null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json') {
|
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html') {
|
||||||
const result = await window.electronAPI.export.exportSessions(
|
const result = await window.electronAPI.export.exportSessions(
|
||||||
sessionList,
|
sessionList,
|
||||||
exportFolder,
|
exportFolder,
|
||||||
@@ -161,16 +302,28 @@ function ExportPage() {
|
|||||||
)
|
)
|
||||||
setExportResult(result)
|
setExportResult(result)
|
||||||
} else {
|
} else {
|
||||||
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` })
|
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` })
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('导出失败:', e)
|
console.error('导出过程中发生异常:', e)
|
||||||
setExportResult({ success: false, error: String(e) })
|
setExportResult({ success: false, error: String(e) })
|
||||||
} finally {
|
} finally {
|
||||||
setIsExporting(false)
|
setIsExporting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startExport = () => {
|
||||||
|
if (selectedSessions.size === 0 || !exportFolder) return
|
||||||
|
|
||||||
|
if (options.exportMedia && selectedSessions.size > 1) {
|
||||||
|
setShowMediaLayoutPrompt(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared'
|
||||||
|
runExport(layout)
|
||||||
|
}
|
||||||
|
|
||||||
const getDaysInMonth = (date: Date) => {
|
const getDaysInMonth = (date: Date) => {
|
||||||
const year = date.getFullYear()
|
const year = date.getFullYear()
|
||||||
const month = date.getMonth()
|
const month = date.getMonth()
|
||||||
@@ -203,18 +356,54 @@ function ExportPage() {
|
|||||||
const year = calendarDate.getFullYear()
|
const year = calendarDate.getFullYear()
|
||||||
const month = calendarDate.getMonth()
|
const month = calendarDate.getMonth()
|
||||||
const selectedDate = new Date(year, month, day)
|
const selectedDate = new Date(year, month, day)
|
||||||
|
// 设置时间为当天的开始或结束
|
||||||
|
selectedDate.setHours(selectingStart ? 0 : 23, selectingStart ? 0 : 59, selectingStart ? 0 : 59, selectingStart ? 0 : 999)
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
// 如果选择的日期晚于当前时间,限制为当前时间
|
||||||
|
if (selectedDate > now) {
|
||||||
|
selectedDate.setTime(now.getTime())
|
||||||
|
}
|
||||||
|
|
||||||
if (selectingStart) {
|
if (selectingStart) {
|
||||||
setOptions({
|
// 选择开始日期
|
||||||
...options,
|
const currentEnd = options.dateRange?.end || new Date()
|
||||||
dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, 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)
|
setSelectingStart(false)
|
||||||
} else {
|
} else {
|
||||||
setOptions({
|
// 选择结束日期
|
||||||
...options,
|
const currentStart = options.dateRange?.start || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||||
dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate }
|
// 如果选择的结束日期早于开始日期,则同时更新开始日期
|
||||||
})
|
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)
|
setSelectingStart(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,6 +417,25 @@ function ExportPage() {
|
|||||||
{ value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' },
|
{ value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' },
|
||||||
{ value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' }
|
{ value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' }
|
||||||
]
|
]
|
||||||
|
const displayNameOptions = [
|
||||||
|
{
|
||||||
|
value: 'group-nickname',
|
||||||
|
label: '群昵称优先',
|
||||||
|
desc: '仅群聊有效,私聊显示备注/昵称'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'remark',
|
||||||
|
label: '备注优先',
|
||||||
|
desc: '有备注显示备注,否则显示昵称'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'nickname',
|
||||||
|
label: '微信昵称',
|
||||||
|
desc: '始终显示微信昵称'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const displayNameOption = displayNameOptions.find(option => option.value === options.displayNamePreference)
|
||||||
|
const displayNameLabel = displayNameOption?.label || '备注优先'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="export-page">
|
<div className="export-page">
|
||||||
@@ -311,7 +519,7 @@ function ExportPage() {
|
|||||||
<div
|
<div
|
||||||
key={fmt.value}
|
key={fmt.value}
|
||||||
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
|
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
|
||||||
onClick={() => setOptions({ ...options, format: fmt.value as any })}
|
onClick={() => handleFormatChange(fmt.value as ExportOptions['format'])}
|
||||||
>
|
>
|
||||||
<fmt.icon size={24} />
|
<fmt.icon size={24} />
|
||||||
<span className="format-label">{fmt.label}</span>
|
<span className="format-label">{fmt.label}</span>
|
||||||
@@ -342,17 +550,142 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 发送者名称显示偏好 */}
|
||||||
|
{(options.format === 'html' || options.format === 'json' || options.format === 'txt') && (
|
||||||
|
<div className="setting-section">
|
||||||
|
<h3>发送者名称显示</h3>
|
||||||
|
<p className="setting-subtitle">选择导出时优先显示的名称</p>
|
||||||
|
<div className="select-field" ref={displayNameDropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showDisplayNameSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => setShowDisplayNameSelect(!showDisplayNameSelect)}
|
||||||
|
>
|
||||||
|
<span className="select-value">{displayNameLabel}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showDisplayNameSelect && (
|
||||||
|
<div className="select-dropdown">
|
||||||
|
{displayNameOptions.map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`select-option ${options.displayNamePreference === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setOptions({
|
||||||
|
...options,
|
||||||
|
displayNamePreference: option.value as ExportOptions['displayNamePreference']
|
||||||
|
})
|
||||||
|
setShowDisplayNameSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{option.label}</span>
|
||||||
|
<span className="option-desc">{option.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="setting-section">
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={options.exportAvatars}
|
checked={options.exportImages}
|
||||||
onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })}
|
disabled={!options.exportMedia}
|
||||||
|
onChange={e => setOptions({ ...options, exportImages: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
<span>导出头像图片</span>
|
|
||||||
</label>
|
</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
|
||||||
|
type="checkbox"
|
||||||
|
checked={options.exportAvatars}
|
||||||
|
onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -406,6 +739,43 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 媒体导出布局选择弹窗 */}
|
||||||
|
{showMediaLayoutPrompt && (
|
||||||
|
<div className="export-overlay" onClick={() => setShowMediaLayoutPrompt(false)}>
|
||||||
|
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3>导出文件夹布局</h3>
|
||||||
|
<p className="layout-subtitle">检测到同时导出多个会话并包含媒体文件,请选择存放方式:</p>
|
||||||
|
<div className="layout-options">
|
||||||
|
<button
|
||||||
|
className="layout-option-btn primary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowMediaLayoutPrompt(false)
|
||||||
|
runExport('shared')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="layout-title">所有会话在同一文件夹</span>
|
||||||
|
<span className="layout-desc">媒体会按会话名归档到 media 子目录</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="layout-option-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setShowMediaLayoutPrompt(false)
|
||||||
|
runExport('per-session')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="layout-title">每个会话一个文件夹</span>
|
||||||
|
<span className="layout-desc">每个会话单独包含导出文件和媒体</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="layout-actions">
|
||||||
|
<button className="layout-cancel-btn" onClick={() => setShowMediaLayoutPrompt(false)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 导出进度弹窗 */}
|
{/* 导出进度弹窗 */}
|
||||||
{isExporting && (
|
{isExporting && (
|
||||||
<div className="export-overlay">
|
<div className="export-overlay">
|
||||||
@@ -462,6 +832,9 @@ function ExportPage() {
|
|||||||
<div className="export-overlay" onClick={() => setShowDatePicker(false)}>
|
<div className="export-overlay" onClick={() => setShowDatePicker(false)}>
|
||||||
<div className="date-picker-modal" onClick={e => e.stopPropagation()}>
|
<div className="date-picker-modal" onClick={e => e.stopPropagation()}>
|
||||||
<h3>选择时间范围</h3>
|
<h3>选择时间范围</h3>
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: '8px 0 16px 0' }}>
|
||||||
|
点击选择开始和结束日期,系统会自动调整确保时间顺序正确
|
||||||
|
</p>
|
||||||
<div className="quick-select">
|
<div className="quick-select">
|
||||||
<button
|
<button
|
||||||
className="quick-btn"
|
className="quick-btn"
|
||||||
@@ -556,12 +929,16 @@ function ExportPage() {
|
|||||||
const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString()
|
const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString()
|
||||||
const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString()
|
const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString()
|
||||||
const isInRange = options.dateRange && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end
|
const isInRange = options.dateRange && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const isFuture = currentDate > today
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day}
|
key={day}
|
||||||
className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''}`}
|
className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''} ${isFuture ? 'disabled' : ''}`}
|
||||||
onClick={() => handleDateSelect(day)}
|
onClick={() => !isFuture && handleDateSelect(day)}
|
||||||
|
style={{ cursor: isFuture ? 'not-allowed' : 'pointer', opacity: isFuture ? 0.3 : 1 }}
|
||||||
>
|
>
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react'
|
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
@@ -93,7 +93,7 @@ function GroupAnalyticsPage() {
|
|||||||
}
|
}
|
||||||
}, [dateRangeReady])
|
}, [dateRangeReady])
|
||||||
|
|
||||||
const loadGroups = async () => {
|
const loadGroups = useCallback(async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupChats()
|
const result = await window.electronAPI.groupAnalytics.getGroupChats()
|
||||||
@@ -106,7 +106,23 @@ function GroupAnalyticsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleChange = () => {
|
||||||
|
setGroups([])
|
||||||
|
setFilteredGroups([])
|
||||||
|
setSelectedGroup(null)
|
||||||
|
setSelectedFunction(null)
|
||||||
|
setMembers([])
|
||||||
|
setRankings([])
|
||||||
|
setActiveHours({})
|
||||||
|
setMediaStats(null)
|
||||||
|
void loadGroups()
|
||||||
|
}
|
||||||
|
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||||
|
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||||
|
}, [loadGroups])
|
||||||
|
|
||||||
const handleGroupSelect = (group: GroupChatInfo) => {
|
const handleGroupSelect = (group: GroupChatInfo) => {
|
||||||
if (selectedGroup?.username !== group.username) {
|
if (selectedGroup?.username !== group.username) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -51,12 +51,12 @@
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
@@ -68,15 +68,15 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
&::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-thumb {
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|
||||||
.section-desc {
|
.section-desc {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -124,24 +124,24 @@
|
|||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
|
|
||||||
.optional {
|
.optional {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-hint {
|
.form-hint {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.key-status {
|
.key-status {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
@@ -189,32 +189,143 @@
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:read-only {
|
&:read-only {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-field {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 20;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option.active .option-desc {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.input-with-toggle {
|
.input-with-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
padding-right: 70px;
|
padding-right: 70px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-visibility {
|
.toggle-visibility {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
@@ -226,7 +337,7 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -235,6 +346,184 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.whisper-section {
|
||||||
|
background: color-mix(in srgb, var(--primary) 3%, transparent);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-status-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 12px 0 16px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.ok {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #10b981;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.warn {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: #f59e0b;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--primary) 0%, var(--primary-hover) 100%);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
rgba(255, 255, 255, 0) 0%,
|
||||||
|
rgba(255, 255, 255, 0.2) 50%,
|
||||||
|
rgba(255, 255, 255, 0) 100%);
|
||||||
|
animation: progress-shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&.percent {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download-model {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%);
|
||||||
|
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px color-mix(in srgb, var(--primary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover svg {
|
||||||
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress-shimmer {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.log-toggle-line {
|
.log-toggle-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -248,6 +537,72 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-checkboxes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-custom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1.5px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.5);
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .checkbox-custom {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked+.checkbox-custom {
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 10%, transparent);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active .checkbox-custom {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.switch {
|
.switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 46px;
|
width: 46px;
|
||||||
@@ -287,12 +642,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;
|
||||||
}
|
}
|
||||||
@@ -321,7 +676,7 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -331,25 +686,40 @@
|
|||||||
.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
@@ -378,12 +748,12 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
animation: slideDown 0.3s ease;
|
animation: slideDown 0.3s ease;
|
||||||
|
|
||||||
&.success {
|
&.success {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.error {
|
&.error {
|
||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
color: white;
|
color: white;
|
||||||
@@ -395,6 +765,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-50%) translateY(-10px);
|
transform: translateX(-50%) translateY(-10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(-50%) translateY(0);
|
transform: translateX(-50%) translateY(0);
|
||||||
@@ -402,9 +773,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% {
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
@@ -419,7 +793,7 @@
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
|
||||||
.mode-btn {
|
.mode-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -433,11 +807,11 @@
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
@@ -460,26 +834,26 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--text-tertiary);
|
border-color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
|
|
||||||
.theme-preview {
|
.theme-preview {
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-preview {
|
.theme-preview {
|
||||||
height: 60px;
|
height: 60px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.theme-accent {
|
.theme-accent {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
@@ -490,24 +864,24 @@
|
|||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-info {
|
.theme-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|
||||||
.theme-name {
|
.theme-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-desc {
|
.theme-desc {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-check {
|
.theme-check {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
@@ -667,8 +1041,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
from { transform: rotate(0deg); }
|
from {
|
||||||
to { transform: rotate(360deg); }
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -774,10 +1153,9 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding-right: 36px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,11 +1173,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.open {
|
&.open {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
@@ -811,13 +1189,15 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
background: var(--bg-secondary);
|
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
|
||||||
border: 1px solid var(--border-primary);
|
border: 1px solid var(--border-primary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxid-option {
|
.wxid-option {
|
||||||
@@ -827,21 +1207,21 @@
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--primary-light);
|
background: var(--primary-light);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxid-value {
|
.wxid-value {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxid-time {
|
.wxid-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
@@ -872,14 +1252,14 @@
|
|||||||
.wxid-dialog-header {
|
.wxid-dialog-header {
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
border-bottom: 1px solid var(--border-primary);
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -901,25 +1281,25 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--primary-light);
|
background: var(--primary-light);
|
||||||
|
|
||||||
.wxid-id {
|
.wxid-id {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxid-id {
|
.wxid-id {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxid-date {
|
.wxid-date {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useAppStore } from '../stores/appStore'
|
import { useAppStore } from '../stores/appStore'
|
||||||
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import { useThemeStore, themes } from '../stores/themeStore'
|
import { useThemeStore, themes } from '../stores/themeStore'
|
||||||
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import { dialog } from '../services/ipc'
|
import { dialog } from '../services/ipc'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import {
|
import {
|
||||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||||
RotateCcw, Trash2, Save, Plug, Check, Sun, Moon,
|
RotateCcw, Trash2, Save, Plug, Check, Sun, Moon,
|
||||||
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown
|
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import './SettingsPage.scss'
|
import './SettingsPage.scss'
|
||||||
|
|
||||||
type SettingsTab = 'appearance' | 'database' | 'cache' | 'about'
|
type SettingsTab = 'appearance' | 'database' | 'whisper' | 'export' | 'cache' | 'about'
|
||||||
|
|
||||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||||
{ id: 'appearance', label: '外观', icon: Palette },
|
{ id: 'appearance', label: '外观', icon: Palette },
|
||||||
{ id: 'database', label: '数据库连接', icon: Database },
|
{ id: 'database', label: '数据库连接', icon: Database },
|
||||||
|
{ id: 'whisper', label: '语音识别模型', icon: Mic },
|
||||||
|
{ id: 'export', label: '导出', icon: Download },
|
||||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||||
{ id: 'about', label: '关于', icon: Info }
|
{ id: 'about', label: '关于', icon: Info }
|
||||||
]
|
]
|
||||||
@@ -25,8 +29,10 @@ interface WxidOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
const { setDbConnected, setLoading, reset } = useAppStore()
|
const { isDbConnected, setDbConnected, setLoading, reset } = useAppStore()
|
||||||
|
const resetChatStore = useChatStore((state) => state.reset)
|
||||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||||
|
const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>('appearance')
|
const [activeTab, setActiveTab] = useState<SettingsTab>('appearance')
|
||||||
const [decryptKey, setDecryptKey] = useState('')
|
const [decryptKey, setDecryptKey] = useState('')
|
||||||
@@ -36,9 +42,26 @@ function SettingsPage() {
|
|||||||
const [wxid, setWxid] = useState('')
|
const [wxid, setWxid] = useState('')
|
||||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||||
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
||||||
const wxidDropdownRef = useRef<HTMLDivElement>(null)
|
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
|
||||||
|
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
|
||||||
|
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||||
|
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const [cachePath, setCachePath] = useState('')
|
const [cachePath, setCachePath] = useState('')
|
||||||
const [logEnabled, setLogEnabled] = useState(false)
|
const [logEnabled, setLogEnabled] = useState(false)
|
||||||
|
const [whisperModelName, setWhisperModelName] = useState('base')
|
||||||
|
const [whisperModelDir, setWhisperModelDir] = useState('')
|
||||||
|
const [isWhisperDownloading, setIsWhisperDownloading] = useState(false)
|
||||||
|
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
|
||||||
|
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
||||||
|
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||||
|
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||||
|
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||||
|
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
|
||||||
|
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
||||||
|
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true)
|
||||||
|
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||||
|
|
||||||
const [isLoading, setIsLoadingState] = useState(false)
|
const [isLoading, setIsLoadingState] = useState(false)
|
||||||
const [isTesting, setIsTesting] = useState(false)
|
const [isTesting, setIsTesting] = useState(false)
|
||||||
@@ -55,6 +78,11 @@ function SettingsPage() {
|
|||||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||||
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
||||||
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
||||||
|
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
|
||||||
|
const [isClearingImageCache, setIsClearingImageCache] = useState(false)
|
||||||
|
const [isClearingAllCache, setIsClearingAllCache] = useState(false)
|
||||||
|
|
||||||
|
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
@@ -64,13 +92,20 @@ function SettingsPage() {
|
|||||||
// 点击外部关闭下拉框
|
// 点击外部关闭下拉框
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(e.target as Node)) {
|
const target = e.target as Node
|
||||||
setShowWxidSelect(false)
|
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
|
||||||
|
setShowExportFormatSelect(false)
|
||||||
|
}
|
||||||
|
if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) {
|
||||||
|
setShowExportDateRangeSelect(false)
|
||||||
|
}
|
||||||
|
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [showWxidSelect])
|
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
|
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
|
||||||
@@ -95,16 +130,52 @@ function SettingsPage() {
|
|||||||
const savedLogEnabled = await configService.getLogEnabled()
|
const savedLogEnabled = await configService.getLogEnabled()
|
||||||
const savedImageXorKey = await configService.getImageXorKey()
|
const savedImageXorKey = await configService.getImageXorKey()
|
||||||
const savedImageAesKey = await configService.getImageAesKey()
|
const savedImageAesKey = await configService.getImageAesKey()
|
||||||
|
const savedWhisperModelName = await configService.getWhisperModelName()
|
||||||
|
const savedWhisperModelDir = await configService.getWhisperModelDir()
|
||||||
|
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
|
||||||
|
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
|
||||||
|
const savedExportDefaultFormat = await configService.getExportDefaultFormat()
|
||||||
|
const savedExportDefaultDateRange = await configService.getExportDefaultDateRange()
|
||||||
|
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
|
||||||
|
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
|
||||||
|
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
||||||
|
|
||||||
if (savedKey) setDecryptKey(savedKey)
|
|
||||||
if (savedPath) setDbPath(savedPath)
|
if (savedPath) setDbPath(savedPath)
|
||||||
if (savedWxid) setWxid(savedWxid)
|
if (savedWxid) setWxid(savedWxid)
|
||||||
if (savedCachePath) setCachePath(savedCachePath)
|
if (savedCachePath) setCachePath(savedCachePath)
|
||||||
if (savedImageXorKey != null) {
|
|
||||||
setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
const wxidConfig = savedWxid ? await configService.getWxidConfig(savedWxid) : null
|
||||||
|
const decryptKeyToUse = wxidConfig?.decryptKey ?? savedKey ?? ''
|
||||||
|
const imageXorKeyToUse = typeof wxidConfig?.imageXorKey === 'number'
|
||||||
|
? wxidConfig.imageXorKey
|
||||||
|
: savedImageXorKey
|
||||||
|
const imageAesKeyToUse = wxidConfig?.imageAesKey ?? savedImageAesKey ?? ''
|
||||||
|
|
||||||
|
setDecryptKey(decryptKeyToUse)
|
||||||
|
if (typeof imageXorKeyToUse === 'number') {
|
||||||
|
setImageXorKey(`0x${imageXorKeyToUse.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||||
|
} else {
|
||||||
|
setImageXorKey('')
|
||||||
}
|
}
|
||||||
if (savedImageAesKey) setImageAesKey(savedImageAesKey)
|
setImageAesKey(imageAesKeyToUse)
|
||||||
setLogEnabled(savedLogEnabled)
|
setLogEnabled(savedLogEnabled)
|
||||||
|
setAutoTranscribeVoice(savedAutoTranscribe)
|
||||||
|
setTranscribeLanguages(savedTranscribeLanguages)
|
||||||
|
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
|
||||||
|
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
|
||||||
|
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
||||||
|
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true)
|
||||||
|
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
||||||
|
|
||||||
|
// 如果语言列表为空,保存默认值
|
||||||
|
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||||
|
const defaultLanguages = ['zh']
|
||||||
|
setTranscribeLanguages(defaultLanguages)
|
||||||
|
await configService.setTranscribeLanguages(defaultLanguages)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载配置失败:', e)
|
console.error('加载配置失败:', e)
|
||||||
}
|
}
|
||||||
@@ -112,6 +183,21 @@ function SettingsPage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.whisper?.getModelStatus()
|
||||||
|
if (result?.success) {
|
||||||
|
setWhisperModelStatus({
|
||||||
|
exists: Boolean(result.exists),
|
||||||
|
modelPath: result.modelPath,
|
||||||
|
tokensPath: result.tokensPath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setWhisperModelStatus(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadAppVersion = async () => {
|
const loadAppVersion = async () => {
|
||||||
try {
|
try {
|
||||||
const version = await window.electronAPI.app.getVersion()
|
const version = await window.electronAPI.app.getVersion()
|
||||||
@@ -129,6 +215,19 @@ function SettingsPage() {
|
|||||||
return () => removeListener?.()
|
return () => removeListener?.()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload) => {
|
||||||
|
if (typeof payload.percent === 'number') {
|
||||||
|
setWhisperDownloadProgress(payload.percent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => removeListener?.()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshWhisperStatus(whisperModelDir)
|
||||||
|
}, [whisperModelDir])
|
||||||
|
|
||||||
const handleCheckUpdate = async () => {
|
const handleCheckUpdate = async () => {
|
||||||
setIsCheckingUpdate(true)
|
setIsCheckingUpdate(true)
|
||||||
setUpdateInfo(null)
|
setUpdateInfo(null)
|
||||||
@@ -136,9 +235,9 @@ function SettingsPage() {
|
|||||||
const result = await window.electronAPI.app.checkForUpdates()
|
const result = await window.electronAPI.app.checkForUpdates()
|
||||||
if (result.hasUpdate) {
|
if (result.hasUpdate) {
|
||||||
setUpdateInfo(result)
|
setUpdateInfo(result)
|
||||||
showMessage(`发现新版本 ${result.version}`, true)
|
showMessage(`发现新版:${result.version}`, true)
|
||||||
} else {
|
} else {
|
||||||
showMessage('当前已是最新版本', true)
|
showMessage('当前已是最新版', true)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showMessage(`检查更新失败: ${e}`, false)
|
showMessage(`检查更新失败: ${e}`, false)
|
||||||
@@ -164,6 +263,103 @@ function SettingsPage() {
|
|||||||
setTimeout(() => setMessage(null), 3000)
|
setTimeout(() => setMessage(null), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WxidKeys = {
|
||||||
|
decryptKey: string
|
||||||
|
imageXorKey: number | null
|
||||||
|
imageAesKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatImageXorKey = (value: number) => `0x${value.toString(16).toUpperCase().padStart(2, '0')}`
|
||||||
|
|
||||||
|
const parseImageXorKey = (value: string) => {
|
||||||
|
if (!value) return null
|
||||||
|
const parsed = parseInt(value.replace(/^0x/i, ''), 16)
|
||||||
|
return Number.isNaN(parsed) ? null : parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildKeysFromState = (): WxidKeys => ({
|
||||||
|
decryptKey: decryptKey || '',
|
||||||
|
imageXorKey: parseImageXorKey(imageXorKey),
|
||||||
|
imageAesKey: imageAesKey || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildKeysFromConfig = (wxidConfig: configService.WxidConfig | null): WxidKeys => ({
|
||||||
|
decryptKey: wxidConfig?.decryptKey || '',
|
||||||
|
imageXorKey: typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : null,
|
||||||
|
imageAesKey: wxidConfig?.imageAesKey || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyKeysToState = (keys: WxidKeys) => {
|
||||||
|
setDecryptKey(keys.decryptKey)
|
||||||
|
if (typeof keys.imageXorKey === 'number') {
|
||||||
|
setImageXorKey(formatImageXorKey(keys.imageXorKey))
|
||||||
|
} else {
|
||||||
|
setImageXorKey('')
|
||||||
|
}
|
||||||
|
setImageAesKey(keys.imageAesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncKeysToConfig = async (keys: WxidKeys) => {
|
||||||
|
await configService.setDecryptKey(keys.decryptKey)
|
||||||
|
await configService.setImageXorKey(typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0)
|
||||||
|
await configService.setImageAesKey(keys.imageAesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyWxidSelection = async (
|
||||||
|
selectedWxid: string,
|
||||||
|
options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string }
|
||||||
|
) => {
|
||||||
|
if (!selectedWxid) return
|
||||||
|
|
||||||
|
const currentWxid = wxid
|
||||||
|
const isSameWxid = currentWxid === selectedWxid
|
||||||
|
if (currentWxid && currentWxid !== selectedWxid) {
|
||||||
|
const currentKeys = buildKeysFromState()
|
||||||
|
await configService.setWxidConfig(currentWxid, {
|
||||||
|
decryptKey: currentKeys.decryptKey,
|
||||||
|
imageXorKey: typeof currentKeys.imageXorKey === 'number' ? currentKeys.imageXorKey : 0,
|
||||||
|
imageAesKey: currentKeys.imageAesKey
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferCurrentKeys = options?.preferCurrentKeys ?? false
|
||||||
|
const keys = preferCurrentKeys
|
||||||
|
? buildKeysFromState()
|
||||||
|
: buildKeysFromConfig(await configService.getWxidConfig(selectedWxid))
|
||||||
|
|
||||||
|
setWxid(selectedWxid)
|
||||||
|
applyKeysToState(keys)
|
||||||
|
await configService.setMyWxid(selectedWxid)
|
||||||
|
await syncKeysToConfig(keys)
|
||||||
|
await configService.setWxidConfig(selectedWxid, {
|
||||||
|
decryptKey: keys.decryptKey,
|
||||||
|
imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0,
|
||||||
|
imageAesKey: keys.imageAesKey
|
||||||
|
})
|
||||||
|
setShowWxidSelect(false)
|
||||||
|
if (isDbConnected) {
|
||||||
|
try {
|
||||||
|
await window.electronAPI.chat.close()
|
||||||
|
const result = await window.electronAPI.chat.connect()
|
||||||
|
setDbConnected(result.success, dbPath || undefined)
|
||||||
|
if (!result.success && result.error) {
|
||||||
|
showMessage(result.error, false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage(`切换账号后重新连接失败: ${e}`, false)
|
||||||
|
setDbConnected(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isSameWxid) {
|
||||||
|
clearAnalyticsStoreCache()
|
||||||
|
resetChatStore()
|
||||||
|
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } }))
|
||||||
|
}
|
||||||
|
if (options?.showToast ?? true) {
|
||||||
|
showMessage(options?.toastText || `已选择账号:${selectedWxid}`, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleAutoDetectPath = async () => {
|
const handleAutoDetectPath = async () => {
|
||||||
if (isDetectingPath) return
|
if (isDetectingPath) return
|
||||||
setIsDetectingPath(true)
|
setIsDetectingPath(true)
|
||||||
@@ -177,11 +373,10 @@ function SettingsPage() {
|
|||||||
const wxids = await window.electronAPI.dbPath.scanWxids(result.path)
|
const wxids = await window.electronAPI.dbPath.scanWxids(result.path)
|
||||||
setWxidOptions(wxids)
|
setWxidOptions(wxids)
|
||||||
if (wxids.length === 1) {
|
if (wxids.length === 1) {
|
||||||
setWxid(wxids[0].wxid)
|
await applyWxidSelection(wxids[0].wxid, {
|
||||||
await configService.setMyWxid(wxids[0].wxid)
|
toastText: `已检测到账号:${wxids[0].wxid}`
|
||||||
showMessage(`已检测到账号:${wxids[0].wxid}`, true)
|
})
|
||||||
} else if (wxids.length > 1) {
|
} else if (wxids.length > 1) {
|
||||||
// 多账号时弹出选择对话框
|
|
||||||
setShowWxidSelect(true)
|
setShowWxidSelect(true)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -206,7 +401,10 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScanWxid = async (silent = false) => {
|
const handleScanWxid = async (
|
||||||
|
silent = false,
|
||||||
|
options?: { preferCurrentKeys?: boolean; showDialog?: boolean }
|
||||||
|
) => {
|
||||||
if (!dbPath) {
|
if (!dbPath) {
|
||||||
if (!silent) showMessage('请先选择数据库目录', false)
|
if (!silent) showMessage('请先选择数据库目录', false)
|
||||||
return
|
return
|
||||||
@@ -214,12 +412,14 @@ function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||||
setWxidOptions(wxids)
|
setWxidOptions(wxids)
|
||||||
|
const allowDialog = options?.showDialog ?? !silent
|
||||||
if (wxids.length === 1) {
|
if (wxids.length === 1) {
|
||||||
setWxid(wxids[0].wxid)
|
await applyWxidSelection(wxids[0].wxid, {
|
||||||
await configService.setMyWxid(wxids[0].wxid)
|
preferCurrentKeys: options?.preferCurrentKeys ?? false,
|
||||||
if (!silent) showMessage(`已检测到账号:${wxids[0].wxid}`, true)
|
showToast: !silent,
|
||||||
} else if (wxids.length > 1) {
|
toastText: `已检测到账号:${wxids[0].wxid}`
|
||||||
// 多账号时弹出选择对话框
|
})
|
||||||
|
} else if (wxids.length > 1 && allowDialog) {
|
||||||
setShowWxidSelect(true)
|
setShowWxidSelect(true)
|
||||||
} else {
|
} else {
|
||||||
if (!silent) showMessage('未检测到账号目录,请检查路径', false)
|
if (!silent) showMessage('未检测到账号目录,请检查路径', false)
|
||||||
@@ -230,10 +430,7 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectWxid = async (selectedWxid: string) => {
|
const handleSelectWxid = async (selectedWxid: string) => {
|
||||||
setWxid(selectedWxid)
|
await applyWxidSelection(selectedWxid)
|
||||||
await configService.setMyWxid(selectedWxid)
|
|
||||||
setShowWxidSelect(false)
|
|
||||||
showMessage(`已选择账号:${selectedWxid}`, true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectCachePath = async () => {
|
const handleSelectCachePath = async () => {
|
||||||
@@ -250,6 +447,51 @@ function SettingsPage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleSelectWhisperModelDir = async () => {
|
||||||
|
try {
|
||||||
|
const result = await dialog.openFile({ title: '选择 Whisper 模型下载目录', properties: ['openDirectory'] })
|
||||||
|
if (!result.canceled && result.filePaths.length > 0) {
|
||||||
|
const dir = result.filePaths[0]
|
||||||
|
setWhisperModelDir(dir)
|
||||||
|
await configService.setWhisperModelDir(dir)
|
||||||
|
showMessage('已选择 Whisper 模型目录', true)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('选择目录失败', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWhisperModelChange = async (value: string) => {
|
||||||
|
setWhisperModelName(value)
|
||||||
|
setWhisperDownloadProgress(0)
|
||||||
|
await configService.setWhisperModelName(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownloadWhisperModel = async () => {
|
||||||
|
if (isWhisperDownloading) return
|
||||||
|
setIsWhisperDownloading(true)
|
||||||
|
setWhisperDownloadProgress(0)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.whisper.downloadModel()
|
||||||
|
if (result.success) {
|
||||||
|
setWhisperDownloadProgress(100)
|
||||||
|
showMessage('SenseVoiceSmall 模型下载完成', true)
|
||||||
|
await refreshWhisperStatus(whisperModelDir)
|
||||||
|
} else {
|
||||||
|
showMessage(result.error || '模型下载失败', false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage(`模型下载失败: ${e}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsWhisperDownloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetWhisperModelDir = async () => {
|
||||||
|
setWhisperModelDir('')
|
||||||
|
await configService.setWhisperModelDir('')
|
||||||
|
}
|
||||||
|
|
||||||
const handleAutoGetDbKey = async () => {
|
const handleAutoGetDbKey = async () => {
|
||||||
if (isFetchingDbKey) return
|
if (isFetchingDbKey) return
|
||||||
setIsFetchingDbKey(true)
|
setIsFetchingDbKey(true)
|
||||||
@@ -261,7 +503,7 @@ function SettingsPage() {
|
|||||||
setDecryptKey(result.key)
|
setDecryptKey(result.key)
|
||||||
setDbKeyStatus('密钥获取成功')
|
setDbKeyStatus('密钥获取成功')
|
||||||
showMessage('已自动获取解密密钥', true)
|
showMessage('已自动获取解密密钥', true)
|
||||||
await handleScanWxid(true)
|
await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false })
|
||||||
} else {
|
} else {
|
||||||
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
|
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
|
||||||
setIsManualStartPrompt(true)
|
setIsManualStartPrompt(true)
|
||||||
@@ -347,30 +589,21 @@ function SettingsPage() {
|
|||||||
await configService.setDbPath(dbPath)
|
await configService.setDbPath(dbPath)
|
||||||
await configService.setMyWxid(wxid)
|
await configService.setMyWxid(wxid)
|
||||||
await configService.setCachePath(cachePath)
|
await configService.setCachePath(cachePath)
|
||||||
if (imageXorKey) {
|
const parsedXorKey = parseImageXorKey(imageXorKey)
|
||||||
const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16)
|
await configService.setImageXorKey(typeof parsedXorKey === 'number' ? parsedXorKey : 0)
|
||||||
if (!Number.isNaN(parsed)) {
|
await configService.setImageAesKey(imageAesKey || '')
|
||||||
await configService.setImageXorKey(parsed)
|
await configService.setWxidConfig(wxid, {
|
||||||
}
|
decryptKey,
|
||||||
} else {
|
imageXorKey: typeof parsedXorKey === 'number' ? parsedXorKey : 0,
|
||||||
await configService.setImageXorKey(0)
|
imageAesKey
|
||||||
}
|
})
|
||||||
if (imageAesKey) {
|
await configService.setWhisperModelDir(whisperModelDir)
|
||||||
await configService.setImageAesKey(imageAesKey)
|
await configService.setAutoTranscribeVoice(autoTranscribeVoice)
|
||||||
} else {
|
await configService.setTranscribeLanguages(transcribeLanguages)
|
||||||
await configService.setImageAesKey('')
|
|
||||||
}
|
|
||||||
await configService.setOnboardingDone(true)
|
await configService.setOnboardingDone(true)
|
||||||
|
|
||||||
showMessage('配置保存成功,正在测试连接...', true)
|
// 保存按钮只负责持久化配置,不做连接测试/重连,避免影响聊天页的活动连接
|
||||||
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
|
showMessage('配置保存成功', true)
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setDbConnected(true, dbPath)
|
|
||||||
showMessage('配置保存成功!数据库连接正常', true)
|
|
||||||
} else {
|
|
||||||
showMessage(result.error || '数据库连接失败,请检查配置', false)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showMessage(`保存配置失败: ${e}`, false)
|
showMessage(`保存配置失败: ${e}`, false)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -380,7 +613,7 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleClearConfig = async () => {
|
const handleClearConfig = async () => {
|
||||||
const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置。')
|
const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置?')
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
setIsLoadingState(true)
|
setIsLoadingState(true)
|
||||||
setLoading(true, '正在清除配置...')
|
setLoading(true, '正在清除配置...')
|
||||||
@@ -395,6 +628,12 @@ function SettingsPage() {
|
|||||||
setWxid('')
|
setWxid('')
|
||||||
setCachePath('')
|
setCachePath('')
|
||||||
setLogEnabled(false)
|
setLogEnabled(false)
|
||||||
|
setAutoTranscribeVoice(false)
|
||||||
|
setTranscribeLanguages(['zh'])
|
||||||
|
setWhisperModelDir('')
|
||||||
|
setWhisperModelStatus(null)
|
||||||
|
setWhisperDownloadProgress(0)
|
||||||
|
setIsWhisperDownloading(false)
|
||||||
setDbConnected(false)
|
setDbConnected(false)
|
||||||
await window.electronAPI.window.openOnboardingWindow()
|
await window.electronAPI.window.openOnboardingWindow()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -428,6 +667,59 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClearAnalyticsCache = async () => {
|
||||||
|
if (isClearingCache) return
|
||||||
|
setIsClearingAnalyticsCache(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.cache.clearAnalytics()
|
||||||
|
if (result.success) {
|
||||||
|
clearAnalyticsStoreCache()
|
||||||
|
showMessage('已清除分析缓存', true)
|
||||||
|
} else {
|
||||||
|
showMessage(`清除分析缓存失败: ${result.error || '未知错误'}`, false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage(`清除分析缓存失败: ${e}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsClearingAnalyticsCache(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearImageCache = async () => {
|
||||||
|
if (isClearingCache) return
|
||||||
|
setIsClearingImageCache(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.cache.clearImages()
|
||||||
|
if (result.success) {
|
||||||
|
showMessage('已清除图片缓存', true)
|
||||||
|
} else {
|
||||||
|
showMessage(`清除图片缓存失败: ${result.error || '未知错误'}`, false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage(`清除图片缓存失败: ${e}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsClearingImageCache(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearAllCache = async () => {
|
||||||
|
if (isClearingCache) return
|
||||||
|
setIsClearingAllCache(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.cache.clearAll()
|
||||||
|
if (result.success) {
|
||||||
|
clearAnalyticsStoreCache()
|
||||||
|
showMessage('已清除所有缓存', true)
|
||||||
|
} else {
|
||||||
|
showMessage(`清除所有缓存失败: ${result.error || '未知错误'}`, false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage(`清除所有缓存失败: ${e}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsClearingAllCache(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const renderAppearanceTab = () => (
|
const renderAppearanceTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<div className="theme-mode-toggle">
|
<div className="theme-mode-toggle">
|
||||||
@@ -484,6 +776,7 @@ function SettingsPage() {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>数据库根目录</label>
|
<label>数据库根目录</label>
|
||||||
<span className="form-hint">xwechat_files 目录</span>
|
<span className="form-hint">xwechat_files 目录</span>
|
||||||
|
<span className="form-hint" style={{ color: '#ff6b6b' }}>⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</span>
|
||||||
<input type="text" placeholder="例如: C:\Users\xxx\Documents\xwechat_files" value={dbPath} onChange={(e) => setDbPath(e.target.value)} />
|
<input type="text" placeholder="例如: C:\Users\xxx\Documents\xwechat_files" value={dbPath} onChange={(e) => setDbPath(e.target.value)} />
|
||||||
<div className="btn-row">
|
<div className="btn-row">
|
||||||
<button className="btn btn-primary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
|
<button className="btn btn-primary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
|
||||||
@@ -496,37 +789,13 @@ function SettingsPage() {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>账号 wxid</label>
|
<label>账号 wxid</label>
|
||||||
<span className="form-hint">微信账号标识</span>
|
<span className="form-hint">微信账号标识</span>
|
||||||
<div className="wxid-input-wrapper" ref={wxidDropdownRef}>
|
<div className="wxid-input-wrapper">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="例如: wxid_xxxxxx"
|
placeholder="例如: wxid_xxxxxx"
|
||||||
value={wxid}
|
value={wxid}
|
||||||
onChange={(e) => setWxid(e.target.value)}
|
onChange={(e) => setWxid(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`wxid-dropdown-btn ${showWxidSelect ? 'open' : ''}`}
|
|
||||||
onClick={() => wxidOptions.length > 0 ? setShowWxidSelect(!showWxidSelect) : handleScanWxid()}
|
|
||||||
title={wxidOptions.length > 0 ? "选择已检测到的账号" : "扫描账号"}
|
|
||||||
>
|
|
||||||
<ChevronDown size={16} />
|
|
||||||
</button>
|
|
||||||
{showWxidSelect && wxidOptions.length > 0 && (
|
|
||||||
<div className="wxid-dropdown">
|
|
||||||
{wxidOptions.map((opt) => (
|
|
||||||
<div
|
|
||||||
key={opt.wxid}
|
|
||||||
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
|
|
||||||
onClick={() => handleSelectWxid(opt.wxid)}
|
|
||||||
>
|
|
||||||
<span className="wxid-value">{opt.wxid}</span>
|
|
||||||
<span className="wxid-time">
|
|
||||||
{new Date(opt.modifiedTime).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => handleScanWxid()}><Search size={14} /> 扫描 wxid</button>
|
<button className="btn btn-secondary btn-sm" onClick={() => handleScanWxid()}><Search size={14} /> 扫描 wxid</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -548,16 +817,6 @@ function SettingsPage() {
|
|||||||
{isFetchingImageKey && <div className="form-hint status-text">正在扫描内存,请稍候...</div>}
|
{isFetchingImageKey && <div className="form-hint status-text">正在扫描内存,请稍候...</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>缓存目录 <span className="optional">(可选)</span></label>
|
|
||||||
<span className="form-hint">留空使用默认目录</span>
|
|
||||||
<input type="text" placeholder="留空使用默认目录" value={cachePath} onChange={(e) => setCachePath(e.target.value)} />
|
|
||||||
<div className="btn-row">
|
|
||||||
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> 浏览选择</button>
|
|
||||||
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> 恢复默认</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>调试日志</label>
|
<label>调试日志</label>
|
||||||
<span className="form-hint">开启后写入 WCDB 调试日志,便于排查连接问题</span>
|
<span className="form-hint">开启后写入 WCDB 调试日志,便于排查连接问题</span>
|
||||||
@@ -590,16 +849,335 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
const renderWhisperTab = () => (
|
||||||
|
<div className="tab-content">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>自动语音转文字</label>
|
||||||
|
<span className="form-hint">语音解密后自动转写为文字(需下载模型)</span>
|
||||||
|
<div className="log-toggle-line">
|
||||||
|
<span className="log-status">{autoTranscribeVoice ? '已开启' : '已关闭'}</span>
|
||||||
|
<label className="switch" htmlFor="auto-transcribe-toggle">
|
||||||
|
<input
|
||||||
|
id="auto-transcribe-toggle"
|
||||||
|
className="switch-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoTranscribeVoice}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const enabled = e.target.checked
|
||||||
|
setAutoTranscribeVoice(enabled)
|
||||||
|
await configService.setAutoTranscribeVoice(enabled)
|
||||||
|
showMessage(enabled ? '已开启自动转文字' : '已关闭自动转文字', true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>支持的语言</label>
|
||||||
|
<span className="form-hint">选择需要识别的语言(至少选择一种)</span>
|
||||||
|
<div className="language-checkboxes">
|
||||||
|
{[
|
||||||
|
{ code: 'zh', name: '中文' },
|
||||||
|
{ code: 'yue', name: '粤语' },
|
||||||
|
{ code: 'en', name: '英文' },
|
||||||
|
{ code: 'ja', name: '日文' },
|
||||||
|
{ code: 'ko', name: '韩文' }
|
||||||
|
].map((lang) => (
|
||||||
|
<label key={lang.code} className="language-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={transcribeLanguages.includes(lang.code)}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const checked = e.target.checked
|
||||||
|
let newLanguages: string[]
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
newLanguages = [...transcribeLanguages, lang.code]
|
||||||
|
} else {
|
||||||
|
if (transcribeLanguages.length <= 1) {
|
||||||
|
showMessage('至少需要选择一种语言', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newLanguages = transcribeLanguages.filter(l => l !== lang.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTranscribeLanguages(newLanguages)
|
||||||
|
await configService.setTranscribeLanguages(newLanguages)
|
||||||
|
showMessage(`已${checked ? '添加' : '移除'}${lang.name}`, true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="checkbox-custom">
|
||||||
|
<Check size={14} />
|
||||||
|
<span>{lang.name}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group whisper-section">
|
||||||
|
<label>语音识别模型 (SenseVoiceSmall)</label>
|
||||||
|
<span className="form-hint">基于 Sherpa-onnx,支持中、粤、英、日、韩及情感/事件识别</span>
|
||||||
|
<span className="form-hint">模型下载目录</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="留空使用默认目录"
|
||||||
|
value={whisperModelDir}
|
||||||
|
onChange={(e) => setWhisperModelDir(e.target.value)}
|
||||||
|
onBlur={() => configService.setWhisperModelDir(whisperModelDir)}
|
||||||
|
/>
|
||||||
|
<div className="btn-row">
|
||||||
|
<button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> 选择目录</button>
|
||||||
|
<button className="btn btn-secondary" onClick={handleResetWhisperModelDir}><RotateCcw size={16} /> 默认目录</button>
|
||||||
|
</div>
|
||||||
|
<div className="whisper-status-line">
|
||||||
|
<span className={`status ${whisperModelStatus?.exists ? 'ok' : 'warn'}`}>
|
||||||
|
{whisperModelStatus?.exists ? '已下载 (240 MB)' : '未下载 (240 MB)'}
|
||||||
|
</span>
|
||||||
|
{whisperModelStatus?.modelPath && <span className="path">{whisperModelStatus.modelPath}</span>}
|
||||||
|
</div>
|
||||||
|
{isWhisperDownloading ? (
|
||||||
|
<div className="whisper-progress">
|
||||||
|
<div className="progress-info">
|
||||||
|
<span>正在准备模型文件...</span>
|
||||||
|
<span className="percent">{whisperDownloadProgress.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar-container">
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div className="progress-fill" style={{ width: `${whisperDownloadProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-primary btn-download-model" onClick={handleDownloadWhisperModel}>
|
||||||
|
<Download size={18} /> 下载模型
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const exportFormatOptions = [
|
||||||
|
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
||||||
|
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
||||||
|
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
||||||
|
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
||||||
|
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
||||||
|
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
||||||
|
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
|
||||||
|
]
|
||||||
|
const exportDateRangeOptions = [
|
||||||
|
{ value: 'today', label: '今天' },
|
||||||
|
{ value: '7d', label: '最近7天' },
|
||||||
|
{ value: '30d', label: '最近30天' },
|
||||||
|
{ value: '90d', label: '最近90天' },
|
||||||
|
{ value: 'all', label: '全部时间' }
|
||||||
|
]
|
||||||
|
const exportExcelColumnOptions = [
|
||||||
|
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
|
||||||
|
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
|
||||||
|
return options.find((option) => option.value === value)?.label ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderExportTab = () => {
|
||||||
|
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||||
|
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
|
||||||
|
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
|
||||||
|
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tab-content">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>默认导出格式</label>
|
||||||
|
<span className="form-hint">导出页面默认选中的格式</span>
|
||||||
|
<div className="select-field" ref={exportFormatDropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowExportFormatSelect(!showExportFormatSelect)
|
||||||
|
setShowExportDateRangeSelect(false)
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="select-value">{exportFormatLabel}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showExportFormatSelect && (
|
||||||
|
<div className="select-dropdown">
|
||||||
|
{exportFormatOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`select-option ${exportDefaultFormat === option.value ? 'active' : ''}`}
|
||||||
|
onClick={async () => {
|
||||||
|
setExportDefaultFormat(option.value)
|
||||||
|
await configService.setExportDefaultFormat(option.value)
|
||||||
|
showMessage('已更新导出格式默认值', true)
|
||||||
|
setShowExportFormatSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{option.label}</span>
|
||||||
|
{option.desc && <span className="option-desc">{option.desc}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>默认导出时间范围</label>
|
||||||
|
<span className="form-hint">控制导出页面的默认时间选择</span>
|
||||||
|
<div className="select-field" ref={exportDateRangeDropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showExportDateRangeSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowExportDateRangeSelect(!showExportDateRangeSelect)
|
||||||
|
setShowExportFormatSelect(false)
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="select-value">{exportDateRangeLabel}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showExportDateRangeSelect && (
|
||||||
|
<div className="select-dropdown">
|
||||||
|
{exportDateRangeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`select-option ${exportDefaultDateRange === option.value ? 'active' : ''}`}
|
||||||
|
onClick={async () => {
|
||||||
|
setExportDefaultDateRange(option.value)
|
||||||
|
await configService.setExportDefaultDateRange(option.value)
|
||||||
|
showMessage('已更新默认导出时间范围', true)
|
||||||
|
setShowExportDateRangeSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>默认导出媒体文件</label>
|
||||||
|
<span className="form-hint">控制图片/语音/表情的默认导出开关</span>
|
||||||
|
<div className="log-toggle-line">
|
||||||
|
<span className="log-status">{exportDefaultMedia ? '已开启' : '已关闭'}</span>
|
||||||
|
<label className="switch" htmlFor="export-default-media">
|
||||||
|
<input
|
||||||
|
id="export-default-media"
|
||||||
|
className="switch-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultMedia}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const enabled = e.target.checked
|
||||||
|
setExportDefaultMedia(enabled)
|
||||||
|
await configService.setExportDefaultMedia(enabled)
|
||||||
|
showMessage(enabled ? '已开启默认媒体导出' : '已关闭默认媒体导出', true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>默认语音转文字</label>
|
||||||
|
<span className="form-hint">导出时默认将语音转写为文字</span>
|
||||||
|
<div className="log-toggle-line">
|
||||||
|
<span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span>
|
||||||
|
<label className="switch" htmlFor="export-default-voice-as-text">
|
||||||
|
<input
|
||||||
|
id="export-default-voice-as-text"
|
||||||
|
className="switch-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultVoiceAsText}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const enabled = e.target.checked
|
||||||
|
setExportDefaultVoiceAsText(enabled)
|
||||||
|
await configService.setExportDefaultVoiceAsText(enabled)
|
||||||
|
showMessage(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Excel 列显示</label>
|
||||||
|
<span className="form-hint">控制 Excel 导出的列字段</span>
|
||||||
|
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||||
|
setShowExportFormatSelect(false)
|
||||||
|
setShowExportDateRangeSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="select-value">{exportExcelColumnsLabel}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showExportExcelColumnsSelect && (
|
||||||
|
<div className="select-dropdown">
|
||||||
|
{exportExcelColumnOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
|
||||||
|
onClick={async () => {
|
||||||
|
const compact = option.value === 'compact'
|
||||||
|
setExportDefaultExcelCompactColumns(compact)
|
||||||
|
await configService.setExportDefaultExcelCompactColumns(compact)
|
||||||
|
showMessage(compact ? '已启用精简列' : '已启用完整列', true)
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{option.label}</span>
|
||||||
|
{option.desc && <span className="option-desc">{option.desc}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
const renderCacheTab = () => (
|
const renderCacheTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<p className="section-desc">管理应用缓存数据</p>
|
<p className="section-desc">管理应用缓存数据</p>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>缓存目录 <span className="optional">(可选)</span></label>
|
||||||
|
<span className="form-hint">留空使用默认目录</span>
|
||||||
|
<input type="text" placeholder="留空使用默认目录" value={cachePath} onChange={(e) => setCachePath(e.target.value)} />
|
||||||
|
<div className="btn-row">
|
||||||
|
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> 浏览选择</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> 恢复默认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="btn-row">
|
<div className="btn-row">
|
||||||
<button className="btn btn-secondary"><Trash2 size={16} /> 清除分析缓存</button>
|
<button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}>
|
||||||
<button className="btn btn-secondary"><Trash2 size={16} /> 清除图片缓存</button>
|
<Trash2 size={16} /> 清除分析缓存
|
||||||
<button className="btn btn-danger"><Trash2 size={16} /> 清除所有缓存</button>
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={handleClearImageCache} disabled={isClearingCache}>
|
||||||
|
<Trash2 size={16} /> 清除图片缓存
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-danger" onClick={handleClearAllCache} disabled={isClearingCache}>
|
||||||
|
<Trash2 size={16} /> 清除所有缓存</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
<p className="section-desc">清除当前配置并重新开始首次引导</p>
|
<p className="section-desc">清除当前配置并重新开始首次引导</p>
|
||||||
@@ -624,7 +1202,7 @@ function SettingsPage() {
|
|||||||
<div className="about-update">
|
<div className="about-update">
|
||||||
{updateInfo?.hasUpdate ? (
|
{updateInfo?.hasUpdate ? (
|
||||||
<>
|
<>
|
||||||
<p className="update-hint">新版本 v{updateInfo.version} 可用</p>
|
<p className="update-hint">新版 v{updateInfo.version} 可用</p>
|
||||||
{isDownloading ? (
|
{isDownloading ? (
|
||||||
<div className="download-progress">
|
<div className="download-progress">
|
||||||
<div className="progress-bar">
|
<div className="progress-bar">
|
||||||
@@ -675,13 +1253,13 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="wxid-dialog-list">
|
<div className="wxid-dialog-list">
|
||||||
{wxidOptions.map((opt) => (
|
{wxidOptions.map((opt) => (
|
||||||
<div
|
<div
|
||||||
key={opt.wxid}
|
key={opt.wxid}
|
||||||
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
|
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
|
||||||
onClick={() => handleSelectWxid(opt.wxid)}
|
onClick={() => handleSelectWxid(opt.wxid)}
|
||||||
>
|
>
|
||||||
<span className="wxid-id">{opt.wxid}</span>
|
<span className="wxid-id">{opt.wxid}</span>
|
||||||
<span className="wxid-date">最后修改: {new Date(opt.modifiedTime).toLocaleString()}</span>
|
<span className="wxid-date">最后修改 {new Date(opt.modifiedTime).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -716,6 +1294,8 @@ function SettingsPage() {
|
|||||||
<div className="settings-body">
|
<div className="settings-body">
|
||||||
{activeTab === 'appearance' && renderAppearanceTab()}
|
{activeTab === 'appearance' && renderAppearanceTab()}
|
||||||
{activeTab === 'database' && renderDatabaseTab()}
|
{activeTab === 'database' && renderDatabaseTab()}
|
||||||
|
{activeTab === 'whisper' && renderWhisperTab()}
|
||||||
|
{activeTab === 'export' && renderExportTab()}
|
||||||
{activeTab === 'cache' && renderCacheTab()}
|
{activeTab === 'cache' && renderCacheTab()}
|
||||||
{activeTab === 'about' && renderAboutTab()}
|
{activeTab === 'about' && renderAboutTab()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
940
src/pages/SnsPage.scss
Normal file
940
src/pages/SnsPage.scss
Normal file
@@ -0,0 +1,940 @@
|
|||||||
|
.sns-page {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.sns-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-sidebar {
|
||||||
|
width: 300px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&.closed {
|
||||||
|
width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
.title-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
.title-icon {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
color: var(--accent-color);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.filter-card {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 14px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.jump-date-card {
|
||||||
|
.jump-date-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(var(--accent-color-rgb), 0.05);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--accent-color);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--accent-color-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-jump-date-inline {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
background: rgba(var(--accent-color-rgb), 0.06);
|
||||||
|
border: 1px dashed rgba(var(--accent-color-rgb), 0.3);
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.contact-card {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0; // 改为 0 以支持 flex 压缩
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--accent-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 10px 10px 36px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
box-shadow: 0 0 0 4px rgba(var(--accent-color-rgb), 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-input {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-filter-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
padding: 16px 16px 1px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.clear-selection-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--accent-color);
|
||||||
|
background: rgba(var(--accent-color-rgb), 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-count {
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-search {
|
||||||
|
padding: 0 16px 12px 16px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 26px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 30px 8px 30px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
right: 24px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 0 4px 8px 4px;
|
||||||
|
|
||||||
|
.contact-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(var(--accent-color-rgb), 0.08);
|
||||||
|
|
||||||
|
.contact-name {
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-box {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
background: var(--accent-color);
|
||||||
|
|
||||||
|
.inner-check {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.active-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
right: -1px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
border: 2px solid var(--bg-secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: badge-pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-box {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
.inner-check {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: white;
|
||||||
|
transform: scale(0);
|
||||||
|
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-contacts {
|
||||||
|
padding: 32px 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 4px 10px rgba(var(--accent-color-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
|
||||||
|
.sns-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 64px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
z-index: 5;
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-trigger {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.refresh-btn {
|
||||||
|
&:hover {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-content-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sns-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 0;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
|
||||||
|
.active-filters-bar {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto 24px auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: rgba(var(--accent-color-rgb), 0.08);
|
||||||
|
border: 1px solid rgba(var(--accent-color-rgb), 0.2);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent-color);
|
||||||
|
|
||||||
|
.filter-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-chip-btn {
|
||||||
|
background: var(--accent-color);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--accent-color-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-post-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-post-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-post {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.03);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
|
||||||
|
.post-info {
|
||||||
|
margin-left: 14px;
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-body {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.post-text {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-media-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
&.media-count-1 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
|
||||||
|
.media-item {
|
||||||
|
width: 320px;
|
||||||
|
height: 240px;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
aspect-ratio: auto;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.media-count-2,
|
||||||
|
&.media-count-4 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.media-count-3,
|
||||||
|
&.media-count-5,
|
||||||
|
&.media-count-6,
|
||||||
|
&.media-count-7,
|
||||||
|
&.media-count-8,
|
||||||
|
&.media-count-9 {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-item {
|
||||||
|
width: 160px; // 多图模式下项固定大小(或由 grid 控制,但确保有高度)
|
||||||
|
height: 160px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-error-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-video-placeholder {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(var(--accent-color-rgb), 0.08);
|
||||||
|
color: var(--accent-color);
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid rgba(var(--accent-color-rgb), 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(var(--accent-color-rgb), 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-footer {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
left: 20px;
|
||||||
|
border-left: 8px solid transparent;
|
||||||
|
border-right: 8px solid transparent;
|
||||||
|
border-bottom: 8px solid var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.likes-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
color: var(--accent-color);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-top: 3px;
|
||||||
|
margin-right: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.likes-list {
|
||||||
|
line-height: 1.6;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
.comment-item {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-user {
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-text {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
&.loading-more,
|
||||||
|
&.loading-newer {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.newer-hint {
|
||||||
|
background: rgba(var(--accent-color-rgb), 0.08);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px dashed rgba(var(--accent-color-rgb), 0.2);
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(var(--accent-color-rgb), 0.15);
|
||||||
|
border-style: solid;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
|
||||||
|
.no-results-icon {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-inline {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 4px 15px rgba(var(--accent-color-rgb), 0.3);
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(var(--accent-color-rgb), 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes badge-pop {
|
||||||
|
from {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
610
src/pages/SnsPage.tsx
Normal file
610
src/pages/SnsPage.tsx
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
|
import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon } from 'lucide-react'
|
||||||
|
import { Avatar } from '../components/Avatar'
|
||||||
|
import { ImagePreview } from '../components/ImagePreview'
|
||||||
|
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||||
|
import './SnsPage.scss'
|
||||||
|
|
||||||
|
interface SnsPost {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
avatarUrl?: string
|
||||||
|
createTime: number
|
||||||
|
contentDesc: string
|
||||||
|
type?: number
|
||||||
|
media: { url: string; thumb: string }[]
|
||||||
|
likes: string[]
|
||||||
|
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => {
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`media-item ${error ? 'error' : ''}`}>
|
||||||
|
{!error ? (
|
||||||
|
<img
|
||||||
|
src={thumb || url}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
onClick={onPreview}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="media-error-placeholder" onClick={onPreview}>
|
||||||
|
<ImageIcon size={24} style={{ opacity: 0.3 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SnsPage() {
|
||||||
|
const [posts, setPosts] = useState<SnsPost[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const loadingRef = useRef(false)
|
||||||
|
|
||||||
|
// 筛选与搜索状态
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
|
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
||||||
|
|
||||||
|
// 联系人列表状态
|
||||||
|
const [contacts, setContacts] = useState<Contact[]>([])
|
||||||
|
const [contactSearch, setContactSearch] = useState('')
|
||||||
|
const [contactsLoading, setContactsLoading] = useState(false)
|
||||||
|
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
||||||
|
const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined)
|
||||||
|
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const postsContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [hasNewer, setHasNewer] = useState(false)
|
||||||
|
const [loadingNewer, setLoadingNewer] = useState(false)
|
||||||
|
const postsRef = useRef<SnsPost[]>([])
|
||||||
|
const scrollAdjustmentRef = useRef<number>(0)
|
||||||
|
|
||||||
|
// 同步 posts 到 ref 供 loadPosts 使用
|
||||||
|
useEffect(() => {
|
||||||
|
postsRef.current = posts
|
||||||
|
}, [posts])
|
||||||
|
|
||||||
|
// 处理向上加载动态时的滚动位置保持
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollAdjustmentRef.current !== 0 && postsContainerRef.current) {
|
||||||
|
const container = postsContainerRef.current;
|
||||||
|
const newHeight = container.scrollHeight;
|
||||||
|
const diff = newHeight - scrollAdjustmentRef.current;
|
||||||
|
if (diff > 0) {
|
||||||
|
container.scrollTop += diff;
|
||||||
|
}
|
||||||
|
scrollAdjustmentRef.current = 0;
|
||||||
|
}
|
||||||
|
}, [posts])
|
||||||
|
|
||||||
|
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
|
||||||
|
const { reset = false, direction = 'older' } = options
|
||||||
|
if (loadingRef.current) return
|
||||||
|
|
||||||
|
loadingRef.current = true
|
||||||
|
if (direction === 'newer') setLoadingNewer(true)
|
||||||
|
else setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const limit = 20
|
||||||
|
let startTs: number | undefined = undefined
|
||||||
|
let endTs: number | undefined = undefined
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
if (jumpTargetDate) {
|
||||||
|
endTs = Math.floor(jumpTargetDate.getTime() / 1000) + 86399
|
||||||
|
}
|
||||||
|
} else if (direction === 'newer') {
|
||||||
|
const currentPosts = postsRef.current
|
||||||
|
if (currentPosts.length > 0) {
|
||||||
|
const topTs = currentPosts[0].createTime
|
||||||
|
console.log('[SnsPage] Fetching newer posts starts from:', topTs + 1);
|
||||||
|
|
||||||
|
const result = await window.electronAPI.sns.getTimeline(
|
||||||
|
limit,
|
||||||
|
0,
|
||||||
|
selectedUsernames,
|
||||||
|
searchKeyword,
|
||||||
|
topTs + 1,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success && result.timeline && result.timeline.length > 0) {
|
||||||
|
if (postsContainerRef.current) {
|
||||||
|
scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIds = new Set(currentPosts.map(p => p.id));
|
||||||
|
const uniqueNewer = result.timeline.filter(p => !existingIds.has(p.id));
|
||||||
|
|
||||||
|
if (uniqueNewer.length > 0) {
|
||||||
|
setPosts(prev => [...uniqueNewer, ...prev]);
|
||||||
|
}
|
||||||
|
setHasNewer(result.timeline.length >= limit);
|
||||||
|
} else {
|
||||||
|
setHasNewer(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoadingNewer(false);
|
||||||
|
loadingRef.current = false;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
const currentPosts = postsRef.current
|
||||||
|
if (currentPosts.length > 0) {
|
||||||
|
endTs = currentPosts[currentPosts.length - 1].createTime - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await window.electronAPI.sns.getTimeline(
|
||||||
|
limit,
|
||||||
|
0,
|
||||||
|
selectedUsernames,
|
||||||
|
searchKeyword,
|
||||||
|
startTs,
|
||||||
|
endTs
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success && result.timeline) {
|
||||||
|
if (reset) {
|
||||||
|
setPosts(result.timeline)
|
||||||
|
setHasMore(result.timeline.length >= limit)
|
||||||
|
|
||||||
|
// 探测上方是否还有新动态(利用 DLL 过滤,而非底层 SQL)
|
||||||
|
const topTs = result.timeline[0]?.createTime || 0;
|
||||||
|
if (topTs > 0) {
|
||||||
|
const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, searchKeyword, topTs + 1, undefined);
|
||||||
|
setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0));
|
||||||
|
} else {
|
||||||
|
setHasNewer(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postsContainerRef.current) {
|
||||||
|
postsContainerRef.current.scrollTop = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (result.timeline.length > 0) {
|
||||||
|
setPosts(prev => [...prev, ...result.timeline!])
|
||||||
|
}
|
||||||
|
if (result.timeline.length < limit) {
|
||||||
|
setHasMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load SNS timeline:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setLoadingNewer(false)
|
||||||
|
loadingRef.current = false
|
||||||
|
}
|
||||||
|
}, [selectedUsernames, searchKeyword, jumpTargetDate])
|
||||||
|
|
||||||
|
// 获取联系人列表
|
||||||
|
const loadContacts = useCallback(async () => {
|
||||||
|
setContactsLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.chat.getSessions()
|
||||||
|
if (result.success && result.sessions) {
|
||||||
|
const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder'];
|
||||||
|
const initialContacts = result.sessions
|
||||||
|
.filter((s: any) => {
|
||||||
|
if (!s.username) return false;
|
||||||
|
const u = s.username.toLowerCase();
|
||||||
|
if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) return false;
|
||||||
|
if (u.startsWith('gh_')) return false;
|
||||||
|
if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((s: any) => ({
|
||||||
|
username: s.username,
|
||||||
|
displayName: s.displayName || s.username,
|
||||||
|
avatarUrl: s.avatarUrl
|
||||||
|
}))
|
||||||
|
setContacts(initialContacts)
|
||||||
|
|
||||||
|
const usernames = initialContacts.map(c => c.username)
|
||||||
|
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
|
||||||
|
if (enriched.success && enriched.contacts) {
|
||||||
|
setContacts(prev => prev.map(c => {
|
||||||
|
const extra = enriched.contacts![c.username]
|
||||||
|
if (extra) {
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
displayName: extra.displayName || c.displayName,
|
||||||
|
avatarUrl: extra.avatarUrl || c.avatarUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load contacts:', error)
|
||||||
|
} finally {
|
||||||
|
setContactsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 初始加载
|
||||||
|
useEffect(() => {
|
||||||
|
const checkSchema = async () => {
|
||||||
|
try {
|
||||||
|
const schema = await window.electronAPI.chat.execQuery('sns', null, "PRAGMA table_info(SnsTimeLine)");
|
||||||
|
console.log('[SnsPage] SnsTimeLine Schema:', schema);
|
||||||
|
if (schema.success && schema.rows) {
|
||||||
|
const columns = schema.rows.map((r: any) => r.name);
|
||||||
|
console.log('[SnsPage] Available columns:', columns);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SnsPage] Failed to check schema:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkSchema();
|
||||||
|
loadContacts()
|
||||||
|
}, [loadContacts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleChange = () => {
|
||||||
|
setPosts([])
|
||||||
|
setHasMore(true)
|
||||||
|
setHasNewer(false)
|
||||||
|
setSelectedUsernames([])
|
||||||
|
setSearchKeyword('')
|
||||||
|
setJumpTargetDate(undefined)
|
||||||
|
loadContacts()
|
||||||
|
loadPosts({ reset: true })
|
||||||
|
}
|
||||||
|
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||||
|
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||||
|
}, [loadContacts, loadPosts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPosts({ reset: true })
|
||||||
|
}, [selectedUsernames, searchKeyword, jumpTargetDate])
|
||||||
|
|
||||||
|
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
|
||||||
|
|
||||||
|
// 加载更旧的动态(触底)
|
||||||
|
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) {
|
||||||
|
loadPosts({ direction: 'older' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载更新的动态(触顶触发)
|
||||||
|
// 这里的阈值可以保留,但主要依赖下面的 handleWheel 捕获到顶后的上划
|
||||||
|
if (scrollTop < 10 && hasNewer && !loading && !loadingNewer) {
|
||||||
|
loadPosts({ direction: 'newer' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理到顶后的手动上滚意图
|
||||||
|
const handleWheel = (e: React.WheelEvent<HTMLDivElement>) => {
|
||||||
|
const container = postsContainerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
// deltaY < 0 表示向上滚,scrollTop === 0 表示已经在最顶端
|
||||||
|
if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) {
|
||||||
|
console.log('[SnsPage] Wheel-up detected at top, loading newer posts...');
|
||||||
|
loadPosts({ direction: 'newer' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (ts: number) => {
|
||||||
|
const date = new Date(ts * 1000)
|
||||||
|
const isCurrentYear = date.getFullYear() === new Date().getFullYear()
|
||||||
|
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: isCurrentYear ? undefined : 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleUserSelection = (username: string) => {
|
||||||
|
// 选择联系人时,如果当前有时间跳转,建议清除时间跳转以避免“跳到旧动态”的困惑
|
||||||
|
// 或者保持原样。根据用户反馈“乱跳”,我们在这里选择:
|
||||||
|
// 如果用户选择了新的一个人,而之前有时间跳转,我们重置时间跳转到最新。
|
||||||
|
setJumpTargetDate(undefined);
|
||||||
|
|
||||||
|
setSelectedUsernames(prev => {
|
||||||
|
if (prev.includes(username)) {
|
||||||
|
return prev.filter(u => u !== username)
|
||||||
|
} else {
|
||||||
|
return [...prev, username]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchKeyword('')
|
||||||
|
setSelectedUsernames([])
|
||||||
|
setJumpTargetDate(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredContacts = contacts.filter(c =>
|
||||||
|
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||||
|
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sns-page">
|
||||||
|
<div className="sns-container">
|
||||||
|
{/* 侧边栏:过滤与搜索 */}
|
||||||
|
<aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}>
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<div className="title-wrapper">
|
||||||
|
<Filter size={18} className="title-icon" />
|
||||||
|
<h3>筛选条件</h3>
|
||||||
|
</div>
|
||||||
|
<button className="toggle-btn" onClick={() => setIsSidebarOpen(false)}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-content custom-scrollbar">
|
||||||
|
{/* 1. 搜索分组 (放到最顶上) */}
|
||||||
|
<div className="filter-card">
|
||||||
|
<div className="filter-section">
|
||||||
|
<label><Search size={14} /> 关键词搜索</label>
|
||||||
|
<div className="search-input-wrapper">
|
||||||
|
<Search size={14} className="input-icon" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索动态内容..."
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={e => setSearchKeyword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{searchKeyword && (
|
||||||
|
<button className="clear-input" onClick={() => setSearchKeyword('')}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. 日期跳转 (放搜索下面) */}
|
||||||
|
<div className="filter-card jump-date-card">
|
||||||
|
<div className="filter-section">
|
||||||
|
<label><Calendar size={14} /> 时间跳转</label>
|
||||||
|
<button className={`jump-date-btn ${jumpTargetDate ? 'active' : ''}`} onClick={() => setShowJumpDialog(true)}>
|
||||||
|
<span className="text">
|
||||||
|
{jumpTargetDate ? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }) : '选择跳转日期...'}
|
||||||
|
</span>
|
||||||
|
<Calendar size={14} className="icon" />
|
||||||
|
</button>
|
||||||
|
{jumpTargetDate && (
|
||||||
|
<button className="clear-jump-date-inline" onClick={() => setJumpTargetDate(undefined)}>
|
||||||
|
返回最新动态
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* 3. 联系人筛选 (放最下面,高度自适应) */}
|
||||||
|
<div className="filter-card contact-card">
|
||||||
|
<div className="contact-filter-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<label><User size={14} /> 联系人</label>
|
||||||
|
<div className="header-actions">
|
||||||
|
{selectedUsernames.length > 0 && (
|
||||||
|
<button className="clear-selection-btn" onClick={() => setSelectedUsernames([])}>清除</button>
|
||||||
|
)}
|
||||||
|
{selectedUsernames.length > 0 && (
|
||||||
|
<span className="selected-count">{selectedUsernames.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="contact-search">
|
||||||
|
<Search size={12} className="search-icon" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索好友..."
|
||||||
|
value={contactSearch}
|
||||||
|
onChange={e => setContactSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
{contactSearch && (
|
||||||
|
<X size={12} className="clear-search-icon" onClick={() => setContactSearch('')} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="contact-list custom-scrollbar">
|
||||||
|
{filteredContacts.map(contact => (
|
||||||
|
<div
|
||||||
|
key={contact.username}
|
||||||
|
className={`contact-item ${selectedUsernames.includes(contact.username) ? 'active' : ''}`}
|
||||||
|
onClick={() => toggleUserSelection(contact.username)}
|
||||||
|
>
|
||||||
|
<div className="avatar-wrapper">
|
||||||
|
<Avatar src={contact.avatarUrl} name={contact.displayName} size={32} shape="rounded" />
|
||||||
|
{selectedUsernames.includes(contact.username) && (
|
||||||
|
<div className="active-badge"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="contact-name">{contact.displayName}</span>
|
||||||
|
<div className="check-box">
|
||||||
|
{selectedUsernames.includes(contact.username) && <div className="inner-check"></div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredContacts.length === 0 && (
|
||||||
|
<div className="empty-contacts">无可显示联系人</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<button className="clear-btn" onClick={clearFilters}>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
重置所有筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="sns-main">
|
||||||
|
<div className="sns-header">
|
||||||
|
<div className="header-left">
|
||||||
|
{!isSidebarOpen && (
|
||||||
|
<button className="icon-btn sidebar-trigger" onClick={() => setIsSidebarOpen(true)}>
|
||||||
|
<Filter size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h2>社交动态</h2>
|
||||||
|
</div>
|
||||||
|
<div className="header-right">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (jumpTargetDate) setJumpTargetDate(undefined);
|
||||||
|
loadPosts({ reset: true });
|
||||||
|
}}
|
||||||
|
disabled={loading || loadingNewer}
|
||||||
|
className="icon-btn refresh-btn"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={(loading || loadingNewer) ? 'spinning' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sns-content-wrapper">
|
||||||
|
<div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
|
||||||
|
<div className="posts-list">
|
||||||
|
{loadingNewer && (
|
||||||
|
<div className="status-indicator loading-newer">
|
||||||
|
<RefreshCw size={16} className="spinning" />
|
||||||
|
<span>正在检查更新的动态...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingNewer && hasNewer && (
|
||||||
|
<div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
|
||||||
|
查看更新的动态
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{posts.map((post, index) => {
|
||||||
|
return (
|
||||||
|
<div key={post.id} className="sns-post-row">
|
||||||
|
<div className="sns-post-wrapper">
|
||||||
|
<div className="sns-post">
|
||||||
|
<div className="post-header">
|
||||||
|
<Avatar
|
||||||
|
src={post.avatarUrl}
|
||||||
|
name={post.nickname}
|
||||||
|
size={44}
|
||||||
|
shape="rounded"
|
||||||
|
/>
|
||||||
|
<div className="post-info">
|
||||||
|
<div className="nickname">{post.nickname}</div>
|
||||||
|
<div className="time">{formatTime(post.createTime)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="post-body">
|
||||||
|
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
|
||||||
|
|
||||||
|
{post.type === 15 ? (
|
||||||
|
<div className="post-video-placeholder">
|
||||||
|
<Play size={20} />
|
||||||
|
<span>视频动态</span>
|
||||||
|
</div>
|
||||||
|
) : post.media.length > 0 && (
|
||||||
|
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
|
||||||
|
{post.media.map((m, idx) => (
|
||||||
|
<MediaItem key={idx} url={m.url} thumb={m.thumb} onPreview={() => setPreviewImage(m.url)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(post.likes.length > 0 || post.comments.length > 0) && (
|
||||||
|
<div className="post-footer">
|
||||||
|
{post.likes.length > 0 && (
|
||||||
|
<div className="likes-section">
|
||||||
|
<Heart size={14} className="icon" />
|
||||||
|
<span className="likes-list">
|
||||||
|
{post.likes.join('、')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{post.comments.length > 0 && (
|
||||||
|
<div className="comments-section">
|
||||||
|
{post.comments.map((c, idx) => (
|
||||||
|
<div key={idx} className="comment-item">
|
||||||
|
<span className="comment-user">{c.nickname}</span>
|
||||||
|
{c.refNickname && (
|
||||||
|
<>
|
||||||
|
<span className="reply-text">回复</span>
|
||||||
|
<span className="comment-user">{c.refNickname}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="comment-separator">: </span>
|
||||||
|
<span className="comment-content">{c.content}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="status-indicator loading-more">
|
||||||
|
<RefreshCw size={16} className="spinning" />
|
||||||
|
<span>正在加载更多...</span>
|
||||||
|
</div>}
|
||||||
|
{!hasMore && posts.length > 0 && <div className="status-indicator no-more">已经到底啦</div>}
|
||||||
|
{!loading && posts.length === 0 && (
|
||||||
|
<div className="no-results">
|
||||||
|
<div className="no-results-icon"><Search size={48} /></div>
|
||||||
|
<p>未找到相关动态</p>
|
||||||
|
{(selectedUsernames.length > 0 || searchKeyword) && (
|
||||||
|
<button onClick={clearFilters} className="reset-inline">
|
||||||
|
重置搜索条件
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{previewImage && (
|
||||||
|
<ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} />
|
||||||
|
)}
|
||||||
|
<JumpToDateDialog
|
||||||
|
isOpen={showJumpDialog}
|
||||||
|
onClose={() => {
|
||||||
|
setShowJumpDialog(false)
|
||||||
|
}}
|
||||||
|
onSelect={(date) => {
|
||||||
|
setJumpTargetDate(date)
|
||||||
|
setShowJumpDialog(false)
|
||||||
|
}}
|
||||||
|
currentDate={jumpTargetDate || new Date()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
216
src/pages/VideoWindow.scss
Normal file
216
src/pages/VideoWindow.scss
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
.video-window-container {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: #000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.title-bar {
|
||||||
|
height: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
display: flex;
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding-right: 140px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
.window-drag-area {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-viewport {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #000;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0; // 重要:让 flex 子元素可以收缩
|
||||||
|
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-loading-overlay,
|
||||||
|
.video-error-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-error-overlay {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
z-index: 4;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .play-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.4) 60%, transparent);
|
||||||
|
padding: 40px 16px 12px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s;
|
||||||
|
z-index: 6;
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 0.15s;
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary, #4a9eff);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .progress-track {
|
||||||
|
height: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-left,
|
||||||
|
.controls-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
width: 60px;
|
||||||
|
height: 3px;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标悬停时显示控制栏
|
||||||
|
&:hover .video-controls {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放时如果鼠标不动,隐藏控制栏
|
||||||
|
&.hide-controls .video-controls {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-window-empty {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
199
src/pages/VideoWindow.tsx
Normal file
199
src/pages/VideoWindow.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { Play, Pause, Volume2, VolumeX, RotateCcw } from 'lucide-react'
|
||||||
|
import './VideoWindow.scss'
|
||||||
|
|
||||||
|
export default function VideoWindow() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const videoPath = searchParams.get('videoPath')
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [isMuted, setIsMuted] = useState(false)
|
||||||
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
|
const [duration, setDuration] = useState(0)
|
||||||
|
const [volume, setVolume] = useState(1)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const progressRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
//播放/暂停
|
||||||
|
const togglePlay = useCallback(() => {
|
||||||
|
if (!videoRef.current) return
|
||||||
|
if (isPlaying) {
|
||||||
|
videoRef.current.pause()
|
||||||
|
} else {
|
||||||
|
videoRef.current.play()
|
||||||
|
}
|
||||||
|
}, [isPlaying])
|
||||||
|
|
||||||
|
// 静音切换
|
||||||
|
const toggleMute = useCallback(() => {
|
||||||
|
if (!videoRef.current) return
|
||||||
|
videoRef.current.muted = !isMuted
|
||||||
|
setIsMuted(!isMuted)
|
||||||
|
}, [isMuted])
|
||||||
|
|
||||||
|
// 进度条点击
|
||||||
|
const handleProgressClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!videoRef.current || !progressRef.current) return
|
||||||
|
e.stopPropagation()
|
||||||
|
const rect = progressRef.current.getBoundingClientRect()
|
||||||
|
const percent = (e.clientX - rect.left) / rect.width
|
||||||
|
videoRef.current.currentTime = percent * duration
|
||||||
|
}, [duration])
|
||||||
|
|
||||||
|
// 音量调节
|
||||||
|
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newVolume = parseFloat(e.target.value)
|
||||||
|
setVolume(newVolume)
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.volume = newVolume
|
||||||
|
setIsMuted(newVolume === 0)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 重新播放
|
||||||
|
const handleReplay = useCallback(() => {
|
||||||
|
if (!videoRef.current) return
|
||||||
|
videoRef.current.currentTime = 0
|
||||||
|
videoRef.current.play()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 快捷键
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') window.electronAPI.window.close()
|
||||||
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
togglePlay()
|
||||||
|
}
|
||||||
|
if (e.key === 'm' || e.key === 'M') toggleMute()
|
||||||
|
if (e.key === 'ArrowLeft' && videoRef.current) {
|
||||||
|
videoRef.current.currentTime -= 5
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowRight' && videoRef.current) {
|
||||||
|
videoRef.current.currentTime += 5
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp' && videoRef.current) {
|
||||||
|
videoRef.current.volume = Math.min(1, videoRef.current.volume + 0.1)
|
||||||
|
setVolume(videoRef.current.volume)
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown' && videoRef.current) {
|
||||||
|
videoRef.current.volume = Math.max(0, videoRef.current.volume - 0.1)
|
||||||
|
setVolume(videoRef.current.volume)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [togglePlay, toggleMute])
|
||||||
|
|
||||||
|
if (!videoPath) {
|
||||||
|
return (
|
||||||
|
<div className="video-window-empty">
|
||||||
|
<span>无效的视频路径</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="video-window-container">
|
||||||
|
<div className="title-bar">
|
||||||
|
<div className="window-drag-area"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="video-viewport" onClick={togglePlay}>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="video-loading-overlay">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="video-error-overlay">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoPath}
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
const video = e.currentTarget
|
||||||
|
setDuration(video.duration)
|
||||||
|
setIsLoading(false)
|
||||||
|
// 根据视频尺寸调整窗口大小
|
||||||
|
if (video.videoWidth && video.videoHeight) {
|
||||||
|
window.electronAPI.window.resizeToFitVideo(video.videoWidth, video.videoHeight)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
||||||
|
onPlay={() => setIsPlaying(true)}
|
||||||
|
onPause={() => setIsPlaying(false)}
|
||||||
|
onEnded={() => setIsPlaying(false)}
|
||||||
|
onError={() => {
|
||||||
|
setError('视频加载失败')
|
||||||
|
setIsLoading(false)
|
||||||
|
}}
|
||||||
|
onWaiting={() => setIsLoading(true)}
|
||||||
|
onCanPlay={() => setIsLoading(false)}
|
||||||
|
autoPlay
|
||||||
|
/>
|
||||||
|
{!isPlaying && !isLoading && !error && (
|
||||||
|
<div className="play-overlay">
|
||||||
|
<Play size={64} fill="white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="video-controls" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div
|
||||||
|
className="progress-bar"
|
||||||
|
ref={progressRef}
|
||||||
|
onClick={handleProgressClick}
|
||||||
|
>
|
||||||
|
<div className="progress-track">
|
||||||
|
<div className="progress-fill" style={{ width: `${progress}%` }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="controls-row">
|
||||||
|
<div className="controls-left">
|
||||||
|
<button onClick={togglePlay} title={isPlaying ? '暂停 (空格)' : '播放 (空格)'}>
|
||||||
|
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleReplay} title="重新播放">
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="time-display">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="controls-right">
|
||||||
|
<div className="volume-control">
|
||||||
|
<button onClick={toggleMute} title={isMuted ? '取消静音 (M)' : '静音 (M)'}>
|
||||||
|
{isMuted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={isMuted ? 0 : volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="volume-slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -269,15 +269,14 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
await configService.setDecryptKey(decryptKey)
|
await configService.setDecryptKey(decryptKey)
|
||||||
await configService.setMyWxid(wxid)
|
await configService.setMyWxid(wxid)
|
||||||
await configService.setCachePath(cachePath)
|
await configService.setCachePath(cachePath)
|
||||||
if (imageXorKey) {
|
const parsedXorKey = imageXorKey ? parseInt(imageXorKey.replace(/^0x/i, ''), 16) : null
|
||||||
const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16)
|
await configService.setImageXorKey(typeof parsedXorKey === 'number' && !Number.isNaN(parsedXorKey) ? parsedXorKey : 0)
|
||||||
if (!Number.isNaN(parsed)) {
|
await configService.setImageAesKey(imageAesKey || '')
|
||||||
await configService.setImageXorKey(parsed)
|
await configService.setWxidConfig(wxid, {
|
||||||
}
|
decryptKey,
|
||||||
}
|
imageXorKey: typeof parsedXorKey === 'number' && !Number.isNaN(parsedXorKey) ? parsedXorKey : 0,
|
||||||
if (imageAesKey) {
|
imageAesKey
|
||||||
await configService.setImageAesKey(imageAesKey)
|
})
|
||||||
}
|
|
||||||
await configService.setOnboardingDone(true)
|
await configService.setOnboardingDone(true)
|
||||||
|
|
||||||
setDbConnected(true, dbPath)
|
setDbConnected(true, dbPath)
|
||||||
@@ -313,6 +312,67 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
if (isDbConnected) {
|
if (isDbConnected) {
|
||||||
return (
|
return (
|
||||||
<div className={rootClassName}>
|
<div className={rootClassName}>
|
||||||
|
<div className="welcome-container">
|
||||||
|
{showWindowControls && (
|
||||||
|
<div className="window-controls">
|
||||||
|
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
|
||||||
|
<Minus size={14} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="window-btn is-close" onClick={handleCloseWindow} aria-label="关闭">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="welcome-sidebar">
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<img src="./logo.png" alt="WeFlow" className="sidebar-logo" />
|
||||||
|
<div className="sidebar-brand">
|
||||||
|
<span className="brand-name">WeFlow</span>
|
||||||
|
<span className="brand-tag">Connected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-spacer" style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<ShieldCheck size={14} />
|
||||||
|
<span>本地安全存储</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="welcome-content success-content">
|
||||||
|
<div className="success-body">
|
||||||
|
<div className="success-icon">
|
||||||
|
<CheckCircle2 size={48} />
|
||||||
|
</div>
|
||||||
|
<h1 className="success-title">配置已完成</h1>
|
||||||
|
<p className="success-desc">数据库已连接,你可以直接进入首页使用全部功能。</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-large"
|
||||||
|
onClick={() => {
|
||||||
|
if (standalone) {
|
||||||
|
setIsClosing(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.electronAPI.window.completeOnboarding()
|
||||||
|
}, 450)
|
||||||
|
} else {
|
||||||
|
navigate('/home')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
进入首页 <ArrowRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={rootClassName}>
|
||||||
|
<div className="welcome-container">
|
||||||
{showWindowControls && (
|
{showWindowControls && (
|
||||||
<div className="window-controls">
|
<div className="window-controls">
|
||||||
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
|
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
|
||||||
@@ -323,233 +383,204 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="welcome-shell">
|
<div className="welcome-sidebar">
|
||||||
<div className="welcome-panel">
|
<div className="sidebar-header">
|
||||||
<div className="panel-header">
|
<img src="./logo.png" alt="WeFlow" className="sidebar-logo" />
|
||||||
<img src="./logo.png" alt="WeFlow" className="panel-logo" />
|
<div className="sidebar-brand">
|
||||||
<div>
|
<span className="brand-name">WeFlow</span>
|
||||||
<p className="panel-kicker">WeFlow</p>
|
<span className="brand-tag">Setup</span>
|
||||||
<h1>已连接数据库</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-note">
|
|
||||||
<CheckCircle2 size={16} />
|
|
||||||
<span>配置已完成,可直接进入首页</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-full"
|
|
||||||
onClick={() => {
|
|
||||||
if (standalone) {
|
|
||||||
setIsClosing(true)
|
|
||||||
setTimeout(() => {
|
|
||||||
window.electronAPI.window.completeOnboarding()
|
|
||||||
}, 450)
|
|
||||||
} else {
|
|
||||||
navigate('/home')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
进入首页
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
<div className="sidebar-nav">
|
||||||
<div className={rootClassName}>
|
|
||||||
{showWindowControls && (
|
|
||||||
<div className="window-controls">
|
|
||||||
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
|
|
||||||
<Minus size={14} />
|
|
||||||
</button>
|
|
||||||
<button type="button" className="window-btn is-close" onClick={handleCloseWindow} aria-label="关闭">
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="welcome-shell">
|
|
||||||
<div className="welcome-panel">
|
|
||||||
<div className="panel-header">
|
|
||||||
<img src="./logo.png" alt="WeFlow" className="panel-logo" />
|
|
||||||
<div>
|
|
||||||
<p className="panel-kicker">首次配置</p>
|
|
||||||
<h1>WeFlow 初始引导</h1>
|
|
||||||
<p className="panel-subtitle">一步一步完成数据库与密钥设置</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="step-list">
|
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
<div key={step.id} className={`step-item ${index === stepIndex ? 'active' : ''} ${index < stepIndex ? 'done' : ''}`}>
|
<div key={step.id} className={`nav-item ${index === stepIndex ? 'active' : ''} ${index < stepIndex ? 'completed' : ''}`}>
|
||||||
<div className="step-index">{index < stepIndex ? <CheckCircle2 size={14} /> : index + 1}</div>
|
<div className="nav-indicator">
|
||||||
<div>
|
{index < stepIndex ? <CheckCircle2 size={14} /> : <div className="dot" />}
|
||||||
<div className="step-title">{step.title}</div>
|
</div>
|
||||||
<div className="step-desc">{step.desc}</div>
|
<div className="nav-info">
|
||||||
|
<div className="nav-title">{step.title}</div>
|
||||||
|
<div className="nav-desc">{step.desc}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-foot">
|
|
||||||
<ShieldCheck size={16} />
|
<div className="sidebar-footer">
|
||||||
|
<ShieldCheck size={14} />
|
||||||
<span>数据仅在本地处理,不上传服务器</span>
|
<span>数据仅在本地处理,不上传服务器</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="setup-card">
|
<div className="welcome-content">
|
||||||
<div className="setup-header">
|
<div className="content-header">
|
||||||
<div className="setup-icon">
|
|
||||||
{currentStep.id === 'intro' && <Sparkles size={18} />}
|
|
||||||
{currentStep.id === 'db' && <Database size={18} />}
|
|
||||||
{currentStep.id === 'cache' && <HardDrive size={18} />}
|
|
||||||
{currentStep.id === 'key' && <KeyRound size={18} />}
|
|
||||||
{currentStep.id === 'image' && <ShieldCheck size={18} />}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h2>{currentStep.title}</h2>
|
<h2>{currentStep.title}</h2>
|
||||||
<p>{currentStep.desc}</p>
|
<p className="header-desc">{currentStep.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentStep.id === 'intro' && (
|
<div className="content-body">
|
||||||
<div className="setup-body">
|
{currentStep.id === 'intro' && (
|
||||||
<div className="intro-card">
|
<div className="intro-block">
|
||||||
<Wand2 size={18} />
|
{/* 内容移至底部 */}
|
||||||
<div>
|
</div>
|
||||||
<h3>准备好了吗?</h3>
|
)}
|
||||||
<p>接下来只需配置数据库目录和获取解密密钥。</p>
|
|
||||||
|
{currentStep.id === 'db' && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="field-label">数据库根目录</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input"
|
||||||
|
placeholder="例如:C:\\Users\\xxx\\Documents\\xwechat_files"
|
||||||
|
value={dbPath}
|
||||||
|
onChange={(e) => setDbPath(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="action-row">
|
||||||
</div>
|
<button className="btn btn-secondary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
|
||||||
)}
|
<FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'}
|
||||||
|
</button>
|
||||||
{currentStep.id === 'db' && (
|
<button className="btn btn-secondary" onClick={handleSelectPath}>
|
||||||
<div className="setup-body">
|
<FolderOpen size={16} /> 浏览...
|
||||||
<label className="field-label">数据库根目录</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="field-input"
|
|
||||||
placeholder="例如:C:\\Users\\xxx\\Documents\\xwechat_files"
|
|
||||||
value={dbPath}
|
|
||||||
onChange={(e) => setDbPath(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="button-row">
|
|
||||||
<button className="btn btn-secondary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
|
|
||||||
<FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'}
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-primary" onClick={handleSelectPath}>
|
|
||||||
<FolderOpen size={16} /> 浏览选择
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="field-hint">建议选择包含 xwechat_files 的目录</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep.id === 'cache' && (
|
|
||||||
<div className="setup-body">
|
|
||||||
<label className="field-label">缓存目录</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="field-input"
|
|
||||||
placeholder="留空使用默认目录"
|
|
||||||
value={cachePath}
|
|
||||||
onChange={(e) => setCachePath(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="button-row">
|
|
||||||
<button className="btn btn-primary" onClick={handleSelectCachePath}>
|
|
||||||
<FolderOpen size={16} /> 浏览选择
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary" onClick={() => setCachePath('')}>
|
|
||||||
<RotateCcw size={16} /> 使用默认
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="field-hint">用于头像、表情与图片缓存,留空使用默认目录</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep.id === 'key' && (
|
|
||||||
<div className="setup-body">
|
|
||||||
<label className="field-label">微信账号 wxid</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="field-input"
|
|
||||||
placeholder="获取密钥后将自动填充"
|
|
||||||
value={wxid}
|
|
||||||
onChange={(e) => setWxid(e.target.value)}
|
|
||||||
/>
|
|
||||||
<label className="field-label">解密密钥</label>
|
|
||||||
<div className="field-with-toggle">
|
|
||||||
<input
|
|
||||||
type={showDecryptKey ? 'text' : 'password'}
|
|
||||||
className="field-input"
|
|
||||||
placeholder="64 位十六进制密钥"
|
|
||||||
value={decryptKey}
|
|
||||||
onChange={(e) => setDecryptKey(e.target.value.trim())}
|
|
||||||
/>
|
|
||||||
<button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}>
|
|
||||||
{showDecryptKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isManualStartPrompt ? (
|
|
||||||
<div className="manual-prompt">
|
|
||||||
<p className="prompt-text">未能自动启动微信,请手动启动并登录后点击下方确认</p>
|
|
||||||
<button className="btn btn-primary" onClick={handleManualConfirm}>
|
|
||||||
我已启动微信,继续检测
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<button className="btn btn-secondary btn-inline" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}>
|
<div className="field-hint">请选择微信-设置-存储位置对应的目录</div>
|
||||||
{isFetchingDbKey ? '获取中...' : '自动获取密钥'}
|
<div className="field-hint warning">
|
||||||
|
⚠️ 目录路径不可包含中文,如有中文请先在微信中迁移至全英文目录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep.id === 'cache' && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="field-label">缓存目录</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input"
|
||||||
|
placeholder="留空即使用默认目录"
|
||||||
|
value={cachePath}
|
||||||
|
onChange={(e) => setCachePath(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="action-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 className="field-hint">用于头像、表情与图片缓存</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep.id === 'key' && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="field-label">微信账号 (Wxid)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input"
|
||||||
|
placeholder="等待获取..."
|
||||||
|
value={wxid}
|
||||||
|
readOnly
|
||||||
|
onChange={(e) => setWxid(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="field-label mt-4">解密密钥</label>
|
||||||
|
<div className="field-with-toggle">
|
||||||
|
<input
|
||||||
|
type={showDecryptKey ? 'text' : 'password'}
|
||||||
|
className="field-input"
|
||||||
|
placeholder="64 位十六进制密钥"
|
||||||
|
value={decryptKey}
|
||||||
|
onChange={(e) => setDecryptKey(e.target.value.trim())}
|
||||||
|
/>
|
||||||
|
<button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}>
|
||||||
|
{showDecryptKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="key-actions">
|
||||||
|
{isManualStartPrompt ? (
|
||||||
|
<div className="manual-prompt">
|
||||||
|
<p>未能自动启动微信,请手动启动并登录</p>
|
||||||
|
<button className="btn btn-primary" onClick={handleManualConfirm}>
|
||||||
|
我已登录,继续
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-secondary btn-block" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}>
|
||||||
|
{isFetchingDbKey ? '正在获取...' : '自动获取密钥'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dbKeyStatus && <div className="status-message">{dbKeyStatus}</div>}
|
||||||
|
<div className="field-hint">点击自动获取后微信将重启,请留意弹窗提示</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep.id === 'image' && (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="grid-2">
|
||||||
|
<div>
|
||||||
|
<label className="field-label">图片 XOR 密钥</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input"
|
||||||
|
placeholder="0x..."
|
||||||
|
value={imageXorKey}
|
||||||
|
onChange={(e) => setImageXorKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">图片 AES 密钥</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input"
|
||||||
|
placeholder="16位密钥"
|
||||||
|
value={imageAesKey}
|
||||||
|
onChange={(e) => setImageAesKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
||||||
|
{isFetchingImageKey ? '扫描中...' : '自动获取图片密钥'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
|
|
||||||
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
|
{imageKeyStatus && <div className="status-message">{imageKeyStatus}</div>}
|
||||||
<div className="field-hint">获取密钥会自动识别最近登录的账号</div>
|
<div className="field-hint">请在微信中打开几张图片后再点击获取</div>
|
||||||
<div className="field-hint">点击自动获取后微信将重新启动,当页面提示可以登录微信了再点击登录</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{currentStep.id === 'image' && (
|
|
||||||
<div className="setup-body">
|
|
||||||
<label className="field-label">图片 XOR 密钥</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="field-input"
|
|
||||||
placeholder="例如:0xA4"
|
|
||||||
value={imageXorKey}
|
|
||||||
onChange={(e) => setImageXorKey(e.target.value)}
|
|
||||||
/>
|
|
||||||
<label className="field-label">图片 AES 密钥</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="field-input"
|
|
||||||
placeholder="16 位密钥"
|
|
||||||
value={imageAesKey}
|
|
||||||
onChange={(e) => setImageAesKey(e.target.value)}
|
|
||||||
/>
|
|
||||||
<button className="btn btn-secondary btn-inline" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
|
||||||
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
|
||||||
</button>
|
|
||||||
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
|
|
||||||
<div className="field-hint">如获取失败,请先打开朋友圈图片再重试</div>
|
|
||||||
{isFetchingImageKey && <div className="field-hint status-text">正在扫描内存,请稍候...</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
<div className="setup-actions">
|
{currentStep.id === 'intro' && (
|
||||||
<button className="btn btn-tertiary" onClick={handleBack} disabled={stepIndex === 0}>
|
<div className="intro-footer">
|
||||||
|
<p>接下来的几个步骤将引导您连接本地微信数据库。</p>
|
||||||
|
<p>WeFlow 需要访问您的本地数据文件以提供分析与导出功能。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="content-actions">
|
||||||
|
<button className="btn btn-ghost" onClick={handleBack} disabled={stepIndex === 0}>
|
||||||
<ArrowLeft size={16} /> 上一步
|
<ArrowLeft size={16} /> 上一步
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{stepIndex < steps.length - 1 ? (
|
{stepIndex < steps.length - 1 ? (
|
||||||
<button className="btn btn-primary" onClick={handleNext} disabled={!canGoNext()}>
|
<button className="btn btn-primary" onClick={handleNext} disabled={!canGoNext()}>
|
||||||
下一步 <ArrowRight size={16} />
|
下一步 <ArrowRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn btn-primary" onClick={handleConnect} disabled={isConnecting || !canGoNext()}>
|
<button className="btn btn-primary" onClick={handleConnect} disabled={isConnecting || !canGoNext()}>
|
||||||
{isConnecting ? '连接中...' : '测试并完成'}
|
{isConnecting ? '连接中...' : '完成配置'} <ArrowRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const CONFIG_KEYS = {
|
|||||||
DECRYPT_KEY: 'decryptKey',
|
DECRYPT_KEY: 'decryptKey',
|
||||||
DB_PATH: 'dbPath',
|
DB_PATH: 'dbPath',
|
||||||
MY_WXID: 'myWxid',
|
MY_WXID: 'myWxid',
|
||||||
|
WXID_CONFIGS: 'wxidConfigs',
|
||||||
THEME: 'theme',
|
THEME: 'theme',
|
||||||
THEME_ID: 'themeId',
|
THEME_ID: 'themeId',
|
||||||
LAST_SESSION: 'lastSession',
|
LAST_SESSION: 'lastSession',
|
||||||
@@ -17,9 +18,27 @@ export const CONFIG_KEYS = {
|
|||||||
ONBOARDING_DONE: 'onboardingDone',
|
ONBOARDING_DONE: 'onboardingDone',
|
||||||
LLM_MODEL_PATH: 'llmModelPath',
|
LLM_MODEL_PATH: 'llmModelPath',
|
||||||
IMAGE_XOR_KEY: 'imageXorKey',
|
IMAGE_XOR_KEY: 'imageXorKey',
|
||||||
IMAGE_AES_KEY: 'imageAesKey'
|
IMAGE_AES_KEY: 'imageAesKey',
|
||||||
|
WHISPER_MODEL_NAME: 'whisperModelName',
|
||||||
|
WHISPER_MODEL_DIR: 'whisperModelDir',
|
||||||
|
WHISPER_DOWNLOAD_SOURCE: 'whisperDownloadSource',
|
||||||
|
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice',
|
||||||
|
TRANSCRIBE_LANGUAGES: 'transcribeLanguages',
|
||||||
|
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
|
||||||
|
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
||||||
|
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
||||||
|
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
|
||||||
|
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
||||||
|
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
export interface WxidConfig {
|
||||||
|
decryptKey?: string
|
||||||
|
imageXorKey?: number
|
||||||
|
imageAesKey?: string
|
||||||
|
updatedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
// 获取解密密钥
|
// 获取解密密钥
|
||||||
export async function getDecryptKey(): Promise<string | null> {
|
export async function getDecryptKey(): Promise<string | null> {
|
||||||
const value = await config.get(CONFIG_KEYS.DECRYPT_KEY)
|
const value = await config.get(CONFIG_KEYS.DECRYPT_KEY)
|
||||||
@@ -53,6 +72,32 @@ export async function setMyWxid(wxid: string): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.MY_WXID, wxid)
|
await config.set(CONFIG_KEYS.MY_WXID, wxid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWxidConfigs(): Promise<Record<string, WxidConfig>> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.WXID_CONFIGS)
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return value as Record<string, WxidConfig>
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWxidConfig(wxid: string): Promise<WxidConfig | null> {
|
||||||
|
if (!wxid) return null
|
||||||
|
const configs = await getWxidConfigs()
|
||||||
|
return configs[wxid] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setWxidConfig(wxid: string, configValue: WxidConfig): Promise<void> {
|
||||||
|
if (!wxid) return
|
||||||
|
const configs = await getWxidConfigs()
|
||||||
|
const previous = configs[wxid] || {}
|
||||||
|
configs[wxid] = {
|
||||||
|
...previous,
|
||||||
|
...configValue,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
await config.set(CONFIG_KEYS.WXID_CONFIGS, configs)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取主题
|
// 获取主题
|
||||||
export async function getTheme(): Promise<'light' | 'dark'> {
|
export async function getTheme(): Promise<'light' | 'dark'> {
|
||||||
const value = await config.get(CONFIG_KEYS.THEME)
|
const value = await config.get(CONFIG_KEYS.THEME)
|
||||||
@@ -144,6 +189,39 @@ export async function setLlmModelPath(path: string): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path)
|
await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取 Whisper 模型名称
|
||||||
|
export async function getWhisperModelName(): Promise<string | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.WHISPER_MODEL_NAME)
|
||||||
|
return (value as string) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 Whisper 模型名称
|
||||||
|
export async function setWhisperModelName(name: string): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.WHISPER_MODEL_NAME, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Whisper 模型目录
|
||||||
|
export async function getWhisperModelDir(): Promise<string | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.WHISPER_MODEL_DIR)
|
||||||
|
return (value as string) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 Whisper 模型目录
|
||||||
|
export async function setWhisperModelDir(dir: string): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.WHISPER_MODEL_DIR, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Whisper 下载源
|
||||||
|
export async function getWhisperDownloadSource(): Promise<string | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.WHISPER_DOWNLOAD_SOURCE)
|
||||||
|
return (value as string) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 Whisper 下载源
|
||||||
|
export async function setWhisperDownloadSource(source: string): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.WHISPER_DOWNLOAD_SOURCE, source)
|
||||||
|
}
|
||||||
|
|
||||||
// 清除所有配置
|
// 清除所有配置
|
||||||
export async function clearConfig(): Promise<void> {
|
export async function clearConfig(): Promise<void> {
|
||||||
await config.clear()
|
await config.clear()
|
||||||
@@ -182,3 +260,95 @@ export async function getOnboardingDone(): Promise<boolean> {
|
|||||||
export async function setOnboardingDone(done: boolean): Promise<void> {
|
export async function setOnboardingDone(done: boolean): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.ONBOARDING_DONE, done)
|
await config.set(CONFIG_KEYS.ONBOARDING_DONE, done)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取自动语音转文字开关
|
||||||
|
export async function getAutoTranscribeVoice(): Promise<boolean> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.AUTO_TRANSCRIBE_VOICE)
|
||||||
|
return value === true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置自动语音转文字开关
|
||||||
|
export async function setAutoTranscribeVoice(enabled: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.AUTO_TRANSCRIBE_VOICE, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取语音转文字支持的语言列表
|
||||||
|
export async function getTranscribeLanguages(): Promise<string[]> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.TRANSCRIBE_LANGUAGES)
|
||||||
|
// 默认只支持中文
|
||||||
|
return (value as string[]) || ['zh']
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置语音转文字支持的语言列表
|
||||||
|
export async function setTranscribeLanguages(languages: string[]): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.TRANSCRIBE_LANGUAGES, languages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取导出默认格式
|
||||||
|
export async function getExportDefaultFormat(): Promise<string | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT)
|
||||||
|
return (value as string) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置导出默认格式
|
||||||
|
export async function setExportDefaultFormat(format: string): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取导出默认时间范围
|
||||||
|
export async function getExportDefaultDateRange(): Promise<string | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE)
|
||||||
|
return (value as string) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置导出默认时间范围
|
||||||
|
export async function setExportDefaultDateRange(range: string): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取导出默认媒体设置
|
||||||
|
export async function getExportDefaultMedia(): Promise<boolean | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置导出默认媒体设置
|
||||||
|
export async function setExportDefaultMedia(enabled: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取导出默认语音转文字
|
||||||
|
export async function getExportDefaultVoiceAsText(): Promise<boolean | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_VOICE_AS_TEXT)
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置导出默认语音转文字
|
||||||
|
export async function setExportDefaultVoiceAsText(enabled: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_VOICE_AS_TEXT, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取导出默认 Excel 列模式
|
||||||
|
export async function getExportDefaultExcelCompactColumns(): Promise<boolean | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS)
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置导出默认 Excel 列模式
|
||||||
|
export async function setExportDefaultExcelCompactColumns(enabled: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取导出默认 TXT 列配置
|
||||||
|
export async function getExportDefaultTxtColumns(): Promise<string[] | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS)
|
||||||
|
return Array.isArray(value) ? (value as string[]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置导出默认 TXT 列配置
|
||||||
|
export async function setExportDefaultTxtColumns(columns: string[]): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,25 +6,26 @@ export interface ChatState {
|
|||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
isConnecting: boolean
|
isConnecting: boolean
|
||||||
connectionError: string | null
|
connectionError: string | null
|
||||||
|
|
||||||
// 会话列表
|
// 会话列表
|
||||||
sessions: ChatSession[]
|
sessions: ChatSession[]
|
||||||
filteredSessions: ChatSession[]
|
filteredSessions: ChatSession[]
|
||||||
currentSessionId: string | null
|
currentSessionId: string | null
|
||||||
isLoadingSessions: boolean
|
isLoadingSessions: boolean
|
||||||
|
|
||||||
// 消息
|
// 消息
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
isLoadingMessages: boolean
|
isLoadingMessages: boolean
|
||||||
isLoadingMore: boolean
|
isLoadingMore: boolean
|
||||||
hasMoreMessages: boolean
|
hasMoreMessages: boolean
|
||||||
|
hasMoreLater: boolean
|
||||||
|
|
||||||
// 联系人缓存
|
// 联系人缓存
|
||||||
contacts: Map<string, Contact>
|
contacts: Map<string, Contact>
|
||||||
|
|
||||||
// 搜索
|
// 搜索
|
||||||
searchKeyword: string
|
searchKeyword: string
|
||||||
|
|
||||||
// 操作
|
// 操作
|
||||||
setConnected: (connected: boolean) => void
|
setConnected: (connected: boolean) => void
|
||||||
setConnecting: (connecting: boolean) => void
|
setConnecting: (connecting: boolean) => void
|
||||||
@@ -38,6 +39,7 @@ export interface ChatState {
|
|||||||
setLoadingMessages: (loading: boolean) => void
|
setLoadingMessages: (loading: boolean) => void
|
||||||
setLoadingMore: (loading: boolean) => void
|
setLoadingMore: (loading: boolean) => void
|
||||||
setHasMoreMessages: (hasMore: boolean) => void
|
setHasMoreMessages: (hasMore: boolean) => void
|
||||||
|
setHasMoreLater: (hasMore: boolean) => void
|
||||||
setContacts: (contacts: Contact[]) => void
|
setContacts: (contacts: Contact[]) => void
|
||||||
addContact: (contact: Contact) => void
|
addContact: (contact: Contact) => void
|
||||||
setSearchKeyword: (keyword: string) => void
|
setSearchKeyword: (keyword: string) => void
|
||||||
@@ -56,48 +58,51 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
isLoadingMessages: false,
|
isLoadingMessages: false,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
hasMoreMessages: true,
|
hasMoreMessages: true,
|
||||||
|
hasMoreLater: false,
|
||||||
contacts: new Map(),
|
contacts: new Map(),
|
||||||
searchKeyword: '',
|
searchKeyword: '',
|
||||||
|
|
||||||
setConnected: (connected) => set({ isConnected: connected }),
|
setConnected: (connected) => set({ isConnected: connected }),
|
||||||
setConnecting: (connecting) => set({ isConnecting: connecting }),
|
setConnecting: (connecting) => set({ isConnecting: connecting }),
|
||||||
setConnectionError: (error) => set({ connectionError: error }),
|
setConnectionError: (error) => set({ connectionError: error }),
|
||||||
|
|
||||||
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
|
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
|
||||||
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
|
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
|
||||||
|
|
||||||
setCurrentSession: (sessionId) => set({
|
setCurrentSession: (sessionId) => set({
|
||||||
currentSessionId: sessionId,
|
currentSessionId: sessionId,
|
||||||
messages: [],
|
messages: [],
|
||||||
hasMoreMessages: true
|
hasMoreMessages: true,
|
||||||
|
hasMoreLater: false
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
||||||
|
|
||||||
setMessages: (messages) => set({ messages }),
|
setMessages: (messages) => set({ messages }),
|
||||||
|
|
||||||
appendMessages: (newMessages, prepend = false) => set((state) => ({
|
appendMessages: (newMessages, prepend = false) => set((state) => ({
|
||||||
messages: prepend
|
messages: prepend
|
||||||
? [...newMessages, ...state.messages]
|
? [...newMessages, ...state.messages]
|
||||||
: [...state.messages, ...newMessages]
|
: [...state.messages, ...newMessages]
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setLoadingMessages: (loading) => set({ isLoadingMessages: loading }),
|
setLoadingMessages: (loading) => set({ isLoadingMessages: loading }),
|
||||||
setLoadingMore: (loading) => set({ isLoadingMore: loading }),
|
setLoadingMore: (loading) => set({ isLoadingMore: loading }),
|
||||||
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
|
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
|
||||||
|
setHasMoreLater: (hasMore) => set({ hasMoreLater: hasMore }),
|
||||||
setContacts: (contacts) => set({
|
|
||||||
contacts: new Map(contacts.map(c => [c.username, c]))
|
setContacts: (contacts) => set({
|
||||||
|
contacts: new Map(contacts.map(c => [c.username, c]))
|
||||||
}),
|
}),
|
||||||
|
|
||||||
addContact: (contact) => set((state) => {
|
addContact: (contact) => set((state) => {
|
||||||
const newContacts = new Map(state.contacts)
|
const newContacts = new Map(state.contacts)
|
||||||
newContacts.set(contact.username, contact)
|
newContacts.set(contact.username, contact)
|
||||||
return { contacts: newContacts }
|
return { contacts: newContacts }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setSearchKeyword: (keyword) => set({ searchKeyword: keyword }),
|
setSearchKeyword: (keyword) => set({ searchKeyword: keyword }),
|
||||||
|
|
||||||
reset: () => set({
|
reset: () => set({
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
@@ -110,6 +115,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
isLoadingMessages: false,
|
isLoadingMessages: false,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
hasMoreMessages: true,
|
hasMoreMessages: true,
|
||||||
|
hasMoreLater: false,
|
||||||
contacts: new Map(),
|
contacts: new Map(),
|
||||||
searchKeyword: ''
|
searchKeyword: ''
|
||||||
})
|
})
|
||||||
|
|||||||
69
src/types/electron.d.ts
vendored
69
src/types/electron.d.ts
vendored
@@ -9,6 +9,8 @@ export interface ElectronAPI {
|
|||||||
completeOnboarding: () => Promise<boolean>
|
completeOnboarding: () => Promise<boolean>
|
||||||
openOnboardingWindow: () => Promise<boolean>
|
openOnboardingWindow: () => Promise<boolean>
|
||||||
setTitleBarOverlay: (options: { symbolColor: string }) => void
|
setTitleBarOverlay: (options: { symbolColor: string }) => void
|
||||||
|
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
|
||||||
|
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
||||||
}
|
}
|
||||||
config: {
|
config: {
|
||||||
get: (key: string) => Promise<unknown>
|
get: (key: string) => Promise<unknown>
|
||||||
@@ -61,7 +63,7 @@ export interface ElectronAPI {
|
|||||||
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{
|
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
@@ -94,7 +96,11 @@ export interface ElectronAPI {
|
|||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
getVoiceData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
|
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
|
||||||
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||||
|
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||||
|
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
image: {
|
image: {
|
||||||
@@ -104,6 +110,21 @@ export interface ElectronAPI {
|
|||||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
||||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
|
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
|
||||||
}
|
}
|
||||||
|
video: {
|
||||||
|
getVideoInfo: (videoMd5: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
exists: boolean
|
||||||
|
videoUrl?: string
|
||||||
|
coverUrl?: string
|
||||||
|
thumbUrl?: string
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
parseVideoMd5: (content: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
md5?: string
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
analytics: {
|
analytics: {
|
||||||
getOverallStatistics: (force?: boolean) => Promise<{
|
getOverallStatistics: (force?: boolean) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -148,6 +169,11 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||||
}
|
}
|
||||||
|
cache: {
|
||||||
|
clearAnalytics: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
clearImages: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
clearAll: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
}
|
||||||
groupAnalytics: {
|
groupAnalytics: {
|
||||||
getGroupChats: () => Promise<{
|
getGroupChats: () => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -289,6 +315,30 @@ export interface ElectronAPI {
|
|||||||
success: boolean
|
success: boolean
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
onProgress: (callback: (payload: ExportProgress) => void) => () => void
|
||||||
|
}
|
||||||
|
whisper: {
|
||||||
|
downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }>
|
||||||
|
getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }>
|
||||||
|
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void
|
||||||
|
}
|
||||||
|
sns: {
|
||||||
|
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
timeline?: Array<{
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
avatarUrl?: string
|
||||||
|
createTime: number
|
||||||
|
contentDesc: string
|
||||||
|
type?: number
|
||||||
|
media: Array<{ url: string; thumb: string }>
|
||||||
|
likes: Array<string>
|
||||||
|
comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }>
|
||||||
|
}>
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +347,21 @@ export interface ExportOptions {
|
|||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null
|
||||||
exportMedia?: boolean
|
exportMedia?: boolean
|
||||||
exportAvatars?: boolean
|
exportAvatars?: boolean
|
||||||
|
exportImages?: boolean
|
||||||
|
exportVoices?: boolean
|
||||||
|
exportEmojis?: boolean
|
||||||
|
exportVoiceAsText?: boolean
|
||||||
|
excelCompactColumns?: boolean
|
||||||
|
txtColumns?: string[]
|
||||||
|
sessionLayout?: 'shared' | 'per-session'
|
||||||
|
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportProgress {
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
currentSession: string
|
||||||
|
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WxidInfo {
|
export interface WxidInfo {
|
||||||
|
|||||||
@@ -33,11 +33,14 @@ export interface Message {
|
|||||||
isSend: number | null
|
isSend: number | null
|
||||||
senderUsername: string | null
|
senderUsername: string | null
|
||||||
parsedContent: string
|
parsedContent: string
|
||||||
|
rawContent?: string // 原始消息内容(保留用于兼容)
|
||||||
|
content?: string // 原始消息内容(XML)
|
||||||
imageMd5?: string
|
imageMd5?: string
|
||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
emojiCdnUrl?: string
|
emojiCdnUrl?: string
|
||||||
emojiMd5?: string
|
emojiMd5?: string
|
||||||
voiceDurationSeconds?: number
|
voiceDurationSeconds?: number
|
||||||
|
videoMd5?: string
|
||||||
// 引用消息
|
// 引用消息
|
||||||
quotedContent?: string
|
quotedContent?: string
|
||||||
quotedSender?: string
|
quotedSender?: string
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
"electron/**/*.ts"
|
"electron/**/*.ts",
|
||||||
|
"electron/**/*.d.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ export default defineConfig({
|
|||||||
external: [
|
external: [
|
||||||
'better-sqlite3',
|
'better-sqlite3',
|
||||||
'koffi',
|
'koffi',
|
||||||
'fsevents'
|
'fsevents',
|
||||||
|
'whisper-node',
|
||||||
|
'shelljs',
|
||||||
|
'exceljs'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,6 +90,23 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
entry: 'electron/transcribeWorker.ts',
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-electron',
|
||||||
|
rollupOptions: {
|
||||||
|
external: [
|
||||||
|
'sherpa-onnx-node'
|
||||||
|
],
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'transcribeWorker.js',
|
||||||
|
inlineDynamicImports: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
entry: 'electron/preload.ts',
|
entry: 'electron/preload.ts',
|
||||||
onstart(options) {
|
onstart(options) {
|
||||||
|
|||||||
Reference in New Issue
Block a user