mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-26 15:45:51 +00:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7365831ec1 | ||
|
|
4a09b682b2 | ||
|
|
afbd52a91e | ||
|
|
1c6e14acb4 | ||
|
|
6968936c8f | ||
|
|
a571278145 | ||
|
|
e4e25394e2 | ||
|
|
fe47d7b9e3 | ||
|
|
4bb5bc6e32 | ||
|
|
49d951e96a | ||
|
|
9585a02959 | ||
|
|
a51fa5e4a2 | ||
|
|
bc0671440c | ||
|
|
1a07c3970f | ||
|
|
83c07b27f9 | ||
|
|
fbcf7d2fc3 | ||
|
|
b547ac1aed | ||
|
|
411f8a8d61 | ||
|
|
b3741a5cf4 | ||
|
|
b1cf524612 | ||
|
|
364c920fff | ||
|
|
e89ccee5f4 | ||
|
|
6a86e69cd4 | ||
|
|
ab2c086e93 | ||
|
|
b9c65e634c | ||
|
|
b7852a8c07 | ||
|
|
4b9d94eb62 | ||
|
|
70481fd468 | ||
|
|
52c67f4d23 | ||
|
|
d3618f3065 | ||
|
|
29472beee8 | ||
|
|
acaac507b1 | ||
|
|
f25c23b2b3 | ||
|
|
5ab0466a87 | ||
|
|
d49c44f3be | ||
|
|
4577b4e955 | ||
|
|
dafde2eaba | ||
|
|
db4fab9130 | ||
|
|
9aee578707 | ||
|
|
6d74eb65ae | ||
|
|
6e8ae3a12b | ||
|
|
a4be7f9005 | ||
|
|
587ee630d7 | ||
|
|
6952a5f680 | ||
|
|
b263ecd45c | ||
|
|
74fc0e4e88 | ||
|
|
a873366342 | ||
|
|
c4dc266f93 | ||
|
|
96ff783bbd | ||
|
|
804a65f52b | ||
|
|
e88c859f4f | ||
|
|
c1a393eaf6 | ||
|
|
15e08dc529 | ||
|
|
e55bcaf7eb | ||
|
|
4e64c6ad6e | ||
|
|
5a15e1a1d6 | ||
|
|
ba07d47496 | ||
|
|
25325e80ee | ||
|
|
89783b4d45 | ||
|
|
d5f0094025 | ||
|
|
b4f37451be | ||
|
|
84ea378815 | ||
|
|
72d4db1f27 | ||
|
|
21ea879d97 | ||
|
|
a5baef2240 | ||
|
|
bbecf54aba | ||
|
|
5f868d193c | ||
|
|
62b035ab39 | ||
|
|
ff5ee33e08 | ||
|
|
8e28016e5e | ||
|
|
f17a18cb6d | ||
|
|
999f45e5f5 | ||
|
|
3e303fadd7 | ||
|
|
3b7590d8ce | ||
|
|
fabbada580 | ||
|
|
6e434d37dc | ||
|
|
904da80f81 | ||
|
|
2a4bd52f0a | ||
|
|
b4248d4a12 | ||
|
|
75b056d5ba | ||
|
|
e87e12c939 | ||
|
|
5cb7e3bc73 | ||
|
|
1930b91a5b | ||
|
|
ea0dad132c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -61,3 +61,6 @@ wcdb/
|
|||||||
chatlab-format.md
|
chatlab-format.md
|
||||||
*.bak
|
*.bak
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
.claude/
|
||||||
|
.agents/
|
||||||
|
resources/wx_send
|
||||||
@@ -19,6 +19,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/hicccc77/WeFlow/issues">
|
<a href="https://github.com/hicccc77/WeFlow/issues">
|
||||||
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
||||||
|
<img src="https://gh-down-badges.linkof.link/hicccc77/WeFlow/" alt="Downloads" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://t.me/weflow_cc">
|
<a href="https://t.me/weflow_cc">
|
||||||
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
||||||
@@ -35,6 +36,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
- 本地实时查看聊天记录
|
- 本地实时查看聊天记录
|
||||||
|
- 朋友圈图片、视频、**实况**的预览和解密
|
||||||
- 统计分析与群聊画像
|
- 统计分析与群聊画像
|
||||||
- 年度报告与可视化概览
|
- 年度报告与可视化概览
|
||||||
- 导出聊天记录为 HTML 等格式
|
- 导出聊天记录为 HTML 等格式
|
||||||
@@ -86,6 +88,7 @@ npm run build
|
|||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
||||||
|
- [WeChat-Channels-Video-File-Decryption](https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption) 提供了视频解密相关的技术参考
|
||||||
|
|
||||||
## 支持我们
|
## 支持我们
|
||||||
|
|
||||||
|
|||||||
@@ -50,12 +50,20 @@ GET /api/v1/messages
|
|||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
|--------|------|------|------|
|
||||||
| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) |
|
| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) |
|
||||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
| `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` |
|
||||||
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
|
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
|
||||||
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
|
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
|
||||||
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
|
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
|
||||||
|
| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) |
|
||||||
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
|
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
|
||||||
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
|
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
|
||||||
|
| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti`);`0` 时媒体返回占位符 |
|
||||||
|
| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian`) |
|
||||||
|
| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce`) |
|
||||||
|
| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` |
|
||||||
|
| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` |
|
||||||
|
|
||||||
|
默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media`
|
||||||
|
|
||||||
**示例请求**
|
**示例请求**
|
||||||
|
|
||||||
@@ -68,6 +76,12 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1
|
|||||||
|
|
||||||
# 带时间范围查询
|
# 带时间范围查询
|
||||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
|
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
|
||||||
|
|
||||||
|
# 开启媒体导出(只导出图片和语音)
|
||||||
|
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0
|
||||||
|
|
||||||
|
# 关键词过滤
|
||||||
|
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应(原始格式)**
|
**响应(原始格式)**
|
||||||
@@ -77,15 +91,21 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=202
|
|||||||
"talker": "wxid_xxx",
|
"talker": "wxid_xxx",
|
||||||
"count": 50,
|
"count": 50,
|
||||||
"hasMore": true,
|
"hasMore": true,
|
||||||
|
"media": {
|
||||||
|
"enabled": true,
|
||||||
|
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||||
|
"count": 12
|
||||||
|
},
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"localId": 123,
|
"localId": 123,
|
||||||
"talker": "wxid_xxx",
|
"localType": 3,
|
||||||
"type": 1,
|
"content": "[图片]",
|
||||||
"content": "消息内容",
|
|
||||||
"createTime": 1738713600000,
|
"createTime": 1738713600000,
|
||||||
"isSelf": false,
|
"senderUsername": "wxid_sender",
|
||||||
"sender": "wxid_sender"
|
"mediaType": "image",
|
||||||
|
"mediaFileName": "image_123.jpg",
|
||||||
|
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -119,9 +139,15 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=202
|
|||||||
"accountName": "用户名",
|
"accountName": "用户名",
|
||||||
"timestamp": 1738713600000,
|
"timestamp": 1738713600000,
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"content": "消息内容"
|
"content": "消息内容",
|
||||||
|
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"media": {
|
||||||
|
"enabled": true,
|
||||||
|
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||||
|
"count": 12
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
4707
electron/assets/wasm/wasm_video_decode.js
Normal file
4707
electron/assets/wasm/wasm_video_decode.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
electron/assets/wasm/wasm_video_decode.wasm
Normal file
BIN
electron/assets/wasm/wasm_video_decode.wasm
Normal file
Binary file not shown.
458
electron/main.ts
458
electron/main.ts
@@ -18,10 +18,10 @@ import { exportService, ExportOptions, ExportProgress } from './services/exportS
|
|||||||
import { KeyService } from './services/keyService'
|
import { KeyService } from './services/keyService'
|
||||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||||
import { videoService } from './services/videoService'
|
import { videoService } from './services/videoService'
|
||||||
import { snsService } from './services/snsService'
|
import { snsService, isVideoUrl } from './services/snsService'
|
||||||
import { contactExportService } from './services/contactExportService'
|
import { contactExportService } from './services/contactExportService'
|
||||||
import { windowsHelloService } from './services/windowsHelloService'
|
import { windowsHelloService } from './services/windowsHelloService'
|
||||||
import { llamaService } from './services/llamaService'
|
|
||||||
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||||
import { httpService } from './services/httpService'
|
import { httpService } from './services/httpService'
|
||||||
|
|
||||||
@@ -82,11 +82,18 @@ let configService: ConfigService | null = null
|
|||||||
// 协议窗口实例
|
// 协议窗口实例
|
||||||
let agreementWindow: BrowserWindow | null = null
|
let agreementWindow: BrowserWindow | null = null
|
||||||
let onboardingWindow: BrowserWindow | null = null
|
let onboardingWindow: BrowserWindow | null = null
|
||||||
|
// Splash 启动窗口
|
||||||
|
let splashWindow: BrowserWindow | null = null
|
||||||
const keyService = new KeyService()
|
const keyService = new KeyService()
|
||||||
|
|
||||||
let mainWindowReady = false
|
let mainWindowReady = false
|
||||||
let shouldShowMain = true
|
let shouldShowMain = true
|
||||||
|
|
||||||
|
// 更新下载状态管理(Issue #294 修复)
|
||||||
|
let isDownloadInProgress = false
|
||||||
|
let downloadProgressHandler: ((progress: any) => void) | null = null
|
||||||
|
let downloadedHandler: (() => void) | null = null
|
||||||
|
|
||||||
function createWindow(options: { autoShow?: boolean } = {}) {
|
function createWindow(options: { autoShow?: boolean } = {}) {
|
||||||
// 获取图标路径 - 打包后在 resources 目录
|
// 获取图标路径 - 打包后在 resources 目录
|
||||||
const { autoShow = true } = options
|
const { autoShow = true } = options
|
||||||
@@ -104,7 +111,8 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, 'preload.js'),
|
preload: join(__dirname, 'preload.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false
|
nodeIntegration: false,
|
||||||
|
webSecurity: false // Allow loading local files (video playback)
|
||||||
},
|
},
|
||||||
titleBarStyle: 'hidden',
|
titleBarStyle: 'hidden',
|
||||||
titleBarOverlay: {
|
titleBarOverlay: {
|
||||||
@@ -116,9 +124,10 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 窗口准备好后显示
|
// 窗口准备好后显示
|
||||||
|
// Splash 模式下不在这里 show,由启动流程统一控制
|
||||||
win.once('ready-to-show', () => {
|
win.once('ready-to-show', () => {
|
||||||
mainWindowReady = true
|
mainWindowReady = true
|
||||||
if (autoShow || shouldShowMain) {
|
if (autoShow && !splashWindow) {
|
||||||
win.show()
|
win.show()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -172,6 +181,20 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 忽略微信 CDN 域名的证书错误(部分节点证书配置不正确)
|
||||||
|
win.webContents.on('certificate-error', (event, url, _error, _cert, callback) => {
|
||||||
|
const trusted = ['.qq.com', '.qpic.cn', '.weixin.qq.com', '.wechat.com']
|
||||||
|
try {
|
||||||
|
const host = new URL(url).hostname
|
||||||
|
if (trusted.some(d => host.endsWith(d))) {
|
||||||
|
event.preventDefault()
|
||||||
|
callback(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
callback(false)
|
||||||
|
})
|
||||||
|
|
||||||
return win
|
return win
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +253,73 @@ function createAgreementWindow() {
|
|||||||
return agreementWindow
|
return agreementWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Splash 启动窗口
|
||||||
|
* 使用纯 HTML 页面,不依赖 React,确保极速显示
|
||||||
|
*/
|
||||||
|
function createSplashWindow(): BrowserWindow {
|
||||||
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
|
const iconPath = isDev
|
||||||
|
? join(__dirname, '../public/icon.ico')
|
||||||
|
: join(process.resourcesPath, 'icon.ico')
|
||||||
|
|
||||||
|
splashWindow = new BrowserWindow({
|
||||||
|
width: 760,
|
||||||
|
height: 460,
|
||||||
|
resizable: false,
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
backgroundColor: '#00000000',
|
||||||
|
hasShadow: false,
|
||||||
|
center: true,
|
||||||
|
skipTaskbar: false,
|
||||||
|
icon: iconPath,
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
// 不需要 preload —— 通过 executeJavaScript 单向推送进度
|
||||||
|
},
|
||||||
|
show: false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
splashWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}splash.html`)
|
||||||
|
} else {
|
||||||
|
splashWindow.loadFile(join(__dirname, '../dist/splash.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
splashWindow.once('ready-to-show', () => {
|
||||||
|
splashWindow?.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
splashWindow.on('closed', () => {
|
||||||
|
splashWindow = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return splashWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向 Splash 窗口发送进度更新
|
||||||
|
*/
|
||||||
|
function updateSplashProgress(percent: number, text: string, indeterminate = false) {
|
||||||
|
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||||
|
splashWindow.webContents
|
||||||
|
.executeJavaScript(`updateProgress(${percent}, ${JSON.stringify(text)}, ${indeterminate})`)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭 Splash 窗口
|
||||||
|
*/
|
||||||
|
function closeSplash() {
|
||||||
|
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||||
|
splashWindow.close()
|
||||||
|
splashWindow = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建首次引导窗口
|
* 创建首次引导窗口
|
||||||
*/
|
*/
|
||||||
@@ -382,7 +472,7 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe
|
|||||||
/**
|
/**
|
||||||
* 创建独立的图片查看窗口
|
* 创建独立的图片查看窗口
|
||||||
*/
|
*/
|
||||||
function createImageViewerWindow(imagePath: string) {
|
function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
|
||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? join(__dirname, '../public/icon.ico')
|
? join(__dirname, '../public/icon.ico')
|
||||||
@@ -415,7 +505,8 @@ function createImageViewerWindow(imagePath: string) {
|
|||||||
win.show()
|
win.show()
|
||||||
})
|
})
|
||||||
|
|
||||||
const imageParam = `imagePath=${encodeURIComponent(imagePath)}`
|
let imageParam = `imagePath=${encodeURIComponent(imagePath)}`
|
||||||
|
if (liveVideoPath) imageParam += `&liveVideoPath=${encodeURIComponent(liveVideoPath)}`
|
||||||
|
|
||||||
if (process.env.VITE_DEV_SERVER_URL) {
|
if (process.env.VITE_DEV_SERVER_URL) {
|
||||||
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`)
|
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`)
|
||||||
@@ -602,22 +693,61 @@ function registerIpcHandlers() {
|
|||||||
if (!AUTO_UPDATE_ENABLED) {
|
if (!AUTO_UPDATE_ENABLED) {
|
||||||
throw new Error('自动更新已暂时禁用')
|
throw new Error('自动更新已暂时禁用')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 防止重复下载(Issue #294 修复)
|
||||||
|
if (isDownloadInProgress) {
|
||||||
|
throw new Error('更新正在下载中,请稍候')
|
||||||
|
}
|
||||||
|
|
||||||
|
isDownloadInProgress = true
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
|
||||||
// 监听下载进度
|
// 清理旧的监听器(Issue #294 修复:防止监听器泄漏)
|
||||||
autoUpdater.on('download-progress', (progress) => {
|
if (downloadProgressHandler) {
|
||||||
win?.webContents.send('app:downloadProgress', progress)
|
autoUpdater.removeListener('download-progress', downloadProgressHandler)
|
||||||
})
|
downloadProgressHandler = null
|
||||||
|
}
|
||||||
|
if (downloadedHandler) {
|
||||||
|
autoUpdater.removeListener('update-downloaded', downloadedHandler)
|
||||||
|
downloadedHandler = null
|
||||||
|
}
|
||||||
|
|
||||||
// 下载完成后自动安装
|
// 创建新的监听器并保存引用
|
||||||
autoUpdater.on('update-downloaded', () => {
|
downloadProgressHandler = (progress) => {
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.webContents.send('app:downloadProgress', progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadedHandler = () => {
|
||||||
|
console.log('[Update] 更新下载完成,准备安装')
|
||||||
|
if (downloadProgressHandler) {
|
||||||
|
autoUpdater.removeListener('download-progress', downloadProgressHandler)
|
||||||
|
downloadProgressHandler = null
|
||||||
|
}
|
||||||
|
downloadedHandler = null
|
||||||
|
isDownloadInProgress = false
|
||||||
autoUpdater.quitAndInstall(false, true)
|
autoUpdater.quitAndInstall(false, true)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
autoUpdater.on('download-progress', downloadProgressHandler)
|
||||||
|
autoUpdater.once('update-downloaded', downloadedHandler)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[Update] 开始下载更新...')
|
||||||
await autoUpdater.downloadUpdate()
|
await autoUpdater.downloadUpdate()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('下载更新失败:', error)
|
console.error('[Update] 下载更新失败:', error)
|
||||||
|
// 失败时清理状态和监听器
|
||||||
|
isDownloadInProgress = false
|
||||||
|
if (downloadProgressHandler) {
|
||||||
|
autoUpdater.removeListener('download-progress', downloadProgressHandler)
|
||||||
|
downloadProgressHandler = null
|
||||||
|
}
|
||||||
|
if (downloadedHandler) {
|
||||||
|
autoUpdater.removeListener('update-downloaded', downloadedHandler)
|
||||||
|
downloadedHandler = null
|
||||||
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -798,67 +928,18 @@ function registerIpcHandlers() {
|
|||||||
return chatService.getNewMessages(sessionId, minTime, limit)
|
return chatService.getNewMessages(sessionId, minTime, limit)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('chat:updateMessage', async (_, sessionId: string, localId: number, createTime: number, newContent: string) => {
|
||||||
|
return chatService.updateMessage(sessionId, localId, createTime, newContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('chat:deleteMessage', async (_, sessionId: string, localId: number, createTime: number, dbPathHint?: string) => {
|
||||||
|
return chatService.deleteMessage(sessionId, localId, createTime, dbPathHint)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getContact', async (_, username: string) => {
|
ipcMain.handle('chat:getContact', async (_, username: string) => {
|
||||||
return await chatService.getContact(username)
|
return await chatService.getContact(username)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Llama AI
|
|
||||||
ipcMain.handle('llama:init', async () => {
|
|
||||||
return await llamaService.init()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('llama:loadModel', async (_, modelPath: string) => {
|
|
||||||
return llamaService.loadModel(modelPath)
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('llama:createSession', async (_, systemPrompt?: string) => {
|
|
||||||
return llamaService.createSession(systemPrompt)
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('llama:chat', async (event, message: string, options?: { thinking?: boolean }) => {
|
|
||||||
// We use a callback to stream back to the renderer
|
|
||||||
const webContents = event.sender
|
|
||||||
try {
|
|
||||||
if (!webContents) return { success: false, error: 'No sender' }
|
|
||||||
|
|
||||||
const response = await llamaService.chat(message, options, (token) => {
|
|
||||||
if (!webContents.isDestroyed()) {
|
|
||||||
webContents.send('llama:token', token)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return { success: true, response }
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: String(e) }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('llama:downloadModel', async (event, url: string, savePath: string) => {
|
|
||||||
const webContents = event.sender
|
|
||||||
try {
|
|
||||||
await llamaService.downloadModel(url, savePath, (payload) => {
|
|
||||||
if (!webContents.isDestroyed()) {
|
|
||||||
webContents.send('llama:downloadProgress', payload)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return { success: true }
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: String(e) }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('llama:getModelsPath', async () => {
|
|
||||||
return llamaService.getModelsPath()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('llama:checkFileExists', async (_, filePath: string) => {
|
|
||||||
const { existsSync } = await import('fs')
|
|
||||||
return existsSync(filePath)
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('llama:getModelStatus', async (_, modelPath: string) => {
|
|
||||||
return llamaService.getModelStatus(modelPath)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => {
|
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => {
|
||||||
return await chatService.getContactAvatar(username)
|
return await chatService.getContactAvatar(username)
|
||||||
@@ -903,6 +984,9 @@ function registerIpcHandlers() {
|
|||||||
ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => {
|
ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => {
|
||||||
return chatService.getAllVoiceMessages(sessionId)
|
return chatService.getAllVoiceMessages(sessionId)
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('chat:getAllImageMessages', async (_, sessionId: string) => {
|
||||||
|
return chatService.getAllImageMessages(sessionId)
|
||||||
|
})
|
||||||
ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => {
|
ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => {
|
||||||
return chatService.getMessageDates(sessionId)
|
return chatService.getMessageDates(sessionId)
|
||||||
})
|
})
|
||||||
@@ -928,12 +1012,74 @@ function registerIpcHandlers() {
|
|||||||
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:getSnsUsernames', async () => {
|
||||||
|
return snsService.getSnsUsernames()
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('sns:debugResource', async (_, url: string) => {
|
ipcMain.handle('sns:debugResource', async (_, url: string) => {
|
||||||
return snsService.debugResource(url)
|
return snsService.debugResource(url)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('sns:proxyImage', async (_, url: string) => {
|
ipcMain.handle('sns:proxyImage', async (_, payload: string | { url: string; key?: string | number }) => {
|
||||||
return snsService.proxyImage(url)
|
const url = typeof payload === 'string' ? payload : payload?.url
|
||||||
|
const key = typeof payload === 'string' ? undefined : payload?.key
|
||||||
|
return snsService.proxyImage(url, key)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:downloadImage', async (_, payload: { url: string; key?: string | number }) => {
|
||||||
|
try {
|
||||||
|
const { url, key } = payload
|
||||||
|
const result = await snsService.downloadImage(url, key)
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return { success: false, error: result.error || '下载图片失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dialog } = await import('electron')
|
||||||
|
const ext = (result.contentType || '').split('/')[1] || 'jpg'
|
||||||
|
const defaultPath = `SNS_${Date.now()}.${ext}`
|
||||||
|
|
||||||
|
|
||||||
|
const filters = isVideoUrl(url)
|
||||||
|
? [{ name: 'Videos', extensions: ['mp4', 'mov', 'avi', 'mkv'] }]
|
||||||
|
: [{ name: 'Images', extensions: [ext, 'jpg', 'jpeg', 'png', 'webp', 'gif'] }]
|
||||||
|
|
||||||
|
const { filePath, canceled } = await dialog.showSaveDialog({
|
||||||
|
defaultPath,
|
||||||
|
filters
|
||||||
|
})
|
||||||
|
|
||||||
|
if (canceled || !filePath) {
|
||||||
|
return { success: false, error: '用户已取消' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs = await import('fs/promises')
|
||||||
|
await fs.writeFile(filePath, result.data)
|
||||||
|
|
||||||
|
return { success: true, filePath }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:exportTimeline', async (event, options: any) => {
|
||||||
|
return snsService.exportTimeline(options, (progress) => {
|
||||||
|
if (!event.sender.isDestroyed()) {
|
||||||
|
event.sender.send('sns:exportProgress', progress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:selectExportDir', async () => {
|
||||||
|
const { dialog } = await import('electron')
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
properties: ['openDirectory', 'createDirectory'],
|
||||||
|
title: '选择导出目录'
|
||||||
|
})
|
||||||
|
if (result.canceled || !result.filePaths?.[0]) {
|
||||||
|
return { canceled: true }
|
||||||
|
}
|
||||||
|
return { canceled: false, filePath: result.filePaths[0] }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 私聊克隆
|
// 私聊克隆
|
||||||
@@ -958,7 +1104,65 @@ function registerIpcHandlers() {
|
|||||||
? mainWindow
|
? mainWindow
|
||||||
: (BrowserWindow.fromWebContents(event.sender) || undefined)
|
: (BrowserWindow.fromWebContents(event.sender) || undefined)
|
||||||
|
|
||||||
return windowsHelloService.verify(message, targetWin)
|
const result = await windowsHelloService.verify(message, targetWin)
|
||||||
|
|
||||||
|
// Hello 验证成功后,自动用 authHelloSecret 中的密码解锁密钥
|
||||||
|
if (result && configService) {
|
||||||
|
const secret = configService.getHelloSecret()
|
||||||
|
if (secret && configService.isLockMode()) {
|
||||||
|
configService.unlock(secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// 验证应用锁状态(检测 lock: 前缀,防篡改)
|
||||||
|
ipcMain.handle('auth:verifyEnabled', async () => {
|
||||||
|
return configService?.verifyAuthEnabled() ?? false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 密码解锁(验证 + 解密密钥到内存)
|
||||||
|
ipcMain.handle('auth:unlock', async (_event, password: string) => {
|
||||||
|
if (!configService) return { success: false, error: '配置服务未初始化' }
|
||||||
|
return configService.unlock(password)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 开启应用锁
|
||||||
|
ipcMain.handle('auth:enableLock', async (_event, password: string) => {
|
||||||
|
if (!configService) return { success: false, error: '配置服务未初始化' }
|
||||||
|
return configService.enableLock(password)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 关闭应用锁
|
||||||
|
ipcMain.handle('auth:disableLock', async (_event, password: string) => {
|
||||||
|
if (!configService) return { success: false, error: '配置服务未初始化' }
|
||||||
|
return configService.disableLock(password)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 修改密码
|
||||||
|
ipcMain.handle('auth:changePassword', async (_event, oldPassword: string, newPassword: string) => {
|
||||||
|
if (!configService) return { success: false, error: '配置服务未初始化' }
|
||||||
|
return configService.changePassword(oldPassword, newPassword)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置 Hello Secret
|
||||||
|
ipcMain.handle('auth:setHelloSecret', async (_event, password: string) => {
|
||||||
|
if (!configService) return { success: false }
|
||||||
|
configService.setHelloSecret(password)
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 清除 Hello Secret
|
||||||
|
ipcMain.handle('auth:clearHelloSecret', async () => {
|
||||||
|
if (!configService) return { success: false }
|
||||||
|
configService.clearHelloSecret()
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查是否处于 lock: 模式
|
||||||
|
ipcMain.handle('auth:isLockMode', async () => {
|
||||||
|
return configService?.isLockMode() ?? false
|
||||||
})
|
})
|
||||||
|
|
||||||
// 导出相关
|
// 导出相关
|
||||||
@@ -1077,6 +1281,13 @@ function registerIpcHandlers() {
|
|||||||
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
|
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'groupAnalytics:exportGroupMemberMessages',
|
||||||
|
async (_, chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) => {
|
||||||
|
return groupAnalyticsService.exportGroupMemberMessages(chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 打开协议窗口
|
// 打开协议窗口
|
||||||
ipcMain.handle('window:openAgreementWindow', async () => {
|
ipcMain.handle('window:openAgreementWindow', async () => {
|
||||||
createAgreementWindow()
|
createAgreementWindow()
|
||||||
@@ -1084,8 +1295,18 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 打开图片查看窗口
|
// 打开图片查看窗口
|
||||||
ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => {
|
ipcMain.handle('window:openImageViewerWindow', async (_, imagePath: string, liveVideoPath?: string) => {
|
||||||
createImageViewerWindow(imagePath)
|
// 如果是 dataUrl,写入临时文件
|
||||||
|
if (imagePath.startsWith('data:')) {
|
||||||
|
const commaIdx = imagePath.indexOf(',')
|
||||||
|
const meta = imagePath.slice(5, commaIdx) // e.g. "image/jpeg;base64"
|
||||||
|
const ext = meta.split('/')[1]?.split(';')[0] || 'jpg'
|
||||||
|
const tmpPath = join(app.getPath('temp'), `weflow_preview_${Date.now()}.${ext}`)
|
||||||
|
await writeFile(tmpPath, Buffer.from(imagePath.slice(commaIdx + 1), 'base64'))
|
||||||
|
createImageViewerWindow(`file://${tmpPath.replace(/\\/g, '/')}`, liveVideoPath)
|
||||||
|
} else {
|
||||||
|
createImageViewerWindow(imagePath, liveVideoPath)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 完成引导,关闭引导窗口并显示主窗口
|
// 完成引导,关闭引导窗口并显示主窗口
|
||||||
@@ -1311,7 +1532,8 @@ function registerIpcHandlers() {
|
|||||||
ipcMain.handle('http:status', async () => {
|
ipcMain.handle('http:status', async () => {
|
||||||
return {
|
return {
|
||||||
running: httpService.isRunning(),
|
running: httpService.isRunning(),
|
||||||
port: httpService.getPort()
|
port: httpService.getPort(),
|
||||||
|
mediaExportPath: httpService.getDefaultMediaExportPath()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1356,26 +1578,70 @@ function checkForUpdatesOnStartup() {
|
|||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(async () => {
|
||||||
|
// 立即创建 Splash 窗口,确保用户尽快看到反馈
|
||||||
|
createSplashWindow()
|
||||||
|
|
||||||
|
// 等待 Splash 页面加载完成后再推送进度
|
||||||
|
if (splashWindow) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (splashWindow!.webContents.isLoading()) {
|
||||||
|
splashWindow!.webContents.once('did-finish-load', () => resolve())
|
||||||
|
} else {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
splashWindow.webContents
|
||||||
|
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
// 初始化配置服务
|
||||||
|
updateSplashProgress(5, '正在加载配置...')
|
||||||
configService = new ConfigService()
|
configService = new ConfigService()
|
||||||
|
|
||||||
|
// 将用户主题配置推送给 Splash 窗口
|
||||||
|
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||||
|
const themeId = configService.get('themeId') || 'cloud-dancer'
|
||||||
|
const themeMode = configService.get('theme') || 'system'
|
||||||
|
splashWindow.webContents
|
||||||
|
.executeJavaScript(`applyTheme(${JSON.stringify(themeId)}, ${JSON.stringify(themeMode)})`)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
await delay(200)
|
||||||
|
|
||||||
|
// 设置资源路径
|
||||||
|
updateSplashProgress(10, '正在初始化...')
|
||||||
const candidateResources = app.isPackaged
|
const candidateResources = app.isPackaged
|
||||||
? join(process.resourcesPath, 'resources')
|
? join(process.resourcesPath, 'resources')
|
||||||
: join(app.getAppPath(), 'resources')
|
: join(app.getAppPath(), 'resources')
|
||||||
const fallbackResources = join(process.cwd(), 'resources')
|
const fallbackResources = join(process.cwd(), 'resources')
|
||||||
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
|
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
|
||||||
const userDataPath = app.getPath('userData')
|
const userDataPath = app.getPath('userData')
|
||||||
|
await delay(200)
|
||||||
|
|
||||||
|
// 初始化数据库服务
|
||||||
|
updateSplashProgress(18, '正在初始化...')
|
||||||
wcdbService.setPaths(resourcesPath, userDataPath)
|
wcdbService.setPaths(resourcesPath, userDataPath)
|
||||||
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
||||||
|
await delay(200)
|
||||||
|
|
||||||
|
// 注册 IPC 处理器
|
||||||
|
updateSplashProgress(25, '正在初始化...')
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
|
await delay(200)
|
||||||
|
|
||||||
|
// 检查配置状态
|
||||||
const onboardingDone = configService.get('onboardingDone')
|
const onboardingDone = configService.get('onboardingDone')
|
||||||
shouldShowMain = onboardingDone === true
|
shouldShowMain = onboardingDone === true
|
||||||
mainWindow = createWindow({ autoShow: shouldShowMain })
|
|
||||||
|
|
||||||
if (!onboardingDone) {
|
// 创建主窗口(不显示,由启动流程统一控制)
|
||||||
createOnboardingWindow()
|
updateSplashProgress(30, '正在加载界面...')
|
||||||
}
|
mainWindow = createWindow({ autoShow: false })
|
||||||
|
|
||||||
// 解决朋友圈图片无法加载问题(添加 Referer)
|
// 配置网络服务
|
||||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
{
|
{
|
||||||
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
|
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
|
||||||
@@ -1386,7 +1652,31 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 启动时检测更新
|
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
|
||||||
|
updateSplashProgress(30, '正在加载界面...', true)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (mainWindowReady) {
|
||||||
|
resolve()
|
||||||
|
} else {
|
||||||
|
mainWindow!.once('ready-to-show', () => {
|
||||||
|
mainWindowReady = true
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载完成,收尾
|
||||||
|
updateSplashProgress(100, '启动完成')
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
|
closeSplash()
|
||||||
|
|
||||||
|
if (!onboardingDone) {
|
||||||
|
createOnboardingWindow()
|
||||||
|
} else {
|
||||||
|
mainWindow?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动时检测更新(不阻塞启动)
|
||||||
checkForUpdatesOnStartup()
|
checkForUpdatesOnStartup()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
|
|||||||
@@ -24,7 +24,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// 认证
|
// 认证
|
||||||
auth: {
|
auth: {
|
||||||
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
|
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message),
|
||||||
|
verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'),
|
||||||
|
unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password),
|
||||||
|
enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password),
|
||||||
|
disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password),
|
||||||
|
changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword),
|
||||||
|
setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password),
|
||||||
|
clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'),
|
||||||
|
isLockMode: () => ipcRenderer.invoke('auth:isLockMode')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
@@ -78,8 +86,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
|
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
|
||||||
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
|
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
|
||||||
openImageViewerWindow: (imagePath: string) =>
|
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
|
||||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath),
|
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
|
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
|
||||||
},
|
},
|
||||||
@@ -131,6 +139,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
|
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
|
||||||
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
||||||
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
|
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
|
||||||
|
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) =>
|
||||||
|
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
|
||||||
|
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
|
||||||
|
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
|
||||||
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
|
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
|
||||||
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
|
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
|
||||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||||
@@ -142,6 +154,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
||||||
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
||||||
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
||||||
|
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
||||||
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
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),
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||||
@@ -216,7 +229,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||||
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath)
|
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
|
||||||
|
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
||||||
|
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 年度报告
|
// 年度报告
|
||||||
@@ -270,29 +285,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
sns: {
|
sns: {
|
||||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
||||||
|
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
|
||||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||||
proxyImage: (url: string) => ipcRenderer.invoke('sns:proxyImage', url)
|
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||||
|
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
|
||||||
|
exportTimeline: (options: any) => ipcRenderer.invoke('sns:exportTimeline', options),
|
||||||
|
onExportProgress: (callback: (payload: any) => void) => {
|
||||||
|
ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
|
||||||
|
return () => ipcRenderer.removeAllListeners('sns:exportProgress')
|
||||||
},
|
},
|
||||||
|
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir')
|
||||||
// Llama AI
|
|
||||||
llama: {
|
|
||||||
loadModel: (modelPath: string) => ipcRenderer.invoke('llama:loadModel', modelPath),
|
|
||||||
createSession: (systemPrompt?: string) => ipcRenderer.invoke('llama:createSession', systemPrompt),
|
|
||||||
chat: (message: string, options?: any) => ipcRenderer.invoke('llama:chat', message, options),
|
|
||||||
downloadModel: (url: string, savePath: string) => ipcRenderer.invoke('llama:downloadModel', url, savePath),
|
|
||||||
getModelsPath: () => ipcRenderer.invoke('llama:getModelsPath'),
|
|
||||||
checkFileExists: (filePath: string) => ipcRenderer.invoke('llama:checkFileExists', filePath),
|
|
||||||
getModelStatus: (modelPath: string) => ipcRenderer.invoke('llama:getModelStatus', modelPath),
|
|
||||||
onToken: (callback: (token: string) => void) => {
|
|
||||||
const listener = (_: any, token: string) => callback(token)
|
|
||||||
ipcRenderer.on('llama:token', listener)
|
|
||||||
return () => ipcRenderer.removeListener('llama:token', listener)
|
|
||||||
},
|
|
||||||
onDownloadProgress: (callback: (payload: { downloaded: number; total: number; speed: number }) => void) => {
|
|
||||||
const listener = (_: any, payload: { downloaded: number; total: number; speed: number }) => callback(payload)
|
|
||||||
ipcRenderer.on('llama:downloadProgress', listener)
|
|
||||||
return () => ipcRenderer.removeListener('llama:downloadProgress', listener)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// HTTP API 服务
|
// HTTP API 服务
|
||||||
|
|||||||
@@ -79,14 +79,14 @@ class AnalyticsService {
|
|||||||
const chunkSize = 200
|
const chunkSize = 200
|
||||||
for (let i = 0; i < usernames.length; i += chunkSize) {
|
for (let i = 0; i < usernames.length; i += chunkSize) {
|
||||||
const chunk = usernames.slice(i, i + chunkSize)
|
const chunk = usernames.slice(i, i + chunkSize)
|
||||||
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
|
// 使用参数化查询防止SQL注入
|
||||||
if (!inList) continue
|
const placeholders = chunk.map(() => '?').join(',')
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT username, alias
|
SELECT username, alias
|
||||||
FROM contact
|
FROM contact
|
||||||
WHERE username IN (${inList})
|
WHERE username IN (${placeholders})
|
||||||
`
|
`
|
||||||
const result = await wcdbService.execQuery('contact', null, sql)
|
const result = await wcdbService.execQuery('contact', null, sql, chunk)
|
||||||
if (!result.success || !result.rows) continue
|
if (!result.success || !result.rows) continue
|
||||||
for (const row of result.rows as Record<string, any>[]) {
|
for (const row of result.rows as Record<string, any>[]) {
|
||||||
const username = row.username || ''
|
const username = row.username || ''
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,17 @@
|
|||||||
|
import { join } from 'path'
|
||||||
|
import { app, safeStorage } from 'electron'
|
||||||
|
import crypto from 'crypto'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
|
|
||||||
|
// 加密前缀标记
|
||||||
|
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||||
|
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
||||||
|
|
||||||
interface ConfigSchema {
|
interface ConfigSchema {
|
||||||
// 数据库相关
|
// 数据库相关
|
||||||
dbPath: string // 数据库根目录 (xwechat_files)
|
dbPath: string
|
||||||
decryptKey: string // 解密密钥
|
decryptKey: string
|
||||||
myWxid: string // 当前用户 wxid
|
myWxid: string
|
||||||
onboardingDone: boolean
|
onboardingDone: boolean
|
||||||
imageXorKey: number
|
imageXorKey: number
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
@@ -31,8 +38,9 @@ interface ConfigSchema {
|
|||||||
|
|
||||||
// 安全相关
|
// 安全相关
|
||||||
authEnabled: boolean
|
authEnabled: boolean
|
||||||
authPassword: string // SHA-256 hash
|
authPassword: string // SHA-256 hash(safeStorage 加密)
|
||||||
authUseHello: boolean
|
authUseHello: boolean
|
||||||
|
authHelloSecret: string // 原始密码(safeStorage 加密,Hello 解锁时使用)
|
||||||
|
|
||||||
// 更新相关
|
// 更新相关
|
||||||
ignoredUpdateVersion: string
|
ignoredUpdateVersion: string
|
||||||
@@ -45,10 +53,23 @@ interface ConfigSchema {
|
|||||||
wordCloudExcludeWords: string[]
|
wordCloudExcludeWords: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 需要 safeStorage 加密的字段(普通模式)
|
||||||
|
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
|
||||||
|
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||||
|
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||||
|
|
||||||
|
// 需要与密码绑定的敏感密钥字段(锁定模式时用 lock: 加密)
|
||||||
|
const LOCKABLE_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey'])
|
||||||
|
const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||||
|
|
||||||
export class ConfigService {
|
export class ConfigService {
|
||||||
private static instance: ConfigService
|
private static instance: ConfigService
|
||||||
private store!: Store<ConfigSchema>
|
private store!: Store<ConfigSchema>
|
||||||
|
|
||||||
|
// 锁定模式运行时状态
|
||||||
|
private unlockedKeys: Map<string, any> = new Map()
|
||||||
|
private unlockPassword: string | null = null
|
||||||
|
|
||||||
static getInstance(): ConfigService {
|
static getInstance(): ConfigService {
|
||||||
if (!ConfigService.instance) {
|
if (!ConfigService.instance) {
|
||||||
ConfigService.instance = new ConfigService()
|
ConfigService.instance = new ConfigService()
|
||||||
@@ -86,11 +107,10 @@ export class ConfigService {
|
|||||||
transcribeLanguages: ['zh'],
|
transcribeLanguages: ['zh'],
|
||||||
exportDefaultConcurrency: 2,
|
exportDefaultConcurrency: 2,
|
||||||
analyticsExcludedUsernames: [],
|
analyticsExcludedUsernames: [],
|
||||||
|
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
authPassword: '',
|
authPassword: '',
|
||||||
authUseHello: false,
|
authUseHello: false,
|
||||||
|
authHelloSecret: '',
|
||||||
ignoredUpdateVersion: '',
|
ignoredUpdateVersion: '',
|
||||||
notificationEnabled: true,
|
notificationEnabled: true,
|
||||||
notificationPosition: 'top-right',
|
notificationPosition: 'top-right',
|
||||||
@@ -99,21 +119,535 @@ export class ConfigService {
|
|||||||
wordCloudExcludeWords: []
|
wordCloudExcludeWords: []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
this.migrateAuthFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 状态查询 ===
|
||||||
|
|
||||||
|
isLockMode(): boolean {
|
||||||
|
const raw: any = this.store.get('decryptKey')
|
||||||
|
return typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
isUnlocked(): boolean {
|
||||||
|
return !this.isLockMode() || this.unlockedKeys.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// === get / set ===
|
||||||
|
|
||||||
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
|
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
|
||||||
return this.store.get(key)
|
const raw = this.store.get(key)
|
||||||
|
|
||||||
|
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||||
|
const str = typeof raw === 'string' ? raw : ''
|
||||||
|
if (!str || !str.startsWith(SAFE_PREFIX)) return raw
|
||||||
|
return (this.safeDecrypt(str) === 'true') as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||||
|
const str = typeof raw === 'string' ? raw : ''
|
||||||
|
if (!str) return raw
|
||||||
|
if (str.startsWith(LOCK_PREFIX)) {
|
||||||
|
const cached = this.unlockedKeys.get(key as string)
|
||||||
|
return (cached !== undefined ? cached : 0) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
if (!str.startsWith(SAFE_PREFIX)) return raw
|
||||||
|
const num = Number(this.safeDecrypt(str))
|
||||||
|
return (Number.isFinite(num) ? num : 0) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') {
|
||||||
|
if (key === 'authPassword') return this.safeDecrypt(raw) as ConfigSchema[K]
|
||||||
|
if (raw.startsWith(LOCK_PREFIX)) {
|
||||||
|
const cached = this.unlockedKeys.get(key as string)
|
||||||
|
return (cached !== undefined ? cached : '') as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
return this.safeDecrypt(raw) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'wxidConfigs' && raw && typeof raw === 'object') {
|
||||||
|
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
|
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
|
||||||
this.store.set(key, value)
|
let toStore = value
|
||||||
|
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||||
|
|
||||||
|
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||||
|
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||||||
|
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||||
|
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
||||||
|
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
||||||
|
this.unlockedKeys.set(key as string, value)
|
||||||
|
} else {
|
||||||
|
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
} else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') {
|
||||||
|
if (key === 'authPassword') {
|
||||||
|
toStore = this.safeEncrypt(value) as ConfigSchema[K]
|
||||||
|
} else if (inLockMode && LOCKABLE_STRING_KEYS.has(key)) {
|
||||||
|
toStore = this.lockEncrypt(value, this.unlockPassword!) as ConfigSchema[K]
|
||||||
|
this.unlockedKeys.set(key as string, value)
|
||||||
|
} else {
|
||||||
|
toStore = this.safeEncrypt(value) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
} else if (key === 'wxidConfigs' && value && typeof value === 'object') {
|
||||||
|
if (inLockMode) {
|
||||||
|
toStore = this.lockEncryptWxidConfigs(value as any) as ConfigSchema[K]
|
||||||
|
} else {
|
||||||
|
toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAll(): ConfigSchema {
|
this.store.set(key, toStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 加密/解密工具 ===
|
||||||
|
|
||||||
|
private safeEncrypt(plaintext: string): string {
|
||||||
|
if (!plaintext) return ''
|
||||||
|
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
||||||
|
if (!safeStorage.isEncryptionAvailable()) return plaintext
|
||||||
|
const encrypted = safeStorage.encryptString(plaintext)
|
||||||
|
return SAFE_PREFIX + encrypted.toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeDecrypt(stored: string): string {
|
||||||
|
if (!stored) return ''
|
||||||
|
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
||||||
|
if (!safeStorage.isEncryptionAvailable()) return ''
|
||||||
|
try {
|
||||||
|
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
||||||
|
return safeStorage.decryptString(buf)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lockEncrypt(plaintext: string, password: string): string {
|
||||||
|
if (!plaintext) return ''
|
||||||
|
const salt = crypto.randomBytes(16)
|
||||||
|
const iv = crypto.randomBytes(12)
|
||||||
|
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv)
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||||
|
const authTag = cipher.getAuthTag()
|
||||||
|
const combined = Buffer.concat([salt, iv, authTag, encrypted])
|
||||||
|
return LOCK_PREFIX + combined.toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
private lockDecrypt(stored: string, password: string): string | null {
|
||||||
|
if (!stored || !stored.startsWith(LOCK_PREFIX)) return null
|
||||||
|
try {
|
||||||
|
const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64')
|
||||||
|
const salt = combined.subarray(0, 16)
|
||||||
|
const iv = combined.subarray(16, 28)
|
||||||
|
const authTag = combined.subarray(28, 44)
|
||||||
|
const ciphertext = combined.subarray(44)
|
||||||
|
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv)
|
||||||
|
decipher.setAuthTag(authTag)
|
||||||
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||||
|
return decrypted.toString('utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过尝试解密 lock: 字段来验证密码是否正确(当 authPassword 被删除时使用)
|
||||||
|
private verifyPasswordByDecrypt(password: string): boolean {
|
||||||
|
// 依次尝试解密任意一个 lock: 字段,GCM authTag 会验证密码正确性
|
||||||
|
const lockFields = ['decryptKey', 'imageAesKey', 'imageXorKey'] as const
|
||||||
|
for (const key of lockFields) {
|
||||||
|
const raw: any = this.store.get(key as any)
|
||||||
|
if (typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) {
|
||||||
|
const result = this.lockDecrypt(raw, password)
|
||||||
|
// lockDecrypt 返回 null 表示解密失败(密码错误),非 null 表示成功
|
||||||
|
return result !== null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// === wxidConfigs 加密/解密 ===
|
||||||
|
|
||||||
|
private encryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||||||
|
const result: ConfigSchema['wxidConfigs'] = {}
|
||||||
|
for (const [wxid, cfg] of Object.entries(configs)) {
|
||||||
|
result[wxid] = { ...cfg }
|
||||||
|
if (cfg.decryptKey) result[wxid].decryptKey = this.safeEncrypt(cfg.decryptKey)
|
||||||
|
if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeEncrypt(cfg.imageAesKey)
|
||||||
|
if (cfg.imageXorKey !== undefined) {
|
||||||
|
(result[wxid] as any).imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptLockedWxidConfigs(password: string): void {
|
||||||
|
const wxidConfigs = this.store.get('wxidConfigs')
|
||||||
|
if (!wxidConfigs || typeof wxidConfigs !== 'object') return
|
||||||
|
for (const [wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
|
||||||
|
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
const d = this.lockDecrypt(cfg.decryptKey, password)
|
||||||
|
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, d)
|
||||||
|
}
|
||||||
|
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
const d = this.lockDecrypt(cfg.imageAesKey, password)
|
||||||
|
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, d)
|
||||||
|
}
|
||||||
|
if (cfg.imageXorKey && typeof cfg.imageXorKey === 'string' && cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
const d = this.lockDecrypt(cfg.imageXorKey, password)
|
||||||
|
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, Number(d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||||||
|
const result: ConfigSchema['wxidConfigs'] = {}
|
||||||
|
for (const [wxid, cfg] of Object.entries(configs) as [string, any][]) {
|
||||||
|
result[wxid] = { ...cfg, updatedAt: cfg.updatedAt }
|
||||||
|
// decryptKey
|
||||||
|
if (typeof cfg.decryptKey === 'string') {
|
||||||
|
if (cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
result[wxid].decryptKey = this.unlockedKeys.get(`wxid:${wxid}:decryptKey`) ?? ''
|
||||||
|
} else {
|
||||||
|
result[wxid].decryptKey = this.safeDecrypt(cfg.decryptKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// imageAesKey
|
||||||
|
if (typeof cfg.imageAesKey === 'string') {
|
||||||
|
if (cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
result[wxid].imageAesKey = this.unlockedKeys.get(`wxid:${wxid}:imageAesKey`) ?? ''
|
||||||
|
} else {
|
||||||
|
result[wxid].imageAesKey = this.safeDecrypt(cfg.imageAesKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// imageXorKey
|
||||||
|
if (typeof cfg.imageXorKey === 'string') {
|
||||||
|
if (cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
result[wxid].imageXorKey = this.unlockedKeys.get(`wxid:${wxid}:imageXorKey`) ?? 0
|
||||||
|
} else if (cfg.imageXorKey.startsWith(SAFE_PREFIX)) {
|
||||||
|
const num = Number(this.safeDecrypt(cfg.imageXorKey))
|
||||||
|
result[wxid].imageXorKey = Number.isFinite(num) ? num : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
private lockEncryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||||||
|
const result: ConfigSchema['wxidConfigs'] = {}
|
||||||
|
for (const [wxid, cfg] of Object.entries(configs)) {
|
||||||
|
result[wxid] = { ...cfg }
|
||||||
|
if (cfg.decryptKey) result[wxid].decryptKey = this.lockEncrypt(cfg.decryptKey, this.unlockPassword!) as any
|
||||||
|
if (cfg.imageAesKey) result[wxid].imageAesKey = this.lockEncrypt(cfg.imageAesKey, this.unlockPassword!) as any
|
||||||
|
if (cfg.imageXorKey !== undefined) {
|
||||||
|
(result[wxid] as any).imageXorKey = this.lockEncrypt(String(cfg.imageXorKey), this.unlockPassword!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 业务方法 ===
|
||||||
|
|
||||||
|
enableLock(password: string): { success: boolean; error?: string } {
|
||||||
|
try {
|
||||||
|
// 先读取当前所有明文密钥
|
||||||
|
const decryptKey = this.get('decryptKey')
|
||||||
|
const imageAesKey = this.get('imageAesKey')
|
||||||
|
const imageXorKey = this.get('imageXorKey')
|
||||||
|
const wxidConfigs = this.get('wxidConfigs')
|
||||||
|
|
||||||
|
// 存储密码 hash(safeStorage 加密)
|
||||||
|
const passwordHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||||
|
this.store.set('authPassword', this.safeEncrypt(passwordHash) as any)
|
||||||
|
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||||
|
|
||||||
|
// 设置运行时状态
|
||||||
|
this.unlockPassword = password
|
||||||
|
this.unlockedKeys.set('decryptKey', decryptKey)
|
||||||
|
this.unlockedKeys.set('imageAesKey', imageAesKey)
|
||||||
|
this.unlockedKeys.set('imageXorKey', imageXorKey)
|
||||||
|
|
||||||
|
// 用密码派生密钥重新加密所有敏感字段
|
||||||
|
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), password) as any)
|
||||||
|
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), password) as any)
|
||||||
|
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), password) as any)
|
||||||
|
|
||||||
|
// 处理 wxidConfigs 中的嵌套密钥
|
||||||
|
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||||||
|
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
|
||||||
|
this.store.set('wxidConfigs', lockedConfigs)
|
||||||
|
for (const [wxid, cfg] of Object.entries(wxidConfigs)) {
|
||||||
|
if (cfg.decryptKey) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, cfg.decryptKey)
|
||||||
|
if (cfg.imageAesKey) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, cfg.imageAesKey)
|
||||||
|
if (cfg.imageXorKey !== undefined) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, cfg.imageXorKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, error: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock(password: string): { success: boolean; error?: string } {
|
||||||
|
try {
|
||||||
|
// 验证密码
|
||||||
|
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||||||
|
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||||
|
|
||||||
|
if (storedHash && storedHash !== inputHash) {
|
||||||
|
// authPassword 存在但密码不匹配
|
||||||
|
return { success: false, error: '密码错误' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storedHash) {
|
||||||
|
// authPassword 被删除/损坏,尝试用密码直接解密 lock: 字段来验证
|
||||||
|
const verified = this.verifyPasswordByDecrypt(password)
|
||||||
|
if (!verified) {
|
||||||
|
return { success: false, error: '密码错误' }
|
||||||
|
}
|
||||||
|
// 密码正确,自愈 authPassword
|
||||||
|
const newHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||||
|
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
|
||||||
|
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密所有 lock: 字段到内存缓存
|
||||||
|
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||||
|
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
const d = this.lockDecrypt(rawDecryptKey, password)
|
||||||
|
if (d !== null) this.unlockedKeys.set('decryptKey', d)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawImageAesKey: any = this.store.get('imageAesKey')
|
||||||
|
if (typeof rawImageAesKey === 'string' && rawImageAesKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
const d = this.lockDecrypt(rawImageAesKey, password)
|
||||||
|
if (d !== null) this.unlockedKeys.set('imageAesKey', d)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawImageXorKey: any = this.store.get('imageXorKey')
|
||||||
|
if (typeof rawImageXorKey === 'string' && rawImageXorKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
const d = this.lockDecrypt(rawImageXorKey, password)
|
||||||
|
if (d !== null) this.unlockedKeys.set('imageXorKey', Number(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密 wxidConfigs 嵌套密钥
|
||||||
|
this.decryptLockedWxidConfigs(password)
|
||||||
|
|
||||||
|
// 保留密码供 set() 使用
|
||||||
|
this.unlockPassword = password
|
||||||
|
return { success: true }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, error: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disableLock(password: string): { success: boolean; error?: string } {
|
||||||
|
try {
|
||||||
|
// 验证密码
|
||||||
|
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||||||
|
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||||
|
if (storedHash !== inputHash) {
|
||||||
|
return { success: false, error: '密码错误' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先解密所有 lock: 字段
|
||||||
|
if (this.unlockedKeys.size === 0) {
|
||||||
|
this.unlock(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将所有密钥转回 safe: 格式
|
||||||
|
const decryptKey = this.unlockedKeys.get('decryptKey')
|
||||||
|
const imageAesKey = this.unlockedKeys.get('imageAesKey')
|
||||||
|
const imageXorKey = this.unlockedKeys.get('imageXorKey')
|
||||||
|
|
||||||
|
if (decryptKey) this.store.set('decryptKey', this.safeEncrypt(String(decryptKey)) as any)
|
||||||
|
if (imageAesKey) this.store.set('imageAesKey', this.safeEncrypt(String(imageAesKey)) as any)
|
||||||
|
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.safeEncrypt(String(imageXorKey)) as any)
|
||||||
|
|
||||||
|
// 转换 wxidConfigs
|
||||||
|
const wxidConfigs = this.get('wxidConfigs')
|
||||||
|
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||||||
|
const safeConfigs = this.encryptWxidConfigs(wxidConfigs)
|
||||||
|
this.store.set('wxidConfigs', safeConfigs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除 auth 字段
|
||||||
|
this.store.set('authEnabled', false as any)
|
||||||
|
this.store.set('authPassword', '' as any)
|
||||||
|
this.store.set('authUseHello', false as any)
|
||||||
|
this.store.set('authHelloSecret', '' as any)
|
||||||
|
|
||||||
|
// 清除运行时状态
|
||||||
|
this.unlockedKeys.clear()
|
||||||
|
this.unlockPassword = null
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, error: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changePassword(oldPassword: string, newPassword: string): { success: boolean; error?: string } {
|
||||||
|
try {
|
||||||
|
// 验证旧密码
|
||||||
|
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||||||
|
const oldHash = crypto.createHash('sha256').update(oldPassword).digest('hex')
|
||||||
|
if (storedHash !== oldHash) {
|
||||||
|
return { success: false, error: '旧密码错误' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保已解锁
|
||||||
|
if (this.unlockedKeys.size === 0) {
|
||||||
|
this.unlock(oldPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用新密码重新加密所有密钥
|
||||||
|
const decryptKey = this.unlockedKeys.get('decryptKey')
|
||||||
|
const imageAesKey = this.unlockedKeys.get('imageAesKey')
|
||||||
|
const imageXorKey = this.unlockedKeys.get('imageXorKey')
|
||||||
|
|
||||||
|
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), newPassword) as any)
|
||||||
|
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), newPassword) as any)
|
||||||
|
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), newPassword) as any)
|
||||||
|
|
||||||
|
// 重新加密 wxidConfigs
|
||||||
|
const wxidConfigs = this.get('wxidConfigs')
|
||||||
|
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||||||
|
this.unlockPassword = newPassword
|
||||||
|
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
|
||||||
|
this.store.set('wxidConfigs', lockedConfigs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新密码 hash
|
||||||
|
const newHash = crypto.createHash('sha256').update(newPassword).digest('hex')
|
||||||
|
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
|
||||||
|
|
||||||
|
// 更新 Hello secret(如果启用了 Hello)
|
||||||
|
const useHello = this.get('authUseHello')
|
||||||
|
if (useHello) {
|
||||||
|
this.store.set('authHelloSecret', this.safeEncrypt(newPassword) as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.unlockPassword = newPassword
|
||||||
|
return { success: true }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, error: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Hello 相关 ===
|
||||||
|
|
||||||
|
setHelloSecret(password: string): void {
|
||||||
|
this.store.set('authHelloSecret', this.safeEncrypt(password) as any)
|
||||||
|
this.store.set('authUseHello', this.safeEncrypt('true') as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
getHelloSecret(): string {
|
||||||
|
const raw: any = this.store.get('authHelloSecret')
|
||||||
|
if (!raw || typeof raw !== 'string') return ''
|
||||||
|
return this.safeDecrypt(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHelloSecret(): void {
|
||||||
|
this.store.set('authHelloSecret', '' as any)
|
||||||
|
this.store.set('authUseHello', this.safeEncrypt('false') as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 迁移 ===
|
||||||
|
|
||||||
|
private migrateAuthFields(): void {
|
||||||
|
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
||||||
|
// 如果已经是 safe: 或 lock: 前缀则跳过
|
||||||
|
const rawEnabled: any = this.store.get('authEnabled')
|
||||||
|
if (typeof rawEnabled === 'boolean') {
|
||||||
|
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUseHello: any = this.store.get('authUseHello')
|
||||||
|
if (typeof rawUseHello === 'boolean') {
|
||||||
|
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPassword: any = this.store.get('authPassword')
|
||||||
|
if (typeof rawPassword === 'string' && rawPassword && !rawPassword.startsWith(SAFE_PREFIX)) {
|
||||||
|
this.store.set('authPassword', this.safeEncrypt(rawPassword) as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移敏感密钥字段(明文 → safe:)
|
||||||
|
for (const key of LOCKABLE_STRING_KEYS) {
|
||||||
|
const raw: any = this.store.get(key as any)
|
||||||
|
if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX) && !raw.startsWith(LOCK_PREFIX)) {
|
||||||
|
this.store.set(key as any, this.safeEncrypt(raw) as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageXorKey: 数字 → safe:
|
||||||
|
const rawXor: any = this.store.get('imageXorKey')
|
||||||
|
if (typeof rawXor === 'number' && rawXor !== 0) {
|
||||||
|
this.store.set('imageXorKey', this.safeEncrypt(String(rawXor)) as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wxidConfigs 中的嵌套密钥
|
||||||
|
const wxidConfigs: any = this.store.get('wxidConfigs')
|
||||||
|
if (wxidConfigs && typeof wxidConfigs === 'object') {
|
||||||
|
let changed = false
|
||||||
|
for (const [_wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
|
||||||
|
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && !cfg.decryptKey.startsWith(SAFE_PREFIX) && !cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
cfg.decryptKey = this.safeEncrypt(cfg.decryptKey)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && !cfg.imageAesKey.startsWith(SAFE_PREFIX) && !cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
cfg.imageAesKey = this.safeEncrypt(cfg.imageAesKey)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (typeof cfg.imageXorKey === 'number' && cfg.imageXorKey !== 0) {
|
||||||
|
cfg.imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
this.store.set('wxidConfigs', wxidConfigs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 验证 ===
|
||||||
|
|
||||||
|
verifyAuthEnabled(): boolean {
|
||||||
|
// 先检查 authEnabled 字段
|
||||||
|
const rawEnabled: any = this.store.get('authEnabled')
|
||||||
|
if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) {
|
||||||
|
if (this.safeDecrypt(rawEnabled) === 'true') return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
||||||
|
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||||
|
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 工具方法 ===
|
||||||
|
|
||||||
|
getCacheBasePath(): string {
|
||||||
|
return join(app.getPath('userData'), 'cache')
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): Partial<ConfigSchema> {
|
||||||
return this.store.store
|
return this.store.store
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.store.clear()
|
this.store.clear()
|
||||||
|
this.unlockedKeys.clear()
|
||||||
|
this.unlockPassword = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
export interface ContactCacheEntry {
|
export interface ContactCacheEntry {
|
||||||
displayName?: string
|
displayName?: string
|
||||||
@@ -15,7 +16,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('documents'), 'WeFlow')
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
this.cacheFilePath = join(basePath, 'contacts.json')
|
this.cacheFilePath = join(basePath, 'contacts.json')
|
||||||
this.ensureCacheDir()
|
this.ensureCacheDir()
|
||||||
this.loadCache()
|
this.loadCache()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ContactExportOptions {
|
|||||||
groups: boolean
|
groups: boolean
|
||||||
officials: boolean
|
officials: boolean
|
||||||
}
|
}
|
||||||
|
selectedUsernames?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +41,11 @@ class ContactExportService {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (Array.isArray(options.selectedUsernames) && options.selectedUsernames.length > 0) {
|
||||||
|
const selectedSet = new Set(options.selectedUsernames)
|
||||||
|
contacts = contacts.filter(c => selectedSet.has(c.username))
|
||||||
|
}
|
||||||
|
|
||||||
if (contacts.length === 0) {
|
if (contacts.length === 0) {
|
||||||
return { success: false, error: '没有符合条件的联系人' }
|
return { success: false, error: '没有符合条件的联系人' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,6 +186,17 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-link-card {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-link-card:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
.inline-emoji {
|
.inline-emoji {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
@@ -186,6 +186,17 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-link-card {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-link-card:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
.inline-emoji {
|
.inline-emoji {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { chatService } from './chatService'
|
|||||||
import { videoService } from './videoService'
|
import { videoService } from './videoService'
|
||||||
import { voiceTranscribeService } from './voiceTranscribeService'
|
import { voiceTranscribeService } from './voiceTranscribeService'
|
||||||
import { EXPORT_HTML_STYLES } from './exportHtmlStyles'
|
import { EXPORT_HTML_STYLES } from './exportHtmlStyles'
|
||||||
|
import { LRUCache } from '../utils/LRUCache.js'
|
||||||
|
|
||||||
// ChatLab 格式类型定义
|
// ChatLab 格式类型定义
|
||||||
interface ChatLabHeader {
|
interface ChatLabHeader {
|
||||||
@@ -70,6 +71,8 @@ const MESSAGE_TYPE_MAP: Record<number, number> = {
|
|||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null
|
||||||
|
senderUsername?: string
|
||||||
|
fileNameSuffix?: string
|
||||||
exportMedia?: boolean
|
exportMedia?: boolean
|
||||||
exportAvatars?: boolean
|
exportAvatars?: boolean
|
||||||
exportImages?: boolean
|
exportImages?: boolean
|
||||||
@@ -138,12 +141,15 @@ async function parallelLimit<T, R>(
|
|||||||
|
|
||||||
class ExportService {
|
class ExportService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private contactCache: Map<string, { displayName: string; avatarUrl?: string }> = new Map()
|
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
|
||||||
private inlineEmojiCache: Map<string, string> = new Map()
|
private inlineEmojiCache: LRUCache<string, string>
|
||||||
private htmlStyleCache: string | null = null
|
private htmlStyleCache: string | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
|
// 限制缓存大小,防止内存泄漏
|
||||||
|
this.contactCache = new LRUCache(500) // 最多缓存500个联系人
|
||||||
|
this.inlineEmojiCache = new LRUCache(100) // 最多缓存100个表情
|
||||||
}
|
}
|
||||||
|
|
||||||
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
|
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
|
||||||
@@ -217,9 +223,9 @@ class ExportService {
|
|||||||
*/
|
*/
|
||||||
async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||||
try {
|
try {
|
||||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
// 使用参数化查询防止SQL注入
|
||||||
const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`
|
const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1'
|
||||||
const result = await wcdbService.execQuery('contact', null, sql)
|
const result = await wcdbService.execQuery('contact', null, sql, [chatroomId])
|
||||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||||
return new Map<string, string>()
|
return new Map<string, string>()
|
||||||
}
|
}
|
||||||
@@ -534,11 +540,14 @@ class ExportService {
|
|||||||
groupNicknamesMap: Map<string, string>,
|
groupNicknamesMap: Map<string, string>,
|
||||||
getContactName: (username: string) => Promise<string>
|
getContactName: (username: string) => Promise<string>
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const xmlType = this.extractXmlValue(content, 'type')
|
const normalizedContent = this.normalizeAppMessageContent(content || '')
|
||||||
if (xmlType !== '2000') return null
|
if (!normalizedContent) return null
|
||||||
|
|
||||||
const payerUsername = this.extractXmlValue(content, 'payer_username')
|
const xmlType = this.extractXmlValue(normalizedContent, 'type')
|
||||||
const receiverUsername = this.extractXmlValue(content, 'receiver_username')
|
if (xmlType && xmlType !== '2000') return null
|
||||||
|
|
||||||
|
const payerUsername = this.extractXmlValue(normalizedContent, 'payer_username')
|
||||||
|
const receiverUsername = this.extractXmlValue(normalizedContent, 'receiver_username')
|
||||||
if (!payerUsername || !receiverUsername) return null
|
if (!payerUsername || !receiverUsername) return null
|
||||||
|
|
||||||
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
|
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
|
||||||
@@ -565,6 +574,52 @@ class ExportService {
|
|||||||
return `${payerName} 转账给 ${receiverName}`
|
return `${payerName} 转账给 ${receiverName}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isSameWxid(lhs?: string, rhs?: string): boolean {
|
||||||
|
const left = new Set(this.buildGroupNicknameIdCandidates([lhs]).map((id) => id.toLowerCase()))
|
||||||
|
if (left.size === 0) return false
|
||||||
|
const right = this.buildGroupNicknameIdCandidates([rhs]).map((id) => id.toLowerCase())
|
||||||
|
return right.some((id) => left.has(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTransferPrefix(content: string, myWxid?: string, senderWxid?: string, isSend?: boolean): '[转账]' | '[转账收款]' {
|
||||||
|
const normalizedContent = this.normalizeAppMessageContent(content || '')
|
||||||
|
if (!normalizedContent) return '[转账]'
|
||||||
|
|
||||||
|
const paySubtype = this.extractXmlValue(normalizedContent, 'paysubtype')
|
||||||
|
// 转账消息在部分账号数据中 `payer_username` 可能为空,优先用 `paysubtype` 判定
|
||||||
|
// 实测:1=发起侧,3=收款侧
|
||||||
|
if (paySubtype === '3') return '[转账收款]'
|
||||||
|
if (paySubtype === '1') return '[转账]'
|
||||||
|
|
||||||
|
const payerUsername = this.extractXmlValue(normalizedContent, 'payer_username')
|
||||||
|
const receiverUsername = this.extractXmlValue(normalizedContent, 'receiver_username')
|
||||||
|
const senderIsPayer = senderWxid ? this.isSameWxid(senderWxid, payerUsername) : false
|
||||||
|
const senderIsReceiver = senderWxid ? this.isSameWxid(senderWxid, receiverUsername) : false
|
||||||
|
|
||||||
|
// 实测字段语义:sender 命中 receiver_username 为转账发起侧,命中 payer_username 为收款侧
|
||||||
|
if (senderWxid) {
|
||||||
|
if (senderIsReceiver && !senderIsPayer) return '[转账]'
|
||||||
|
if (senderIsPayer && !senderIsReceiver) return '[转账收款]'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:按当前账号角色判断
|
||||||
|
if (myWxid) {
|
||||||
|
if (this.isSameWxid(myWxid, receiverUsername)) return '[转账]'
|
||||||
|
if (this.isSameWxid(myWxid, payerUsername)) return '[转账收款]'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '[转账]'
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTransferExportContent(content: string): boolean {
|
||||||
|
return content.startsWith('[转账]') || content.startsWith('[转账收款]')
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendTransferDesc(content: string, transferDesc: string): string {
|
||||||
|
const prefix = content.startsWith('[转账收款]') ? '[转账收款]' : '[转账]'
|
||||||
|
return content.replace(prefix, `${prefix} (${transferDesc})`)
|
||||||
|
}
|
||||||
|
|
||||||
private looksLikeBase64(s: string): boolean {
|
private looksLikeBase64(s: string): boolean {
|
||||||
if (s.length % 4 !== 0) return false
|
if (s.length % 4 !== 0) return false
|
||||||
return /^[A-Za-z0-9+/=]+$/.test(s)
|
return /^[A-Za-z0-9+/=]+$/.test(s)
|
||||||
@@ -574,7 +629,15 @@ class ExportService {
|
|||||||
* 解析消息内容为可读文本
|
* 解析消息内容为可读文本
|
||||||
* 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理
|
* 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理
|
||||||
*/
|
*/
|
||||||
private parseMessageContent(content: string, localType: number, sessionId?: string, createTime?: number): string | null {
|
private parseMessageContent(
|
||||||
|
content: string,
|
||||||
|
localType: number,
|
||||||
|
sessionId?: string,
|
||||||
|
createTime?: number,
|
||||||
|
myWxid?: string,
|
||||||
|
senderWxid?: string,
|
||||||
|
isSend?: boolean
|
||||||
|
): string | null {
|
||||||
if (!content) return null
|
if (!content) return null
|
||||||
|
|
||||||
// 检查 XML 中的 type 标签(支持大 localType 的情况)
|
// 检查 XML 中的 type 标签(支持大 localType 的情况)
|
||||||
@@ -611,10 +674,11 @@ class ExportService {
|
|||||||
if (type === '2000') {
|
if (type === '2000') {
|
||||||
const feedesc = this.extractXmlValue(content, 'feedesc')
|
const feedesc = this.extractXmlValue(content, 'feedesc')
|
||||||
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
||||||
|
const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend)
|
||||||
if (feedesc) {
|
if (feedesc) {
|
||||||
return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}`
|
return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}`
|
||||||
}
|
}
|
||||||
return '[转账]'
|
return transferPrefix
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === '6') return title ? `[文件] ${title}` : '[文件]'
|
if (type === '6') return title ? `[文件] ${title}` : '[文件]'
|
||||||
@@ -650,10 +714,11 @@ class ExportService {
|
|||||||
if (xmlType === '2000') {
|
if (xmlType === '2000') {
|
||||||
const feedesc = this.extractXmlValue(content, 'feedesc')
|
const feedesc = this.extractXmlValue(content, 'feedesc')
|
||||||
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
||||||
|
const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend)
|
||||||
if (feedesc) {
|
if (feedesc) {
|
||||||
return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}`
|
return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}`
|
||||||
}
|
}
|
||||||
return '[转账]'
|
return transferPrefix
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他类型
|
// 其他类型
|
||||||
@@ -676,7 +741,10 @@ class ExportService {
|
|||||||
content: string,
|
content: string,
|
||||||
localType: number,
|
localType: number,
|
||||||
options: { exportVoiceAsText?: boolean },
|
options: { exportVoiceAsText?: boolean },
|
||||||
voiceTranscript?: string
|
voiceTranscript?: string,
|
||||||
|
myWxid?: string,
|
||||||
|
senderWxid?: string,
|
||||||
|
isSend?: boolean
|
||||||
): string {
|
): string {
|
||||||
const safeContent = content || ''
|
const safeContent = content || ''
|
||||||
|
|
||||||
@@ -742,8 +810,9 @@ class ExportService {
|
|||||||
if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) {
|
if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) {
|
||||||
const feedesc = this.extractXmlValue(normalized, 'feedesc')
|
const feedesc = this.extractXmlValue(normalized, 'feedesc')
|
||||||
const payMemo = this.extractXmlValue(normalized, 'pay_memo')
|
const payMemo = this.extractXmlValue(normalized, 'pay_memo')
|
||||||
|
const transferPrefix = this.getTransferPrefix(normalized, myWxid, senderWxid, isSend)
|
||||||
if (feedesc) {
|
if (feedesc) {
|
||||||
return payMemo ? `[转账]${feedesc} ${payMemo}` : `[转账]${feedesc}`
|
return payMemo ? `${transferPrefix}${feedesc} ${payMemo}` : `${transferPrefix}${feedesc}`
|
||||||
}
|
}
|
||||||
const amount = this.extractAmountFromText(
|
const amount = this.extractAmountFromText(
|
||||||
[
|
[
|
||||||
@@ -756,7 +825,7 @@ class ExportService {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
)
|
)
|
||||||
return amount ? `[转账]${amount}` : '[转账]'
|
return amount ? `${transferPrefix}${amount}` : transferPrefix
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) {
|
if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) {
|
||||||
@@ -1256,7 +1325,7 @@ class ExportService {
|
|||||||
return rendered.join('')
|
return rendered.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatHtmlMessageText(content: string, localType: number): string {
|
private formatHtmlMessageText(content: string, localType: number, myWxid?: string, senderWxid?: string, isSend?: boolean): string {
|
||||||
if (!content) return ''
|
if (!content) return ''
|
||||||
|
|
||||||
if (localType === 1) {
|
if (localType === 1) {
|
||||||
@@ -1264,10 +1333,59 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (localType === 34) {
|
if (localType === 34) {
|
||||||
return this.parseMessageContent(content, localType) || ''
|
return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false })
|
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractHtmlLinkCard(content: string, localType: number): { title: string; url: string } | null {
|
||||||
|
if (!content) return null
|
||||||
|
|
||||||
|
const normalized = this.normalizeAppMessageContent(content)
|
||||||
|
const isAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')
|
||||||
|
if (!isAppMessage) return null
|
||||||
|
|
||||||
|
const subType = this.extractXmlValue(normalized, 'type')
|
||||||
|
if (subType && subType !== '5' && subType !== '49') return null
|
||||||
|
|
||||||
|
const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url'))
|
||||||
|
if (!url) return null
|
||||||
|
|
||||||
|
const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url
|
||||||
|
return { title, url }
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeHtmlLinkUrl(rawUrl: string): string {
|
||||||
|
const value = (rawUrl || '').trim()
|
||||||
|
if (!value) return ''
|
||||||
|
|
||||||
|
const parseHttpUrl = (candidate: string): string => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(candidate)
|
||||||
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||||
|
return parsed.toString()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith('//')) {
|
||||||
|
return parseHttpUrl(`https:${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const direct = parseHttpUrl(value)
|
||||||
|
if (direct) return direct
|
||||||
|
|
||||||
|
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)
|
||||||
|
const isDomainLike = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:[/:?#].*)?$/.test(value)
|
||||||
|
if (!hasScheme && isDomainLike) {
|
||||||
|
return parseHttpUrl(`https://${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1353,6 +1471,7 @@ class ExportService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success || !result.localPath) {
|
if (!result.success || !result.localPath) {
|
||||||
|
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||||
// 尝试获取缩略图
|
// 尝试获取缩略图
|
||||||
const thumbResult = await imageDecryptService.resolveCachedImage({
|
const thumbResult = await imageDecryptService.resolveCachedImage({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -1360,18 +1479,24 @@ class ExportService {
|
|||||||
imageDatName
|
imageDatName
|
||||||
})
|
})
|
||||||
if (!thumbResult.success || !thumbResult.localPath) {
|
if (!thumbResult.success || !thumbResult.localPath) {
|
||||||
|
console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'} → 将显示 [图片] 占位符`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
||||||
result.localPath = thumbResult.localPath
|
result.localPath = thumbResult.localPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 为每条消息生成稳定且唯一的文件名前缀,避免跨日期/消息发生同名覆盖
|
||||||
|
const messageId = String(msg.localId || Date.now())
|
||||||
|
const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '')
|
||||||
|
|
||||||
// 从 data URL 或 file URL 获取实际路径
|
// 从 data URL 或 file URL 获取实际路径
|
||||||
let sourcePath = result.localPath
|
let sourcePath = result.localPath
|
||||||
if (sourcePath.startsWith('data:')) {
|
if (sourcePath.startsWith('data:')) {
|
||||||
// 是 data URL,需要保存为文件
|
// 是 data URL,需要保存为文件
|
||||||
const base64Data = sourcePath.split(',')[1]
|
const base64Data = sourcePath.split(',')[1]
|
||||||
const ext = this.getExtFromDataUrl(sourcePath)
|
const ext = this.getExtFromDataUrl(sourcePath)
|
||||||
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}`
|
const fileName = `${messageId}_${imageKey}${ext}`
|
||||||
const destPath = path.join(imagesDir, fileName)
|
const destPath = path.join(imagesDir, fileName)
|
||||||
|
|
||||||
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
|
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
|
||||||
@@ -1385,9 +1510,12 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 复制文件
|
// 复制文件
|
||||||
if (!fs.existsSync(sourcePath)) return null
|
if (!fs.existsSync(sourcePath)) {
|
||||||
|
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
const ext = path.extname(sourcePath) || '.jpg'
|
const ext = path.extname(sourcePath) || '.jpg'
|
||||||
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}`
|
const fileName = `${messageId}_${imageKey}${ext}`
|
||||||
const destPath = path.join(imagesDir, fileName)
|
const destPath = path.join(imagesDir, fileName)
|
||||||
|
|
||||||
if (!fs.existsSync(destPath)) {
|
if (!fs.existsSync(destPath)) {
|
||||||
@@ -1399,6 +1527,7 @@ class ExportService {
|
|||||||
kind: 'image'
|
kind: 'image'
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(`[Export] 导出图片异常 (localId=${msg.localId}):`, e, `→ 将显示 [图片] 占位符`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1479,49 +1608,30 @@ class ExportService {
|
|||||||
fs.mkdirSync(emojisDir, { recursive: true })
|
fs.mkdirSync(emojisDir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用消息对象中已提取的字段
|
// 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑)
|
||||||
const emojiUrl = msg.emojiCdnUrl
|
const localPath = await chatService.downloadEmojiFile(msg)
|
||||||
const emojiMd5 = msg.emojiMd5
|
|
||||||
|
|
||||||
if (!emojiUrl && !emojiMd5) {
|
|
||||||
|
|
||||||
|
if (!localPath || !fs.existsSync(localPath)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 确定目标文件名
|
||||||
|
const ext = path.extname(localPath) || '.gif'
|
||||||
const key = emojiMd5 || String(msg.localId)
|
const key = msg.emojiMd5 || String(msg.localId)
|
||||||
// 根据 URL 判断扩展名
|
|
||||||
let ext = '.gif'
|
|
||||||
if (emojiUrl) {
|
|
||||||
if (emojiUrl.includes('.png')) ext = '.png'
|
|
||||||
else if (emojiUrl.includes('.jpg') || emojiUrl.includes('.jpeg')) ext = '.jpg'
|
|
||||||
}
|
|
||||||
const fileName = `${key}${ext}`
|
const fileName = `${key}${ext}`
|
||||||
const destPath = path.join(emojisDir, fileName)
|
const destPath = path.join(emojisDir, fileName)
|
||||||
|
|
||||||
// 如果已存在则跳过
|
// 复制文件到导出目录 (如果不存在)
|
||||||
if (fs.existsSync(destPath)) {
|
if (!fs.existsSync(destPath)) {
|
||||||
|
fs.copyFileSync(localPath, destPath)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
||||||
kind: 'emoji'
|
kind: 'emoji'
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 下载表情
|
|
||||||
if (emojiUrl) {
|
|
||||||
const downloaded = await this.downloadFile(emojiUrl, destPath)
|
|
||||||
if (downloaded) {
|
|
||||||
return {
|
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
|
||||||
kind: 'emoji'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error('ExportService: exportEmoji failed', e)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1686,7 +1796,14 @@ class ExportService {
|
|||||||
fileStream.close()
|
fileStream.close()
|
||||||
resolve(true)
|
resolve(true)
|
||||||
})
|
})
|
||||||
fileStream.on('error', () => {
|
fileStream.on('error', (err) => {
|
||||||
|
// 确保在错误情况下销毁流,释放文件句柄
|
||||||
|
fileStream.destroy()
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
response.on('error', (err) => {
|
||||||
|
// 确保在响应错误时也关闭文件句柄
|
||||||
|
fileStream.destroy()
|
||||||
resolve(false)
|
resolve(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1704,7 +1821,8 @@ class ExportService {
|
|||||||
private async collectMessages(
|
private async collectMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
cleanedMyWxid: string,
|
cleanedMyWxid: string,
|
||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null,
|
||||||
|
senderUsernameFilter?: string
|
||||||
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
|
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
|
||||||
const rows: any[] = []
|
const rows: any[] = []
|
||||||
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
|
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
|
||||||
@@ -1712,22 +1830,43 @@ class ExportService {
|
|||||||
let firstTime: number | null = null
|
let firstTime: number | null = null
|
||||||
let lastTime: number | null = null
|
let lastTime: number | null = null
|
||||||
|
|
||||||
|
// 修复时间范围:0 表示不限制,而不是时间戳 0
|
||||||
|
const beginTime = dateRange?.start || 0
|
||||||
|
const endTime = dateRange?.end && dateRange.end > 0 ? dateRange.end : 0
|
||||||
|
|
||||||
|
console.log(`[Export] 收集消息: sessionId=${sessionId}, 时间范围: ${beginTime} ~ ${endTime || '无限制'}`)
|
||||||
|
|
||||||
const cursor = await wcdbService.openMessageCursor(
|
const cursor = await wcdbService.openMessageCursor(
|
||||||
sessionId,
|
sessionId,
|
||||||
500,
|
500,
|
||||||
true,
|
true,
|
||||||
dateRange?.start || 0,
|
beginTime,
|
||||||
dateRange?.end || 0
|
endTime
|
||||||
)
|
)
|
||||||
if (!cursor.success || !cursor.cursor) {
|
if (!cursor.success || !cursor.cursor) {
|
||||||
|
console.error(`[Export] 打开游标失败: ${cursor.error || '未知错误'}`)
|
||||||
return { rows, memberSet, firstTime, lastTime }
|
return { rows, memberSet, firstTime, lastTime }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let hasMore = true
|
let hasMore = true
|
||||||
|
let batchCount = 0
|
||||||
while (hasMore) {
|
while (hasMore) {
|
||||||
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
|
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
|
||||||
if (!batch.success || !batch.rows) break
|
batchCount++
|
||||||
|
|
||||||
|
if (!batch.success) {
|
||||||
|
console.error(`[Export] 获取批次 ${batchCount} 失败: ${batch.error}`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!batch.rows) {
|
||||||
|
console.warn(`[Export] 批次 ${batchCount} 无数据`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Export] 批次 ${batchCount}: 收到 ${batch.rows.length} 条消息`)
|
||||||
|
|
||||||
for (const row of batch.rows) {
|
for (const row of batch.rows) {
|
||||||
const createTime = parseInt(row.create_time || '0', 10)
|
const createTime = parseInt(row.create_time || '0', 10)
|
||||||
if (dateRange) {
|
if (dateRange) {
|
||||||
@@ -1765,6 +1904,10 @@ class ExportService {
|
|||||||
} else {
|
} else {
|
||||||
actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
|
actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (senderUsernameFilter && !this.isSameWxid(actualSender, senderUsernameFilter)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
senderSet.add(actualSender)
|
senderSet.add(actualSender)
|
||||||
|
|
||||||
// 提取媒体相关字段
|
// 提取媒体相关字段
|
||||||
@@ -1814,8 +1957,17 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
hasMore = batch.hasMore === true
|
hasMore = batch.hasMore === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[Export] 收集完成: 共 ${rows.length} 条消息, ${batchCount} 个批次`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Export] 收集消息异常:`, err)
|
||||||
} finally {
|
} finally {
|
||||||
|
try {
|
||||||
await wcdbService.closeMessageCursor(cursor.cursor)
|
await wcdbService.closeMessageCursor(cursor.cursor)
|
||||||
|
console.log(`[Export] 游标已关闭`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Export] 关闭游标失败:`, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (senderSet.size > 0) {
|
if (senderSet.size > 0) {
|
||||||
@@ -2193,7 +2345,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||||
const allMessages = collected.rows
|
const allMessages = collected.rows
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
@@ -2354,11 +2506,19 @@ class ExportService {
|
|||||||
// 使用预先转写的文字
|
// 使用预先转写的文字
|
||||||
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||||
} else {
|
} else {
|
||||||
content = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime)
|
content = this.parseMessageContent(
|
||||||
|
msg.content,
|
||||||
|
msg.localType,
|
||||||
|
sessionId,
|
||||||
|
msg.createTime,
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转账消息:追加 "谁转账给谁" 信息
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
if (content && content.startsWith('[转账]') && msg.content) {
|
if (content && this.isTransferExportContent(content) && msg.content) {
|
||||||
const transferDesc = await this.resolveTransferDesc(
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
msg.content,
|
msg.content,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -2369,7 +2529,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (transferDesc) {
|
if (transferDesc) {
|
||||||
content = content.replace('[转账]', `[转账] (${transferDesc})`)
|
content = this.appendTransferDesc(content, transferDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2580,7 +2740,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
@@ -2724,11 +2884,19 @@ class ExportService {
|
|||||||
} else if (mediaItem) {
|
} else if (mediaItem) {
|
||||||
content = mediaItem.relativePath
|
content = mediaItem.relativePath
|
||||||
} else {
|
} else {
|
||||||
content = this.parseMessageContent(msg.content, msg.localType)
|
content = this.parseMessageContent(
|
||||||
|
msg.content,
|
||||||
|
msg.localType,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转账消息:追加 "谁转账给谁" 信息
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
if (content && content.startsWith('[转账]') && msg.content) {
|
if (content && this.isTransferExportContent(content) && msg.content) {
|
||||||
const transferDesc = await this.resolveTransferDesc(
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
msg.content,
|
msg.content,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -2742,7 +2910,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (transferDesc) {
|
if (transferDesc) {
|
||||||
content = content.replace('[转账]', `[转账] (${transferDesc})`)
|
content = this.appendTransferDesc(content, transferDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2906,7 +3074,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
@@ -3215,19 +3383,25 @@ class ExportService {
|
|||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId),
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
)
|
)
|
||||||
: (mediaItem?.relativePath
|
: (mediaItem?.relativePath
|
||||||
|| this.formatPlainExportContent(
|
|| this.formatPlainExportContent(
|
||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId),
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
))
|
))
|
||||||
|
|
||||||
// 转账消息:追加 "谁转账给谁" 信息
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
let enrichedContentValue = contentValue
|
let enrichedContentValue = contentValue
|
||||||
if (contentValue.startsWith('[转账]') && msg.content) {
|
if (this.isTransferExportContent(contentValue) && msg.content) {
|
||||||
const transferDesc = await this.resolveTransferDesc(
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
msg.content,
|
msg.content,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -3241,7 +3415,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (transferDesc) {
|
if (transferDesc) {
|
||||||
enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`)
|
enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3387,7 +3561,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
@@ -3526,19 +3700,25 @@ class ExportService {
|
|||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId),
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
)
|
)
|
||||||
: (mediaItem?.relativePath
|
: (mediaItem?.relativePath
|
||||||
|| this.formatPlainExportContent(
|
|| this.formatPlainExportContent(
|
||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId),
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
))
|
))
|
||||||
|
|
||||||
// 转账消息:追加 "谁转账给谁" 信息
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
let enrichedContentValue = contentValue
|
let enrichedContentValue = contentValue
|
||||||
if (contentValue.startsWith('[转账]') && msg.content) {
|
if (this.isTransferExportContent(contentValue) && msg.content) {
|
||||||
const transferDesc = await this.resolveTransferDesc(
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
msg.content,
|
msg.content,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -3552,7 +3732,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (transferDesc) {
|
if (transferDesc) {
|
||||||
enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`)
|
enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3661,7 +3841,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||||
}
|
}
|
||||||
@@ -3824,7 +4004,15 @@ class ExportService {
|
|||||||
|
|
||||||
const msgText = msg.localType === 34 && options.exportVoiceAsText
|
const msgText = msg.localType === 34 && options.exportVoiceAsText
|
||||||
? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]')
|
? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]')
|
||||||
: (this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime) || '')
|
: (this.parseMessageContent(
|
||||||
|
msg.content,
|
||||||
|
msg.localType,
|
||||||
|
sessionId,
|
||||||
|
msg.createTime,
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
|
) || '')
|
||||||
const src = this.getWeCloneSource(msg, typeName, mediaItem)
|
const src = this.getWeCloneSource(msg, typeName, mediaItem)
|
||||||
|
|
||||||
const row = [
|
const row = [
|
||||||
@@ -3974,6 +4162,15 @@ class ExportService {
|
|||||||
const isGroup = sessionId.includes('@chatroom')
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
|
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
|
||||||
|
const getContactCached = async (username: string) => {
|
||||||
|
if (contactCache.has(username)) {
|
||||||
|
return contactCache.get(username)!
|
||||||
|
}
|
||||||
|
const result = await wcdbService.getContact(username)
|
||||||
|
contactCache.set(username, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
current: 0,
|
current: 0,
|
||||||
@@ -3986,13 +4183,31 @@ class ExportService {
|
|||||||
await this.ensureVoiceModel(onProgress)
|
await this.ensureVoiceModel(onProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const senderUsernames = new Set<string>()
|
||||||
|
for (const msg of collected.rows) {
|
||||||
|
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||||
|
}
|
||||||
|
senderUsernames.add(sessionId)
|
||||||
|
await this.preloadContacts(senderUsernames, contactCache)
|
||||||
|
|
||||||
|
const groupNicknameCandidates = isGroup
|
||||||
|
? this.buildGroupNicknameIdCandidates([
|
||||||
|
...Array.from(senderUsernames.values()),
|
||||||
|
...collected.rows.map(msg => msg.senderUsername),
|
||||||
|
cleanedMyWxid
|
||||||
|
])
|
||||||
|
: []
|
||||||
|
const groupNicknamesMap = isGroup
|
||||||
|
? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
|
||||||
|
: new Map<string, string>()
|
||||||
|
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
||||||
}
|
}
|
||||||
@@ -4198,13 +4413,38 @@ class ExportService {
|
|||||||
const timeText = this.formatTimestamp(msg.createTime)
|
const timeText = this.formatTimestamp(msg.createTime)
|
||||||
const typeName = this.getMessageTypeName(msg.localType)
|
const typeName = this.getMessageTypeName(msg.localType)
|
||||||
|
|
||||||
let textContent = this.formatHtmlMessageText(msg.content, msg.localType)
|
let textContent = this.formatHtmlMessageText(
|
||||||
|
msg.content,
|
||||||
|
msg.localType,
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
|
)
|
||||||
if (msg.localType === 34 && useVoiceTranscript) {
|
if (msg.localType === 34 && useVoiceTranscript) {
|
||||||
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||||
}
|
}
|
||||||
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) {
|
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) {
|
||||||
textContent = ''
|
textContent = ''
|
||||||
}
|
}
|
||||||
|
if (this.isTransferExportContent(textContent) && msg.content) {
|
||||||
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
|
msg.content,
|
||||||
|
cleanedMyWxid,
|
||||||
|
groupNicknamesMap,
|
||||||
|
async (username) => {
|
||||||
|
const c = await getContactCached(username)
|
||||||
|
if (c.success && c.contact) {
|
||||||
|
return c.contact.remark || c.contact.nickName || c.contact.alias || username
|
||||||
|
}
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (transferDesc) {
|
||||||
|
textContent = this.appendTransferDesc(textContent, transferDesc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkCard = this.extractHtmlLinkCard(msg.content, msg.localType)
|
||||||
|
|
||||||
let mediaHtml = ''
|
let mediaHtml = ''
|
||||||
if (mediaItem?.kind === 'image') {
|
if (mediaItem?.kind === 'image') {
|
||||||
@@ -4220,9 +4460,11 @@ class ExportService {
|
|||||||
mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></video>`
|
mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></video>`
|
||||||
}
|
}
|
||||||
|
|
||||||
const textHtml = textContent
|
const textHtml = linkCard
|
||||||
|
? `<div class="message-text"><a class="message-link-card" href="${this.escapeAttribute(linkCard.url)}" target="_blank" rel="noopener noreferrer">${this.renderTextWithEmoji(linkCard.title).replace(/\r?\n/g, '<br />')}</a></div>`
|
||||||
|
: (textContent
|
||||||
? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
|
? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
|
||||||
: ''
|
: '')
|
||||||
const senderNameHtml = isGroup
|
const senderNameHtml = isGroup
|
||||||
? `<div class="sender-name">${this.escapeHtml(senderName)}</div>`
|
? `<div class="sender-name">${this.escapeHtml(senderName)}</div>`
|
||||||
: ''
|
: ''
|
||||||
@@ -4368,6 +4610,12 @@ class ExportService {
|
|||||||
</html>`);
|
</html>`);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
stream.on('error', (err) => {
|
||||||
|
// 确保在流错误时销毁流,释放文件句柄
|
||||||
|
stream.destroy()
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
|
||||||
stream.end(() => {
|
stream.end(() => {
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
current: 100,
|
current: 100,
|
||||||
@@ -4413,7 +4661,7 @@ class ExportService {
|
|||||||
|
|
||||||
for (const sessionId of sessionIds) {
|
for (const sessionId of sessionIds) {
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||||
const msgs = collected.rows
|
const msgs = collected.rows
|
||||||
const voiceMsgs = msgs.filter(m => m.localType === 34)
|
const voiceMsgs = msgs.filter(m => m.localType === 34)
|
||||||
const mediaMsgs = msgs.filter(m => {
|
const mediaMsgs = msgs.filter(m => {
|
||||||
@@ -4512,7 +4760,10 @@ class ExportService {
|
|||||||
phase: 'exporting'
|
phase: 'exporting'
|
||||||
})
|
})
|
||||||
|
|
||||||
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
|
const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim()
|
||||||
|
const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session'
|
||||||
|
const suffix = sanitizeName(options.fileNameSuffix || '')
|
||||||
|
const safeName = suffix ? `${baseName}_${suffix}` : baseName
|
||||||
const useSessionFolder = sessionLayout === 'per-session'
|
const useSessionFolder = sessionLayout === 'per-session'
|
||||||
const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir
|
const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ExcelJS from 'exceljs'
|
|||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { chatService } from './chatService'
|
import { chatService } from './chatService'
|
||||||
|
import type { Message } from './chatService'
|
||||||
|
|
||||||
export interface GroupChatInfo {
|
export interface GroupChatInfo {
|
||||||
username: string
|
username: string
|
||||||
@@ -339,6 +340,92 @@ class GroupAnalyticsService {
|
|||||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private formatUnixTime(createTime: number): string {
|
||||||
|
if (!Number.isFinite(createTime) || createTime <= 0) return ''
|
||||||
|
const milliseconds = createTime > 1e12 ? createTime : createTime * 1000
|
||||||
|
const date = new Date(milliseconds)
|
||||||
|
if (Number.isNaN(date.getTime())) return String(createTime)
|
||||||
|
return this.formatDateTime(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSimpleMessageTypeName(localType: number): string {
|
||||||
|
const typeMap: Record<number, string> = {
|
||||||
|
1: '文本',
|
||||||
|
3: '图片',
|
||||||
|
34: '语音',
|
||||||
|
42: '名片',
|
||||||
|
43: '视频',
|
||||||
|
47: '表情',
|
||||||
|
48: '位置',
|
||||||
|
49: '链接/文件',
|
||||||
|
50: '通话',
|
||||||
|
10000: '系统',
|
||||||
|
266287972401: '拍一拍',
|
||||||
|
8594229559345: '红包',
|
||||||
|
8589934592049: '转账'
|
||||||
|
}
|
||||||
|
return typeMap[localType] || `类型(${localType})`
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeIdCandidates(values: Array<string | null | undefined>): string[] {
|
||||||
|
return this.buildIdCandidates(values).map(value => value.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSameAccountIdentity(left: string | null | undefined, right: string | null | undefined): boolean {
|
||||||
|
const leftCandidates = this.normalizeIdCandidates([left])
|
||||||
|
const rightCandidates = this.normalizeIdCandidates([right])
|
||||||
|
if (leftCandidates.length === 0 || rightCandidates.length === 0) return false
|
||||||
|
|
||||||
|
const rightSet = new Set(rightCandidates)
|
||||||
|
for (const leftCandidate of leftCandidates) {
|
||||||
|
if (rightSet.has(leftCandidate)) return true
|
||||||
|
for (const rightCandidate of rightCandidates) {
|
||||||
|
if (leftCandidate.startsWith(`${rightCandidate}_`) || rightCandidate.startsWith(`${leftCandidate}_`)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveExportMessageContent(message: Message): string {
|
||||||
|
const parsed = String(message.parsedContent || '').trim()
|
||||||
|
if (parsed) return parsed
|
||||||
|
const raw = String(message.rawContent || '').trim()
|
||||||
|
if (raw) return raw
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private async collectMessagesByMember(
|
||||||
|
chatroomId: string,
|
||||||
|
memberUsername: string,
|
||||||
|
startTime: number,
|
||||||
|
endTime: number
|
||||||
|
): Promise<{ success: boolean; data?: Message[]; error?: string }> {
|
||||||
|
const batchSize = 500
|
||||||
|
const matchedMessages: Message[] = []
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true)
|
||||||
|
if (!batch.success || !batch.messages) {
|
||||||
|
return { success: false, error: batch.error || '获取群消息失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of batch.messages) {
|
||||||
|
if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) {
|
||||||
|
matchedMessages.push(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchedCount = batch.messages.length
|
||||||
|
if (fetchedCount <= 0 || !batch.hasMore) break
|
||||||
|
offset += fetchedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: matchedMessages }
|
||||||
|
}
|
||||||
|
|
||||||
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
@@ -611,6 +698,181 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportGroupMemberMessages(
|
||||||
|
chatroomId: string,
|
||||||
|
memberUsername: string,
|
||||||
|
outputPath: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||||
|
try {
|
||||||
|
const conn = await this.ensureConnected()
|
||||||
|
if (!conn.success) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||||
|
const normalizedMemberUsername = String(memberUsername || '').trim()
|
||||||
|
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
|
||||||
|
if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' }
|
||||||
|
|
||||||
|
const beginTimestamp = Number.isFinite(startTime) && typeof startTime === 'number'
|
||||||
|
? Math.max(0, Math.floor(startTime))
|
||||||
|
: 0
|
||||||
|
const endTimestampValue = Number.isFinite(endTime) && typeof endTime === 'number'
|
||||||
|
? Math.max(0, Math.floor(endTime))
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const exportDate = new Date()
|
||||||
|
const exportTime = this.formatDateTime(exportDate)
|
||||||
|
const exportVersion = '0.0.2'
|
||||||
|
const exportGenerator = 'WeFlow'
|
||||||
|
const exportPlatform = 'wechat'
|
||||||
|
|
||||||
|
const groupDisplay = await wcdbService.getDisplayNames([normalizedChatroomId, normalizedMemberUsername])
|
||||||
|
const groupName = groupDisplay.success && groupDisplay.map
|
||||||
|
? (groupDisplay.map[normalizedChatroomId] || normalizedChatroomId)
|
||||||
|
: normalizedChatroomId
|
||||||
|
const defaultMemberDisplayName = groupDisplay.success && groupDisplay.map
|
||||||
|
? (groupDisplay.map[normalizedMemberUsername] || normalizedMemberUsername)
|
||||||
|
: normalizedMemberUsername
|
||||||
|
|
||||||
|
let memberDisplayName = defaultMemberDisplayName
|
||||||
|
let memberAlias = ''
|
||||||
|
let memberRemark = ''
|
||||||
|
let memberGroupNickname = ''
|
||||||
|
const membersResult = await this.getGroupMembers(normalizedChatroomId)
|
||||||
|
if (membersResult.success && membersResult.data) {
|
||||||
|
const matchedMember = membersResult.data.find((item) =>
|
||||||
|
this.isSameAccountIdentity(item.username, normalizedMemberUsername)
|
||||||
|
)
|
||||||
|
if (matchedMember) {
|
||||||
|
memberDisplayName = matchedMember.displayName || defaultMemberDisplayName
|
||||||
|
memberAlias = matchedMember.alias || ''
|
||||||
|
memberRemark = matchedMember.remark || ''
|
||||||
|
memberGroupNickname = matchedMember.groupNickname || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collected = await this.collectMessagesByMember(
|
||||||
|
normalizedChatroomId,
|
||||||
|
normalizedMemberUsername,
|
||||||
|
beginTimestamp,
|
||||||
|
endTimestampValue
|
||||||
|
)
|
||||||
|
if (!collected.success || !collected.data) {
|
||||||
|
return { success: false, error: collected.error || '获取成员消息失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = collected.data.map((message, index) => ({
|
||||||
|
index: index + 1,
|
||||||
|
time: this.formatUnixTime(message.createTime),
|
||||||
|
sender: message.senderUsername || '',
|
||||||
|
messageType: this.getSimpleMessageTypeName(message.localType),
|
||||||
|
content: this.resolveExportMessageContent(message)
|
||||||
|
}))
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
||||||
|
const ext = path.extname(outputPath).toLowerCase()
|
||||||
|
if (ext === '.csv') {
|
||||||
|
const infoTitleRow = ['会话信息']
|
||||||
|
const infoRow = ['群聊ID', normalizedChatroomId, '', '群聊名称', groupName, '成员wxid', normalizedMemberUsername, '']
|
||||||
|
const memberRow = ['成员显示名', memberDisplayName, '成员备注', memberRemark, '群昵称', memberGroupNickname, '微信号', memberAlias]
|
||||||
|
const metaRow = ['导出工具', exportGenerator, '导出版本', exportVersion, '平台', exportPlatform, '导出时间', exportTime]
|
||||||
|
const header = ['序号', '时间', '发送者wxid', '消息类型', '内容']
|
||||||
|
|
||||||
|
const csvRows: string[][] = [infoTitleRow, infoRow, memberRow, metaRow, header]
|
||||||
|
for (const record of records) {
|
||||||
|
csvRows.push([String(record.index), record.time, record.sender, record.messageType, record.content])
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvLines = csvRows.map((row) => row.map((cell) => this.escapeCsvValue(cell)).join(','))
|
||||||
|
const content = '\ufeff' + csvLines.join('\n')
|
||||||
|
fs.writeFileSync(outputPath, content, 'utf8')
|
||||||
|
} else {
|
||||||
|
const workbook = new ExcelJS.Workbook()
|
||||||
|
const worksheet = workbook.addWorksheet(this.sanitizeWorksheetName('成员消息记录'))
|
||||||
|
|
||||||
|
worksheet.getCell(1, 1).value = '会话信息'
|
||||||
|
worksheet.getCell(1, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getRow(1).height = 24
|
||||||
|
|
||||||
|
worksheet.getCell(2, 1).value = '群聊ID'
|
||||||
|
worksheet.getCell(2, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.mergeCells(2, 2, 2, 3)
|
||||||
|
worksheet.getCell(2, 2).value = normalizedChatroomId
|
||||||
|
|
||||||
|
worksheet.getCell(2, 4).value = '群聊名称'
|
||||||
|
worksheet.getCell(2, 4).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(2, 5).value = groupName
|
||||||
|
worksheet.getCell(2, 6).value = '成员wxid'
|
||||||
|
worksheet.getCell(2, 6).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.mergeCells(2, 7, 2, 8)
|
||||||
|
worksheet.getCell(2, 7).value = normalizedMemberUsername
|
||||||
|
|
||||||
|
worksheet.getCell(3, 1).value = '成员显示名'
|
||||||
|
worksheet.getCell(3, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(3, 2).value = memberDisplayName
|
||||||
|
worksheet.getCell(3, 3).value = '成员备注'
|
||||||
|
worksheet.getCell(3, 3).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(3, 4).value = memberRemark
|
||||||
|
worksheet.getCell(3, 5).value = '群昵称'
|
||||||
|
worksheet.getCell(3, 5).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(3, 6).value = memberGroupNickname
|
||||||
|
worksheet.getCell(3, 7).value = '微信号'
|
||||||
|
worksheet.getCell(3, 7).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(3, 8).value = memberAlias
|
||||||
|
|
||||||
|
worksheet.getCell(4, 1).value = '导出工具'
|
||||||
|
worksheet.getCell(4, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(4, 2).value = exportGenerator
|
||||||
|
worksheet.getCell(4, 3).value = '导出版本'
|
||||||
|
worksheet.getCell(4, 3).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(4, 4).value = exportVersion
|
||||||
|
worksheet.getCell(4, 5).value = '平台'
|
||||||
|
worksheet.getCell(4, 5).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(4, 6).value = exportPlatform
|
||||||
|
worksheet.getCell(4, 7).value = '导出时间'
|
||||||
|
worksheet.getCell(4, 7).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(4, 8).value = exportTime
|
||||||
|
|
||||||
|
const headerRow = worksheet.getRow(5)
|
||||||
|
const header = ['序号', '时间', '发送者wxid', '消息类型', '内容']
|
||||||
|
header.forEach((title, index) => {
|
||||||
|
const cell = headerRow.getCell(index + 1)
|
||||||
|
cell.value = title
|
||||||
|
cell.font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
})
|
||||||
|
headerRow.height = 22
|
||||||
|
|
||||||
|
worksheet.getColumn(1).width = 10
|
||||||
|
worksheet.getColumn(2).width = 22
|
||||||
|
worksheet.getColumn(3).width = 30
|
||||||
|
worksheet.getColumn(4).width = 16
|
||||||
|
worksheet.getColumn(5).width = 90
|
||||||
|
worksheet.getColumn(6).width = 16
|
||||||
|
worksheet.getColumn(7).width = 20
|
||||||
|
worksheet.getColumn(8).width = 24
|
||||||
|
|
||||||
|
let currentRow = 6
|
||||||
|
for (const record of records) {
|
||||||
|
const row = worksheet.getRow(currentRow)
|
||||||
|
row.getCell(1).value = record.index
|
||||||
|
row.getCell(2).value = record.time
|
||||||
|
row.getCell(3).value = record.sender
|
||||||
|
row.getCell(4).value = record.messageType
|
||||||
|
row.getCell(5).value = record.content
|
||||||
|
row.alignment = { vertical: 'top', wrapText: true }
|
||||||
|
currentRow += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
await workbook.xlsx.writeFile(outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, count: records.length }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async exportGroupMembers(chatroomId: string, outputPath: string): Promise<{ success: boolean; count?: number; error?: string }> {
|
async exportGroupMembers(chatroomId: string, outputPath: string): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* HTTP API 服务
|
* HTTP API 服务
|
||||||
* 提供 ChatLab 标准化格式的消息查询 API
|
* 提供 ChatLab 标准化格式的消息查询 API
|
||||||
*/
|
*/
|
||||||
import * as http from 'http'
|
import * as http from 'http'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
import { URL } from 'url'
|
import { URL } from 'url'
|
||||||
import { chatService, Message } from './chatService'
|
import { chatService, Message } from './chatService'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
|
import { videoService } from './videoService'
|
||||||
|
|
||||||
// ChatLab 格式定义
|
// ChatLab 格式定义
|
||||||
interface ChatLabHeader {
|
interface ChatLabHeader {
|
||||||
@@ -42,6 +45,7 @@ interface ChatLabMessage {
|
|||||||
content: string | null
|
content: string | null
|
||||||
platformMessageId?: string
|
platformMessageId?: string
|
||||||
replyToMessageId?: string
|
replyToMessageId?: string
|
||||||
|
mediaPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatLabData {
|
interface ChatLabData {
|
||||||
@@ -51,6 +55,22 @@ interface ChatLabData {
|
|||||||
messages: ChatLabMessage[]
|
messages: ChatLabMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApiMediaOptions {
|
||||||
|
enabled: boolean
|
||||||
|
exportImages: boolean
|
||||||
|
exportVoices: boolean
|
||||||
|
exportVideos: boolean
|
||||||
|
exportEmojis: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaKind = 'image' | 'voice' | 'video' | 'emoji'
|
||||||
|
|
||||||
|
interface ApiExportedMedia {
|
||||||
|
kind: MediaKind
|
||||||
|
fileName: string
|
||||||
|
fullPath: string
|
||||||
|
}
|
||||||
|
|
||||||
// ChatLab 消息类型映射
|
// ChatLab 消息类型映射
|
||||||
const ChatLabType = {
|
const ChatLabType = {
|
||||||
TEXT: 0,
|
TEXT: 0,
|
||||||
@@ -80,6 +100,7 @@ class HttpService {
|
|||||||
private port: number = 5031
|
private port: number = 5031
|
||||||
private running: boolean = false
|
private running: boolean = false
|
||||||
private connections: Set<import('net').Socket> = new Set()
|
private connections: Set<import('net').Socket> = new Set()
|
||||||
|
private connectionMutex: boolean = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = ConfigService.getInstance()
|
this.configService = ConfigService.getInstance()
|
||||||
@@ -100,9 +121,20 @@ class HttpService {
|
|||||||
|
|
||||||
// 跟踪所有连接,以便关闭时能强制断开
|
// 跟踪所有连接,以便关闭时能强制断开
|
||||||
this.server.on('connection', (socket) => {
|
this.server.on('connection', (socket) => {
|
||||||
|
// 使用互斥锁防止并发修改
|
||||||
|
if (!this.connectionMutex) {
|
||||||
|
this.connectionMutex = true
|
||||||
this.connections.add(socket)
|
this.connections.add(socket)
|
||||||
|
this.connectionMutex = false
|
||||||
|
}
|
||||||
|
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
|
// 使用互斥锁防止并发修改
|
||||||
|
if (!this.connectionMutex) {
|
||||||
|
this.connectionMutex = true
|
||||||
this.connections.delete(socket)
|
this.connections.delete(socket)
|
||||||
|
this.connectionMutex = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -130,11 +162,20 @@ class HttpService {
|
|||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
// 强制关闭所有活动连接
|
// 使用互斥锁保护连接集合操作
|
||||||
for (const socket of this.connections) {
|
this.connectionMutex = true
|
||||||
socket.destroy()
|
const socketsToClose = Array.from(this.connections)
|
||||||
}
|
|
||||||
this.connections.clear()
|
this.connections.clear()
|
||||||
|
this.connectionMutex = false
|
||||||
|
|
||||||
|
// 强制关闭所有活动连接
|
||||||
|
for (const socket of socketsToClose) {
|
||||||
|
try {
|
||||||
|
socket.destroy()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HttpService] Error destroying socket:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.server.close(() => {
|
this.server.close(() => {
|
||||||
this.running = false
|
this.running = false
|
||||||
@@ -163,6 +204,10 @@ class HttpService {
|
|||||||
return this.port
|
return this.port
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDefaultMediaExportPath(): string {
|
||||||
|
return this.getApiMediaExportPath()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 HTTP 请求
|
* 处理 HTTP 请求
|
||||||
*/
|
*/
|
||||||
@@ -213,7 +258,7 @@ class HttpService {
|
|||||||
ascending: boolean
|
ascending: boolean
|
||||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
// 使用固定 batch 大小(与 limit 相同或最大 500)来减少循环次数
|
// 使用固定 batch 大小(与 limit 相同或最多 500)来减少循环次数
|
||||||
const batchSize = Math.min(limit, 500)
|
const batchSize = Math.min(limit, 500)
|
||||||
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
||||||
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
||||||
@@ -240,7 +285,7 @@ class HttpService {
|
|||||||
let rows = batch.rows
|
let rows = batch.rows
|
||||||
hasMore = batch.hasMore === true
|
hasMore = batch.hasMore === true
|
||||||
|
|
||||||
// 处理 offset: 跳过前 N 条
|
// 处理 offset:跳过前 N 条
|
||||||
if (skipped < offset) {
|
if (skipped < offset) {
|
||||||
const remaining = offset - skipped
|
const remaining = offset - skipped
|
||||||
if (remaining >= rows.length) {
|
if (remaining >= rows.length) {
|
||||||
@@ -256,7 +301,7 @@ class HttpService {
|
|||||||
|
|
||||||
const trimmedRows = allRows.slice(0, limit)
|
const trimmedRows = allRows.slice(0, limit)
|
||||||
const finalHasMore = hasMore || allRows.length > limit
|
const finalHasMore = hasMore || allRows.length > limit
|
||||||
const messages = this.mapRowsToMessagesSimple(trimmedRows)
|
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
|
||||||
return { success: true, messages, hasMore: finalHasMore }
|
return { success: true, messages, hasMore: finalHasMore }
|
||||||
} finally {
|
} finally {
|
||||||
await wcdbService.closeMessageCursor(cursor)
|
await wcdbService.closeMessageCursor(cursor)
|
||||||
@@ -268,154 +313,134 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 简单的行数据到 Message 映射(用于 API 输出)
|
* Query param helpers.
|
||||||
*/
|
*/
|
||||||
private mapRowsToMessagesSimple(rows: Record<string, any>[]): Message[] {
|
private parseIntParam(value: string | null, defaultValue: number, min: number, max: number): number {
|
||||||
const myWxid = this.configService.get('myWxid') || ''
|
const parsed = parseInt(value || '', 10)
|
||||||
const messages: Message[] = []
|
if (!Number.isFinite(parsed)) return defaultValue
|
||||||
|
return Math.min(Math.max(parsed, min), max)
|
||||||
for (const row of rows) {
|
|
||||||
const content = this.getField(row, ['message_content', 'messageContent', 'content', 'msg_content', 'WCDB_CT_message_content']) || ''
|
|
||||||
const localType = parseInt(this.getField(row, ['local_type', 'localType', 'type', 'msg_type', 'WCDB_CT_local_type']) || '1', 10)
|
|
||||||
const isSendRaw = this.getField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
|
||||||
const senderUsername = this.getField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || ''
|
|
||||||
const createTime = parseInt(this.getField(row, ['create_time', 'createTime', 'msg_create_time', 'WCDB_CT_create_time']) || '0', 10)
|
|
||||||
const localId = parseInt(this.getField(row, ['local_id', 'localId', 'WCDB_CT_local_id', 'rowid']) || '0', 10)
|
|
||||||
const serverId = this.getField(row, ['server_id', 'serverId', 'WCDB_CT_server_id']) || ''
|
|
||||||
|
|
||||||
let isSend: number
|
|
||||||
if (isSendRaw !== null && isSendRaw !== undefined) {
|
|
||||||
isSend = parseInt(isSendRaw, 10)
|
|
||||||
} else if (senderUsername && myWxid) {
|
|
||||||
isSend = senderUsername.toLowerCase() === myWxid.toLowerCase() ? 1 : 0
|
|
||||||
} else {
|
|
||||||
isSend = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析消息内容中的特殊字段
|
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
|
||||||
let parsedContent = content
|
|
||||||
let xmlType: string | undefined
|
|
||||||
let linkTitle: string | undefined
|
|
||||||
let fileName: string | undefined
|
|
||||||
let emojiCdnUrl: string | undefined
|
|
||||||
let emojiMd5: string | undefined
|
|
||||||
let imageMd5: string | undefined
|
|
||||||
let videoMd5: string | undefined
|
|
||||||
let cardNickname: string | undefined
|
|
||||||
|
|
||||||
if (localType === 49 && content) {
|
|
||||||
// 提取 type 子标签
|
|
||||||
const typeMatch = /<type>(\d+)<\/type>/i.exec(content)
|
|
||||||
if (typeMatch) xmlType = typeMatch[1]
|
|
||||||
// 提取 title
|
|
||||||
const titleMatch = /<title>([^<]*)<\/title>/i.exec(content)
|
|
||||||
if (titleMatch) linkTitle = titleMatch[1]
|
|
||||||
// 提取文件名
|
|
||||||
const fnMatch = /<title>([^<]*)<\/title>/i.exec(content)
|
|
||||||
if (fnMatch) fileName = fnMatch[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localType === 47 && content) {
|
|
||||||
const cdnMatch = /cdnurl\s*=\s*"([^"]+)"/i.exec(content)
|
|
||||||
if (cdnMatch) emojiCdnUrl = cdnMatch[1]
|
|
||||||
const md5Match = /md5\s*=\s*"([^"]+)"/i.exec(content)
|
|
||||||
if (md5Match) emojiMd5 = md5Match[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.push({
|
|
||||||
localId,
|
|
||||||
talker: '',
|
|
||||||
localType,
|
|
||||||
createTime,
|
|
||||||
sortSeq: createTime,
|
|
||||||
content: parsedContent,
|
|
||||||
isSend,
|
|
||||||
senderUsername,
|
|
||||||
serverId: serverId ? parseInt(serverId, 10) || 0 : 0,
|
|
||||||
rawContent: content,
|
|
||||||
parsedContent: content,
|
|
||||||
emojiCdnUrl,
|
|
||||||
emojiMd5,
|
|
||||||
imageMd5,
|
|
||||||
videoMd5,
|
|
||||||
xmlType,
|
|
||||||
linkTitle,
|
|
||||||
fileName,
|
|
||||||
cardNickname
|
|
||||||
} as Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从行数据中获取字段值(兼容多种字段名)
|
|
||||||
*/
|
|
||||||
private getField(row: Record<string, any>, keys: string[]): string | null {
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (row[key] !== undefined && row[key] !== null) {
|
const raw = url.searchParams.get(key)
|
||||||
return String(row[key])
|
if (raw === null) continue
|
||||||
|
const normalized = raw.trim().toLowerCase()
|
||||||
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
|
||||||
|
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseMediaOptions(url: URL): ApiMediaOptions {
|
||||||
|
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
|
||||||
|
if (!mediaEnabled) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
exportImages: false,
|
||||||
|
exportVoices: false,
|
||||||
|
exportVideos: false,
|
||||||
|
exportEmojis: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
exportImages: this.parseBooleanParam(url, ['image', 'tupian'], true),
|
||||||
|
exportVoices: this.parseBooleanParam(url, ['voice', 'vioce'], true),
|
||||||
|
exportVideos: this.parseBooleanParam(url, ['video'], true),
|
||||||
|
exportEmojis: this.parseBooleanParam(url, ['emoji'], true)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理消息查询
|
|
||||||
* GET /api/v1/messages?talker=xxx&limit=100&start=20260101&chatlab=1
|
|
||||||
*/
|
|
||||||
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
|
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
const talker = url.searchParams.get('talker')
|
const talker = (url.searchParams.get('talker') || '').trim()
|
||||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '100', 10), 10000)
|
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
||||||
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
|
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
||||||
|
const keyword = (url.searchParams.get('keyword') || '').trim().toLowerCase()
|
||||||
const startParam = url.searchParams.get('start')
|
const startParam = url.searchParams.get('start')
|
||||||
const endParam = url.searchParams.get('end')
|
const endParam = url.searchParams.get('end')
|
||||||
const chatlab = url.searchParams.get('chatlab') === '1'
|
const chatlab = this.parseBooleanParam(url, ['chatlab'], false)
|
||||||
const formatParam = url.searchParams.get('format')
|
const formatParam = (url.searchParams.get('format') || '').trim().toLowerCase()
|
||||||
const format = formatParam || (chatlab ? 'chatlab' : 'json')
|
const format = formatParam || (chatlab ? 'chatlab' : 'json')
|
||||||
|
const mediaOptions = this.parseMediaOptions(url)
|
||||||
|
|
||||||
if (!talker) {
|
if (!talker) {
|
||||||
this.sendError(res, 400, 'Missing required parameter: talker')
|
this.sendError(res, 400, 'Missing required parameter: talker')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析时间参数 (支持 YYYYMMDD 格式)
|
if (format !== 'json' && format !== 'chatlab') {
|
||||||
|
this.sendError(res, 400, 'Invalid format, supported: json/chatlab')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const startTime = this.parseTimeParam(startParam)
|
const startTime = this.parseTimeParam(startParam)
|
||||||
const endTime = this.parseTimeParam(endParam, true)
|
const endTime = this.parseTimeParam(endParam, true)
|
||||||
|
const queryOffset = keyword ? 0 : offset
|
||||||
|
const queryLimit = keyword ? 10000 : limit
|
||||||
|
|
||||||
// 使用批量获取方法,绕过 chatService 的单 batch 限制
|
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true)
|
||||||
const result = await this.fetchMessagesBatch(talker, offset, limit, startTime, endTime, true)
|
|
||||||
if (!result.success || !result.messages) {
|
if (!result.success || !result.messages) {
|
||||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === 'chatlab') {
|
let messages = result.messages
|
||||||
// 获取会话显示名
|
let hasMore = result.hasMore === true
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
const filtered = messages.filter((msg) => {
|
||||||
|
const content = (msg.parsedContent || msg.rawContent || '').toLowerCase()
|
||||||
|
return content.includes(keyword)
|
||||||
|
})
|
||||||
|
const endIndex = offset + limit
|
||||||
|
hasMore = filtered.length > endIndex
|
||||||
|
messages = filtered.slice(offset, endIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaMap = mediaOptions.enabled
|
||||||
|
? await this.exportMediaForMessages(messages, talker, mediaOptions)
|
||||||
|
: new Map<number, ApiExportedMedia>()
|
||||||
|
|
||||||
const displayNames = await this.getDisplayNames([talker])
|
const displayNames = await this.getDisplayNames([talker])
|
||||||
const talkerName = displayNames[talker] || talker
|
const talkerName = displayNames[talker] || talker
|
||||||
|
|
||||||
const chatLabData = await this.convertToChatLab(result.messages, talker, talkerName)
|
if (format === 'chatlab') {
|
||||||
this.sendJson(res, chatLabData)
|
const chatLabData = await this.convertToChatLab(messages, talker, talkerName, mediaMap)
|
||||||
} else {
|
this.sendJson(res, {
|
||||||
// 返回原始消息格式
|
...chatLabData,
|
||||||
|
media: {
|
||||||
|
enabled: mediaOptions.enabled,
|
||||||
|
exportPath: this.getApiMediaExportPath(),
|
||||||
|
count: mediaMap.size
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiMessages = messages.map((msg) => this.toApiMessage(msg, mediaMap.get(msg.localId)))
|
||||||
this.sendJson(res, {
|
this.sendJson(res, {
|
||||||
success: true,
|
success: true,
|
||||||
talker,
|
talker,
|
||||||
count: result.messages.length,
|
count: apiMessages.length,
|
||||||
hasMore: result.hasMore,
|
hasMore,
|
||||||
messages: result.messages
|
media: {
|
||||||
|
enabled: mediaOptions.enabled,
|
||||||
|
exportPath: this.getApiMediaExportPath(),
|
||||||
|
count: mediaMap.size
|
||||||
|
},
|
||||||
|
messages: apiMessages
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理会话列表查询
|
* 处理会话列表查询
|
||||||
* GET /api/v1/sessions?keyword=xxx&limit=100
|
* GET /api/v1/sessions?keyword=xxx&limit=100
|
||||||
*/
|
*/
|
||||||
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
|
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
const keyword = url.searchParams.get('keyword') || ''
|
const keyword = (url.searchParams.get('keyword') || '').trim()
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '100', 10)
|
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessions = await chatService.getSessions()
|
const sessions = await chatService.getSessions()
|
||||||
@@ -457,8 +482,8 @@ class HttpService {
|
|||||||
* GET /api/v1/contacts?keyword=xxx&limit=100
|
* GET /api/v1/contacts?keyword=xxx&limit=100
|
||||||
*/
|
*/
|
||||||
private async handleContacts(url: URL, res: http.ServerResponse): Promise<void> {
|
private async handleContacts(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
const keyword = url.searchParams.get('keyword') || ''
|
const keyword = (url.searchParams.get('keyword') || '').trim()
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '100', 10)
|
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contacts = await chatService.getContacts()
|
const contacts = await chatService.getContacts()
|
||||||
@@ -490,6 +515,156 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getApiMediaExportPath(): string {
|
||||||
|
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeFileName(value: string, fallback: string): string {
|
||||||
|
const safe = (value || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||||||
|
.replace(/\.+$/g, '')
|
||||||
|
return safe || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureDir(dirPath: string): void {
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectImageExt(buffer: Buffer): string {
|
||||||
|
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg'
|
||||||
|
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return '.png'
|
||||||
|
if (buffer.length >= 6) {
|
||||||
|
const sig6 = buffer.subarray(0, 6).toString('ascii')
|
||||||
|
if (sig6 === 'GIF87a' || sig6 === 'GIF89a') return '.gif'
|
||||||
|
}
|
||||||
|
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return '.webp'
|
||||||
|
if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4d) return '.bmp'
|
||||||
|
return '.jpg'
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exportMediaForMessages(
|
||||||
|
messages: Message[],
|
||||||
|
talker: string,
|
||||||
|
options: ApiMediaOptions
|
||||||
|
): Promise<Map<number, ApiExportedMedia>> {
|
||||||
|
const mediaMap = new Map<number, ApiExportedMedia>()
|
||||||
|
if (!options.enabled || messages.length === 0) {
|
||||||
|
return mediaMap
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
|
||||||
|
this.ensureDir(sessionDir)
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
|
||||||
|
if (exported) {
|
||||||
|
mediaMap.set(msg.localId, exported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaMap
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exportMediaForMessage(
|
||||||
|
msg: Message,
|
||||||
|
talker: string,
|
||||||
|
sessionDir: string,
|
||||||
|
options: ApiMediaOptions
|
||||||
|
): Promise<ApiExportedMedia | null> {
|
||||||
|
try {
|
||||||
|
if (msg.localType === 3 && options.exportImages) {
|
||||||
|
const result = await chatService.getImageData(talker, String(msg.localId))
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const imageBuffer = Buffer.from(result.data, 'base64')
|
||||||
|
const ext = this.detectImageExt(imageBuffer)
|
||||||
|
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||||
|
const fileName = `${fileBase}${ext}`
|
||||||
|
const targetDir = path.join(sessionDir, 'images')
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
this.ensureDir(targetDir)
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.writeFileSync(fullPath, imageBuffer)
|
||||||
|
}
|
||||||
|
return { kind: 'image', fileName, fullPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.localType === 34 && options.exportVoices) {
|
||||||
|
const result = await chatService.getVoiceData(
|
||||||
|
talker,
|
||||||
|
String(msg.localId),
|
||||||
|
msg.createTime || undefined,
|
||||||
|
msg.serverId || undefined
|
||||||
|
)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const fileName = `voice_${msg.localId}.wav`
|
||||||
|
const targetDir = path.join(sessionDir, 'voices')
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
this.ensureDir(targetDir)
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
|
||||||
|
}
|
||||||
|
return { kind: 'voice', fileName, fullPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.localType === 43 && options.exportVideos && msg.videoMd5) {
|
||||||
|
const info = await videoService.getVideoInfo(msg.videoMd5)
|
||||||
|
if (info.exists && info.videoUrl && fs.existsSync(info.videoUrl)) {
|
||||||
|
const ext = path.extname(info.videoUrl) || '.mp4'
|
||||||
|
const fileName = `${this.sanitizeFileName(msg.videoMd5, `video_${msg.localId}`)}${ext}`
|
||||||
|
const targetDir = path.join(sessionDir, 'videos')
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
this.ensureDir(targetDir)
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.copyFileSync(info.videoUrl, fullPath)
|
||||||
|
}
|
||||||
|
return { kind: 'video', fileName, fullPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.localType === 47 && options.exportEmojis && msg.emojiCdnUrl) {
|
||||||
|
const result = await chatService.downloadEmoji(msg.emojiCdnUrl, msg.emojiMd5)
|
||||||
|
if (result.success && result.localPath && fs.existsSync(result.localPath)) {
|
||||||
|
const sourceExt = path.extname(result.localPath) || '.gif'
|
||||||
|
const fileName = `${this.sanitizeFileName(msg.emojiMd5 || `emoji_${msg.localId}`, `emoji_${msg.localId}`)}${sourceExt}`
|
||||||
|
const targetDir = path.join(sessionDir, 'emojis')
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
this.ensureDir(targetDir)
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.copyFileSync(result.localPath, fullPath)
|
||||||
|
}
|
||||||
|
return { kind: 'emoji', fileName, fullPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[HttpService] exportMediaForMessage failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private toApiMessage(msg: Message, media?: ApiExportedMedia): Record<string, any> {
|
||||||
|
return {
|
||||||
|
localId: msg.localId,
|
||||||
|
serverId: msg.serverId,
|
||||||
|
localType: msg.localType,
|
||||||
|
createTime: msg.createTime,
|
||||||
|
sortSeq: msg.sortSeq,
|
||||||
|
isSend: msg.isSend,
|
||||||
|
senderUsername: msg.senderUsername,
|
||||||
|
content: this.getMessageContent(msg),
|
||||||
|
rawContent: msg.rawContent,
|
||||||
|
parsedContent: msg.parsedContent,
|
||||||
|
mediaType: media?.kind,
|
||||||
|
mediaFileName: media?.fileName,
|
||||||
|
mediaPath: media?.fullPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析时间参数
|
* 解析时间参数
|
||||||
* 支持 YYYYMMDD 格式,返回秒级时间戳
|
* 支持 YYYYMMDD 格式,返回秒级时间戳
|
||||||
@@ -497,7 +672,7 @@ class HttpService {
|
|||||||
private parseTimeParam(param: string | null, isEnd: boolean = false): number {
|
private parseTimeParam(param: string | null, isEnd: boolean = false): number {
|
||||||
if (!param) return 0
|
if (!param) return 0
|
||||||
|
|
||||||
// 纯数字且长度为8,视为 YYYYMMDD
|
// 纯数字且长度为 8,视为 YYYYMMDD
|
||||||
if (/^\d{8}$/.test(param)) {
|
if (/^\d{8}$/.test(param)) {
|
||||||
const year = parseInt(param.slice(0, 4), 10)
|
const year = parseInt(param.slice(0, 4), 10)
|
||||||
const month = parseInt(param.slice(4, 6), 10) - 1
|
const month = parseInt(param.slice(4, 6), 10) - 1
|
||||||
@@ -539,7 +714,12 @@ class HttpService {
|
|||||||
/**
|
/**
|
||||||
* 转换为 ChatLab 格式
|
* 转换为 ChatLab 格式
|
||||||
*/
|
*/
|
||||||
private async convertToChatLab(messages: Message[], talkerId: string, talkerName: string): Promise<ChatLabData> {
|
private async convertToChatLab(
|
||||||
|
messages: Message[],
|
||||||
|
talkerId: string,
|
||||||
|
talkerName: string,
|
||||||
|
mediaMap: Map<number, ApiExportedMedia> = new Map()
|
||||||
|
): Promise<ChatLabData> {
|
||||||
const isGroup = talkerId.endsWith('@chatroom')
|
const isGroup = talkerId.endsWith('@chatroom')
|
||||||
const myWxid = this.configService.get('myWxid') || ''
|
const myWxid = this.configService.get('myWxid') || ''
|
||||||
|
|
||||||
@@ -603,7 +783,8 @@ class HttpService {
|
|||||||
timestamp: msg.createTime,
|
timestamp: msg.createTime,
|
||||||
type: this.mapMessageType(msg.localType, msg),
|
type: this.mapMessageType(msg.localType, msg),
|
||||||
content: this.getMessageContent(msg),
|
content: this.getMessageContent(msg),
|
||||||
platformMessageId: msg.serverId ? String(msg.serverId) : undefined
|
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
||||||
|
mediaPath: mediaMap.get(msg.localId)?.fullPath
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -705,13 +886,13 @@ class HttpService {
|
|||||||
case 1:
|
case 1:
|
||||||
return msg.rawContent || null
|
return msg.rawContent || null
|
||||||
case 3:
|
case 3:
|
||||||
return msg.imageMd5 || '[图片]'
|
return '[图片]'
|
||||||
case 34:
|
case 34:
|
||||||
return '[语音]'
|
return '[语音]'
|
||||||
case 43:
|
case 43:
|
||||||
return msg.videoMd5 || '[视频]'
|
return '[视频]'
|
||||||
case 47:
|
case 47:
|
||||||
return msg.emojiCdnUrl || msg.emojiMd5 || '[表情]'
|
return '[表情]'
|
||||||
case 42:
|
case 42:
|
||||||
return msg.cardNickname || '[名片]'
|
return msg.cardNickname || '[名片]'
|
||||||
case 48:
|
case 48:
|
||||||
@@ -743,3 +924,4 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const httpService = new HttpService()
|
export const httpService = new HttpService()
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type DecryptResult = {
|
|||||||
localPath?: string
|
localPath?: string
|
||||||
error?: string
|
error?: string
|
||||||
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
||||||
|
liveVideoPath?: string // 实况照片的视频路径
|
||||||
}
|
}
|
||||||
|
|
||||||
type HardlinkState = {
|
type HardlinkState = {
|
||||||
@@ -61,6 +62,7 @@ export class ImageDecryptService {
|
|||||||
private cacheIndexed = false
|
private cacheIndexed = false
|
||||||
private cacheIndexing: Promise<void> | null = null
|
private cacheIndexing: Promise<void> | null = null
|
||||||
private updateFlags = new Map<string, boolean>()
|
private updateFlags = new Map<string, boolean>()
|
||||||
|
private noLiveSet = new Set<string>() // 已确认无 live 视频的图片路径
|
||||||
|
|
||||||
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||||
if (!this.configService.get('logEnabled')) return
|
if (!this.configService.get('logEnabled')) return
|
||||||
@@ -116,8 +118,9 @@ export class ImageDecryptService {
|
|||||||
} else {
|
} else {
|
||||||
this.updateFlags.delete(key)
|
this.updateFlags.delete(key)
|
||||||
}
|
}
|
||||||
|
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(cached)
|
||||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
|
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
|
||||||
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate }
|
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate, liveVideoPath }
|
||||||
}
|
}
|
||||||
if (cached && !this.isImageFile(cached)) {
|
if (cached && !this.isImageFile(cached)) {
|
||||||
this.resolvedCache.delete(key)
|
this.resolvedCache.delete(key)
|
||||||
@@ -136,8 +139,9 @@ export class ImageDecryptService {
|
|||||||
} else {
|
} else {
|
||||||
this.updateFlags.delete(key)
|
this.updateFlags.delete(key)
|
||||||
}
|
}
|
||||||
|
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
|
||||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
|
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
|
||||||
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
|
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate, liveVideoPath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||||
@@ -151,13 +155,25 @@ export class ImageDecryptService {
|
|||||||
return { success: false, error: '缺少图片标识' }
|
return { success: false, error: '缺少图片标识' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.force) {
|
||||||
|
const hdCached = this.findCachedOutput(cacheKey, true, payload.sessionId)
|
||||||
|
if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached) && !this.isThumbnailPath(hdCached)) {
|
||||||
|
const dataUrl = this.fileToDataUrl(hdCached)
|
||||||
|
const localPath = dataUrl || this.filePathToUrl(hdCached)
|
||||||
|
const liveVideoPath = this.checkLiveVideoCache(hdCached)
|
||||||
|
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||||
|
return { success: true, localPath, isThumb: false, liveVideoPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!payload.force) {
|
if (!payload.force) {
|
||||||
const cached = this.resolvedCache.get(cacheKey)
|
const cached = this.resolvedCache.get(cacheKey)
|
||||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||||
const dataUrl = this.fileToDataUrl(cached)
|
const dataUrl = this.fileToDataUrl(cached)
|
||||||
const localPath = dataUrl || this.filePathToUrl(cached)
|
const localPath = dataUrl || this.filePathToUrl(cached)
|
||||||
|
const liveVideoPath = this.isThumbnailPath(cached) ? undefined : this.checkLiveVideoCache(cached)
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||||
return { success: true, localPath }
|
return { success: true, localPath, liveVideoPath }
|
||||||
}
|
}
|
||||||
if (cached && !this.isImageFile(cached)) {
|
if (cached && !this.isImageFile(cached)) {
|
||||||
this.resolvedCache.delete(cacheKey)
|
this.resolvedCache.delete(cacheKey)
|
||||||
@@ -235,8 +251,9 @@ export class ImageDecryptService {
|
|||||||
const dataUrl = this.fileToDataUrl(existing)
|
const dataUrl = this.fileToDataUrl(existing)
|
||||||
const localPath = dataUrl || this.filePathToUrl(existing)
|
const localPath = dataUrl || this.filePathToUrl(existing)
|
||||||
const isThumb = this.isThumbnailPath(existing)
|
const isThumb = this.isThumbnailPath(existing)
|
||||||
|
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||||
return { success: true, localPath, isThumb }
|
return { success: true, localPath, isThumb, liveVideoPath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +313,15 @@ export class ImageDecryptService {
|
|||||||
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
|
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 }
|
|
||||||
|
// 检测实况照片(Motion Photo)
|
||||||
|
let liveVideoPath: string | undefined
|
||||||
|
if (!isThumb && (finalExt === '.jpg' || finalExt === '.jpeg')) {
|
||||||
|
const videoPath = await this.extractMotionPhotoVideo(outputPath, decrypted)
|
||||||
|
if (videoPath) liveVideoPath = this.filePathToUrl(videoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, localPath, isThumb, liveVideoPath }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
@@ -332,23 +357,37 @@ export class ImageDecryptService {
|
|||||||
* 获取解密后的缓存目录(用于查找 hardlink.db)
|
* 获取解密后的缓存目录(用于查找 hardlink.db)
|
||||||
*/
|
*/
|
||||||
private getDecryptedCacheDir(wxid: string): string | null {
|
private getDecryptedCacheDir(wxid: string): string | null {
|
||||||
const cachePath = this.configService.get('cachePath')
|
|
||||||
if (!cachePath) return null
|
|
||||||
|
|
||||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||||
const cacheAccountDir = join(cachePath, cleanedWxid)
|
const configured = this.configService.get('cachePath')
|
||||||
|
const documentsPath = app.getPath('documents')
|
||||||
|
const baseCandidates = Array.from(new Set([
|
||||||
|
configured || '',
|
||||||
|
join(documentsPath, 'WeFlow'),
|
||||||
|
join(documentsPath, 'WeFlowData'),
|
||||||
|
this.configService.getCacheBasePath()
|
||||||
|
].filter(Boolean)))
|
||||||
|
|
||||||
// 检查缓存目录下是否有 hardlink.db
|
for (const base of baseCandidates) {
|
||||||
if (existsSync(join(cacheAccountDir, 'hardlink.db'))) {
|
const accountCandidates = Array.from(new Set([
|
||||||
return cacheAccountDir
|
join(base, wxid),
|
||||||
|
join(base, cleanedWxid),
|
||||||
|
join(base, 'databases', wxid),
|
||||||
|
join(base, 'databases', cleanedWxid)
|
||||||
|
]))
|
||||||
|
for (const accountDir of accountCandidates) {
|
||||||
|
if (existsSync(join(accountDir, 'hardlink.db'))) {
|
||||||
|
return accountDir
|
||||||
}
|
}
|
||||||
if (existsSync(join(cachePath, 'hardlink.db'))) {
|
const hardlinkSubdir = join(accountDir, 'db_storage', 'hardlink')
|
||||||
return cachePath
|
if (existsSync(join(hardlinkSubdir, 'hardlink.db'))) {
|
||||||
|
return hardlinkSubdir
|
||||||
}
|
}
|
||||||
const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink')
|
|
||||||
if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) {
|
|
||||||
return cacheHardlinkDir
|
|
||||||
}
|
}
|
||||||
|
if (existsSync(join(base, 'hardlink.db'))) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +396,8 @@ export class ImageDecryptService {
|
|||||||
existsSync(join(dirPath, 'hardlink.db')) ||
|
existsSync(join(dirPath, 'hardlink.db')) ||
|
||||||
existsSync(join(dirPath, 'db_storage')) ||
|
existsSync(join(dirPath, 'db_storage')) ||
|
||||||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
|
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
|
||||||
existsSync(join(dirPath, 'FileStorage', 'Image2'))
|
existsSync(join(dirPath, 'FileStorage', 'Image2')) ||
|
||||||
|
existsSync(join(dirPath, 'msg', 'attach'))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +463,12 @@ export class ImageDecryptService {
|
|||||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
|
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||||
return hdPath
|
return hdPath
|
||||||
}
|
}
|
||||||
|
const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || imageMd5 || '', false)
|
||||||
|
if (hdInDir) {
|
||||||
|
this.cacheDatPath(accountDir, imageMd5, hdInDir)
|
||||||
|
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdInDir)
|
||||||
|
return hdInDir
|
||||||
|
}
|
||||||
// 没找到高清图,返回 null(不进行全局搜索)
|
// 没找到高清图,返回 null(不进行全局搜索)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -440,9 +486,16 @@ export class ImageDecryptService {
|
|||||||
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
||||||
const hdPath = this.findHdVariantInSameDir(fallbackPath)
|
const hdPath = this.findHdVariantInSameDir(fallbackPath)
|
||||||
if (hdPath) {
|
if (hdPath) {
|
||||||
|
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||||
return hdPath
|
return hdPath
|
||||||
}
|
}
|
||||||
|
const hdInDir = await this.searchDatFileInDir(dirname(fallbackPath), imageDatName || imageMd5 || '', false)
|
||||||
|
if (hdInDir) {
|
||||||
|
this.cacheDatPath(accountDir, imageMd5, hdInDir)
|
||||||
|
this.cacheDatPath(accountDir, imageDatName, hdInDir)
|
||||||
|
return hdInDir
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||||
@@ -465,15 +518,17 @@ export class ImageDecryptService {
|
|||||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||||
return hdPath
|
return hdPath
|
||||||
}
|
}
|
||||||
|
const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || '', false)
|
||||||
|
if (hdInDir) {
|
||||||
|
this.cacheDatPath(accountDir, imageDatName, hdInDir)
|
||||||
|
return hdInDir
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
|
// force 模式下也继续尝试缓存目录/文件系统搜索,避免 hardlink.db 缺行时只能拿到缩略图
|
||||||
if (!allowThumbnail) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!imageDatName) return null
|
if (!imageDatName) return null
|
||||||
if (!skipResolvedCache) {
|
if (!skipResolvedCache) {
|
||||||
@@ -483,6 +538,8 @@ export class ImageDecryptService {
|
|||||||
// 缓存的是缩略图,尝试找高清图
|
// 缓存的是缩略图,尝试找高清图
|
||||||
const hdPath = this.findHdVariantInSameDir(cached)
|
const hdPath = this.findHdVariantInSameDir(cached)
|
||||||
if (hdPath) return hdPath
|
if (hdPath) return hdPath
|
||||||
|
const hdInDir = await this.searchDatFileInDir(dirname(cached), imageDatName, false)
|
||||||
|
if (hdInDir) return hdInDir
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1681,6 +1738,76 @@ export class ImageDecryptService {
|
|||||||
return mostCommonKey
|
return mostCommonKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查图片对应的 live 视频缓存,返回 file:// URL 或 undefined
|
||||||
|
* 已确认无 live 的路径会被记录,下次直接跳过
|
||||||
|
*/
|
||||||
|
private checkLiveVideoCache(imagePath: string): string | undefined {
|
||||||
|
if (this.noLiveSet.has(imagePath)) return undefined
|
||||||
|
const livePath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4')
|
||||||
|
if (existsSync(livePath)) return this.filePathToUrl(livePath)
|
||||||
|
this.noLiveSet.add(imagePath)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测并分离 Motion Photo(实况照片)
|
||||||
|
* Google Motion Photo = JPEG + MP4 拼接在一起
|
||||||
|
* 返回视频文件路径,如果不是实况照片则返回 null
|
||||||
|
*/
|
||||||
|
private async extractMotionPhotoVideo(imagePath: string, imageBuffer: Buffer): Promise<string | null> {
|
||||||
|
// 只处理 JPEG 文件
|
||||||
|
if (imageBuffer.length < 8) return null
|
||||||
|
if (imageBuffer[0] !== 0xff || imageBuffer[1] !== 0xd8) return null
|
||||||
|
|
||||||
|
// 从末尾向前搜索 MP4 ftyp 原子签名
|
||||||
|
// ftyp 原子结构: [4字节大小][ftyp(66 74 79 70)][品牌...]
|
||||||
|
// 实际起始位置在 ftyp 前4字节(大小字段)
|
||||||
|
const ftypSig = [0x66, 0x74, 0x79, 0x70] // 'ftyp'
|
||||||
|
let videoOffset: number | null = null
|
||||||
|
|
||||||
|
const searchEnd = Math.max(0, imageBuffer.length - 8)
|
||||||
|
for (let i = searchEnd; i > 0; i--) {
|
||||||
|
if (imageBuffer[i] === ftypSig[0] &&
|
||||||
|
imageBuffer[i + 1] === ftypSig[1] &&
|
||||||
|
imageBuffer[i + 2] === ftypSig[2] &&
|
||||||
|
imageBuffer[i + 3] === ftypSig[3]) {
|
||||||
|
// ftyp 前4字节是 box size,实际 MP4 从这里开始
|
||||||
|
videoOffset = i - 4
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备用:从 XMP 元数据中读取偏移量
|
||||||
|
if (videoOffset === null || videoOffset <= 0) {
|
||||||
|
try {
|
||||||
|
const text = imageBuffer.toString('latin1')
|
||||||
|
const match = text.match(/MediaDataOffset="(\d+)"/i) || text.match(/MicroVideoOffset="(\d+)"/i)
|
||||||
|
if (match) {
|
||||||
|
const offset = parseInt(match[1], 10)
|
||||||
|
if (offset > 0 && offset < imageBuffer.length) {
|
||||||
|
videoOffset = imageBuffer.length - offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoOffset === null || videoOffset <= 100) return null
|
||||||
|
|
||||||
|
// 验证视频部分确实以有效 MP4 数据开头
|
||||||
|
const videoStart = imageBuffer[videoOffset + 4] === 0x66 &&
|
||||||
|
imageBuffer[videoOffset + 5] === 0x74 &&
|
||||||
|
imageBuffer[videoOffset + 6] === 0x79 &&
|
||||||
|
imageBuffer[videoOffset + 7] === 0x70
|
||||||
|
if (!videoStart) return null
|
||||||
|
|
||||||
|
// 写出视频文件
|
||||||
|
const videoPath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4')
|
||||||
|
const videoBuffer = imageBuffer.slice(videoOffset)
|
||||||
|
await writeFile(videoPath, videoBuffer)
|
||||||
|
return videoPath
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解包 wxgf 格式
|
* 解包 wxgf 格式
|
||||||
* wxgf 是微信的图片格式,内部使用 HEVC 编码
|
* wxgf 是微信的图片格式,内部使用 HEVC 编码
|
||||||
|
|||||||
127
electron/services/isaac64.ts
Normal file
127
electron/services/isaac64.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* ISAAC-64: A fast cryptographic PRNG
|
||||||
|
* Re-implemented in TypeScript using BigInt for 64-bit support.
|
||||||
|
* Used for WeChat Channels/SNS video decryption.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class Isaac64 {
|
||||||
|
private mm = new BigUint64Array(256);
|
||||||
|
private aa = 0n;
|
||||||
|
private bb = 0n;
|
||||||
|
private cc = 0n;
|
||||||
|
private randrsl = new BigUint64Array(256);
|
||||||
|
private randcnt = 0;
|
||||||
|
private static readonly MASK = 0xFFFFFFFFFFFFFFFFn;
|
||||||
|
|
||||||
|
constructor(seed: number | string | bigint) {
|
||||||
|
const seedBig = BigInt(seed);
|
||||||
|
// 通常单密钥初始化是将密钥放在第一个槽位,其余清零(或者按某种规律填充)
|
||||||
|
// 这里我们尝试仅设置第一个槽位,这在很多 WASM 移植版本中更为常见
|
||||||
|
this.randrsl.fill(0n);
|
||||||
|
this.randrsl[0] = seedBig;
|
||||||
|
this.init(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(flag: boolean) {
|
||||||
|
let a: bigint, b: bigint, c: bigint, d: bigint, e: bigint, f: bigint, g: bigint, h: bigint;
|
||||||
|
a = b = c = d = e = f = g = h = 0x9e3779b97f4a7c15n;
|
||||||
|
|
||||||
|
const mix = () => {
|
||||||
|
a = (a - e) & Isaac64.MASK; f ^= (h >> 9n); h = (h + a) & Isaac64.MASK;
|
||||||
|
b = (b - f) & Isaac64.MASK; g ^= (a << 9n) & Isaac64.MASK; a = (a + b) & Isaac64.MASK;
|
||||||
|
c = (c - g) & Isaac64.MASK; h ^= (b >> 23n); b = (b + c) & Isaac64.MASK;
|
||||||
|
d = (d - h) & Isaac64.MASK; a ^= (c << 15n) & Isaac64.MASK; c = (c + d) & Isaac64.MASK;
|
||||||
|
e = (e - a) & Isaac64.MASK; b ^= (d >> 14n); d = (d + e) & Isaac64.MASK;
|
||||||
|
f = (f - b) & Isaac64.MASK; c ^= (e << 20n) & Isaac64.MASK; e = (e + f) & Isaac64.MASK;
|
||||||
|
g = (g - c) & Isaac64.MASK; d ^= (f >> 17n); f = (f + g) & Isaac64.MASK;
|
||||||
|
h = (h - d) & Isaac64.MASK; e ^= (g << 14n) & Isaac64.MASK; g = (g + h) & Isaac64.MASK;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) mix();
|
||||||
|
|
||||||
|
for (let i = 0; i < 256; i += 8) {
|
||||||
|
if (flag) {
|
||||||
|
a = (a + this.randrsl[i]) & Isaac64.MASK;
|
||||||
|
b = (b + this.randrsl[i + 1]) & Isaac64.MASK;
|
||||||
|
c = (c + this.randrsl[i + 2]) & Isaac64.MASK;
|
||||||
|
d = (d + this.randrsl[i + 3]) & Isaac64.MASK;
|
||||||
|
e = (e + this.randrsl[i + 4]) & Isaac64.MASK;
|
||||||
|
f = (f + this.randrsl[i + 5]) & Isaac64.MASK;
|
||||||
|
g = (g + this.randrsl[i + 6]) & Isaac64.MASK;
|
||||||
|
h = (h + this.randrsl[i + 7]) & Isaac64.MASK;
|
||||||
|
}
|
||||||
|
mix();
|
||||||
|
this.mm[i] = a; this.mm[i + 1] = b; this.mm[i + 2] = c; this.mm[i + 3] = d;
|
||||||
|
this.mm[i + 4] = e; this.mm[i + 5] = f; this.mm[i + 6] = g; this.mm[i + 7] = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flag) {
|
||||||
|
for (let i = 0; i < 256; i += 8) {
|
||||||
|
a = (a + this.mm[i]) & Isaac64.MASK;
|
||||||
|
b = (b + this.mm[i + 1]) & Isaac64.MASK;
|
||||||
|
c = (c + this.mm[i + 2]) & Isaac64.MASK;
|
||||||
|
d = (d + this.mm[i + 3]) & Isaac64.MASK;
|
||||||
|
e = (e + this.mm[i + 4]) & Isaac64.MASK;
|
||||||
|
f = (f + this.mm[i + 5]) & Isaac64.MASK;
|
||||||
|
g = (g + this.mm[i + 6]) & Isaac64.MASK;
|
||||||
|
h = (h + this.mm[i + 7]) & Isaac64.MASK;
|
||||||
|
mix();
|
||||||
|
this.mm[i] = a; this.mm[i + 1] = b; this.mm[i + 2] = c; this.mm[i + 3] = d;
|
||||||
|
this.mm[i + 4] = e; this.mm[i + 5] = f; this.mm[i + 6] = g; this.mm[i + 7] = h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isaac64();
|
||||||
|
this.randcnt = 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isaac64() {
|
||||||
|
this.cc = (this.cc + 1n) & Isaac64.MASK;
|
||||||
|
this.bb = (this.bb + this.cc) & Isaac64.MASK;
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let x = this.mm[i];
|
||||||
|
switch (i & 3) {
|
||||||
|
case 0: this.aa = (this.aa ^ (((this.aa << 21n) & Isaac64.MASK) ^ Isaac64.MASK)) & Isaac64.MASK; break;
|
||||||
|
case 1: this.aa = (this.aa ^ (this.aa >> 5n)) & Isaac64.MASK; break;
|
||||||
|
case 2: this.aa = (this.aa ^ ((this.aa << 12n) & Isaac64.MASK)) & Isaac64.MASK; break;
|
||||||
|
case 3: this.aa = (this.aa ^ (this.aa >> 33n)) & Isaac64.MASK; break;
|
||||||
|
}
|
||||||
|
this.aa = (this.mm[(i + 128) & 255] + this.aa) & Isaac64.MASK;
|
||||||
|
const y = (this.mm[Number(x >> 3n) & 255] + this.aa + this.bb) & Isaac64.MASK;
|
||||||
|
this.mm[i] = y;
|
||||||
|
this.bb = (this.mm[Number(y >> 11n) & 255] + x) & Isaac64.MASK;
|
||||||
|
this.randrsl[i] = this.bb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getNext(): bigint {
|
||||||
|
if (this.randcnt === 0) {
|
||||||
|
this.isaac64();
|
||||||
|
this.randcnt = 256;
|
||||||
|
}
|
||||||
|
return this.randrsl[--this.randcnt];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a keystream where each 64-bit block is Big-Endian.
|
||||||
|
* This matches WeChat's behavior (Reverse index order + byte reversal).
|
||||||
|
*/
|
||||||
|
public generateKeystreamBE(size: number): Buffer {
|
||||||
|
const buffer = Buffer.allocUnsafe(size);
|
||||||
|
const fullBlocks = Math.floor(size / 8);
|
||||||
|
|
||||||
|
for (let i = 0; i < fullBlocks; i++) {
|
||||||
|
buffer.writeBigUInt64BE(this.getNext(), i * 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = size % 8;
|
||||||
|
if (remaining > 0) {
|
||||||
|
const lastK = this.getNext();
|
||||||
|
const temp = Buffer.allocUnsafe(8);
|
||||||
|
temp.writeBigUInt64BE(lastK, 0);
|
||||||
|
temp.copy(buffer, fullBlocks * 8, 0, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -843,7 +843,7 @@ export class KeyService {
|
|||||||
private findTemplateDatFiles(rootDir: string): string[] {
|
private findTemplateDatFiles(rootDir: string): string[] {
|
||||||
const files: string[] = []
|
const files: string[] = []
|
||||||
const stack = [rootDir]
|
const stack = [rootDir]
|
||||||
const maxFiles = 32
|
const maxFiles = 256
|
||||||
while (stack.length && files.length < maxFiles) {
|
while (stack.length && files.length < maxFiles) {
|
||||||
const dir = stack.pop() as string
|
const dir = stack.pop() as string
|
||||||
let entries: string[]
|
let entries: string[]
|
||||||
@@ -877,7 +877,7 @@ export class KeyService {
|
|||||||
if (ma && mb) return mb.localeCompare(ma)
|
if (ma && mb) return mb.localeCompare(ma)
|
||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
return files.slice(0, 16)
|
return files.slice(0, 128)
|
||||||
}
|
}
|
||||||
|
|
||||||
private getXorKey(templateFiles: string[]): number | null {
|
private getXorKey(templateFiles: string[]): number | null {
|
||||||
|
|||||||
@@ -1,371 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import { app, BrowserWindow } from "electron";
|
|
||||||
import path from "path";
|
|
||||||
import { ConfigService } from './config';
|
|
||||||
|
|
||||||
// Define interfaces locally to avoid static import of types that might not be available or cause issues
|
|
||||||
type LlamaModel = any;
|
|
||||||
type LlamaContext = any;
|
|
||||||
type LlamaChatSession = any;
|
|
||||||
|
|
||||||
export class LlamaService {
|
|
||||||
private _model: LlamaModel | null = null;
|
|
||||||
private _context: LlamaContext | null = null;
|
|
||||||
private _sequence: any = null;
|
|
||||||
private _session: LlamaChatSession | null = null;
|
|
||||||
private _llama: any = null;
|
|
||||||
private _nodeLlamaCpp: any = null;
|
|
||||||
private configService = new ConfigService();
|
|
||||||
private _initialized = false;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// 延迟初始化,只在需要时初始化
|
|
||||||
}
|
|
||||||
|
|
||||||
public async init() {
|
|
||||||
if (this._initialized) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Dynamic import to handle ESM module in CJS context
|
|
||||||
this._nodeLlamaCpp = await import("node-llama-cpp");
|
|
||||||
this._llama = await this._nodeLlamaCpp.getLlama();
|
|
||||||
this._initialized = true;
|
|
||||||
console.log("[LlamaService] Llama initialized");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[LlamaService] Failed to initialize Llama:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async loadModel(modelPath: string) {
|
|
||||||
if (!this._llama) await this.init();
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("[LlamaService] Loading model from:", modelPath);
|
|
||||||
if (!this._llama) {
|
|
||||||
throw new Error("Llama not initialized");
|
|
||||||
}
|
|
||||||
this._model = await this._llama.loadModel({
|
|
||||||
modelPath: modelPath,
|
|
||||||
gpuLayers: 'max', // Offload all layers to GPU if possible
|
|
||||||
useMlock: false // Disable mlock to avoid "VirtualLock" errors (common on Windows)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!this._model) throw new Error("Failed to load model");
|
|
||||||
|
|
||||||
this._context = await this._model.createContext({
|
|
||||||
contextSize: 8192, // Balanced context size for better performance
|
|
||||||
batchSize: 2048 // Increase batch size for better prompt processing speed
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!this._context) throw new Error("Failed to create context");
|
|
||||||
|
|
||||||
this._sequence = this._context.getSequence();
|
|
||||||
|
|
||||||
const { LlamaChatSession } = this._nodeLlamaCpp;
|
|
||||||
this._session = new LlamaChatSession({
|
|
||||||
contextSequence: this._sequence
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("[LlamaService] Model loaded successfully");
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[LlamaService] Failed to load model:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async createSession(systemPrompt?: string) {
|
|
||||||
if (!this._context) throw new Error("Model not loaded");
|
|
||||||
if (!this._nodeLlamaCpp) await this.init();
|
|
||||||
|
|
||||||
const { LlamaChatSession } = this._nodeLlamaCpp;
|
|
||||||
|
|
||||||
if (!this._sequence) {
|
|
||||||
this._sequence = this._context.getSequence();
|
|
||||||
}
|
|
||||||
|
|
||||||
this._session = new LlamaChatSession({
|
|
||||||
contextSequence: this._sequence,
|
|
||||||
systemPrompt: systemPrompt
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async chat(message: string, options: { thinking?: boolean } = {}, onToken: (token: string) => void) {
|
|
||||||
if (!this._session) throw new Error("Session not initialized");
|
|
||||||
|
|
||||||
const thinking = options.thinking ?? false;
|
|
||||||
|
|
||||||
// Sampling parameters based on mode
|
|
||||||
const samplingParams = thinking ? {
|
|
||||||
temperature: 0.6,
|
|
||||||
topP: 0.95,
|
|
||||||
topK: 20,
|
|
||||||
repeatPenalty: 1.5 // PresencePenalty=1.5
|
|
||||||
} : {
|
|
||||||
temperature: 0.7,
|
|
||||||
topP: 0.8,
|
|
||||||
topK: 20,
|
|
||||||
repeatPenalty: 1.5
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this._session.prompt(message, {
|
|
||||||
...samplingParams,
|
|
||||||
onTextChunk: (chunk: string) => {
|
|
||||||
onToken(chunk);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[LlamaService] Chat error:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getModelStatus(modelPath: string) {
|
|
||||||
try {
|
|
||||||
const exists = fs.existsSync(modelPath);
|
|
||||||
if (!exists) {
|
|
||||||
return { exists: false, path: modelPath };
|
|
||||||
}
|
|
||||||
const stats = fs.statSync(modelPath);
|
|
||||||
return {
|
|
||||||
exists: true,
|
|
||||||
path: modelPath,
|
|
||||||
size: stats.size
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { exists: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveModelDir(): string {
|
|
||||||
const configured = this.configService.get('whisperModelDir') as string | undefined;
|
|
||||||
if (configured) return configured;
|
|
||||||
return path.join(app.getPath('documents'), 'WeFlow', 'models');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async downloadModel(url: string, savePath: string, onProgress: (payload: { downloaded: number; total: number; speed: number }) => void): Promise<void> {
|
|
||||||
// Ensure directory exists
|
|
||||||
const dir = path.dirname(savePath);
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info(`[LlamaService] Multi-threaded download check for: ${savePath}`);
|
|
||||||
|
|
||||||
if (fs.existsSync(savePath)) {
|
|
||||||
fs.unlinkSync(savePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Get total size and check range support
|
|
||||||
let probeResult;
|
|
||||||
try {
|
|
||||||
probeResult = await this.probeUrl(url);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[LlamaService] Probe failed, falling back to single-thread.", err);
|
|
||||||
return this.downloadSingleThread(url, savePath, onProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { totalSize, acceptRanges, finalUrl } = probeResult;
|
|
||||||
console.log(`[LlamaService] Total size: ${totalSize}, Accept-Ranges: ${acceptRanges}`);
|
|
||||||
|
|
||||||
if (totalSize <= 0 || !acceptRanges) {
|
|
||||||
console.warn("[LlamaService] Ranges not supported or size unknown, falling back to single-thread.");
|
|
||||||
return this.downloadSingleThread(finalUrl, savePath, onProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
const threadCount = 4;
|
|
||||||
const chunkSize = Math.ceil(totalSize / threadCount);
|
|
||||||
const fd = fs.openSync(savePath, 'w');
|
|
||||||
|
|
||||||
let downloadedLength = 0;
|
|
||||||
let lastDownloadedLength = 0;
|
|
||||||
let lastTime = Date.now();
|
|
||||||
let speed = 0;
|
|
||||||
|
|
||||||
const speedInterval = setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
const duration = (now - lastTime) / 1000;
|
|
||||||
if (duration > 0) {
|
|
||||||
speed = (downloadedLength - lastDownloadedLength) / duration;
|
|
||||||
lastDownloadedLength = downloadedLength;
|
|
||||||
lastTime = now;
|
|
||||||
onProgress({ downloaded: downloadedLength, total: totalSize, speed });
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const promises = [];
|
|
||||||
for (let i = 0; i < threadCount; i++) {
|
|
||||||
const start = i * chunkSize;
|
|
||||||
const end = i === threadCount - 1 ? totalSize - 1 : (i + 1) * chunkSize - 1;
|
|
||||||
|
|
||||||
promises.push(this.downloadChunk(finalUrl, fd, start, end, (bytes) => {
|
|
||||||
downloadedLength += bytes;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
console.log("[LlamaService] Multi-threaded download complete");
|
|
||||||
|
|
||||||
// Final progress update
|
|
||||||
onProgress({ downloaded: totalSize, total: totalSize, speed: 0 });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[LlamaService] Multi-threaded download failed:", err);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
clearInterval(speedInterval);
|
|
||||||
fs.closeSync(fd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async probeUrl(url: string): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
|
|
||||||
const protocol = url.startsWith('https') ? require('https') : require('http');
|
|
||||||
const options = {
|
|
||||||
method: 'GET',
|
|
||||||
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',
|
|
||||||
'Referer': 'https://www.modelscope.cn/',
|
|
||||||
'Range': 'bytes=0-0'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = protocol.get(url, options, (res: any) => {
|
|
||||||
if ([301, 302, 307, 308].includes(res.statusCode)) {
|
|
||||||
const location = res.headers.location;
|
|
||||||
const nextUrl = new URL(location, url).href;
|
|
||||||
this.probeUrl(nextUrl).then(resolve).catch(reject);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.statusCode !== 206 && res.statusCode !== 200) {
|
|
||||||
reject(new Error(`Probe failed: HTTP ${res.statusCode}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentRange = res.headers['content-range'];
|
|
||||||
let totalSize = 0;
|
|
||||||
if (contentRange) {
|
|
||||||
const parts = contentRange.split('/');
|
|
||||||
totalSize = parseInt(parts[parts.length - 1], 10);
|
|
||||||
} else {
|
|
||||||
totalSize = parseInt(res.headers['content-length'] || '0', 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
const acceptRanges = res.headers['accept-ranges'] === 'bytes' || !!contentRange;
|
|
||||||
resolve({ totalSize, acceptRanges, finalUrl: url });
|
|
||||||
res.destroy();
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async downloadChunk(url: string, fd: number, start: number, end: number, onData: (bytes: number) => void): Promise<void> {
|
|
||||||
const protocol = url.startsWith('https') ? require('https') : require('http');
|
|
||||||
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',
|
|
||||||
'Referer': 'https://www.modelscope.cn/',
|
|
||||||
'Range': `bytes=${start}-${end}`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = protocol.get(url, options, (res: any) => {
|
|
||||||
if (res.statusCode !== 206) {
|
|
||||||
reject(new Error(`Chunk download failed: HTTP ${res.statusCode}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentOffset = start;
|
|
||||||
res.on('data', (chunk: Buffer) => {
|
|
||||||
try {
|
|
||||||
fs.writeSync(fd, chunk, 0, chunk.length, currentOffset);
|
|
||||||
currentOffset += chunk.length;
|
|
||||||
onData(chunk.length);
|
|
||||||
} catch (err) {
|
|
||||||
reject(err);
|
|
||||||
res.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.on('end', () => resolve());
|
|
||||||
res.on('error', reject);
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async downloadSingleThread(url: string, savePath: string, onProgress: (payload: { downloaded: number; total: number; speed: number }) => void): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const protocol = url.startsWith('https') ? require('https') : require('http');
|
|
||||||
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',
|
|
||||||
'Referer': 'https://www.modelscope.cn/'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const request = protocol.get(url, options, (response: any) => {
|
|
||||||
if ([301, 302, 307, 308].includes(response.statusCode)) {
|
|
||||||
const location = response.headers.location;
|
|
||||||
const nextUrl = new URL(location, url).href;
|
|
||||||
this.downloadSingleThread(nextUrl, savePath, onProgress).then(resolve).catch(reject);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
|
||||||
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalLength = parseInt(response.headers['content-length'] || '0', 10);
|
|
||||||
let downloadedLength = 0;
|
|
||||||
let lastDownloadedLength = 0;
|
|
||||||
let lastTime = Date.now();
|
|
||||||
let speed = 0;
|
|
||||||
|
|
||||||
const fileStream = fs.createWriteStream(savePath);
|
|
||||||
response.pipe(fileStream);
|
|
||||||
|
|
||||||
const speedInterval = setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
const duration = (now - lastTime) / 1000;
|
|
||||||
if (duration > 0) {
|
|
||||||
speed = (downloadedLength - lastDownloadedLength) / duration;
|
|
||||||
lastDownloadedLength = downloadedLength;
|
|
||||||
lastTime = now;
|
|
||||||
onProgress({ downloaded: downloadedLength, total: totalLength, speed });
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
response.on('data', (chunk: any) => {
|
|
||||||
downloadedLength += chunk.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
fileStream.on('finish', () => {
|
|
||||||
clearInterval(speedInterval);
|
|
||||||
fileStream.close();
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
fileStream.on('error', (err: any) => {
|
|
||||||
clearInterval(speedInterval);
|
|
||||||
fs.unlink(savePath, () => { });
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
request.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public getModelsPath() {
|
|
||||||
return this.resolveModelDir();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const llamaService = new LlamaService();
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
export interface SessionMessageCacheEntry {
|
export interface SessionMessageCacheEntry {
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
@@ -15,7 +16,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('documents'), 'WeFlow')
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
this.cacheFilePath = join(basePath, 'session-messages.json')
|
this.cacheFilePath = join(basePath, 'session-messages.json')
|
||||||
this.ensureCacheDir()
|
this.ensureCacheDir()
|
||||||
this.loadCache()
|
this.loadCache()
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { ContactCacheService } from './contactCacheService'
|
import { ContactCacheService } from './contactCacheService'
|
||||||
|
import { existsSync, mkdirSync } from 'fs'
|
||||||
|
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||||
|
import { basename, join } from 'path'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { WasmService } from './wasmService'
|
||||||
|
|
||||||
export interface SnsLivePhoto {
|
export interface SnsLivePhoto {
|
||||||
url: string
|
url: string
|
||||||
@@ -32,82 +37,161 @@ export interface SnsPost {
|
|||||||
media: SnsMedia[]
|
media: SnsMedia[]
|
||||||
likes: string[]
|
likes: string[]
|
||||||
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
|
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
|
||||||
rawXml?: string // 原始 XML 数据
|
rawXml?: string
|
||||||
|
linkTitle?: string
|
||||||
|
linkUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const fixSnsUrl = (url: string, token?: string) => {
|
|
||||||
if (!url) return url;
|
|
||||||
|
|
||||||
// 1. 统一使用 https
|
|
||||||
// 2. 将 /150 (缩略图) 强制改为 /0 (原图)
|
|
||||||
let fixedUrl = url.replace('http://', 'https://').replace(/\/150($|\?)/, '/0$1');
|
|
||||||
|
|
||||||
if (!token || fixedUrl.includes('token=')) return fixedUrl;
|
const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => {
|
||||||
|
if (!url) return url
|
||||||
|
|
||||||
const connector = fixedUrl.includes('?') ? '&' : '?';
|
let fixedUrl = url.replace('http://', 'https://')
|
||||||
return `${fixedUrl}${connector}token=${token}&idx=1`;
|
|
||||||
};
|
// 只有非视频(即图片)才需要处理 /150 变 /0
|
||||||
|
if (!isVideo) {
|
||||||
|
fixedUrl = fixedUrl.replace(/\/150($|\?)/, '/0$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || fixedUrl.includes('token=')) return fixedUrl
|
||||||
|
|
||||||
|
// 根据用户要求,视频链接组合方式为: BASE_URL + "?" + "token=" + token + "&idx=1" + 原有参数
|
||||||
|
if (isVideo) {
|
||||||
|
const urlParts = fixedUrl.split('?')
|
||||||
|
const baseUrl = urlParts[0]
|
||||||
|
const existingParams = urlParts[1] ? `&${urlParts[1]}` : ''
|
||||||
|
return `${baseUrl}?token=${token}&idx=1${existingParams}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const connector = fixedUrl.includes('?') ? '&' : '?'
|
||||||
|
return `${fixedUrl}${connector}token=${token}&idx=1`
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => {
|
||||||
|
if (!buf || buf.length < 4) return fallback
|
||||||
|
|
||||||
|
// JPEG
|
||||||
|
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'
|
||||||
|
|
||||||
|
// PNG
|
||||||
|
if (
|
||||||
|
buf.length >= 8 &&
|
||||||
|
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
|
||||||
|
buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
|
||||||
|
) return 'image/png'
|
||||||
|
|
||||||
|
// GIF
|
||||||
|
if (buf.length >= 6) {
|
||||||
|
const sig = buf.subarray(0, 6).toString('ascii')
|
||||||
|
if (sig === 'GIF87a' || sig === 'GIF89a') return 'image/gif'
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebP
|
||||||
|
if (
|
||||||
|
buf.length >= 12 &&
|
||||||
|
buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
|
||||||
|
buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50
|
||||||
|
) return 'image/webp'
|
||||||
|
|
||||||
|
// BMP
|
||||||
|
if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp'
|
||||||
|
|
||||||
|
// MP4: 00 00 00 18 / 20 / ... + 'ftyp'
|
||||||
|
if (buf.length > 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'video/mp4'
|
||||||
|
|
||||||
|
// Fallback logic for video
|
||||||
|
if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4'
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isVideoUrl = (url: string) => {
|
||||||
|
if (!url) return false
|
||||||
|
// 排除 vweixinthumb 域名 (缩略图)
|
||||||
|
if (url.includes('vweixinthumb')) return false
|
||||||
|
return url.includes('snsvideodownload') || url.includes('video') || url.includes('.mp4')
|
||||||
|
}
|
||||||
|
|
||||||
|
import { Isaac64 } from './isaac64'
|
||||||
|
|
||||||
|
const extractVideoKey = (xml: string): string | undefined => {
|
||||||
|
if (!xml) return undefined
|
||||||
|
// 匹配 <enc key="2105122989" ... /> 或 <enc key="2105122989">
|
||||||
|
const match = xml.match(/<enc\s+key="(\d+)"/i)
|
||||||
|
return match ? match[1] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
class SnsService {
|
class SnsService {
|
||||||
|
private configService: ConfigService
|
||||||
private contactCache: ContactCacheService
|
private contactCache: ContactCacheService
|
||||||
|
private imageCache = new Map<string, string>()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const config = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
this.contactCache = new ContactCacheService(config.get('cachePath') as string)
|
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSnsCacheDir(): string {
|
||||||
|
const cachePath = this.configService.getCacheBasePath()
|
||||||
|
const snsCacheDir = join(cachePath, 'sns_cache')
|
||||||
|
if (!existsSync(snsCacheDir)) {
|
||||||
|
mkdirSync(snsCacheDir, { recursive: true })
|
||||||
|
}
|
||||||
|
return snsCacheDir
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCacheFilePath(url: string): string {
|
||||||
|
const hash = crypto.createHash('md5').update(url).digest('hex')
|
||||||
|
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
|
||||||
|
return join(this.getSnsCacheDir(), `${hash}${ext}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有发过朋友圈的用户名列表
|
||||||
|
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||||
|
const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine')
|
||||||
|
if (!result.success || !result.rows) {
|
||||||
|
// 尝试 userName 列名
|
||||||
|
const result2 = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT userName FROM SnsTimeLine')
|
||||||
|
if (!result2.success || !result2.rows) return { success: false, error: result.error || result2.error }
|
||||||
|
return { success: true, usernames: result2.rows.map((r: any) => r.userName).filter(Boolean) }
|
||||||
|
}
|
||||||
|
return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> {
|
async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> {
|
||||||
|
|
||||||
|
|
||||||
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (result.success && result.timeline) {
|
if (result.success && result.timeline) {
|
||||||
const enrichedTimeline = result.timeline.map((post: any, index: number) => {
|
const enrichedTimeline = result.timeline.map((post: any) => {
|
||||||
const contact = this.contactCache.get(post.username)
|
const contact = this.contactCache.get(post.username)
|
||||||
|
const isVideoPost = post.type === 15
|
||||||
|
|
||||||
// 修复媒体 URL
|
// 尝试从 rawXml 中提取视频解密密钥 (针对视频号视频)
|
||||||
const fixedMedia = post.media.map((m: any, mIdx: number) => {
|
const videoKey = extractVideoKey(post.rawXml || '')
|
||||||
const base = {
|
|
||||||
url: fixSnsUrl(m.url, m.token),
|
const fixedMedia = (post.media || []).map((m: any) => ({
|
||||||
thumb: fixSnsUrl(m.thumb, m.token),
|
// 如果是视频动态,url 是视频,thumb 是缩略图
|
||||||
|
url: fixSnsUrl(m.url, m.token, isVideoPost),
|
||||||
|
thumb: fixSnsUrl(m.thumb, m.token, false),
|
||||||
md5: m.md5,
|
md5: m.md5,
|
||||||
token: m.token,
|
token: m.token,
|
||||||
key: m.key,
|
// 只有在视频动态 (Type 15) 下才尝试将 XML 提取的 videoKey 赋予主媒体
|
||||||
encIdx: m.encIdx || m.enc_idx, // 兼容不同命名
|
// 对于图片或实况照片的静态部分,应保留原始 m.key (由 DLL/DB 提供),避免由于错误的 Isaac64 密钥导致图片解密损坏
|
||||||
livePhoto: m.livePhoto ? {
|
key: isVideoPost ? (videoKey || m.key) : m.key,
|
||||||
|
encIdx: m.encIdx || m.enc_idx,
|
||||||
|
livePhoto: m.livePhoto
|
||||||
|
? {
|
||||||
...m.livePhoto,
|
...m.livePhoto,
|
||||||
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token),
|
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true),
|
||||||
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token),
|
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false),
|
||||||
token: m.livePhoto.token,
|
token: m.livePhoto.token,
|
||||||
key: m.livePhoto.key
|
// 实况照片的视频部分优先使用从 XML 提取的 Key
|
||||||
} : undefined
|
key: videoKey || m.livePhoto.key || m.key,
|
||||||
|
encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx
|
||||||
}
|
}
|
||||||
|
: undefined
|
||||||
// [MOCK] 模拟数据:如果后端没返回 key (说明 DLL 未更新),注入一些 Mock 数据以便前端开发
|
}))
|
||||||
if (!base.key) {
|
|
||||||
base.key = 'mock_key_for_dev'
|
|
||||||
if (!base.token) {
|
|
||||||
base.token = 'mock_token_for_dev'
|
|
||||||
base.url = fixSnsUrl(base.url, base.token)
|
|
||||||
base.thumb = fixSnsUrl(base.thumb, base.token)
|
|
||||||
}
|
|
||||||
base.encIdx = '1'
|
|
||||||
|
|
||||||
// 强制给第一个帖子的第一张图加 LivePhoto 模拟
|
|
||||||
if (index === 0 && mIdx === 0 && !base.livePhoto) {
|
|
||||||
base.livePhoto = {
|
|
||||||
url: fixSnsUrl('https://tm.sh/d4cb0.mp4', 'mock_live_token'),
|
|
||||||
thumb: base.thumb,
|
|
||||||
token: 'mock_live_token',
|
|
||||||
key: 'mock_live_key'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return base
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...post,
|
...post,
|
||||||
@@ -116,20 +200,15 @@ class SnsService {
|
|||||||
media: fixedMedia
|
media: fixedMedia
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
return { ...result, timeline: enrichedTimeline }
|
return { ...result, timeline: enrichedTimeline }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> {
|
async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
try {
|
try {
|
||||||
const { app, net } = require('electron')
|
|
||||||
// Remove mocking 'require' if it causes issues, but here we need 'net' or 'https'
|
|
||||||
// implementing with 'https' for reliability if 'net' is main-process only special
|
|
||||||
const https = require('https')
|
const https = require('https')
|
||||||
const urlObj = new URL(url)
|
const urlObj = new URL(url)
|
||||||
|
|
||||||
@@ -138,13 +217,12 @@ class SnsService {
|
|||||||
path: urlObj.pathname + urlObj.search,
|
path: urlObj.pathname + urlObj.search,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351',
|
||||||
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||||
"Accept-Encoding": "gzip, deflate, br",
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||||
"Referer": "https://servicewechat.com/",
|
'Connection': 'keep-alive',
|
||||||
"Connection": "keep-alive",
|
'Range': 'bytes=0-10'
|
||||||
"Range": "bytes=0-10" // Keep our range check
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,17 +232,15 @@ class SnsService {
|
|||||||
status: res.statusCode,
|
status: res.statusCode,
|
||||||
headers: {
|
headers: {
|
||||||
'x-enc': res.headers['x-enc'],
|
'x-enc': res.headers['x-enc'],
|
||||||
|
'x-time': res.headers['x-time'],
|
||||||
'content-length': res.headers['content-length'],
|
'content-length': res.headers['content-length'],
|
||||||
'content-type': res.headers['content-type']
|
'content-type': res.headers['content-type']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
req.destroy() // We only need headers
|
req.destroy()
|
||||||
})
|
|
||||||
|
|
||||||
req.on('error', (e: any) => {
|
|
||||||
resolve({ success: false, error: e.message })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
req.on('error', (e: any) => resolve({ success: false, error: e.message }))
|
||||||
req.end()
|
req.end()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
resolve({ success: false, error: e.message })
|
resolve({ success: false, error: e.message })
|
||||||
@@ -172,14 +248,528 @@ class SnsService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private imageCache = new Map<string, string>()
|
|
||||||
|
|
||||||
async proxyImage(url: string): Promise<{ success: boolean; dataUrl?: string; error?: string }> {
|
|
||||||
// Check cache
|
async proxyImage(url: string, key?: string | number): Promise<{ success: boolean; dataUrl?: string; videoPath?: string; error?: string }> {
|
||||||
if (this.imageCache.has(url)) {
|
if (!url) return { success: false, error: 'url 不能为空' }
|
||||||
return { success: true, dataUrl: this.imageCache.get(url) }
|
const cacheKey = `${url}|${key ?? ''}`
|
||||||
|
|
||||||
|
if (this.imageCache.has(cacheKey)) {
|
||||||
|
return { success: true, dataUrl: this.imageCache.get(cacheKey) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await this.fetchAndDecryptImage(url, key)
|
||||||
|
if (result.success) {
|
||||||
|
// 如果是视频,返回本地文件路径 (需配合 webSecurity: false 或自定义协议)
|
||||||
|
if (result.contentType?.startsWith('video/')) {
|
||||||
|
// Return cachePath directly for video
|
||||||
|
// 注意:fetchAndDecryptImage 需要修改以返回 cachePath
|
||||||
|
return { success: true, videoPath: result.cachePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data && result.contentType) {
|
||||||
|
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
|
||||||
|
this.imageCache.set(cacheKey, dataUrl)
|
||||||
|
return { success: true, dataUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> {
|
||||||
|
return this.fetchAndDecryptImage(url, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出朋友圈动态
|
||||||
|
* 支持筛选条件(用户名、关键词)和媒体文件导出
|
||||||
|
*/
|
||||||
|
async exportTimeline(options: {
|
||||||
|
outputDir: string
|
||||||
|
format: 'json' | 'html'
|
||||||
|
usernames?: string[]
|
||||||
|
keyword?: string
|
||||||
|
exportMedia?: boolean
|
||||||
|
startTime?: number
|
||||||
|
endTime?: number
|
||||||
|
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> {
|
||||||
|
const { outputDir, format, usernames, keyword, exportMedia = false, startTime, endTime } = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 确保输出目录存在
|
||||||
|
if (!existsSync(outputDir)) {
|
||||||
|
mkdirSync(outputDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 分页加载全部帖子
|
||||||
|
const allPosts: SnsPost[] = []
|
||||||
|
const pageSize = 50
|
||||||
|
let endTs: number | undefined = endTime // 使用 endTime 作为分页起始上界
|
||||||
|
let hasMore = true
|
||||||
|
|
||||||
|
progressCallback?.({ current: 0, total: 0, status: '正在加载朋友圈数据...' })
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const result = await this.getTimeline(pageSize, 0, usernames, keyword, startTime, endTs)
|
||||||
|
if (result.success && result.timeline && result.timeline.length > 0) {
|
||||||
|
allPosts.push(...result.timeline)
|
||||||
|
// 下一页的 endTs 为当前最后一条帖子的时间 - 1
|
||||||
|
const lastTs = result.timeline[result.timeline.length - 1].createTime - 1
|
||||||
|
endTs = lastTs
|
||||||
|
hasMore = result.timeline.length >= pageSize
|
||||||
|
// 如果已经低于 startTime,提前终止
|
||||||
|
if (startTime && lastTs < startTime) {
|
||||||
|
hasMore = false
|
||||||
|
}
|
||||||
|
progressCallback?.({ current: allPosts.length, total: 0, status: `已加载 ${allPosts.length} 条动态...` })
|
||||||
|
} else {
|
||||||
|
hasMore = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allPosts.length === 0) {
|
||||||
|
return { success: true, filePath: '', postCount: 0, mediaCount: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCallback?.({ current: 0, total: allPosts.length, status: `共 ${allPosts.length} 条动态,准备导出...` })
|
||||||
|
|
||||||
|
// 2. 如果需要导出媒体,创建 media 子目录并下载
|
||||||
|
let mediaCount = 0
|
||||||
|
const mediaDir = join(outputDir, 'media')
|
||||||
|
|
||||||
|
if (exportMedia) {
|
||||||
|
if (!existsSync(mediaDir)) {
|
||||||
|
mkdirSync(mediaDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集所有媒体下载任务
|
||||||
|
const mediaTasks: { media: SnsMedia; postId: string; mi: number }[] = []
|
||||||
|
for (const post of allPosts) {
|
||||||
|
post.media.forEach((media, mi) => mediaTasks.push({ media, postId: post.id, mi }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并发下载(5路)
|
||||||
|
let done = 0
|
||||||
|
const concurrency = 5
|
||||||
|
const runTask = async (task: typeof mediaTasks[0]) => {
|
||||||
|
const { media, postId, mi } = task
|
||||||
|
try {
|
||||||
|
const isVideo = isVideoUrl(media.url)
|
||||||
|
const ext = isVideo ? 'mp4' : 'jpg'
|
||||||
|
const fileName = `${postId}_${mi}.${ext}`
|
||||||
|
const filePath = join(mediaDir, fileName)
|
||||||
|
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
;(media as any).localPath = `media/${fileName}`
|
||||||
|
mediaCount++
|
||||||
|
} else {
|
||||||
|
const result = await this.fetchAndDecryptImage(media.url, media.key)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
await writeFile(filePath, result.data)
|
||||||
|
;(media as any).localPath = `media/${fileName}`
|
||||||
|
mediaCount++
|
||||||
|
} else if (result.success && result.cachePath) {
|
||||||
|
const cachedData = await readFile(result.cachePath)
|
||||||
|
await writeFile(filePath, cachedData)
|
||||||
|
;(media as any).localPath = `media/${fileName}`
|
||||||
|
mediaCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[SnsExport] 媒体下载失败: ${task.media.url}`, e)
|
||||||
|
}
|
||||||
|
done++
|
||||||
|
progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 控制并发的执行器
|
||||||
|
const queue = [...mediaTasks]
|
||||||
|
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const task = queue.shift()!
|
||||||
|
await runTask(task)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all(workers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.5 下载头像
|
||||||
|
const avatarMap = new Map<string, string>()
|
||||||
|
if (format === 'html') {
|
||||||
|
if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true })
|
||||||
|
const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()]
|
||||||
|
let avatarDone = 0
|
||||||
|
const avatarQueue = [...uniqueUsers]
|
||||||
|
const avatarWorkers = Array.from({ length: Math.min(5, avatarQueue.length) }, async () => {
|
||||||
|
while (avatarQueue.length > 0) {
|
||||||
|
const post = avatarQueue.shift()!
|
||||||
|
try {
|
||||||
|
const fileName = `avatar_${crypto.createHash('md5').update(post.username).digest('hex').slice(0, 8)}.jpg`
|
||||||
|
const filePath = join(mediaDir, fileName)
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
avatarMap.set(post.username, `media/${fileName}`)
|
||||||
|
} else {
|
||||||
|
const result = await this.fetchAndDecryptImage(post.avatarUrl!)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
await writeFile(filePath, result.data)
|
||||||
|
avatarMap.set(post.username, `media/${fileName}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* 头像下载失败不影响导出 */ }
|
||||||
|
avatarDone++
|
||||||
|
progressCallback?.({ current: avatarDone, total: uniqueUsers.length, status: `正在下载头像 (${avatarDone}/${uniqueUsers.length})...` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all(avatarWorkers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 生成输出文件
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||||
|
let outputFilePath: string
|
||||||
|
|
||||||
|
if (format === 'json') {
|
||||||
|
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
|
||||||
|
const exportData = {
|
||||||
|
exportTime: new Date().toISOString(),
|
||||||
|
totalPosts: allPosts.length,
|
||||||
|
filters: {
|
||||||
|
usernames: usernames || [],
|
||||||
|
keyword: keyword || ''
|
||||||
|
},
|
||||||
|
posts: allPosts.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
username: p.username,
|
||||||
|
nickname: p.nickname,
|
||||||
|
createTime: p.createTime,
|
||||||
|
createTimeStr: new Date(p.createTime * 1000).toLocaleString('zh-CN'),
|
||||||
|
contentDesc: p.contentDesc,
|
||||||
|
type: p.type,
|
||||||
|
media: p.media.map(m => ({
|
||||||
|
url: m.url,
|
||||||
|
thumb: m.thumb,
|
||||||
|
localPath: (m as any).localPath || undefined
|
||||||
|
})),
|
||||||
|
likes: p.likes,
|
||||||
|
comments: p.comments,
|
||||||
|
linkTitle: (p as any).linkTitle,
|
||||||
|
linkUrl: (p as any).linkUrl
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||||||
|
} else {
|
||||||
|
// HTML 格式
|
||||||
|
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
|
||||||
|
const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap)
|
||||||
|
await writeFile(outputFilePath, html, 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCallback?.({ current: allPosts.length, total: allPosts.length, status: '导出完成!' })
|
||||||
|
|
||||||
|
return { success: true, filePath: outputFilePath, postCount: allPosts.length, mediaCount }
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[SnsExport] 导出失败:', e)
|
||||||
|
return { success: false, error: e.message || String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成朋友圈 HTML 导出文件
|
||||||
|
*/
|
||||||
|
private generateHtml(posts: SnsPost[], filters: { usernames?: string[]; keyword?: string }, avatarMap?: Map<string, string>): string {
|
||||||
|
const escapeHtml = (str: string) => str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/\n/g, '<br>')
|
||||||
|
|
||||||
|
const formatTime = (ts: number) => {
|
||||||
|
const d = new Date(ts * 1000)
|
||||||
|
const now = new Date()
|
||||||
|
const isCurrentYear = d.getFullYear() === now.getFullYear()
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
const m = d.getMonth() + 1, day = d.getDate()
|
||||||
|
return isCurrentYear ? `${m}月${day}日 ${timeStr}` : `${d.getFullYear()}年${m}月${day}日 ${timeStr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成头像首字母
|
||||||
|
const avatarLetter = (name: string) => {
|
||||||
|
const ch = name.charAt(0)
|
||||||
|
return escapeHtml(ch || '?')
|
||||||
|
}
|
||||||
|
|
||||||
|
let filterInfo = ''
|
||||||
|
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
|
||||||
|
if (filters.usernames && filters.usernames.length > 0) filterInfo += `筛选用户: ${filters.usernames.length} 人`
|
||||||
|
|
||||||
|
const postsHtml = posts.map(post => {
|
||||||
|
const mediaCount = post.media.length
|
||||||
|
const gridClass = mediaCount === 1 ? 'grid-1' : mediaCount === 2 || mediaCount === 4 ? 'grid-2' : 'grid-3'
|
||||||
|
|
||||||
|
const mediaHtml = post.media.map((m, mi) => {
|
||||||
|
const localPath = (m as any).localPath
|
||||||
|
if (localPath) {
|
||||||
|
if (isVideoUrl(m.url)) {
|
||||||
|
return `<div class="mi"><video src="${escapeHtml(localPath)}" controls preload="metadata"></video></div>`
|
||||||
|
}
|
||||||
|
return `<div class="mi"><img src="${escapeHtml(localPath)}" loading="lazy" onclick="openLb(this.src)" alt=""></div>`
|
||||||
|
}
|
||||||
|
return `<div class="mi ml"><a href="${escapeHtml(m.url)}" target="_blank">查看媒体</a></div>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
const linkHtml = post.linkTitle && post.linkUrl
|
||||||
|
? `<a class="lk" href="${escapeHtml(post.linkUrl)}" target="_blank"><span class="lk-t">${escapeHtml(post.linkTitle)}</span><span class="lk-a">›</span></a>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const likesHtml = post.likes.length > 0
|
||||||
|
? `<div class="interactions"><div class="likes">♥ ${post.likes.map(l => `<span>${escapeHtml(l)}</span>`).join('、')}</div></div>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const commentsHtml = post.comments.length > 0
|
||||||
|
? `<div class="interactions${post.likes.length > 0 ? ' cmt-border' : ''}"><div class="cmts">${post.comments.map(c => {
|
||||||
|
const ref = c.refNickname ? `<span class="re">回复</span><b>${escapeHtml(c.refNickname)}</b>` : ''
|
||||||
|
return `<div class="cmt"><b>${escapeHtml(c.nickname)}</b>${ref}:${escapeHtml(c.content)}</div>`
|
||||||
|
}).join('')}</div></div>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const avatarSrc = avatarMap?.get(post.username)
|
||||||
|
const avatarHtml = avatarSrc
|
||||||
|
? `<div class="avatar"><img src="${escapeHtml(avatarSrc)}" alt=""></div>`
|
||||||
|
: `<div class="avatar">${avatarLetter(post.nickname)}</div>`
|
||||||
|
|
||||||
|
return `<div class="post">
|
||||||
|
${avatarHtml}
|
||||||
|
<div class="body">
|
||||||
|
<div class="hd"><span class="nick">${escapeHtml(post.nickname)}</span><span class="tm">${formatTime(post.createTime)}</span></div>
|
||||||
|
${post.contentDesc ? `<div class="txt">${escapeHtml(post.contentDesc)}</div>` : ''}
|
||||||
|
${mediaHtml ? `<div class="mg ${gridClass}">${mediaHtml}</div>` : ''}
|
||||||
|
${linkHtml}
|
||||||
|
${likesHtml}
|
||||||
|
${commentsHtml}
|
||||||
|
</div></div>`
|
||||||
|
}).join('\n')
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>朋友圈导出</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--t1);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||||
|
:root{--bg:#F0EEE9;--card:rgba(255,255,255,.92);--t1:#3d3d3d;--t2:#666;--t3:#999;--accent:#8B7355;--border:rgba(0,0,0,.08);--bg3:rgba(0,0,0,.03)}
|
||||||
|
@media(prefers-color-scheme:dark){:root{--bg:#1a1a1a;--card:rgba(40,40,40,.85);--t1:#e0e0e0;--t2:#aaa;--t3:#777;--accent:#c4a882;--border:rgba(255,255,255,.1);--bg3:rgba(255,255,255,.06)}}
|
||||||
|
.container{max-width:800px;margin:0 auto;padding:20px 24px 60px}
|
||||||
|
|
||||||
|
/* 页面标题 */
|
||||||
|
.feed-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;padding:0 4px}
|
||||||
|
.feed-hd h2{font-size:20px;font-weight:700}
|
||||||
|
.feed-hd .info{font-size:12px;color:var(--t3)}
|
||||||
|
|
||||||
|
/* 帖子卡片 - 头像+内容双列 */
|
||||||
|
.post{background:var(--card);border-radius:16px;border:1px solid var(--border);padding:20px;margin-bottom:24px;display:flex;gap:16px;box-shadow:0 2px 8px rgba(0,0,0,.02);transition:transform .2s,box-shadow .2s}
|
||||||
|
.post:hover{transform:translateY(-2px);box-shadow:0 8px 16px rgba(0,0,0,.06)}
|
||||||
|
.avatar{width:48px;height:48px;border-radius:12px;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:600;flex-shrink:0;overflow:hidden}
|
||||||
|
.avatar img{width:100%;height:100%;object-fit:cover}
|
||||||
|
.body{flex:1;min-width:0}
|
||||||
|
.hd{display:flex;flex-direction:column;margin-bottom:8px}
|
||||||
|
.nick{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:2px}
|
||||||
|
.tm{font-size:12px;color:var(--t3)}
|
||||||
|
.txt{font-size:15px;line-height:1.6;white-space:pre-wrap;word-break:break-word;margin-bottom:12px}
|
||||||
|
|
||||||
|
/* 媒体网格 */
|
||||||
|
.mg{display:grid;gap:6px;margin-bottom:12px;max-width:320px}
|
||||||
|
.grid-1{max-width:300px}
|
||||||
|
.grid-1 .mi{border-radius:12px}
|
||||||
|
.grid-1 .mi img{aspect-ratio:auto;max-height:480px;object-fit:contain;background:var(--bg3)}
|
||||||
|
.grid-2{grid-template-columns:1fr 1fr}
|
||||||
|
.grid-3{grid-template-columns:1fr 1fr 1fr}
|
||||||
|
.mi{overflow:hidden;border-radius:12px;background:var(--bg3);position:relative;aspect-ratio:1}
|
||||||
|
.mi img{width:100%;height:100%;object-fit:cover;display:block;cursor:zoom-in;transition:opacity .2s}
|
||||||
|
.mi img:hover{opacity:.9}
|
||||||
|
.mi video{width:100%;height:100%;object-fit:cover;display:block;background:#000}
|
||||||
|
.ml{display:flex;align-items:center;justify-content:center}
|
||||||
|
.ml a{color:var(--accent);text-decoration:none;font-size:13px}
|
||||||
|
|
||||||
|
/* 链接卡片 */
|
||||||
|
.lk{display:flex;align-items:center;gap:10px;padding:10px;background:var(--bg3);border:1px solid var(--border);border-radius:12px;text-decoration:none;color:var(--t1);font-size:14px;margin-bottom:12px;transition:background .15s}
|
||||||
|
.lk:hover{background:var(--border)}
|
||||||
|
.lk-t{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
|
||||||
|
.lk-a{color:var(--t3);font-size:18px;flex-shrink:0}
|
||||||
|
|
||||||
|
/* 互动区域 */
|
||||||
|
.interactions{margin-top:12px;padding-top:12px;border-top:1px dashed var(--border);font-size:13px}
|
||||||
|
.interactions.cmt-border{border-top:none;padding-top:0;margin-top:8px}
|
||||||
|
.likes{color:var(--accent);font-weight:500;line-height:1.8}
|
||||||
|
.cmts{background:var(--bg3);border-radius:8px;padding:8px 12px;line-height:1.4}
|
||||||
|
.cmt{margin-bottom:4px;color:var(--t2)}
|
||||||
|
.cmt:last-child{margin-bottom:0}
|
||||||
|
.cmt b{color:var(--accent);font-weight:500}
|
||||||
|
.re{color:var(--t3);margin:0 4px;font-size:12px}
|
||||||
|
|
||||||
|
/* 灯箱 */
|
||||||
|
.lb{display:none;position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;align-items:center;justify-content:center;cursor:zoom-out}
|
||||||
|
.lb.on{display:flex}
|
||||||
|
.lb img{max-width:92vw;max-height:92vh;object-fit:contain;border-radius:4px}
|
||||||
|
|
||||||
|
/* 回到顶部 */
|
||||||
|
.btt{position:fixed;right:24px;bottom:32px;width:44px;height:44px;border-radius:50%;background:var(--card);box-shadow:0 2px 12px rgba(0,0,0,.12);border:1px solid var(--border);cursor:pointer;font-size:18px;display:none;align-items:center;justify-content:center;z-index:100;color:var(--t2)}
|
||||||
|
.btt:hover{transform:scale(1.1)}
|
||||||
|
.btt.show{display:flex}
|
||||||
|
|
||||||
|
/* 页脚 */
|
||||||
|
.ft{text-align:center;padding:32px 0 24px;font-size:12px;color:var(--t3)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="feed-hd"><h2>朋友圈</h2><span class="info">共 ${posts.length} 条${filterInfo ? ` · ${filterInfo}` : ''}</span></div>
|
||||||
|
${postsHtml}
|
||||||
|
<div class="ft">由 WeFlow 导出 · ${new Date().toLocaleString('zh-CN')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="lb" id="lb" onclick="closeLb()"><img id="lbi" src=""></div>
|
||||||
|
<button class="btt" id="btt" onclick="scrollTo({top:0,behavior:'smooth'})">↑</button>
|
||||||
|
<script>
|
||||||
|
function openLb(s){document.getElementById('lbi').src=s;document.getElementById('lb').classList.add('on');document.body.style.overflow='hidden'}
|
||||||
|
function closeLb(){document.getElementById('lb').classList.remove('on');document.body.style.overflow=''}
|
||||||
|
document.addEventListener('keydown',function(e){if(e.key==='Escape')closeLb()})
|
||||||
|
window.addEventListener('scroll',function(){document.getElementById('btt').classList.toggle('show',window.scrollY>600)})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
|
||||||
|
if (!url) return { success: false, error: 'url 不能为空' }
|
||||||
|
|
||||||
|
const isVideo = isVideoUrl(url)
|
||||||
|
const cachePath = this.getCacheFilePath(url)
|
||||||
|
|
||||||
|
// 1. 尝试从磁盘缓存读取
|
||||||
|
if (existsSync(cachePath)) {
|
||||||
|
try {
|
||||||
|
// 对于视频,不读取整个文件到内存,只确认存在即可
|
||||||
|
if (isVideo) {
|
||||||
|
return { success: true, cachePath, contentType: 'video/mp4' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await readFile(cachePath)
|
||||||
|
const contentType = detectImageMime(data)
|
||||||
|
return { success: true, data, contentType, cachePath }
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
// 视频专用下载逻辑 (下载 -> 解密 -> 缓存)
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
const tmpPath = join(require('os').tmpdir(), `sns_video_${Date.now()}_${Math.random().toString(36).slice(2)}.enc`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const https = require('https')
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
const fs = require('fs')
|
||||||
|
|
||||||
|
const fileStream = fs.createWriteStream(tmpPath)
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: urlObj.hostname,
|
||||||
|
path: urlObj.pathname + urlObj.search,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'MicroMessenger Client',
|
||||||
|
'Accept': '*/*',
|
||||||
|
// 'Accept-Encoding': 'gzip, deflate, br', // 视频流通常不压缩,去掉以免 stream 处理复杂
|
||||||
|
'Connection': 'keep-alive'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = https.request(options, (res: any) => {
|
||||||
|
if (res.statusCode !== 200 && res.statusCode !== 206) {
|
||||||
|
fileStream.close()
|
||||||
|
fs.unlink(tmpPath, () => { }) // 删除临时文件
|
||||||
|
resolve({ success: false, error: `HTTP ${res.statusCode}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.pipe(fileStream)
|
||||||
|
fileStream.on('finish', async () => {
|
||||||
|
fileStream.close()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encryptedBuffer = await readFile(tmpPath)
|
||||||
|
const raw = encryptedBuffer // 引用,方便后续操作
|
||||||
|
|
||||||
|
|
||||||
|
if (key && String(key).trim().length > 0) {
|
||||||
|
try {
|
||||||
|
const keyText = String(key).trim()
|
||||||
|
let keystream: Buffer
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wasmService = WasmService.getInstance()
|
||||||
|
// 只需要前 128KB (131072 bytes) 用于解密头部
|
||||||
|
keystream = await wasmService.getKeystream(keyText, 131072)
|
||||||
|
} catch (wasmErr) {
|
||||||
|
// 打包漏带 wasm 或 wasm 初始化异常时,回退到纯 TS ISAAC64
|
||||||
|
const isaac = new Isaac64(keyText)
|
||||||
|
keystream = isaac.generateKeystreamBE(131072)
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptLen = Math.min(keystream.length, raw.length)
|
||||||
|
|
||||||
|
// XOR 解密
|
||||||
|
for (let i = 0; i < decryptLen; i++) {
|
||||||
|
raw[i] ^= keystream[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 MP4 签名 ('ftyp' at offset 4)
|
||||||
|
const ftyp = raw.subarray(4, 8).toString('ascii')
|
||||||
|
if (ftyp !== 'ftyp') {
|
||||||
|
// 可以在此处记录解密可能失败的标记,但不打印详细 hex
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[SnsService] 视频解密出错: ${err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入最终缓存 (覆盖)
|
||||||
|
await writeFile(cachePath, raw)
|
||||||
|
|
||||||
|
// 删除临时文件
|
||||||
|
try { await import('fs/promises').then(fs => fs.unlink(tmpPath)) } catch (e) { }
|
||||||
|
|
||||||
|
resolve({ success: true, data: raw, contentType: 'video/mp4', cachePath })
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`[SnsService] 视频处理失败:`, e)
|
||||||
|
resolve({ success: false, error: e.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
req.on('error', (e: any) => {
|
||||||
|
fs.unlink(tmpPath, () => { })
|
||||||
|
resolve({ success: false, error: e.message })
|
||||||
|
})
|
||||||
|
|
||||||
|
req.setTimeout(15000, () => {
|
||||||
|
req.destroy()
|
||||||
|
fs.unlink(tmpPath, () => { })
|
||||||
|
resolve({ success: false, error: '请求超时' })
|
||||||
|
})
|
||||||
|
|
||||||
|
req.end()
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
resolve({ success: false, error: e.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片逻辑 (保持流式处理)
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
try {
|
try {
|
||||||
const https = require('https')
|
const https = require('https')
|
||||||
@@ -191,17 +781,16 @@ class SnsService {
|
|||||||
path: urlObj.pathname + urlObj.search,
|
path: urlObj.pathname + urlObj.search,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
|
'User-Agent': 'MicroMessenger Client',
|
||||||
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
'Accept': '*/*',
|
||||||
"Accept-Encoding": "gzip, deflate, br",
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||||
"Referer": "https://servicewechat.com/",
|
'Connection': 'keep-alive'
|
||||||
"Connection": "keep-alive"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const req = https.request(options, (res: any) => {
|
const req = https.request(options, (res: any) => {
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200 && res.statusCode !== 206) {
|
||||||
resolve({ success: false, error: `HTTP ${res.statusCode}` })
|
resolve({ success: false, error: `HTTP ${res.statusCode}` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -209,37 +798,59 @@ class SnsService {
|
|||||||
const chunks: Buffer[] = []
|
const chunks: Buffer[] = []
|
||||||
let stream = res
|
let stream = res
|
||||||
|
|
||||||
// Handle gzip compression
|
|
||||||
const encoding = res.headers['content-encoding']
|
const encoding = res.headers['content-encoding']
|
||||||
if (encoding === 'gzip') {
|
if (encoding === 'gzip') stream = res.pipe(zlib.createGunzip())
|
||||||
stream = res.pipe(zlib.createGunzip())
|
else if (encoding === 'deflate') stream = res.pipe(zlib.createInflate())
|
||||||
} else if (encoding === 'deflate') {
|
else if (encoding === 'br') stream = res.pipe(zlib.createBrotliDecompress())
|
||||||
stream = res.pipe(zlib.createInflate())
|
|
||||||
} else if (encoding === 'br') {
|
|
||||||
stream = res.pipe(zlib.createBrotliDecompress())
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
|
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||||
stream.on('end', () => {
|
stream.on('end', async () => {
|
||||||
const buffer = Buffer.concat(chunks)
|
const raw = Buffer.concat(chunks)
|
||||||
const contentType = res.headers['content-type'] || 'image/jpeg'
|
const xEnc = String(res.headers['x-enc'] || '').trim()
|
||||||
const base64 = buffer.toString('base64')
|
|
||||||
const dataUrl = `data:${contentType};base64,${base64}`
|
|
||||||
|
|
||||||
// Cache
|
let decoded = raw
|
||||||
this.imageCache.set(url, dataUrl)
|
|
||||||
|
|
||||||
resolve({ success: true, dataUrl })
|
// 图片逻辑
|
||||||
})
|
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
|
||||||
stream.on('error', (e: any) => {
|
if (shouldDecrypt) {
|
||||||
resolve({ success: false, error: e.message })
|
try {
|
||||||
|
const keyStr = String(key).trim()
|
||||||
|
if (/^\d+$/.test(keyStr)) {
|
||||||
|
// 使用 WASM 版本的 Isaac64 解密图片
|
||||||
|
// 修正逻辑:使用带 reverse 且修正了 8字节对齐偏移的 getKeystream
|
||||||
|
const wasmService = WasmService.getInstance()
|
||||||
|
const keystream = await wasmService.getKeystream(keyStr, raw.length)
|
||||||
|
|
||||||
|
const decrypted = Buffer.allocUnsafe(raw.length)
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
decrypted[i] = raw[i] ^ keystream[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded = decrypted
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SnsService] TS Decrypt Error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入磁盘缓存
|
||||||
|
try {
|
||||||
|
await writeFile(cachePath, decoded)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[SnsService] 写入缓存失败: ${cachePath}`, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = detectImageMime(decoded, (res.headers['content-type'] || 'image/jpeg') as string)
|
||||||
|
resolve({ success: true, data: decoded, contentType, cachePath })
|
||||||
})
|
})
|
||||||
|
stream.on('error', (e: any) => resolve({ success: false, error: e.message }))
|
||||||
})
|
})
|
||||||
|
|
||||||
req.on('error', (e: any) => {
|
req.on('error', (e: any) => resolve({ success: false, error: e.message }))
|
||||||
resolve({ success: false, error: e.message })
|
req.setTimeout(15000, () => {
|
||||||
|
req.destroy()
|
||||||
|
resolve({ success: false, error: '请求超时' })
|
||||||
})
|
})
|
||||||
|
|
||||||
req.end()
|
req.end()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
resolve({ success: false, error: e.message })
|
resolve({ success: false, error: e.message })
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class VideoService {
|
|||||||
* 获取缓存目录(解密后的数据库存放位置)
|
* 获取缓存目录(解密后的数据库存放位置)
|
||||||
*/
|
*/
|
||||||
private getCachePath(): string {
|
private getCachePath(): string {
|
||||||
return this.configService.get('cachePath') || ''
|
return this.configService.getCacheBasePath()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -458,8 +458,18 @@ export class VoiceTranscribeService {
|
|||||||
|
|
||||||
writer.on('error', (err) => {
|
writer.on('error', (err) => {
|
||||||
clearInterval(speedInterval)
|
clearInterval(speedInterval)
|
||||||
|
// 确保在错误情况下也关闭文件句柄
|
||||||
|
writer.destroy()
|
||||||
reject(err)
|
reject(err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
response.on('error', (err) => {
|
||||||
|
clearInterval(speedInterval)
|
||||||
|
// 确保在响应错误时也关闭文件句柄
|
||||||
|
writer.destroy()
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
|
||||||
response.pipe(writer)
|
response.pipe(writer)
|
||||||
})
|
})
|
||||||
request.on('error', reject)
|
request.on('error', reject)
|
||||||
|
|||||||
180
electron/services/wasmService.ts
Normal file
180
electron/services/wasmService.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import vm from 'vm';
|
||||||
|
|
||||||
|
let app: any;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
app = require('electron').app;
|
||||||
|
} catch (e) {
|
||||||
|
app = { isPackaged: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// This service handles the loading and execution of the WeChat WASM module
|
||||||
|
// to generate the correct Isaac64 keystream for video decryption.
|
||||||
|
export class WasmService {
|
||||||
|
private static instance: WasmService;
|
||||||
|
private module: any = null;
|
||||||
|
private wasmLoaded = false;
|
||||||
|
private initPromise: Promise<void> | null = null;
|
||||||
|
private capturedKeystream: Uint8Array | null = null;
|
||||||
|
|
||||||
|
private constructor() { }
|
||||||
|
|
||||||
|
public static getInstance(): WasmService {
|
||||||
|
if (!WasmService.instance) {
|
||||||
|
WasmService.instance = new WasmService();
|
||||||
|
}
|
||||||
|
return WasmService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async init(): Promise<void> {
|
||||||
|
if (this.wasmLoaded) return;
|
||||||
|
if (this.initPromise) return this.initPromise;
|
||||||
|
|
||||||
|
this.initPromise = new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// For dev, files are in electron/assets/wasm
|
||||||
|
// __dirname in dev (from dist-electron) is .../dist-electron
|
||||||
|
// So we need to go up one level and then into electron/assets/wasm
|
||||||
|
const isDev = !app.isPackaged;
|
||||||
|
const basePath = isDev
|
||||||
|
? path.join(__dirname, '../electron/assets/wasm')
|
||||||
|
: path.join(process.resourcesPath, 'assets/wasm'); // Adjust as needed for production build
|
||||||
|
|
||||||
|
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm');
|
||||||
|
const jsPath = path.join(basePath, 'wasm_video_decode.js');
|
||||||
|
|
||||||
|
|
||||||
|
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
|
||||||
|
throw new Error(`WASM files not found at ${basePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasmBinary = fs.readFileSync(wasmPath);
|
||||||
|
|
||||||
|
// Emulate Emscripten environment
|
||||||
|
// We must use 'any' for global mocking
|
||||||
|
const mockGlobal: any = {
|
||||||
|
console: console,
|
||||||
|
Buffer: Buffer,
|
||||||
|
Uint8Array: Uint8Array,
|
||||||
|
Int8Array: Int8Array,
|
||||||
|
Uint16Array: Uint16Array,
|
||||||
|
Int16Array: Int16Array,
|
||||||
|
Uint32Array: Uint32Array,
|
||||||
|
Int32Array: Int32Array,
|
||||||
|
Float32Array: Float32Array,
|
||||||
|
Float64Array: Float64Array,
|
||||||
|
BigInt64Array: BigInt64Array,
|
||||||
|
BigUint64Array: BigUint64Array,
|
||||||
|
Array: Array,
|
||||||
|
Object: Object,
|
||||||
|
Function: Function,
|
||||||
|
String: String,
|
||||||
|
Number: Number,
|
||||||
|
Boolean: Boolean,
|
||||||
|
Error: Error,
|
||||||
|
Promise: Promise,
|
||||||
|
require: require,
|
||||||
|
process: process,
|
||||||
|
setTimeout: setTimeout,
|
||||||
|
clearTimeout: clearTimeout,
|
||||||
|
setInterval: setInterval,
|
||||||
|
clearInterval: clearInterval,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define Module
|
||||||
|
mockGlobal.Module = {
|
||||||
|
onRuntimeInitialized: () => {
|
||||||
|
this.wasmLoaded = true;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
wasmBinary: wasmBinary,
|
||||||
|
print: (text: string) => console.log('[WASM stdout]', text),
|
||||||
|
printErr: (text: string) => console.error('[WASM stderr]', text)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define necessary globals for Emscripten loader
|
||||||
|
mockGlobal.self = mockGlobal;
|
||||||
|
mockGlobal.self.location = { href: jsPath };
|
||||||
|
mockGlobal.WorkerGlobalScope = function () { };
|
||||||
|
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`; // Needs a URL, file protocol works in Node context for our mock?
|
||||||
|
|
||||||
|
// Define the callback function that WASM calls to return data
|
||||||
|
// The WASM module calls `wasm_isaac_generate(ptr, size)`
|
||||||
|
mockGlobal.wasm_isaac_generate = (ptr: number, size: number) => {
|
||||||
|
// console.log(`[WasmService] wasm_isaac_generate called: ptr=${ptr}, size=${size}`);
|
||||||
|
const buffer = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, size);
|
||||||
|
// Copy the data because WASM memory might change or be invalidated
|
||||||
|
this.capturedKeystream = new Uint8Array(buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute the loader script in the context
|
||||||
|
const jsContent = fs.readFileSync(jsPath, 'utf8');
|
||||||
|
const script = new vm.Script(jsContent, { filename: jsPath });
|
||||||
|
|
||||||
|
// create context
|
||||||
|
const context = vm.createContext(mockGlobal);
|
||||||
|
script.runInContext(context);
|
||||||
|
|
||||||
|
// Store reference to module
|
||||||
|
this.module = mockGlobal.Module;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WasmService] Failed to initialize WASM:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getKeystream(key: string, size: number = 131072): Promise<Buffer> {
|
||||||
|
// ISAAC-64 uses 8-byte blocks. If size is not a multiple of 8,
|
||||||
|
// the global reverse() will cause a shift in alignment.
|
||||||
|
const alignSize = Math.ceil(size / 8) * 8;
|
||||||
|
const buffer = await this.getRawKeystream(key, alignSize);
|
||||||
|
|
||||||
|
// Reverse the entire aligned buffer
|
||||||
|
const reversed = new Uint8Array(buffer);
|
||||||
|
reversed.reverse();
|
||||||
|
|
||||||
|
// Return exactly the requested size from the beginning of the reversed stream.
|
||||||
|
// Since we reversed the 'aligned' buffer, index 0 is the last byte of the last block.
|
||||||
|
return Buffer.from(reversed).subarray(0, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRawKeystream(key: string, size: number = 131072): Promise<Buffer> {
|
||||||
|
await this.init();
|
||||||
|
|
||||||
|
if (!this.module || !this.module.WxIsaac64) {
|
||||||
|
if (this.module.asm && this.module.asm.WxIsaac64) {
|
||||||
|
this.module.WxIsaac64 = this.module.asm.WxIsaac64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.module.WxIsaac64) {
|
||||||
|
throw new Error('[WasmService] WxIsaac64 not found in WASM module');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.capturedKeystream = null;
|
||||||
|
const isaac = new this.module.WxIsaac64(key);
|
||||||
|
isaac.generate(size);
|
||||||
|
|
||||||
|
if (isaac.delete) {
|
||||||
|
isaac.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.capturedKeystream) {
|
||||||
|
return Buffer.from(this.capturedKeystream);
|
||||||
|
} else {
|
||||||
|
throw new Error('[WasmService] Failed to capture keystream (callback not called)');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WasmService] Error generating raw keystream:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ export class WcdbCore {
|
|||||||
private wcdbCloseAccount: any = null
|
private wcdbCloseAccount: any = null
|
||||||
private wcdbSetMyWxid: any = null
|
private wcdbSetMyWxid: any = null
|
||||||
private wcdbFreeString: any = null
|
private wcdbFreeString: any = null
|
||||||
|
private wcdbUpdateMessage: any = null
|
||||||
|
private wcdbDeleteMessage: any = null
|
||||||
private wcdbGetSessions: any = null
|
private wcdbGetSessions: any = null
|
||||||
private wcdbGetMessages: any = null
|
private wcdbGetMessages: any = null
|
||||||
private wcdbGetMessageCount: any = null
|
private wcdbGetMessageCount: any = null
|
||||||
@@ -64,7 +66,13 @@ export class WcdbCore {
|
|||||||
private wcdbVerifyUser: any = null
|
private wcdbVerifyUser: any = null
|
||||||
private wcdbStartMonitorPipe: any = null
|
private wcdbStartMonitorPipe: any = null
|
||||||
private wcdbStopMonitorPipe: any = null
|
private wcdbStopMonitorPipe: any = null
|
||||||
|
private wcdbGetMonitorPipeName: any = null
|
||||||
|
|
||||||
private monitorPipeClient: any = null
|
private monitorPipeClient: any = null
|
||||||
|
private monitorCallback: ((type: string, json: string) => void) | null = null
|
||||||
|
private monitorReconnectTimer: any = null
|
||||||
|
private monitorPipePath: string = ''
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -88,23 +96,46 @@ export class WcdbCore {
|
|||||||
// 使用命名管道 IPC
|
// 使用命名管道 IPC
|
||||||
startMonitor(callback: (type: string, json: string) => void): boolean {
|
startMonitor(callback: (type: string, json: string) => void): boolean {
|
||||||
if (!this.wcdbStartMonitorPipe) {
|
if (!this.wcdbStartMonitorPipe) {
|
||||||
this.writeLog('startMonitor: wcdbStartMonitorPipe not available')
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.monitorCallback = callback
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = this.wcdbStartMonitorPipe()
|
const result = this.wcdbStartMonitorPipe()
|
||||||
if (result !== 0) {
|
if (result !== 0) {
|
||||||
this.writeLog(`startMonitor: wcdbStartMonitorPipe failed with ${result}`)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 DLL 获取动态管道名(含 PID)
|
||||||
|
let pipePath = '\\\\.\\pipe\\weflow_monitor'
|
||||||
|
if (this.wcdbGetMonitorPipeName) {
|
||||||
|
try {
|
||||||
|
const namePtr = [null as any]
|
||||||
|
if (this.wcdbGetMonitorPipeName(namePtr) === 0 && namePtr[0]) {
|
||||||
|
pipePath = this.koffi.decode(namePtr[0], 'char', -1)
|
||||||
|
this.wcdbFreeString(namePtr[0])
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectMonitorPipe(pipePath)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[wcdbCore] startMonitor exception:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接命名管道,支持断开后自动重连
|
||||||
|
private connectMonitorPipe(pipePath: string) {
|
||||||
|
this.monitorPipePath = pipePath
|
||||||
const net = require('net')
|
const net = require('net')
|
||||||
const PIPE_PATH = '\\\\.\\pipe\\weflow_monitor'
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.monitorPipeClient = net.createConnection(PIPE_PATH, () => {
|
if (!this.monitorCallback) return
|
||||||
this.writeLog('Monitor pipe connected')
|
|
||||||
|
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
let buffer = ''
|
let buffer = ''
|
||||||
@@ -116,33 +147,43 @@ export class WcdbCore {
|
|||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(line)
|
const parsed = JSON.parse(line)
|
||||||
callback(parsed.action || 'update', line)
|
this.monitorCallback?.(parsed.action || 'update', line)
|
||||||
} catch {
|
} catch {
|
||||||
callback('update', line)
|
this.monitorCallback?.('update', line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.monitorPipeClient.on('error', (err: Error) => {
|
this.monitorPipeClient.on('error', () => {
|
||||||
this.writeLog(`Monitor pipe error: ${err.message}`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.monitorPipeClient.on('close', () => {
|
this.monitorPipeClient.on('close', () => {
|
||||||
this.writeLog('Monitor pipe closed')
|
|
||||||
this.monitorPipeClient = null
|
this.monitorPipeClient = null
|
||||||
|
this.scheduleReconnect()
|
||||||
})
|
})
|
||||||
}, 100)
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
this.writeLog('Monitor started via named pipe IPC')
|
// 定时重连
|
||||||
return true
|
private scheduleReconnect() {
|
||||||
} catch (e) {
|
if (this.monitorReconnectTimer || !this.monitorCallback) return
|
||||||
console.error('startMonitor failed:', e)
|
this.monitorReconnectTimer = setTimeout(() => {
|
||||||
return false
|
this.monitorReconnectTimer = null
|
||||||
|
if (this.monitorCallback && !this.monitorPipeClient) {
|
||||||
|
this.connectMonitorPipe(this.monitorPipePath)
|
||||||
}
|
}
|
||||||
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
stopMonitor(): void {
|
stopMonitor(): void {
|
||||||
|
this.monitorCallback = null
|
||||||
|
if (this.monitorReconnectTimer) {
|
||||||
|
clearTimeout(this.monitorReconnectTimer)
|
||||||
|
this.monitorReconnectTimer = null
|
||||||
|
}
|
||||||
if (this.monitorPipeClient) {
|
if (this.monitorPipeClient) {
|
||||||
this.monitorPipeClient.destroy()
|
this.monitorPipeClient.destroy()
|
||||||
this.monitorPipeClient = null
|
this.monitorPipeClient = null
|
||||||
@@ -381,6 +422,20 @@ export class WcdbCore {
|
|||||||
this.wcdbSetMyWxid = null
|
this.wcdbSetMyWxid = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_update_message(wcdb_handle handle, const char* session_id, int64_t local_id, int32_t create_time, const char* new_content, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbUpdateMessage = this.lib.func('int32 wcdb_update_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* newContent, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbUpdateMessage = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_delete_message(wcdb_handle handle, const char* session_id, int64_t local_id, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbDeleteMessage = this.lib.func('int32 wcdb_delete_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* dbPathHint, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbDeleteMessage = null
|
||||||
|
}
|
||||||
|
|
||||||
// void wcdb_free_string(char* ptr)
|
// void wcdb_free_string(char* ptr)
|
||||||
this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)')
|
this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)')
|
||||||
|
|
||||||
@@ -549,11 +604,13 @@ export class WcdbCore {
|
|||||||
try {
|
try {
|
||||||
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
|
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
|
||||||
this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_monitor_pipe()')
|
this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_monitor_pipe()')
|
||||||
|
this.wcdbGetMonitorPipeName = this.lib.func('int32 wcdb_get_monitor_pipe_name(_Out_ void** outName)')
|
||||||
this.writeLog('Monitor pipe functions loaded')
|
this.writeLog('Monitor pipe functions loaded')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to load monitor pipe functions:', e)
|
console.warn('Failed to load monitor pipe functions:', e)
|
||||||
this.wcdbStartMonitorPipe = null
|
this.wcdbStartMonitorPipe = null
|
||||||
this.wcdbStopMonitorPipe = null
|
this.wcdbStopMonitorPipe = null
|
||||||
|
this.wcdbGetMonitorPipeName = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
|
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
|
||||||
@@ -563,6 +620,8 @@ export class WcdbCore {
|
|||||||
this.wcdbVerifyUser = null
|
this.wcdbVerifyUser = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
const initResult = this.wcdbInit()
|
const initResult = this.wcdbInit()
|
||||||
if (initResult !== 0) {
|
if (initResult !== 0) {
|
||||||
@@ -965,7 +1024,7 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// 1. 打开游标 (使用 Ascending=1 从指定时间往后查)
|
// 1. 打开游标 (使用 Ascending=1 从指定时间往后查)
|
||||||
const openRes = await this.openMessageCursorLite(sessionId, limit, true, minTime, 0)
|
const openRes = await this.openMessageCursor(sessionId, limit, true, minTime, 0)
|
||||||
if (!openRes.success || !openRes.cursor) {
|
if (!openRes.success || !openRes.cursor) {
|
||||||
return { success: false, error: openRes.error }
|
return { success: false, error: openRes.error }
|
||||||
}
|
}
|
||||||
@@ -1561,12 +1620,20 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
|
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
|
||||||
|
|
||||||
|
// 如果提供了参数,使用参数化查询(需要 C++ 层支持)
|
||||||
|
// 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现
|
||||||
|
// TODO: 需要更新 C++ 层的 wcdb_exec_query 以支持参数绑定
|
||||||
|
if (params && params.length > 0) {
|
||||||
|
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)')
|
||||||
|
}
|
||||||
|
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr)
|
const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr)
|
||||||
if (result !== 0 || !outPtr[0]) {
|
if (result !== 0 || !outPtr[0]) {
|
||||||
@@ -1768,4 +1835,62 @@ export class WcdbCore {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 修改消息内容
|
||||||
|
*/
|
||||||
|
async updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.initialized || !this.wcdbUpdateMessage) return { success: false, error: 'WCDB Not Initialized or Method Missing' }
|
||||||
|
if (!this.handle) return { success: false, error: 'Not Connected' }
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const outError = [null as any]
|
||||||
|
const result = this.wcdbUpdateMessage(this.handle, sessionId, localId, createTime, newContent, outError)
|
||||||
|
|
||||||
|
if (result !== 0) {
|
||||||
|
let errorMsg = 'Unknown Error'
|
||||||
|
if (outError[0]) {
|
||||||
|
errorMsg = this.decodeJsonPtr(outError[0]) || 'Unknown Error (Decode Failed)'
|
||||||
|
}
|
||||||
|
resolve({ success: false, error: errorMsg })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ success: true })
|
||||||
|
} catch (e) {
|
||||||
|
resolve({ success: false, error: String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除消息
|
||||||
|
*/
|
||||||
|
async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.initialized || !this.wcdbDeleteMessage) return { success: false, error: 'WCDB Not Initialized or Method Missing' }
|
||||||
|
if (!this.handle) return { success: false, error: 'Not Connected' }
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const outError = [null as any]
|
||||||
|
const result = this.wcdbDeleteMessage(this.handle, sessionId, localId, createTime || 0, dbPathHint || '', outError)
|
||||||
|
|
||||||
|
if (result !== 0) {
|
||||||
|
let errorMsg = 'Unknown Error'
|
||||||
|
if (outError[0]) {
|
||||||
|
errorMsg = this.decodeJsonPtr(outError[0]) || 'Unknown Error (Decode Failed)'
|
||||||
|
}
|
||||||
|
console.error(`[WcdbCore] deleteMessage fail: code=${result}, error=${errorMsg}`)
|
||||||
|
resolve({ success: false, error: errorMsg })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ success: true })
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[WcdbCore] deleteMessage exception:`, e)
|
||||||
|
resolve({ success: false, error: String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ export class WcdbService {
|
|||||||
*/
|
*/
|
||||||
setMonitor(callback: (type: string, json: string) => void): void {
|
setMonitor(callback: (type: string, json: string) => void): void {
|
||||||
this.monitorListener = callback;
|
this.monitorListener = callback;
|
||||||
// Notify worker to enable monitor
|
|
||||||
this.callWorker('setMonitor').catch(() => { });
|
this.callWorker('setMonitor').catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,10 +361,10 @@ export class WcdbService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行 SQL 查询
|
* 执行 SQL 查询(支持参数化查询)
|
||||||
*/
|
*/
|
||||||
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||||
return this.callWorker('execQuery', { kind, path, sql })
|
return this.callWorker('execQuery', { kind, path, sql, params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -431,6 +430,22 @@ export class WcdbService {
|
|||||||
return this.callWorker('verifyUser', { message, hwnd })
|
return this.callWorker('verifyUser', { message, hwnd })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改消息内容
|
||||||
|
*/
|
||||||
|
async updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('updateMessage', { sessionId, localId, createTime, newContent })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除消息
|
||||||
|
*/
|
||||||
|
async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const wcdbService = new WcdbService()
|
export const wcdbService = new WcdbService()
|
||||||
|
|||||||
114
electron/utils/LRUCache.ts
Normal file
114
electron/utils/LRUCache.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* LRU (Least Recently Used) Cache implementation for memory management
|
||||||
|
*/
|
||||||
|
export class LRUCache<K, V> {
|
||||||
|
private cache: Map<K, V>
|
||||||
|
private maxSize: number
|
||||||
|
|
||||||
|
constructor(maxSize: number = 100) {
|
||||||
|
this.maxSize = maxSize
|
||||||
|
this.cache = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get value from cache
|
||||||
|
*/
|
||||||
|
get(key: K): V | undefined {
|
||||||
|
const value = this.cache.get(key)
|
||||||
|
if (value !== undefined) {
|
||||||
|
// Move to end (most recently used)
|
||||||
|
this.cache.delete(key)
|
||||||
|
this.cache.set(key, value)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set value in cache
|
||||||
|
*/
|
||||||
|
set(key: K, value: V): void {
|
||||||
|
if (this.cache.has(key)) {
|
||||||
|
// Update existing
|
||||||
|
this.cache.delete(key)
|
||||||
|
} else if (this.cache.size >= this.maxSize) {
|
||||||
|
// Remove least recently used (first item)
|
||||||
|
const firstKey = this.cache.keys().next().value
|
||||||
|
if (firstKey !== undefined) {
|
||||||
|
this.cache.delete(firstKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cache.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if key exists
|
||||||
|
*/
|
||||||
|
has(key: K): boolean {
|
||||||
|
return this.cache.has(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete key from cache
|
||||||
|
*/
|
||||||
|
delete(key: K): boolean {
|
||||||
|
return this.cache.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cache entries
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current cache size
|
||||||
|
*/
|
||||||
|
get size(): number {
|
||||||
|
return this.cache.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all keys (for debugging)
|
||||||
|
*/
|
||||||
|
keys(): IterableIterator<K> {
|
||||||
|
return this.cache.keys()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all values (for debugging)
|
||||||
|
*/
|
||||||
|
values(): IterableIterator<V> {
|
||||||
|
return this.cache.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all entries (for iteration support)
|
||||||
|
*/
|
||||||
|
entries(): IterableIterator<[K, V]> {
|
||||||
|
return this.cache.entries()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make LRUCache iterable (for...of support)
|
||||||
|
*/
|
||||||
|
[Symbol.iterator](): IterableIterator<[K, V]> {
|
||||||
|
return this.cache.entries()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force cleanup (optional method for explicit memory management)
|
||||||
|
*/
|
||||||
|
cleanup(): void {
|
||||||
|
// In JavaScript/TypeScript, this is mainly for consistency
|
||||||
|
// The garbage collector will handle actual memory cleanup
|
||||||
|
if (this.cache.size > this.maxSize * 1.5) {
|
||||||
|
// Emergency cleanup if cache somehow exceeds limit
|
||||||
|
const entries = Array.from(this.cache.entries())
|
||||||
|
this.cache.clear()
|
||||||
|
// Keep only the most recent half
|
||||||
|
const keepEntries = entries.slice(-Math.floor(this.maxSize / 2))
|
||||||
|
keepEntries.forEach(([key, value]) => this.cache.set(key, value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,7 +118,7 @@ if (parentPort) {
|
|||||||
result = await core.closeMessageCursor(payload.cursor)
|
result = await core.closeMessageCursor(payload.cursor)
|
||||||
break
|
break
|
||||||
case 'execQuery':
|
case 'execQuery':
|
||||||
result = await core.execQuery(payload.kind, payload.path, payload.sql)
|
result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params)
|
||||||
break
|
break
|
||||||
case 'getEmoticonCdnUrl':
|
case 'getEmoticonCdnUrl':
|
||||||
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
|
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
|
||||||
@@ -150,6 +150,13 @@ if (parentPort) {
|
|||||||
case 'verifyUser':
|
case 'verifyUser':
|
||||||
result = await core.verifyUser(payload.message, payload.hwnd)
|
result = await core.verifyUser(payload.message, payload.hwnd)
|
||||||
break
|
break
|
||||||
|
case 'updateMessage':
|
||||||
|
result = await core.updateMessage(payload.sessionId, payload.localId, payload.createTime, payload.newContent)
|
||||||
|
break
|
||||||
|
case 'deleteMessage':
|
||||||
|
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
|
||||||
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
result = { success: false, error: `Unknown method: ${type}` }
|
result = { success: false, error: `Unknown method: ${type}` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,12 +76,10 @@ export async function showNotification(data: any) {
|
|||||||
const isInList = filterList.includes(sessionId)
|
const isInList = filterList.includes(sessionId)
|
||||||
if (filterMode === 'whitelist' && !isInList) {
|
if (filterMode === 'whitelist' && !isInList) {
|
||||||
// 白名单模式:不在列表中则不显示
|
// 白名单模式:不在列表中则不显示
|
||||||
console.log('[NotificationWindow] Filtered by whitelist:', sessionId)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (filterMode === 'blacklist' && isInList) {
|
if (filterMode === 'blacklist' && isInList) {
|
||||||
// 黑名单模式:在列表中则不显示
|
// 黑名单模式:在列表中则不显示
|
||||||
console.log('[NotificationWindow] Filtered by blacklist:', sessionId)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1919
package-lock.json
generated
1919
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.5.4",
|
"version": "2.1.0",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": "cc",
|
"author": "cc",
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"//": "二改不应改变此处的作者与应用信息",
|
"//": "二改不应改变此处的作者与应用信息",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "echo 'No native modules to rebuild'",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"rebuild": "electron-rebuild",
|
"rebuild": "electron-rebuild",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
@@ -32,7 +32,6 @@
|
|||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"koffi": "^2.9.0",
|
"koffi": "^2.9.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"node-llama-cpp": "^3.15.1",
|
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -107,6 +106,10 @@
|
|||||||
{
|
{
|
||||||
"from": "public/icon.ico",
|
"from": "public/icon.ico",
|
||||||
"to": "icon.ico"
|
"to": "icon.ico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "electron/assets/wasm/",
|
||||||
|
"to": "assets/wasm/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
249
public/splash.html
Normal file
249
public/splash.html
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WeFlow</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 品牌区 */
|
||||||
|
.brand {
|
||||||
|
padding: 48px 52px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
animation: fadeIn 0.4s ease both;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 56px; height: 56px;
|
||||||
|
border-radius: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.app-name {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.app-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* 底部进度区 */
|
||||||
|
.bottom {
|
||||||
|
padding: 0 48px 40px;
|
||||||
|
animation: fadeIn 0.4s ease 0.1s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度条轨道 */
|
||||||
|
.progress-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度条填充 */
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
border-radius: 2px;
|
||||||
|
position: relative;
|
||||||
|
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 扫光:只在有进度时显示,不循环 */
|
||||||
|
.progress-fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.5) 50%, transparent 100%);
|
||||||
|
animation: sweep 1.2s ease-out forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 等待阶段:进度条末端呼吸光点 */
|
||||||
|
.progress-fill.waiting::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -1px; right: -2px;
|
||||||
|
width: 6px; height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: inherit;
|
||||||
|
filter: blur(2px);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.progress-text {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.38;
|
||||||
|
}
|
||||||
|
.version {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes sweep {
|
||||||
|
0% { opacity: 0; transform: translateX(-100%); }
|
||||||
|
20% { opacity: 1; }
|
||||||
|
80% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.4; transform: scaleX(1); }
|
||||||
|
50% { opacity: 1; transform: scaleX(1.8); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="splash" id="splash">
|
||||||
|
<div class="brand">
|
||||||
|
<img class="logo" src="./logo.png" alt="WeFlow" />
|
||||||
|
<div class="brand-text">
|
||||||
|
<div class="app-name" id="appName">WeFlow</div>
|
||||||
|
<div class="app-desc" id="appDesc">微信聊天记录管理工具</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="spacer"></div>
|
||||||
|
|
||||||
|
<div class="bottom">
|
||||||
|
<div class="progress-track" id="progressTrack">
|
||||||
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bottom-row">
|
||||||
|
<div class="progress-text" id="progressText">正在启动...</div>
|
||||||
|
<div class="version" id="versionText"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var themes = {
|
||||||
|
'cloud-dancer': {
|
||||||
|
light: { primary: '#8B7355', bg: '#F0EEE9', bgEnd: '#E5E1DA', text: '#3d3d3d', desc: '#8B7355' },
|
||||||
|
dark: { primary: '#C9A86C', bg: '#1a1816', bgEnd: '#252220', text: '#F0EEE9', desc: '#C9A86C' }
|
||||||
|
},
|
||||||
|
'corundum-blue': {
|
||||||
|
light: { primary: '#4A6670', bg: '#E8EEF0', bgEnd: '#D8E4E8', text: '#3d3d3d', desc: '#4A6670' },
|
||||||
|
dark: { primary: '#6A9AAA', bg: '#141a1c', bgEnd: '#1e2a2e', text: '#E0EEF2', desc: '#6A9AAA' }
|
||||||
|
},
|
||||||
|
'kiwi-green': {
|
||||||
|
light: { primary: '#7A9A5C', bg: '#E8F0E4', bgEnd: '#D8E8D2', text: '#3d3d3d', desc: '#7A9A5C' },
|
||||||
|
dark: { primary: '#9ABA7C', bg: '#161a14', bgEnd: '#222a1e', text: '#E8F0E4', desc: '#9ABA7C' }
|
||||||
|
},
|
||||||
|
'spicy-red': {
|
||||||
|
light: { primary: '#8B4049', bg: '#F0E8E8', bgEnd: '#E8D8D8', text: '#3d3d3d', desc: '#8B4049' },
|
||||||
|
dark: { primary: '#C06068', bg: '#1a1416', bgEnd: '#261e20', text: '#F2E8EA', desc: '#C06068' }
|
||||||
|
},
|
||||||
|
'teal-water': {
|
||||||
|
light: { primary: '#5A8A8A', bg: '#E4F0F0', bgEnd: '#D2E8E8', text: '#3d3d3d', desc: '#5A8A8A' },
|
||||||
|
dark: { primary: '#7ABAAA', bg: '#121a1a', bgEnd: '#1a2626', text: '#E0F2EE', desc: '#7ABAAA' }
|
||||||
|
},
|
||||||
|
'blossom-dream': {
|
||||||
|
light: { primary: '#D4849A', primaryEnd: '#D4849A', bg: '#FCF9FB', bgMid: '#F8F2F8', bgEnd: '#F2F6FB', text: '#2E2633', desc: '#D4849A' },
|
||||||
|
dark: { primary: '#C670C3', primaryEnd: '#8A60C0', bg: '#120B16', bgMid: '#1A1020', bgEnd: '#0E0B18', text: '#F2EAF4', desc: '#C670C3' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyTheme(themeId, mode) {
|
||||||
|
var t = themes[themeId] || themes['cloud-dancer'];
|
||||||
|
var isDark = mode === 'dark';
|
||||||
|
if (mode === 'system') isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
var c = isDark ? t.dark : t.light;
|
||||||
|
|
||||||
|
var el = document.getElementById('splash');
|
||||||
|
var fill = document.getElementById('progressFill');
|
||||||
|
|
||||||
|
if (themeId === 'blossom-dream') {
|
||||||
|
if (isDark) {
|
||||||
|
// 深色
|
||||||
|
el.style.background =
|
||||||
|
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '28 0%, transparent 70%), ' +
|
||||||
|
'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
|
||||||
|
} else {
|
||||||
|
// 浅色
|
||||||
|
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
|
||||||
|
}
|
||||||
|
// 进度条
|
||||||
|
fill.style.background = 'linear-gradient(90deg, ' + c.primary + ' 0%, ' + c.primaryEnd + ' 100%)';
|
||||||
|
} else {
|
||||||
|
if (isDark) {
|
||||||
|
el.style.background =
|
||||||
|
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '22 0%, transparent 70%), ' +
|
||||||
|
'linear-gradient(145deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
|
||||||
|
} else {
|
||||||
|
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
|
||||||
|
}
|
||||||
|
fill.style.background = c.primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('appName').style.color = c.text;
|
||||||
|
document.getElementById('appDesc').style.color = c.desc;
|
||||||
|
document.getElementById('progressText').style.color = c.text;
|
||||||
|
document.getElementById('versionText').style.color = c.text;
|
||||||
|
document.getElementById('progressTrack').style.background = c.primary + (isDark ? '25' : '18');
|
||||||
|
}
|
||||||
|
|
||||||
|
// percent: 实际进度值;waiting: 是否处于等待阶段
|
||||||
|
function updateProgress(percent, text, waiting) {
|
||||||
|
var fill = document.getElementById('progressFill');
|
||||||
|
var label = document.getElementById('progressText');
|
||||||
|
|
||||||
|
if (fill) {
|
||||||
|
fill.style.width = percent + '%';
|
||||||
|
if (waiting) {
|
||||||
|
fill.classList.add('waiting');
|
||||||
|
} else {
|
||||||
|
fill.classList.remove('waiting');
|
||||||
|
// 触发扫光:重置动画
|
||||||
|
fill.style.animation = 'none';
|
||||||
|
fill.offsetHeight;
|
||||||
|
fill.style.animation = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (label && text) label.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVersion(ver) {
|
||||||
|
var el = document.getElementById('versionText');
|
||||||
|
if (el) el.textContent = 'v' + ver;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme('cloud-dancer', 'light');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
42
src/App.scss
42
src/App.scss
@@ -4,6 +4,48 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
animation: appFadeIn 0.35s ease-out;
|
animation: appFadeIn 0.35s ease-out;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 繁花如梦:底色层(::before)+ 光晕层(::after)分离,避免 blur 吃掉边缘
|
||||||
|
[data-theme="blossom-dream"] .app-container {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ::before 纯底色,不模糊
|
||||||
|
[data-theme="blossom-dream"] .app-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -2;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ::after 光晕层,模糊叠加在底色上
|
||||||
|
[data-theme="blossom-dream"] .app-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
|
||||||
|
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-peach) 0%, transparent 65%),
|
||||||
|
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
|
||||||
|
filter: blur(80px);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深色模式光晕更克制
|
||||||
|
[data-theme="blossom-dream"][data-mode="dark"] .app-container::after {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
|
||||||
|
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-purple) 0%, transparent 65%),
|
||||||
|
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
|
||||||
|
filter: blur(100px);
|
||||||
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.window-drag-region {
|
.window-drag-region {
|
||||||
|
|||||||
43
src/App.tsx
43
src/App.tsx
@@ -22,10 +22,9 @@ import SnsPage from './pages/SnsPage'
|
|||||||
import ContactsPage from './pages/ContactsPage'
|
import ContactsPage from './pages/ContactsPage'
|
||||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||||
import NotificationWindow from './pages/NotificationWindow'
|
import NotificationWindow from './pages/NotificationWindow'
|
||||||
import AIChatPage from './pages/AIChatPage'
|
|
||||||
|
|
||||||
import { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
|
||||||
import * as configService from './services/config'
|
import * as configService from './services/config'
|
||||||
import { Download, X, Shield } from 'lucide-react'
|
import { Download, X, Shield } from 'lucide-react'
|
||||||
import './App.scss'
|
import './App.scss'
|
||||||
@@ -35,6 +34,7 @@ import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
|||||||
import LockScreen from './components/LockScreen'
|
import LockScreen from './components/LockScreen'
|
||||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||||
|
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -101,14 +101,27 @@ function App() {
|
|||||||
|
|
||||||
// 应用主题
|
// 应用主题
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
const applyMode = (mode: ThemeMode, systemDark?: boolean) => {
|
||||||
|
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
|
||||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||||
document.documentElement.setAttribute('data-mode', themeMode)
|
document.documentElement.setAttribute('data-mode', effectiveMode)
|
||||||
|
const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
||||||
// 更新窗口控件颜色以适配主题
|
|
||||||
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
|
||||||
if (!isOnboardingWindow && !isNotificationWindow) {
|
if (!isOnboardingWindow && !isNotificationWindow) {
|
||||||
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyMode(themeMode)
|
||||||
|
|
||||||
|
// 监听系统主题变化
|
||||||
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
|
if (useThemeStore.getState().themeMode === 'system') {
|
||||||
|
applyMode('system', e.matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
|
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
|
||||||
|
|
||||||
// 读取已保存的主题设置
|
// 读取已保存的主题设置
|
||||||
@@ -122,7 +135,7 @@ function App() {
|
|||||||
if (savedThemeId && themes.some((theme) => theme.id === savedThemeId)) {
|
if (savedThemeId && themes.some((theme) => theme.id === savedThemeId)) {
|
||||||
setTheme(savedThemeId as ThemeId)
|
setTheme(savedThemeId as ThemeId)
|
||||||
}
|
}
|
||||||
if (savedThemeMode === 'light' || savedThemeMode === 'dark') {
|
if (savedThemeMode === 'light' || savedThemeMode === 'dark' || savedThemeMode === 'system') {
|
||||||
setThemeMode(savedThemeMode)
|
setThemeMode(savedThemeMode)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -182,11 +195,13 @@ function App() {
|
|||||||
if (isNotificationWindow) return // Skip updates in notification window
|
if (isNotificationWindow) return // Skip updates in notification window
|
||||||
|
|
||||||
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
||||||
// 发现新版本时自动打开更新弹窗
|
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
|
||||||
if (info) {
|
if (info) {
|
||||||
setUpdateInfo({ ...info, hasUpdate: true })
|
setUpdateInfo({ ...info, hasUpdate: true })
|
||||||
|
if (!useAppStore.getState().isLocked) {
|
||||||
setShowUpdateDialog(true)
|
setShowUpdateDialog(true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
||||||
setDownloadProgress(progress)
|
setDownloadProgress(progress)
|
||||||
@@ -197,6 +212,13 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
||||||
|
|
||||||
|
// 解锁后显示暂存的更新弹窗
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
|
||||||
|
setShowUpdateDialog(true)
|
||||||
|
}
|
||||||
|
}, [isLocked])
|
||||||
|
|
||||||
const handleUpdateNow = async () => {
|
const handleUpdateNow = async () => {
|
||||||
setShowUpdateDialog(false)
|
setShowUpdateDialog(false)
|
||||||
setIsDownloading(true)
|
setIsDownloading(true)
|
||||||
@@ -291,7 +313,7 @@ function App() {
|
|||||||
const checkLock = async () => {
|
const checkLock = async () => {
|
||||||
// 并行获取配置,减少等待
|
// 并行获取配置,减少等待
|
||||||
const [enabled, useHello] = await Promise.all([
|
const [enabled, useHello] = await Promise.all([
|
||||||
configService.getAuthEnabled(),
|
window.electronAPI.auth.verifyEnabled(),
|
||||||
configService.getAuthUseHello()
|
configService.getAuthUseHello()
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -364,6 +386,7 @@ function App() {
|
|||||||
|
|
||||||
{/* 全局批量转写进度浮窗 */}
|
{/* 全局批量转写进度浮窗 */}
|
||||||
<BatchTranscribeGlobal />
|
<BatchTranscribeGlobal />
|
||||||
|
<BatchImageDecryptGlobal />
|
||||||
|
|
||||||
{/* 用户协议弹窗 */}
|
{/* 用户协议弹窗 */}
|
||||||
{showAgreement && !agreementLoading && (
|
{showAgreement && !agreementLoading && (
|
||||||
@@ -435,7 +458,7 @@ function App() {
|
|||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/home" element={<HomePage />} />
|
<Route path="/home" element={<HomePage />} />
|
||||||
<Route path="/chat" element={<ChatPage />} />
|
<Route path="/chat" element={<ChatPage />} />
|
||||||
<Route path="/ai-chat" element={<AIChatPage />} />
|
|
||||||
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
||||||
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
||||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||||
|
|||||||
133
src/components/BatchImageDecryptGlobal.tsx
Normal file
133
src/components/BatchImageDecryptGlobal.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Loader2, X, Image as ImageIcon, Clock, CheckCircle, XCircle } from 'lucide-react'
|
||||||
|
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
|
||||||
|
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||||
|
import '../styles/batchTranscribe.scss'
|
||||||
|
|
||||||
|
export const BatchImageDecryptGlobal: React.FC = () => {
|
||||||
|
const {
|
||||||
|
isBatchDecrypting,
|
||||||
|
progress,
|
||||||
|
showToast,
|
||||||
|
showResultToast,
|
||||||
|
result,
|
||||||
|
sessionName,
|
||||||
|
startTime,
|
||||||
|
setShowToast,
|
||||||
|
setShowResultToast
|
||||||
|
} = useBatchImageDecryptStore()
|
||||||
|
|
||||||
|
const voiceToastOccupied = useBatchTranscribeStore(
|
||||||
|
state => state.isBatchTranscribing && state.showToast
|
||||||
|
)
|
||||||
|
|
||||||
|
const [eta, setEta] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isBatchDecrypting || !startTime || progress.current === 0) {
|
||||||
|
setEta('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const elapsed = Date.now() - startTime
|
||||||
|
if (elapsed <= 0) return
|
||||||
|
const rate = progress.current / elapsed
|
||||||
|
const remain = progress.total - progress.current
|
||||||
|
if (remain <= 0 || rate <= 0) {
|
||||||
|
setEta('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const seconds = Math.ceil((remain / rate) / 1000)
|
||||||
|
if (seconds < 60) {
|
||||||
|
setEta(`${seconds}秒`)
|
||||||
|
} else {
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = seconds % 60
|
||||||
|
setEta(`${m}分${s}秒`)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [isBatchDecrypting, progress.current, progress.total, startTime])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showResultToast) return
|
||||||
|
const timer = window.setTimeout(() => setShowResultToast(false), 6000)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [showResultToast, setShowResultToast])
|
||||||
|
|
||||||
|
const toastBottom = useMemo(() => (voiceToastOccupied ? 148 : 24), [voiceToastOccupied])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showToast && isBatchDecrypting && createPortal(
|
||||||
|
<div className="batch-progress-toast" style={{ bottom: toastBottom }}>
|
||||||
|
<div className="batch-progress-toast-header">
|
||||||
|
<div className="batch-progress-toast-title">
|
||||||
|
<Loader2 size={14} className="spin" />
|
||||||
|
<span>批量解密图片{sessionName ? `(${sessionName})` : ''}</span>
|
||||||
|
</div>
|
||||||
|
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="batch-progress-toast-body">
|
||||||
|
<div className="progress-info-row">
|
||||||
|
<div className="progress-text">
|
||||||
|
<span>{progress.current} / {progress.total}</span>
|
||||||
|
<span className="progress-percent">
|
||||||
|
{progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{eta && (
|
||||||
|
<div className="progress-eta">
|
||||||
|
<Clock size={12} />
|
||||||
|
<span>剩余 {eta}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{
|
||||||
|
width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showResultToast && createPortal(
|
||||||
|
<div className="batch-progress-toast batch-inline-result-toast" style={{ bottom: toastBottom }}>
|
||||||
|
<div className="batch-progress-toast-header">
|
||||||
|
<div className="batch-progress-toast-title">
|
||||||
|
<ImageIcon size={14} />
|
||||||
|
<span>图片批量解密完成</span>
|
||||||
|
</div>
|
||||||
|
<button className="batch-progress-toast-close" onClick={() => setShowResultToast(false)} title="关闭">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="batch-progress-toast-body">
|
||||||
|
<div className="batch-inline-result-summary">
|
||||||
|
<div className="batch-inline-result-item success">
|
||||||
|
<CheckCircle size={14} />
|
||||||
|
<span>成功 {result.success}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`batch-inline-result-item ${result.fail > 0 ? 'fail' : 'muted'}`}>
|
||||||
|
<XCircle size={14} />
|
||||||
|
<span>失败 {result.fail}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -139,6 +139,18 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,4 +224,68 @@
|
|||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.year-month-picker {
|
||||||
|
padding: 4px 0;
|
||||||
|
|
||||||
|
.year-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.year-label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.month-btn {
|
||||||
|
padding: 8px 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,7 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
|||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||||
const [selectingStart, setSelectingStart] = useState(true)
|
const [selectingStart, setSelectingStart] = useState(true)
|
||||||
|
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// 点击外部关闭
|
// 点击外部关闭
|
||||||
@@ -185,12 +186,38 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
|||||||
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}>
|
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span className="month-year">{currentMonth.getFullYear()}年 {MONTH_NAMES[currentMonth.getMonth()]}</span>
|
<span className="month-year clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||||
|
{currentMonth.getFullYear()}年 {MONTH_NAMES[currentMonth.getMonth()]}
|
||||||
|
</span>
|
||||||
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}>
|
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}>
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{renderCalendar()}
|
{showYearMonthPicker ? (
|
||||||
|
<div className="year-month-picker">
|
||||||
|
<div className="year-selector">
|
||||||
|
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth()))}>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
</button>
|
||||||
|
<span className="year-label">{currentMonth.getFullYear()}年</span>
|
||||||
|
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth()))}>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="month-grid">
|
||||||
|
{MONTH_NAMES.map((name, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={`month-btn ${i === currentMonth.getMonth() ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentMonth(new Date(currentMonth.getFullYear(), i))
|
||||||
|
setShowYearMonthPicker(false)
|
||||||
|
}}
|
||||||
|
>{name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : renderCalendar()}
|
||||||
<div className="selection-hint">
|
<div className="selection-hint">
|
||||||
{selectingStart ? '请选择开始日期' : '请选择结束日期'}
|
{selectingStart ? '请选择开始日期' : '请选择结束日期'}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export function GlobalSessionMonitor() {
|
|||||||
} = useChatStore()
|
} = useChatStore()
|
||||||
|
|
||||||
const sessionsRef = useRef(sessions)
|
const sessionsRef = useRef(sessions)
|
||||||
|
|
||||||
// 保持 ref 同步
|
// 保持 ref 同步
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionsRef.current = sessions
|
sessionsRef.current = sessions
|
||||||
@@ -47,9 +46,10 @@ export function GlobalSessionMonitor() {
|
|||||||
return () => {
|
return () => {
|
||||||
removeListener()
|
removeListener()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
}
|
}
|
||||||
return () => { }
|
return () => { }
|
||||||
}, []) // 空依赖数组 - 主要是静态的
|
}, [])
|
||||||
|
|
||||||
const refreshSessions = async () => {
|
const refreshSessions = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -20,6 +20,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
.image-preview-close {
|
.image-preview-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 40px;
|
bottom: 40px;
|
||||||
@@ -44,3 +53,38 @@
|
|||||||
transform: translateX(-50%) scale(1.1);
|
transform: translateX(-50%) scale(1.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-photo-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
transition: all 0.2s;
|
||||||
|
z-index: 10000;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--accent-color, #007aff);
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,41 @@
|
|||||||
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
|
import { LivePhotoIcon } from './LivePhotoIcon'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import './ImagePreview.scss'
|
import './ImagePreview.scss'
|
||||||
|
|
||||||
interface ImagePreviewProps {
|
interface ImagePreviewProps {
|
||||||
src: string
|
src: string
|
||||||
|
isVideo?: boolean
|
||||||
|
liveVideoPath?: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, onClose }) => {
|
export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, isVideo, liveVideoPath, onClose }) => {
|
||||||
const [scale, setScale] = useState(1)
|
const [scale, setScale] = useState(1)
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [showLive, setShowLive] = useState(false)
|
||||||
const dragStart = useRef({ x: 0, y: 0 })
|
const dragStart = useRef({ x: 0, y: 0 })
|
||||||
const positionStart = useRef({ x: 0, y: 0 })
|
const positionStart = useRef({ x: 0, y: 0 })
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// 滚轮缩放
|
// 滚轮缩放
|
||||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
|
if (showLive) return // 播放实况时禁止缩放? 或者支持缩放? 暂定禁止以简化
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
||||||
setScale(prev => Math.min(Math.max(prev * delta, 0.5), 5))
|
setScale(prev => Math.min(Math.max(prev * delta, 0.5), 5))
|
||||||
}, [])
|
}, [showLive])
|
||||||
|
|
||||||
// 开始拖动
|
// 开始拖动
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
if (scale <= 1) return
|
if (showLive || scale <= 1) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
dragStart.current = { x: e.clientX, y: e.clientY }
|
dragStart.current = { x: e.clientX, y: e.clientY }
|
||||||
positionStart.current = { ...position }
|
positionStart.current = { ...position }
|
||||||
}, [scale, position])
|
}, [scale, position, showLive])
|
||||||
|
|
||||||
// 拖动中
|
// 拖动中
|
||||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
@@ -79,12 +84,38 @@ export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, onClose }) => {
|
|||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className="preview-content"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||||
|
width: 'fit-content',
|
||||||
|
height: 'fit-content'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{(isVideo || showLive) ? (
|
||||||
|
<video
|
||||||
|
src={showLive ? liveVideoPath : src}
|
||||||
|
controls={!showLive}
|
||||||
|
autoPlay
|
||||||
|
loop={showLive}
|
||||||
|
className="preview-image"
|
||||||
|
style={{
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
maxHeight: '90vh',
|
||||||
|
maxWidth: '90vw'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt="图片预览"
|
alt="图片预览"
|
||||||
className={`preview-image ${isDragging ? 'dragging' : ''}`}
|
className={`preview-image ${isDragging ? 'dragging' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
|
transform: `scale(${scale})`,
|
||||||
|
maxHeight: '90vh',
|
||||||
|
maxWidth: '90vw',
|
||||||
cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
|
cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
|
||||||
}}
|
}}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
@@ -92,6 +123,23 @@ export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, onClose }) => {
|
|||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{liveVideoPath && !isVideo && (
|
||||||
|
<button
|
||||||
|
className={`live-photo-btn ${showLive ? 'active' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowLive(!showLive)
|
||||||
|
}}
|
||||||
|
title={showLive ? "显示照片" : "播放实况"}
|
||||||
|
>
|
||||||
|
<LivePhotoIcon size={20} />
|
||||||
|
<span>实况</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button className="image-preview-close" onClick={onClose}>
|
<button className="image-preview-close" onClick={onClose}>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -75,6 +75,18 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn {
|
.nav-btn {
|
||||||
@@ -97,6 +109,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.year-month-picker {
|
||||||
|
padding: 4px 0;
|
||||||
|
|
||||||
|
.year-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.year-label {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.month-btn {
|
||||||
|
padding: 10px 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-grid {
|
.calendar-grid {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
|
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
|
||||||
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
||||||
|
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
>
|
>
|
||||||
<ChevronLeft size={18} />
|
<ChevronLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
<span className="current-month">
|
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -148,6 +149,31 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showYearMonthPicker ? (
|
||||||
|
<div className="year-month-picker">
|
||||||
|
<div className="year-selector">
|
||||||
|
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="year-label">{calendarDate.getFullYear()}年</span>
|
||||||
|
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="month-grid">
|
||||||
|
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||||
|
setShowYearMonthPicker(false)
|
||||||
|
}}
|
||||||
|
>{name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
||||||
{loadingDates && (
|
{loadingDates && (
|
||||||
<div className="calendar-loading">
|
<div className="calendar-loading">
|
||||||
@@ -174,6 +200,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="quick-options">
|
<div className="quick-options">
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import * as configService from '../services/config'
|
|
||||||
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
|
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
|
||||||
import './LockScreen.scss'
|
import './LockScreen.scss'
|
||||||
|
|
||||||
@@ -9,14 +8,6 @@ interface LockScreenProps {
|
|||||||
useHello?: boolean
|
useHello?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sha256(message: string) {
|
|
||||||
const msgBuffer = new TextEncoder().encode(message)
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
|
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
|
||||||
return hashHex
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) {
|
export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) {
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -49,19 +40,9 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
|||||||
|
|
||||||
const quickStartHello = async () => {
|
const quickStartHello = async () => {
|
||||||
try {
|
try {
|
||||||
// 如果父组件已经告诉我们要用 Hello,直接开始,不等待 IPC
|
if (useHello) {
|
||||||
let shouldUseHello = useHello
|
|
||||||
|
|
||||||
// 为了稳健,如果 prop 没传(虽然现在都传了),再 check 一次 config
|
|
||||||
if (!shouldUseHello) {
|
|
||||||
shouldUseHello = await configService.getAuthUseHello()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldUseHello) {
|
|
||||||
// 标记为可用,显示按钮
|
|
||||||
setHelloAvailable(true)
|
setHelloAvailable(true)
|
||||||
setShowHello(true)
|
setShowHello(true)
|
||||||
// 立即执行验证 (0延迟)
|
|
||||||
verifyHello()
|
verifyHello()
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -96,25 +77,19 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
|||||||
e?.preventDefault()
|
e?.preventDefault()
|
||||||
if (!password || isUnlocked) return
|
if (!password || isUnlocked) return
|
||||||
|
|
||||||
// 如果正在进行 Hello 验证,它会自动失败或被取代,UI上不用特意取消
|
|
||||||
// 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可
|
|
||||||
|
|
||||||
// 不再检查 isVerifying,因为我们允许打断 Hello
|
|
||||||
setIsVerifying(true)
|
setIsVerifying(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const storedHash = await configService.getAuthPassword()
|
// 发送原始密码到主进程,由主进程验证并解密密钥
|
||||||
const inputHash = await sha256(password)
|
const result = await window.electronAPI.auth.unlock(password)
|
||||||
|
|
||||||
if (inputHash === storedHash) {
|
if (result.success) {
|
||||||
handleUnlock()
|
handleUnlock()
|
||||||
} else {
|
} else {
|
||||||
setError('密码错误')
|
setError(result.error || '密码错误')
|
||||||
setPassword('')
|
setPassword('')
|
||||||
setIsVerifying(false)
|
setIsVerifying(false)
|
||||||
// 如果密码错误,是否重新触发 Hello?
|
|
||||||
// 用户可能想重试密码,暂时不自动触发
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError('验证失败')
|
setError('验证失败')
|
||||||
|
|||||||
@@ -6,6 +6,15 @@
|
|||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
|
|
||||||
|
// 浅色模式下使用完全不透明背景,并禁用毛玻璃效果
|
||||||
|
[data-mode="light"] &,
|
||||||
|
:not([data-mode]) & {
|
||||||
|
background: rgba(255, 255, 255, 1);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -39,10 +48,16 @@
|
|||||||
backdrop-filter: none !important;
|
backdrop-filter: none !important;
|
||||||
-webkit-backdrop-filter: none !important;
|
-webkit-backdrop-filter: none !important;
|
||||||
|
|
||||||
// Ensure background is solid
|
// 确保背景完全不透明(通知是独立窗口,透明背景会穿透)
|
||||||
background: var(--bg-secondary, #2c2c2c);
|
background: var(--bg-secondary-solid, var(--bg-secondary, #2c2c2c));
|
||||||
color: var(--text-primary, #ffffff);
|
color: var(--text-primary, #ffffff);
|
||||||
|
|
||||||
|
// 浅色模式强制完全不透明白色背景
|
||||||
|
[data-mode="light"] &,
|
||||||
|
:not([data-mode]) & {
|
||||||
|
background: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
box-shadow: none !important; // NO SHADOW
|
box-shadow: none !important; // NO SHADOW
|
||||||
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
|
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
|
||||||
|
|
||||||
|
|||||||
@@ -104,3 +104,30 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
|
||||||
|
[data-theme="blossom-dream"] .sidebar {
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="blossom-dream"][data-mode="dark"] .sidebar {
|
||||||
|
background: rgba(34, 30, 36, 0.75);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 激活项:主品牌色纵向微渐变
|
||||||
|
[data-theme="blossom-dream"] .nav-item.active {
|
||||||
|
background: linear-gradient(180deg, #D4849A 0%, #C4748A 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深色激活项:用藕粉色,背景深灰底 + 粉色文字/图标(高阶玩法)
|
||||||
|
[data-theme="blossom-dream"][data-mode="dark"] .nav-item.active {
|
||||||
|
background: rgba(209, 158, 187, 0.15);
|
||||||
|
color: #D19EBB;
|
||||||
|
border: 1px solid rgba(209, 158, 187, 0.2);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } 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, Aperture, UserCircle, Lock } from 'lucide-react'
|
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react'
|
||||||
import { useAppStore } from '../stores/appStore'
|
import { useAppStore } from '../stores/appStore'
|
||||||
import * as configService from '../services/config'
|
|
||||||
import './Sidebar.scss'
|
import './Sidebar.scss'
|
||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
@@ -12,7 +12,7 @@ function Sidebar() {
|
|||||||
const setLocked = useAppStore(state => state.setLocked)
|
const setLocked = useAppStore(state => state.setLocked)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
configService.getAuthEnabled().then(setAuthEnabled)
|
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
|
|||||||
185
src/components/Sns/SnsFilterPanel.tsx
Normal file
185
src/components/Sns/SnsFilterPanel.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Search, Calendar, User, X, Filter, Check } from 'lucide-react'
|
||||||
|
import { Avatar } from '../Avatar'
|
||||||
|
// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnsFilterPanelProps {
|
||||||
|
searchKeyword: string
|
||||||
|
setSearchKeyword: (val: string) => void
|
||||||
|
jumpTargetDate?: Date
|
||||||
|
setJumpTargetDate: (date?: Date) => void
|
||||||
|
onOpenJumpDialog: () => void
|
||||||
|
selectedUsernames: string[]
|
||||||
|
setSelectedUsernames: (val: string[]) => void
|
||||||
|
contacts: Contact[]
|
||||||
|
contactSearch: string
|
||||||
|
setContactSearch: (val: string) => void
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||||
|
searchKeyword,
|
||||||
|
setSearchKeyword,
|
||||||
|
jumpTargetDate,
|
||||||
|
setJumpTargetDate,
|
||||||
|
onOpenJumpDialog,
|
||||||
|
selectedUsernames,
|
||||||
|
setSelectedUsernames,
|
||||||
|
contacts,
|
||||||
|
contactSearch,
|
||||||
|
setContactSearch,
|
||||||
|
loading
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const filteredContacts = contacts.filter(c =>
|
||||||
|
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||||
|
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleUserSelection = (username: string) => {
|
||||||
|
if (selectedUsernames.includes(username)) {
|
||||||
|
setSelectedUsernames(selectedUsernames.filter(u => u !== username))
|
||||||
|
} else {
|
||||||
|
setJumpTargetDate(undefined) // Reset date jump when selecting user
|
||||||
|
setSelectedUsernames([...selectedUsernames, username])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchKeyword('')
|
||||||
|
setSelectedUsernames([])
|
||||||
|
setJumpTargetDate(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sns-filter-panel">
|
||||||
|
<div className="filter-header">
|
||||||
|
<h3>筛选条件</h3>
|
||||||
|
{(searchKeyword || jumpTargetDate || selectedUsernames.length > 0) && (
|
||||||
|
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-widgets">
|
||||||
|
{/* Search Widget */}
|
||||||
|
<div className="filter-widget search-widget">
|
||||||
|
<div className="widget-header">
|
||||||
|
<Search size={14} />
|
||||||
|
<span>关键词搜索</span>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索动态内容..."
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={e => setSearchKeyword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{searchKeyword && (
|
||||||
|
<button className="clear-input-btn" onClick={() => setSearchKeyword('')}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Widget */}
|
||||||
|
<div className="filter-widget date-widget">
|
||||||
|
<div className="widget-header">
|
||||||
|
<Calendar size={14} />
|
||||||
|
<span>时间跳转</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`date-picker-trigger ${jumpTargetDate ? 'active' : ''}`}
|
||||||
|
onClick={onOpenJumpDialog}
|
||||||
|
>
|
||||||
|
<span className="date-text">
|
||||||
|
{jumpTargetDate
|
||||||
|
? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
|
: '选择日期...'}
|
||||||
|
</span>
|
||||||
|
{jumpTargetDate && (
|
||||||
|
<div
|
||||||
|
className="clear-date-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setJumpTargetDate(undefined)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Widget */}
|
||||||
|
<div className="filter-widget contact-widget">
|
||||||
|
<div className="widget-header">
|
||||||
|
<User size={14} />
|
||||||
|
<span>联系人</span>
|
||||||
|
{selectedUsernames.length > 0 && (
|
||||||
|
<span className="badge">{selectedUsernames.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-search-bar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="查找好友..."
|
||||||
|
value={contactSearch}
|
||||||
|
onChange={e => setContactSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Search size={14} className="search-icon" />
|
||||||
|
{contactSearch && (
|
||||||
|
<X size={14} className="clear-icon" onClick={() => setContactSearch('')} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-list-scroll">
|
||||||
|
{filteredContacts.map(contact => (
|
||||||
|
<div
|
||||||
|
key={contact.username}
|
||||||
|
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`}
|
||||||
|
onClick={() => toggleUserSelection(contact.username)}
|
||||||
|
>
|
||||||
|
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
|
||||||
|
<span className="contact-name">{contact.displayName}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredContacts.length === 0 && (
|
||||||
|
<div className="empty-state">没有找到联系人</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RefreshCw({ size, className }: { size?: number, className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size || 24}
|
||||||
|
height={size || 24}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M23 4v6h-6"></path>
|
||||||
|
<path d="M1 20v-6h6"></path>
|
||||||
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
360
src/components/Sns/SnsMediaGrid.tsx
Normal file
360
src/components/Sns/SnsMediaGrid.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import React, { useState, useRef } from 'react'
|
||||||
|
import { Play, Lock, Download, ImageOff } from 'lucide-react'
|
||||||
|
import { LivePhotoIcon } from '../../components/LivePhotoIcon'
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
interface SnsMedia {
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
md5?: string
|
||||||
|
token?: string
|
||||||
|
key?: string
|
||||||
|
encIdx?: string
|
||||||
|
livePhoto?: {
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
token?: string
|
||||||
|
key?: string
|
||||||
|
encIdx?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnsMediaGridProps {
|
||||||
|
mediaList: SnsMedia[]
|
||||||
|
postType?: number
|
||||||
|
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||||
|
onMediaDeleted?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSnsVideoUrl = (url?: string): boolean => {
|
||||||
|
if (!url) return false
|
||||||
|
const lower = url.toLowerCase()
|
||||||
|
return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb')
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractVideoFrame = async (videoPath: string): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const video = document.createElement('video')
|
||||||
|
video.preload = 'auto'
|
||||||
|
video.src = videoPath
|
||||||
|
video.muted = true
|
||||||
|
video.currentTime = 0 // Initial reset
|
||||||
|
// video.crossOrigin = 'anonymous' // Not needed for file:// usually
|
||||||
|
|
||||||
|
const onSeeked = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = video.videoWidth
|
||||||
|
canvas.height = video.videoHeight
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.8)
|
||||||
|
resolve(dataUrl)
|
||||||
|
} else {
|
||||||
|
reject(new Error('Canvas context failed'))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(e)
|
||||||
|
} finally {
|
||||||
|
// Cleanup
|
||||||
|
video.removeEventListener('seeked', onSeeked)
|
||||||
|
video.src = ''
|
||||||
|
video.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
if (video.duration === Infinity || isNaN(video.duration)) {
|
||||||
|
// Determine duration failed, try a fixed small offset
|
||||||
|
video.currentTime = 1
|
||||||
|
} else {
|
||||||
|
video.currentTime = Math.max(0.1, video.duration / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
video.onseeked = onSeeked
|
||||||
|
|
||||||
|
video.onerror = (e) => {
|
||||||
|
reject(new Error('Video load failed'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsMedia; postType?: number; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => {
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [deleted, setDeleted] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const markDeleted = () => { setDeleted(true); onMediaDeleted?.() }
|
||||||
|
const retryCount = useRef(0)
|
||||||
|
const [retryKey, setRetryKey] = useState(0)
|
||||||
|
const [thumbSrc, setThumbSrc] = useState<string>('')
|
||||||
|
const [videoPath, setVideoPath] = useState<string>('')
|
||||||
|
const [liveVideoPath, setLiveVideoPath] = useState<string>('')
|
||||||
|
const [isDecrypting, setIsDecrypting] = useState(false)
|
||||||
|
const [isGeneratingCover, setIsGeneratingCover] = useState(false)
|
||||||
|
|
||||||
|
const isVideo = isSnsVideoUrl(media.url)
|
||||||
|
const isLive = !!media.livePhoto
|
||||||
|
const targetUrl = media.thumb || media.url
|
||||||
|
// type 7 的朋友圈媒体不需要解密,直接使用原始 URL
|
||||||
|
const skipDecrypt = postType === 7
|
||||||
|
|
||||||
|
// 视频重试:失败时重试最多2次,耗尽才标记删除
|
||||||
|
const videoRetryOrDelete = () => {
|
||||||
|
if (retryCount.current < 2) {
|
||||||
|
retryCount.current++
|
||||||
|
setRetryKey(k => k + 1)
|
||||||
|
} else {
|
||||||
|
markDeleted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple effect to load image/decrypt
|
||||||
|
// Simple effect to load image/decrypt
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
if (!isVideo) {
|
||||||
|
// For images, we proxy to get the local path/base64
|
||||||
|
const result = await window.electronAPI.sns.proxyImage({
|
||||||
|
url: targetUrl,
|
||||||
|
key: skipDecrypt ? undefined : media.key
|
||||||
|
})
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (result.dataUrl) setThumbSrc(result.dataUrl)
|
||||||
|
else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
|
||||||
|
} else {
|
||||||
|
markDeleted()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-load live photo video if needed
|
||||||
|
if (isLive && media.livePhoto?.url) {
|
||||||
|
window.electronAPI.sns.proxyImage({
|
||||||
|
url: media.livePhoto.url,
|
||||||
|
key: skipDecrypt ? undefined : (media.livePhoto.key || media.key)
|
||||||
|
}).then((res: any) => {
|
||||||
|
if (!cancelled && res.success && res.videoPath) {
|
||||||
|
setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`)
|
||||||
|
}
|
||||||
|
}).catch(() => { })
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
} else {
|
||||||
|
// Video logic: Decrypt -> Extract Frame
|
||||||
|
setIsGeneratingCover(true)
|
||||||
|
|
||||||
|
// First check if we already have it decryptable?
|
||||||
|
// Usually we need to call proxyImage with the video URL to decrypt it to cache
|
||||||
|
const result = await window.electronAPI.sns.proxyImage({
|
||||||
|
url: media.url,
|
||||||
|
key: skipDecrypt ? undefined : media.key
|
||||||
|
})
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
if (result.success && result.videoPath) {
|
||||||
|
const localPath = `file://${result.videoPath.replace(/\\/g, '/')}`
|
||||||
|
setVideoPath(localPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const coverDataUrl = await extractVideoFrame(localPath)
|
||||||
|
if (!cancelled) setThumbSrc(coverDataUrl)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Frame extraction failed', err)
|
||||||
|
// 封面提取失败,用视频路径作为 fallback,让 <video> 标签显示
|
||||||
|
if (!cancelled) setThumbSrc(localPath)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoRetryOrDelete()
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeneratingCover(false)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
if (!cancelled) {
|
||||||
|
if (isVideo) {
|
||||||
|
videoRetryOrDelete()
|
||||||
|
} else {
|
||||||
|
markDeleted()
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
setIsGeneratingCover(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [media, isVideo, isLive, targetUrl, retryKey])
|
||||||
|
|
||||||
|
const handlePreview = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (isVideo) {
|
||||||
|
// Decrypt video on demand if not already
|
||||||
|
if (!videoPath) {
|
||||||
|
setIsDecrypting(true)
|
||||||
|
try {
|
||||||
|
const res = await window.electronAPI.sns.proxyImage({
|
||||||
|
url: media.url,
|
||||||
|
key: skipDecrypt ? undefined : media.key
|
||||||
|
})
|
||||||
|
if (res.success && res.videoPath) {
|
||||||
|
const local = `file://${res.videoPath.replace(/\\/g, '/')}`
|
||||||
|
setVideoPath(local)
|
||||||
|
onPreview(local, true, undefined)
|
||||||
|
} else {
|
||||||
|
alert('视频解密失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setIsDecrypting(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onPreview(videoPath, true, undefined)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onPreview(thumbSrc || targetUrl, false, liveVideoPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.sns.proxyImage({
|
||||||
|
url: media.url,
|
||||||
|
key: skipDecrypt ? undefined : media.key
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.download = `sns_media_${Date.now()}.${isVideo ? 'mp4' : 'jpg'}`
|
||||||
|
|
||||||
|
if (result.dataUrl) {
|
||||||
|
link.href = result.dataUrl
|
||||||
|
} else if (result.videoPath) {
|
||||||
|
// For local video files, we need to fetch as blob to force download behavior
|
||||||
|
// or just use the file protocol url if the browser supports it
|
||||||
|
try {
|
||||||
|
const response = await fetch(`file://${result.videoPath}`)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
link.href = url
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Video fetch failed, falling back to direct link', err)
|
||||||
|
link.href = `file://${result.videoPath}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
} else {
|
||||||
|
alert('下载失败: 无法获取资源')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Download error:', e)
|
||||||
|
alert('下载出错')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted) {
|
||||||
|
return (
|
||||||
|
<div className="sns-media-item deleted-media">
|
||||||
|
<div className="deleted-placeholder">
|
||||||
|
<ImageOff size={24} />
|
||||||
|
<span>已删除</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`sns-media-item ${isDecrypting ? 'decrypting' : ''}`}
|
||||||
|
onClick={handlePreview}
|
||||||
|
>
|
||||||
|
{(thumbSrc && !thumbSrc.startsWith('data:') && (thumbSrc.toLowerCase().endsWith('.mp4') || thumbSrc.includes('video'))) ? (
|
||||||
|
<video
|
||||||
|
key={thumbSrc}
|
||||||
|
src={`${thumbSrc}#t=0.1`}
|
||||||
|
className="media-image"
|
||||||
|
preload="auto"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
disablePictureInPicture
|
||||||
|
disableRemotePlayback
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
e.currentTarget.currentTime = 0.1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : thumbSrc ? (
|
||||||
|
<img
|
||||||
|
src={thumbSrc}
|
||||||
|
className="media-image"
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => { if (!loading && !isVideo) markDeleted() }}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isGeneratingCover && (
|
||||||
|
<div className="media-decrypting-mask">
|
||||||
|
<RefreshCw className="spin" size={24} />
|
||||||
|
<span>解密中...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isVideo && (
|
||||||
|
<div className="media-badge video">
|
||||||
|
{/* If we have a cover, show Play. If decrypting for preview, show spin. Generating cover has its own mask. */}
|
||||||
|
{isDecrypting ? <RefreshCw className="spin" size={16} /> : <Play size={16} fill="currentColor" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLive && !isVideo && (
|
||||||
|
<div className="media-badge live">
|
||||||
|
<LivePhotoIcon size={16} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="media-download-btn" onClick={handleDownload} title="下载">
|
||||||
|
<Download size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, postType, onPreview, onMediaDeleted }) => {
|
||||||
|
if (!mediaList || mediaList.length === 0) return null
|
||||||
|
|
||||||
|
const count = mediaList.length
|
||||||
|
let gridClass = ''
|
||||||
|
|
||||||
|
if (count === 1) gridClass = 'grid-1'
|
||||||
|
else if (count === 2) gridClass = 'grid-2'
|
||||||
|
else if (count === 3) gridClass = 'grid-3'
|
||||||
|
else if (count === 4) gridClass = 'grid-4' // 2x2
|
||||||
|
else if (count <= 6) gridClass = 'grid-6' // 3 cols
|
||||||
|
else gridClass = 'grid-9' // 3x3
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`sns-media-grid ${gridClass}`}>
|
||||||
|
{mediaList.map((media, idx) => (
|
||||||
|
<MediaItem key={idx} media={media} postType={postType} onPreview={onPreview} onMediaDeleted={onMediaDeleted} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
302
src/components/Sns/SnsPostItem.tsx
Normal file
302
src/components/Sns/SnsPostItem.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import React, { useState, useMemo } from 'react'
|
||||||
|
import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal, Trash2 } from 'lucide-react'
|
||||||
|
import { SnsPost, SnsLinkCardData } from '../../types/sns'
|
||||||
|
import { Avatar } from '../Avatar'
|
||||||
|
import { SnsMediaGrid } from './SnsMediaGrid'
|
||||||
|
import { getEmojiPath } from 'wechat-emojis'
|
||||||
|
|
||||||
|
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
|
||||||
|
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
|
||||||
|
const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle']
|
||||||
|
const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload']
|
||||||
|
|
||||||
|
const isSnsVideoUrl = (url?: string): boolean => {
|
||||||
|
if (!url) return false
|
||||||
|
const lower = url.toLowerCase()
|
||||||
|
return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb')
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeHtmlEntities = (text: string): string => {
|
||||||
|
if (!text) return ''
|
||||||
|
return text
|
||||||
|
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'/gi, "'")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeUrlCandidate = (raw: string): string | null => {
|
||||||
|
const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim()
|
||||||
|
if (!value) return null
|
||||||
|
if (!/^https?:\/\//i.test(value)) return null
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const simplifyUrlForCompare = (value: string): string => {
|
||||||
|
const normalized = value.trim().toLowerCase().replace(/^https?:\/\//, '')
|
||||||
|
const [withoutQuery] = normalized.split('?')
|
||||||
|
return withoutQuery.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getXmlTagValues = (xml: string, tags: string[]): string[] => {
|
||||||
|
if (!xml) return []
|
||||||
|
const results: string[] = []
|
||||||
|
for (const tag of tags) {
|
||||||
|
const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig')
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
while ((match = reg.exec(xml)) !== null) {
|
||||||
|
if (match[1]) results.push(match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUrlLikeStrings = (text: string): string[] => {
|
||||||
|
if (!text) return []
|
||||||
|
return text.match(/https?:\/\/[^\s<>"']+/gi) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLikelyMediaAssetUrl = (url: string): boolean => {
|
||||||
|
const lower = url.toLowerCase()
|
||||||
|
return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint))
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
|
||||||
|
// type 3 是链接类型,直接用 media[0] 的 url 和 thumb
|
||||||
|
if (post.type === 3) {
|
||||||
|
const url = post.media[0]?.url || post.linkUrl
|
||||||
|
if (!url) return null
|
||||||
|
const titleCandidates = [
|
||||||
|
post.linkTitle || '',
|
||||||
|
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
||||||
|
post.contentDesc || ''
|
||||||
|
]
|
||||||
|
const title = titleCandidates
|
||||||
|
.map((v) => decodeHtmlEntities(v))
|
||||||
|
.find((v) => Boolean(v) && !/^https?:\/\//i.test(v))
|
||||||
|
return { url, title: title || '网页链接', thumb: post.media[0]?.thumb }
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||||
|
if (hasVideoMedia) return null
|
||||||
|
|
||||||
|
const mediaValues = post.media
|
||||||
|
.flatMap((item) => [item.url, item.thumb])
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
const mediaSet = new Set(mediaValues.map((value) => simplifyUrlForCompare(value)))
|
||||||
|
|
||||||
|
const urlCandidates: string[] = [
|
||||||
|
post.linkUrl || '',
|
||||||
|
...getXmlTagValues(post.rawXml || '', LINK_XML_URL_TAGS),
|
||||||
|
...getUrlLikeStrings(post.rawXml || ''),
|
||||||
|
...getUrlLikeStrings(post.contentDesc || '')
|
||||||
|
]
|
||||||
|
|
||||||
|
const normalizedCandidates = urlCandidates
|
||||||
|
.map(normalizeUrlCandidate)
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
|
||||||
|
const dedupedCandidates: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const candidate of normalizedCandidates) {
|
||||||
|
if (seen.has(candidate)) continue
|
||||||
|
seen.add(candidate)
|
||||||
|
dedupedCandidates.push(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkUrl = dedupedCandidates.find((candidate) => {
|
||||||
|
const simplified = simplifyUrlForCompare(candidate)
|
||||||
|
if (mediaSet.has(simplified)) return false
|
||||||
|
if (isLikelyMediaAssetUrl(candidate)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!linkUrl) return null
|
||||||
|
|
||||||
|
const titleCandidates = [
|
||||||
|
post.linkTitle || '',
|
||||||
|
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
||||||
|
post.contentDesc || ''
|
||||||
|
]
|
||||||
|
|
||||||
|
const title = titleCandidates
|
||||||
|
.map((value) => decodeHtmlEntities(value))
|
||||||
|
.find((value) => Boolean(value) && !/^https?:\/\//i.test(value))
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: linkUrl,
|
||||||
|
title: title || '网页链接',
|
||||||
|
thumb: post.media[0]?.thumb || post.media[0]?.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
|
||||||
|
const [thumbFailed, setThumbFailed] = useState(false)
|
||||||
|
const hostname = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return new URL(card.url).hostname.replace(/^www\./i, '')
|
||||||
|
} catch {
|
||||||
|
return card.url
|
||||||
|
}
|
||||||
|
}, [card.url])
|
||||||
|
|
||||||
|
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
try {
|
||||||
|
await window.electronAPI.shell.openExternal(card.url)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SnsLinkCard] openExternal failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className="post-link-card" onClick={handleClick}>
|
||||||
|
<div className="link-thumb">
|
||||||
|
{card.thumb && !thumbFailed ? (
|
||||||
|
<img
|
||||||
|
src={card.thumb}
|
||||||
|
alt=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setThumbFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="link-thumb-fallback">
|
||||||
|
<ImageIcon size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="link-meta">
|
||||||
|
<div className="link-title">{card.title}</div>
|
||||||
|
<div className="link-url">{hostname}</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} className="link-arrow" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnsPostItemProps {
|
||||||
|
post: SnsPost
|
||||||
|
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||||
|
onDebug: (post: SnsPost) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug }) => {
|
||||||
|
const [mediaDeleted, setMediaDeleted] = useState(false)
|
||||||
|
const linkCard = buildLinkCardData(post)
|
||||||
|
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||||
|
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
|
||||||
|
const showMediaGrid = post.media.length > 0 && !showLinkCard
|
||||||
|
|
||||||
|
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 renderTextWithEmoji = (text: string) => {
|
||||||
|
if (!text) return text
|
||||||
|
const parts = text.split(/\[(.*?)\]/g)
|
||||||
|
return parts.map((part, index) => {
|
||||||
|
if (index % 2 === 1) {
|
||||||
|
// @ts-ignore
|
||||||
|
const path = getEmojiPath(part as any)
|
||||||
|
if (path) {
|
||||||
|
return <img key={index} src={`${import.meta.env.BASE_URL}${path}`} alt={`[${part}]`} className="inline-emoji" style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }} />
|
||||||
|
}
|
||||||
|
return `[${part}]`
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`sns-post-item ${mediaDeleted ? 'post-deleted' : ''}`}>
|
||||||
|
<div className="post-avatar-col">
|
||||||
|
<Avatar
|
||||||
|
src={post.avatarUrl}
|
||||||
|
name={post.nickname}
|
||||||
|
size={48}
|
||||||
|
shape="rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="post-content-col">
|
||||||
|
<div className="post-header-row">
|
||||||
|
<div className="post-author-info">
|
||||||
|
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
|
||||||
|
<span className="post-time">{formatTime(post.createTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="post-header-actions">
|
||||||
|
{mediaDeleted && (
|
||||||
|
<span className="post-deleted-badge">
|
||||||
|
<Trash2 size={12} />
|
||||||
|
<span>已删除</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDebug(post);
|
||||||
|
}} title="查看原始数据">
|
||||||
|
<Code size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{post.contentDesc && (
|
||||||
|
<div className="post-text">{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showLinkCard && linkCard && (
|
||||||
|
<SnsLinkCard card={linkCard} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMediaGrid && (
|
||||||
|
<div className="post-media-container">
|
||||||
|
<SnsMediaGrid mediaList={post.media} postType={post.type} onPreview={onPreview} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setMediaDeleted(true) : undefined} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(post.likes.length > 0 || post.comments.length > 0) && (
|
||||||
|
<div className="post-interactions">
|
||||||
|
{post.likes.length > 0 && (
|
||||||
|
<div className="likes-block">
|
||||||
|
<Heart size={14} className="like-icon" />
|
||||||
|
<span className="likes-text">{post.likes.join('、')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{post.comments.length > 0 && (
|
||||||
|
<div className="comments-block">
|
||||||
|
{post.comments.map((c, idx) => (
|
||||||
|
<div key={idx} className="comment-row">
|
||||||
|
<span className="comment-user">{c.nickname}</span>
|
||||||
|
{c.refNickname && (
|
||||||
|
<>
|
||||||
|
<span className="reply-text">回复</span>
|
||||||
|
<span className="comment-user">{c.refNickname}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="comment-colon">:</span>
|
||||||
|
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 繁花如梦:标题栏毛玻璃
|
||||||
|
[data-theme="blossom-dream"] .title-bar {
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
.title-logo {
|
.title-logo {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -1,552 +0,0 @@
|
|||||||
// AI 对话页面 - 简约大气风格
|
|
||||||
.ai-chat-page {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
background: var(--bg-gradient);
|
|
||||||
color: var(--text-primary);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.chat-container {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 顶部 Header - 已移除 ==========
|
|
||||||
// 模型选择器现已集成到输入框
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ========== 聊天区域 ==========
|
|
||||||
.chat-main {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
// 空状态
|
|
||||||
.empty-state {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px;
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--primary-light);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 消息列表
|
|
||||||
.messages-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 24px 32px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border-color);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
max-width: 80%;
|
|
||||||
animation: messageIn 0.3s ease-out;
|
|
||||||
|
|
||||||
// 用户消息
|
|
||||||
&.user {
|
|
||||||
align-self: flex-end;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
background: var(--primary-light);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble {
|
|
||||||
background: var(--primary-gradient);
|
|
||||||
color: white;
|
|
||||||
border-radius: 18px 18px 4px 18px;
|
|
||||||
box-shadow: 0 2px 10px color-mix(in srgb, var(--primary) 20%, transparent);
|
|
||||||
|
|
||||||
.content {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI 消息
|
|
||||||
&.ai {
|
|
||||||
align-self: flex-start;
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 18px 18px 18px 4px;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble {
|
|
||||||
padding: 12px 16px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
.content,
|
|
||||||
.markdown-content {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--text-primary);
|
|
||||||
word-wrap: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Markdown 样式
|
|
||||||
.markdown-content {
|
|
||||||
p {
|
|
||||||
margin: 0 0 0.8em;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
margin: 1em 0 0.5em;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.3;
|
|
||||||
color: var(--text-primary);
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul,
|
|
||||||
ol {
|
|
||||||
margin: 0.5em 0;
|
|
||||||
padding-left: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin: 0.3em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 0.8em 0;
|
|
||||||
|
|
||||||
code {
|
|
||||||
background: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-left: 3px solid var(--primary);
|
|
||||||
padding-left: 12px;
|
|
||||||
margin: 0.8em 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--primary);
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
strong {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0.8em 0;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
padding: 8px 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-spacer {
|
|
||||||
height: 100px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 输入区域
|
|
||||||
.input-area {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 24px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: calc(100% - 64px);
|
|
||||||
max-width: 800px;
|
|
||||||
z-index: 10;
|
|
||||||
|
|
||||||
.input-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
background: var(--card-bg);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:focus-within {
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1),
|
|
||||||
0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 24px;
|
|
||||||
max-height: 120px;
|
|
||||||
padding: 8px 0;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
resize: none;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: inherit;
|
|
||||||
line-height: 1.5;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
// 模型选择器
|
|
||||||
.model-selector {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.model-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
width: auto;
|
|
||||||
height: 36px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&.spin {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
border-color: var(--text-tertiary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.loaded {
|
|
||||||
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.loading {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 100%;
|
|
||||||
right: 0;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
background: var(--card-bg);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
||||||
z-index: 100;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: dropdownIn 0.2s ease-out;
|
|
||||||
min-width: 140px;
|
|
||||||
|
|
||||||
.model-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 10px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&:hover:not(.disabled) {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
.check {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.check {
|
|
||||||
margin-left: 8px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-toggle {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-btn {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--primary-gradient);
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: white;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 25%, transparent);
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 35%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active:not(:disabled) {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
box-shadow: none;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes messageIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dropdownIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
||||||
import { Send, Bot, User, Cpu, ChevronDown, Loader2 } from 'lucide-react'
|
|
||||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
|
|
||||||
import { engineService, PRESET_MODELS, ModelInfo } from '../services/EngineService'
|
|
||||||
import { MessageBubble } from '../components/MessageBubble'
|
|
||||||
import './AIChatPage.scss'
|
|
||||||
|
|
||||||
interface ChatMessage {
|
|
||||||
id: string;
|
|
||||||
role: 'user' | 'ai';
|
|
||||||
content: string;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 消息数量限制,避免内存过载
|
|
||||||
const MAX_MESSAGES = 200
|
|
||||||
|
|
||||||
export default function AIChatPage() {
|
|
||||||
const [input, setInput] = useState('')
|
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
||||||
const [isTyping, setIsTyping] = useState(false)
|
|
||||||
const [models, setModels] = useState<ModelInfo[]>([...PRESET_MODELS])
|
|
||||||
const [selectedModel, setSelectedModel] = useState<string | null>(null)
|
|
||||||
const [modelLoaded, setModelLoaded] = useState(false)
|
|
||||||
const [loadingModel, setLoadingModel] = useState(false)
|
|
||||||
const [isThinkingMode, setIsThinkingMode] = useState(true)
|
|
||||||
const [showModelDropdown, setShowModelDropdown] = useState(false)
|
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
||||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// 流式渲染优化:使用 ref 缓存内容,使用 RAF 批量更新
|
|
||||||
const streamingContentRef = useRef('')
|
|
||||||
const streamingMessageIdRef = useRef<string | null>(null)
|
|
||||||
const rafIdRef = useRef<number | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkModelsStatus()
|
|
||||||
|
|
||||||
// 初始化Llama服务(延迟初始化,用户进入此页面时启动)
|
|
||||||
const initLlama = async () => {
|
|
||||||
try {
|
|
||||||
await window.electronAPI.llama?.init()
|
|
||||||
console.log('[AIChatPage] Llama service initialized')
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[AIChatPage] Failed to initialize Llama:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
initLlama()
|
|
||||||
|
|
||||||
// 清理函数:组件卸载时释放所有资源
|
|
||||||
return () => {
|
|
||||||
// 取消未完成的 RAF
|
|
||||||
if (rafIdRef.current !== null) {
|
|
||||||
cancelAnimationFrame(rafIdRef.current)
|
|
||||||
rafIdRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理 engine service 的回调引用
|
|
||||||
engineService.clearCallbacks()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 监听页面卸载事件,确保资源释放
|
|
||||||
useEffect(() => {
|
|
||||||
const handleBeforeUnload = () => {
|
|
||||||
// 清理回调和监听器
|
|
||||||
engineService.dispose()
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 点击外部关闭下拉框
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
||||||
setShowModelDropdown(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
|
||||||
// 使用 virtuoso 的 scrollToIndex 方法滚动到底部
|
|
||||||
if (virtuosoRef.current && messages.length > 0) {
|
|
||||||
virtuosoRef.current.scrollToIndex({
|
|
||||||
index: messages.length - 1,
|
|
||||||
behavior: 'smooth'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [messages.length])
|
|
||||||
|
|
||||||
const checkModelsStatus = async () => {
|
|
||||||
const updatedModels = await Promise.all(models.map(async (m) => {
|
|
||||||
const exists = await engineService.checkModelExists(m.path)
|
|
||||||
return { ...m, downloaded: exists }
|
|
||||||
}))
|
|
||||||
setModels(updatedModels)
|
|
||||||
|
|
||||||
// Auto-select first available model
|
|
||||||
if (!selectedModel) {
|
|
||||||
const available = updatedModels.find(m => m.downloaded)
|
|
||||||
if (available) {
|
|
||||||
setSelectedModel(available.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自动加载模型
|
|
||||||
const handleLoadModel = async (modelPath?: string) => {
|
|
||||||
const pathToLoad = modelPath || selectedModel
|
|
||||||
if (!pathToLoad) return false
|
|
||||||
|
|
||||||
setLoadingModel(true)
|
|
||||||
try {
|
|
||||||
await engineService.loadModel(pathToLoad)
|
|
||||||
// Initialize session with system prompt
|
|
||||||
await engineService.createSession("You are a helpful AI assistant.")
|
|
||||||
setModelLoaded(true)
|
|
||||||
return true
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Load failed", e)
|
|
||||||
alert("模型加载失败: " + String(e))
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
setLoadingModel(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选择模型(如果有多个)
|
|
||||||
const handleSelectModel = (modelPath: string) => {
|
|
||||||
setSelectedModel(modelPath)
|
|
||||||
setShowModelDropdown(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取可用的已下载模型
|
|
||||||
const availableModels = models.filter(m => m.downloaded)
|
|
||||||
const selectedModelInfo = models.find(m => m.path === selectedModel)
|
|
||||||
|
|
||||||
// 优化的流式更新函数:使用 RAF 批量更新
|
|
||||||
const updateStreamingMessage = useCallback(() => {
|
|
||||||
if (!streamingMessageIdRef.current) return
|
|
||||||
|
|
||||||
setMessages(prev => prev.map(msg =>
|
|
||||||
msg.id === streamingMessageIdRef.current
|
|
||||||
? { ...msg, content: streamingContentRef.current }
|
|
||||||
: msg
|
|
||||||
))
|
|
||||||
|
|
||||||
rafIdRef.current = null
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Token 回调:使用 RAF 批量更新 UI
|
|
||||||
const handleToken = useCallback((token: string) => {
|
|
||||||
streamingContentRef.current += token
|
|
||||||
|
|
||||||
// 使用 requestAnimationFrame 批量更新,避免频繁渲染
|
|
||||||
if (rafIdRef.current === null) {
|
|
||||||
rafIdRef.current = requestAnimationFrame(updateStreamingMessage)
|
|
||||||
}
|
|
||||||
}, [updateStreamingMessage])
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
if (!input.trim() || isTyping) return
|
|
||||||
|
|
||||||
// 如果模型未加载,先自动加载
|
|
||||||
if (!modelLoaded) {
|
|
||||||
if (!selectedModel) {
|
|
||||||
alert("请先下载模型(设置页面)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const loaded = await handleLoadModel()
|
|
||||||
if (!loaded) return
|
|
||||||
}
|
|
||||||
|
|
||||||
const userMsg: ChatMessage = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
role: 'user',
|
|
||||||
content: input,
|
|
||||||
timestamp: Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessages(prev => {
|
|
||||||
const newMessages = [...prev, userMsg]
|
|
||||||
// 限制消息数量,避免内存过载
|
|
||||||
return newMessages.length > MAX_MESSAGES
|
|
||||||
? newMessages.slice(-MAX_MESSAGES)
|
|
||||||
: newMessages
|
|
||||||
})
|
|
||||||
setInput('')
|
|
||||||
setIsTyping(true)
|
|
||||||
|
|
||||||
// Reset textarea height
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.style.height = 'auto'
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiMsgId = (Date.now() + 1).toString()
|
|
||||||
streamingContentRef.current = ''
|
|
||||||
streamingMessageIdRef.current = aiMsgId
|
|
||||||
|
|
||||||
// Optimistic update for AI message start
|
|
||||||
setMessages(prev => {
|
|
||||||
const newMessages = [...prev, {
|
|
||||||
id: aiMsgId,
|
|
||||||
role: 'ai' as const,
|
|
||||||
content: '',
|
|
||||||
timestamp: Date.now()
|
|
||||||
}]
|
|
||||||
return newMessages.length > MAX_MESSAGES
|
|
||||||
? newMessages.slice(-MAX_MESSAGES)
|
|
||||||
: newMessages
|
|
||||||
})
|
|
||||||
|
|
||||||
// Append thinking command based on mode
|
|
||||||
const msgWithSuffix = input + (isThinkingMode ? " /think" : " /no_think")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await engineService.chat(msgWithSuffix, handleToken, { thinking: isThinkingMode })
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Chat failed", e)
|
|
||||||
setMessages(prev => [...prev, {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
role: 'ai',
|
|
||||||
content: "❌ Error: Failed to get response from AI.",
|
|
||||||
timestamp: Date.now()
|
|
||||||
}])
|
|
||||||
} finally {
|
|
||||||
setIsTyping(false)
|
|
||||||
streamingMessageIdRef.current = null
|
|
||||||
|
|
||||||
// 确保最终状态同步
|
|
||||||
if (rafIdRef.current !== null) {
|
|
||||||
cancelAnimationFrame(rafIdRef.current)
|
|
||||||
updateStreamingMessage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染模型选择按钮(集成在输入框作为下拉项)
|
|
||||||
const renderModelSelector = () => {
|
|
||||||
// 没有可用模型
|
|
||||||
if (availableModels.length === 0) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="model-btn disabled"
|
|
||||||
title="请先在设置页面下载模型"
|
|
||||||
>
|
|
||||||
<Bot size={16} />
|
|
||||||
<span>无模型</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只有一个模型,直接显示
|
|
||||||
if (availableModels.length === 1) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`model-btn ${modelLoaded ? 'loaded' : ''} ${loadingModel ? 'loading' : ''}`}
|
|
||||||
title={modelLoaded ? "模型已就绪" : "发送消息时自动加载"}
|
|
||||||
>
|
|
||||||
{loadingModel ? (
|
|
||||||
<Loader2 size={16} className="spin" />
|
|
||||||
) : (
|
|
||||||
<Bot size={16} />
|
|
||||||
)}
|
|
||||||
<span>{loadingModel ? '加载中' : selectedModelInfo?.name || '模型'}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 多个模型,显示下拉选择
|
|
||||||
return (
|
|
||||||
<div className="model-selector" ref={dropdownRef}>
|
|
||||||
<button
|
|
||||||
className={`model-btn ${modelLoaded ? 'loaded' : ''} ${loadingModel ? 'loading' : ''}`}
|
|
||||||
onClick={() => !loadingModel && setShowModelDropdown(!showModelDropdown)}
|
|
||||||
title="点击选择模型"
|
|
||||||
>
|
|
||||||
{loadingModel ? (
|
|
||||||
<Loader2 size={16} className="spin" />
|
|
||||||
) : (
|
|
||||||
<Bot size={16} />
|
|
||||||
)}
|
|
||||||
<span>{loadingModel ? '加载中' : selectedModelInfo?.name || '选择模型'}</span>
|
|
||||||
<ChevronDown size={13} className={showModelDropdown ? 'rotate' : ''} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showModelDropdown && (
|
|
||||||
<div className="model-dropdown">
|
|
||||||
{availableModels.map(model => (
|
|
||||||
<div
|
|
||||||
key={model.path}
|
|
||||||
className={`model-option ${selectedModel === model.path ? 'active' : ''}`}
|
|
||||||
onClick={() => handleSelectModel(model.path)}
|
|
||||||
>
|
|
||||||
<span>{model.name}</span>
|
|
||||||
{selectedModel === model.path && (
|
|
||||||
<span className="check">✓</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ai-chat-page">
|
|
||||||
<div className="chat-main">
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<div className="icon">
|
|
||||||
<Bot size={40} />
|
|
||||||
</div>
|
|
||||||
<h2>AI 为你服务</h2>
|
|
||||||
<p>
|
|
||||||
{availableModels.length === 0
|
|
||||||
? "请先在设置页面下载模型"
|
|
||||||
: "输入消息开始对话,模型将自动加载"
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Virtuoso
|
|
||||||
ref={virtuosoRef}
|
|
||||||
data={messages}
|
|
||||||
className="messages-list"
|
|
||||||
initialTopMostItemIndex={messages.length - 1}
|
|
||||||
followOutput="smooth"
|
|
||||||
itemContent={(index, message) => (
|
|
||||||
<MessageBubble key={message.id} message={message} />
|
|
||||||
)}
|
|
||||||
components={{
|
|
||||||
Footer: () => <div className="list-spacer" />
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="input-area">
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={input}
|
|
||||||
onChange={e => {
|
|
||||||
setInput(e.target.value)
|
|
||||||
e.target.style.height = 'auto'
|
|
||||||
e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`
|
|
||||||
}}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSend()
|
|
||||||
// Reset height after send
|
|
||||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={availableModels.length === 0 ? "请先下载模型..." : "输入消息..."}
|
|
||||||
disabled={availableModels.length === 0 || loadingModel}
|
|
||||||
rows={1}
|
|
||||||
/>
|
|
||||||
<div className="input-actions">
|
|
||||||
{renderModelSelector()}
|
|
||||||
<button
|
|
||||||
className={`mode-toggle ${isThinkingMode ? 'active' : ''}`}
|
|
||||||
onClick={() => setIsThinkingMode(!isThinkingMode)}
|
|
||||||
title={isThinkingMode ? "深度思考模式已开启" : "深度思考模式已关闭"}
|
|
||||||
disabled={availableModels.length === 0}
|
|
||||||
>
|
|
||||||
<Cpu size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="send-btn"
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={!input.trim() || availableModels.length === 0 || isTyping || loadingModel}
|
|
||||||
>
|
|
||||||
<Send size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -45,6 +45,12 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
@@ -482,11 +488,41 @@
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.exclude-footer-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.exclude-count {
|
.exclude-count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.exclude-actions {
|
.exclude-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ function AnalyticsPage() {
|
|||||||
}, [loadExcludedUsernames])
|
}, [loadExcludedUsernames])
|
||||||
|
|
||||||
const handleRefresh = () => loadData(true)
|
const handleRefresh = () => loadData(true)
|
||||||
|
const isNoSessionError = error?.includes('未找到消息会话') ?? false
|
||||||
|
|
||||||
const loadExcludeCandidates = useCallback(async () => {
|
const loadExcludeCandidates = useCallback(async () => {
|
||||||
setExcludeLoading(true)
|
setExcludeLoading(true)
|
||||||
@@ -146,6 +147,17 @@ function AnalyticsPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleInvertSelection = () => {
|
||||||
|
setDraftExcluded((prev) => {
|
||||||
|
const allUsernames = new Set(excludeCandidates.map(c => normalizeUsername(c.username)))
|
||||||
|
const inverted = new Set<string>()
|
||||||
|
for (const u of allUsernames) {
|
||||||
|
if (!prev.has(u)) inverted.add(u)
|
||||||
|
}
|
||||||
|
return inverted
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleApplyExcluded = async () => {
|
const handleApplyExcluded = async () => {
|
||||||
const payload = Array.from(draftExcluded)
|
const payload = Array.from(draftExcluded)
|
||||||
setIsExcludeDialogOpen(false)
|
setIsExcludeDialogOpen(false)
|
||||||
@@ -164,6 +176,23 @@ function AnalyticsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleResetExcluded = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.analytics.setExcludedUsernames([])
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error || '重置排除好友失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExcludedUsernames(new Set())
|
||||||
|
setDraftExcluded(new Set())
|
||||||
|
clearCache()
|
||||||
|
await window.electronAPI.cache.clearAnalytics()
|
||||||
|
await loadData(true)
|
||||||
|
} catch (e) {
|
||||||
|
setError(`重置排除好友失败: ${String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const visibleExcludeCandidates = excludeCandidates
|
const visibleExcludeCandidates = excludeCandidates
|
||||||
.filter((candidate) => {
|
.filter((candidate) => {
|
||||||
const query = excludeQuery.trim().toLowerCase()
|
const query = excludeQuery.trim().toLowerCase()
|
||||||
@@ -344,6 +373,22 @@ function AnalyticsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) {
|
||||||
|
return (
|
||||||
|
<div className="error-container">
|
||||||
|
<p>{error}</p>
|
||||||
|
<div className="error-actions">
|
||||||
|
<button className="btn btn-secondary" onClick={handleResetExcluded}>
|
||||||
|
重置排除好友
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={() => loadData(true)}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (error && !isLoaded) {
|
if (error && !isLoaded) {
|
||||||
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}>重试</button></div>)
|
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}>重试</button></div>)
|
||||||
}
|
}
|
||||||
@@ -493,7 +538,12 @@ function AnalyticsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="exclude-modal-footer">
|
<div className="exclude-modal-footer">
|
||||||
|
<div className="exclude-footer-left">
|
||||||
<span className="exclude-count">已排除 {draftExcluded.size} 人</span>
|
<span className="exclude-count">已排除 {draftExcluded.size} 人</span>
|
||||||
|
<button className="btn btn-text" onClick={toggleInvertSelection} disabled={excludeLoading}>
|
||||||
|
反选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="exclude-actions">
|
<div className="exclude-actions">
|
||||||
<button className="btn btn-secondary" onClick={() => setIsExcludeDialogOpen(false)}>
|
<button className="btn btn-secondary" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||||
取消
|
取消
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
// 左侧联系人面板
|
// 左侧联系人面板
|
||||||
.contacts-panel {
|
.contacts-panel {
|
||||||
width: 380px;
|
width: 350px;
|
||||||
min-width: 380px;
|
min-width: 350px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
@@ -55,6 +55,11 @@
|
|||||||
.spin {
|
.spin {
|
||||||
animation: contactsSpin 1s linear infinite;
|
animation: contactsSpin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.export-mode-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,11 +115,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.type-filters {
|
.type-filters {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 20px 16px;
|
padding: 0 20px 16px;
|
||||||
flex-wrap: nowrap;
|
max-width: 300px;
|
||||||
overflow-x: auto;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -174,6 +179,24 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selection-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 20px 12px;
|
||||||
|
|
||||||
|
.checkbox-item {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.loading-state,
|
.loading-state,
|
||||||
.empty-state {
|
.empty-state {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -213,12 +236,35 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.contact-avatar {
|
.contact-avatar {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
@@ -297,6 +343,94 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 右侧详情面板内的联系人资料
|
||||||
|
.detail-profile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.detail-avatar {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
span { color: #fff; font-size: 28px; font-weight: 600; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-info-list {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
min-width: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.goto-chat-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover { background: var(--primary-hover); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-detail {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
// 右侧设置面板
|
// 右侧设置面板
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown } from 'lucide-react'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react'
|
||||||
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import './ContactsPage.scss'
|
import './ContactsPage.scss'
|
||||||
|
|
||||||
interface ContactInfo {
|
interface ContactInfo {
|
||||||
@@ -8,20 +10,28 @@ interface ContactInfo {
|
|||||||
remark?: string
|
remark?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
type: 'friend' | 'group' | 'official' | 'other'
|
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContactsPage() {
|
function ContactsPage() {
|
||||||
const [contacts, setContacts] = useState<ContactInfo[]>([])
|
const [contacts, setContacts] = useState<ContactInfo[]>([])
|
||||||
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
|
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
|
||||||
|
const [selectedUsernames, setSelectedUsernames] = useState<Set<string>>(new Set())
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
const [contactTypes, setContactTypes] = useState({
|
const [contactTypes, setContactTypes] = useState({
|
||||||
friends: true,
|
friends: true,
|
||||||
groups: true,
|
groups: false,
|
||||||
officials: true
|
officials: false,
|
||||||
|
deletedFriends: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 导出模式与查看详情
|
||||||
|
const [exportMode, setExportMode] = useState(false)
|
||||||
|
const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { setCurrentSession } = useChatStore()
|
||||||
|
|
||||||
// 导出相关状态
|
// 导出相关状态
|
||||||
const [exportFormat, setExportFormat] = useState<'json' | 'csv' | 'vcf'>('json')
|
const [exportFormat, setExportFormat] = useState<'json' | 'csv' | 'vcf'>('json')
|
||||||
const [exportAvatars, setExportAvatars] = useState(true)
|
const [exportAvatars, setExportAvatars] = useState(true)
|
||||||
@@ -62,6 +72,7 @@ function ContactsPage() {
|
|||||||
|
|
||||||
setContacts(contactsResult.contacts)
|
setContacts(contactsResult.contacts)
|
||||||
setFilteredContacts(contactsResult.contacts)
|
setFilteredContacts(contactsResult.contacts)
|
||||||
|
setSelectedUsernames(new Set())
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载通讯录失败:', e)
|
console.error('加载通讯录失败:', e)
|
||||||
@@ -83,6 +94,7 @@ function ContactsPage() {
|
|||||||
if (c.type === 'friend' && !contactTypes.friends) return false
|
if (c.type === 'friend' && !contactTypes.friends) return false
|
||||||
if (c.type === 'group' && !contactTypes.groups) return false
|
if (c.type === 'group' && !contactTypes.groups) return false
|
||||||
if (c.type === 'official' && !contactTypes.officials) return false
|
if (c.type === 'official' && !contactTypes.officials) return false
|
||||||
|
if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -111,6 +123,37 @@ function ContactsPage() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [showFormatSelect])
|
}, [showFormatSelect])
|
||||||
|
|
||||||
|
const selectedInFilteredCount = filteredContacts.reduce((count, contact) => {
|
||||||
|
return selectedUsernames.has(contact.username) ? count + 1 : count
|
||||||
|
}, 0)
|
||||||
|
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length
|
||||||
|
|
||||||
|
const toggleContactSelected = (username: string, checked: boolean) => {
|
||||||
|
setSelectedUsernames(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (checked) {
|
||||||
|
next.add(username)
|
||||||
|
} else {
|
||||||
|
next.delete(username)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAllFilteredSelected = (checked: boolean) => {
|
||||||
|
setSelectedUsernames(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
filteredContacts.forEach(contact => {
|
||||||
|
if (checked) {
|
||||||
|
next.add(contact.username)
|
||||||
|
} else {
|
||||||
|
next.delete(contact.username)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const getAvatarLetter = (name: string) => {
|
const getAvatarLetter = (name: string) => {
|
||||||
if (!name) return '?'
|
if (!name) return '?'
|
||||||
return [...name][0] || '?'
|
return [...name][0] || '?'
|
||||||
@@ -121,6 +164,7 @@ function ContactsPage() {
|
|||||||
case 'friend': return <User size={14} />
|
case 'friend': return <User size={14} />
|
||||||
case 'group': return <Users size={14} />
|
case 'group': return <Users size={14} />
|
||||||
case 'official': return <MessageSquare size={14} />
|
case 'official': return <MessageSquare size={14} />
|
||||||
|
case 'former_friend': return <UserX size={14} />
|
||||||
default: return <User size={14} />
|
default: return <User size={14} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,6 +174,7 @@ function ContactsPage() {
|
|||||||
case 'friend': return '好友'
|
case 'friend': return '好友'
|
||||||
case 'group': return '群聊'
|
case 'group': return '群聊'
|
||||||
case 'official': return '公众号'
|
case 'official': return '公众号'
|
||||||
|
case 'former_friend': return '曾经的好友'
|
||||||
default: return '其他'
|
default: return '其他'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +199,10 @@ function ContactsPage() {
|
|||||||
alert('请先选择导出位置')
|
alert('请先选择导出位置')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (selectedUsernames.size === 0) {
|
||||||
|
alert('请至少选择一个联系人')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsExporting(true)
|
setIsExporting(true)
|
||||||
try {
|
try {
|
||||||
@@ -164,7 +213,8 @@ function ContactsPage() {
|
|||||||
friends: contactTypes.friends,
|
friends: contactTypes.friends,
|
||||||
groups: contactTypes.groups,
|
groups: contactTypes.groups,
|
||||||
officials: contactTypes.officials
|
officials: contactTypes.officials
|
||||||
}
|
},
|
||||||
|
selectedUsernames: Array.from(selectedUsernames)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await window.electronAPI.export.exportContacts(exportFolder, exportOptions)
|
const result = await window.electronAPI.export.exportContacts(exportFolder, exportOptions)
|
||||||
@@ -198,10 +248,19 @@ function ContactsPage() {
|
|||||||
<div className="contacts-panel">
|
<div className="contacts-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>通讯录</h2>
|
<h2>通讯录</h2>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
className={`icon-btn export-mode-btn ${exportMode ? 'active' : ''}`}
|
||||||
|
onClick={() => { setExportMode(!exportMode); setSelectedContact(null) }}
|
||||||
|
title={exportMode ? '退出导出模式' : '进入导出模式'}
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
</button>
|
||||||
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
|
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
|
||||||
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
|
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="search-bar">
|
<div className="search-bar">
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
@@ -220,31 +279,20 @@ function ContactsPage() {
|
|||||||
|
|
||||||
<div className="type-filters">
|
<div className="type-filters">
|
||||||
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
|
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
|
||||||
<input
|
<input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
|
||||||
type="checkbox"
|
<User size={16} /><span>好友</span>
|
||||||
checked={contactTypes.friends}
|
|
||||||
onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<User size={16} />
|
|
||||||
<span>好友</span>
|
|
||||||
</label>
|
</label>
|
||||||
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
|
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
|
||||||
<input
|
<input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
|
||||||
type="checkbox"
|
<Users size={16} /><span>群聊</span>
|
||||||
checked={contactTypes.groups}
|
|
||||||
onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<Users size={16} />
|
|
||||||
<span>群聊</span>
|
|
||||||
</label>
|
</label>
|
||||||
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
|
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
|
||||||
<input
|
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
|
||||||
type="checkbox"
|
<MessageSquare size={16} /><span>公众号</span>
|
||||||
checked={contactTypes.officials}
|
</label>
|
||||||
onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })}
|
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
|
||||||
/>
|
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
|
||||||
<MessageSquare size={16} />
|
<UserX size={16} /><span>曾经的好友</span>
|
||||||
<span>公众号</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -252,6 +300,21 @@ function ContactsPage() {
|
|||||||
共 {filteredContacts.length} 个联系人
|
共 {filteredContacts.length} 个联系人
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{exportMode && (
|
||||||
|
<div className="selection-toolbar">
|
||||||
|
<label className="checkbox-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allFilteredSelected}
|
||||||
|
onChange={e => toggleAllFilteredSelected(e.target.checked)}
|
||||||
|
disabled={filteredContacts.length === 0}
|
||||||
|
/>
|
||||||
|
<span>全选当前筛选结果</span>
|
||||||
|
</label>
|
||||||
|
<span className="selection-count">已选 {selectedUsernames.size}(当前筛选 {selectedInFilteredCount} / {filteredContacts.length})</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="loading-state">
|
<div className="loading-state">
|
||||||
<Loader2 size={32} className="spin" />
|
<Loader2 size={32} className="spin" />
|
||||||
@@ -263,8 +326,30 @@ function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="contacts-list">
|
<div className="contacts-list">
|
||||||
{filteredContacts.map(contact => (
|
{filteredContacts.map(contact => {
|
||||||
<div key={contact.username} className="contact-item">
|
const isChecked = selectedUsernames.has(contact.username)
|
||||||
|
const isActive = !exportMode && selectedContact?.username === contact.username
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={contact.username}
|
||||||
|
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (exportMode) {
|
||||||
|
toggleContactSelected(contact.username, !isChecked)
|
||||||
|
} else {
|
||||||
|
setSelectedContact(isActive ? null : contact)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{exportMode && (
|
||||||
|
<label className="contact-select" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<div className="contact-avatar">
|
<div className="contact-avatar">
|
||||||
{contact.avatarUrl ? (
|
{contact.avatarUrl ? (
|
||||||
<img src={contact.avatarUrl} alt="" />
|
<img src={contact.avatarUrl} alt="" />
|
||||||
@@ -283,12 +368,14 @@ function ContactsPage() {
|
|||||||
<span>{getContactTypeName(contact.type)}</span>
|
<span>{getContactTypeName(contact.type)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧:导出设置 */}
|
{/* 右侧面板 */}
|
||||||
|
{exportMode ? (
|
||||||
<div className="settings-panel">
|
<div className="settings-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>导出设置</h2>
|
<h2>导出设置</h2>
|
||||||
@@ -330,11 +417,7 @@ function ContactsPage() {
|
|||||||
<div className="setting-section">
|
<div className="setting-section">
|
||||||
<h3>导出选项</h3>
|
<h3>导出选项</h3>
|
||||||
<label className="checkbox-item">
|
<label className="checkbox-item">
|
||||||
<input
|
<input type="checkbox" checked={exportAvatars} onChange={e => setExportAvatars(e.target.checked)} />
|
||||||
type="checkbox"
|
|
||||||
checked={exportAvatars}
|
|
||||||
onChange={e => setExportAvatars(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>导出头像</span>
|
<span>导出头像</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,22 +439,64 @@ function ContactsPage() {
|
|||||||
<button
|
<button
|
||||||
className="export-btn"
|
className="export-btn"
|
||||||
onClick={startExport}
|
onClick={startExport}
|
||||||
disabled={!exportFolder || isExporting}
|
disabled={!exportFolder || isExporting || selectedUsernames.size === 0}
|
||||||
>
|
>
|
||||||
{isExporting ? (
|
{isExporting ? (
|
||||||
<>
|
<><Loader2 size={18} className="spin" /><span>导出中...</span></>
|
||||||
<Loader2 size={18} className="spin" />
|
|
||||||
<span>导出中...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<><Download size={18} /><span>开始导出</span></>
|
||||||
<Download size={18} />
|
|
||||||
<span>开始导出</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : selectedContact ? (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>联系人详情</h2>
|
||||||
|
</div>
|
||||||
|
<div className="settings-content">
|
||||||
|
<div className="detail-profile">
|
||||||
|
<div className="detail-avatar">
|
||||||
|
{selectedContact.avatarUrl ? (
|
||||||
|
<img src={selectedContact.avatarUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<span>{getAvatarLetter(selectedContact.displayName)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="detail-name">{selectedContact.displayName}</div>
|
||||||
|
<div className={`contact-type ${selectedContact.type}`}>
|
||||||
|
{getContactTypeIcon(selectedContact.type)}
|
||||||
|
<span>{getContactTypeName(selectedContact.type)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-info-list">
|
||||||
|
<div className="detail-row"><span className="detail-label">用户名</span><span className="detail-value">{selectedContact.username}</span></div>
|
||||||
|
<div className="detail-row"><span className="detail-label">昵称</span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
|
||||||
|
{selectedContact.remark && <div className="detail-row"><span className="detail-label">备注</span><span className="detail-value">{selectedContact.remark}</span></div>}
|
||||||
|
<div className="detail-row"><span className="detail-label">类型</span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="goto-chat-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentSession(selectedContact.username)
|
||||||
|
navigate('/chat')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageCircle size={18} />
|
||||||
|
<span>查看聊天记录</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<div className="empty-detail">
|
||||||
|
<User size={48} />
|
||||||
|
<span>点击左侧联系人查看详情</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -955,6 +955,18 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1015,6 +1027,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.year-month-picker {
|
||||||
|
padding: 4px 0;
|
||||||
|
|
||||||
|
.year-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.year-label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.month-btn {
|
||||||
|
padding: 10px 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.date-picker-actions {
|
.date-picker-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ function ExportPage() {
|
|||||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||||
const [calendarDate, setCalendarDate] = useState(new Date())
|
const [calendarDate, setCalendarDate] = useState(new Date())
|
||||||
const [selectingStart, setSelectingStart] = useState(true)
|
const [selectingStart, setSelectingStart] = useState(true)
|
||||||
|
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||||
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
|
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
|
||||||
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
|
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
|
||||||
const [showPreExportDialog, setShowPreExportDialog] = useState(false)
|
const [showPreExportDialog, setShowPreExportDialog] = useState(false)
|
||||||
@@ -66,6 +67,7 @@ function ExportPage() {
|
|||||||
const [elapsedSeconds, setElapsedSeconds] = useState(0)
|
const [elapsedSeconds, setElapsedSeconds] = useState(0)
|
||||||
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
|
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const preselectAppliedRef = useRef(false)
|
const preselectAppliedRef = useRef(false)
|
||||||
|
const statsRequestIdRef = useRef(0)
|
||||||
|
|
||||||
const preselectSessionIds = useMemo(() => {
|
const preselectSessionIds = useMemo(() => {
|
||||||
const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null
|
const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null
|
||||||
@@ -382,7 +384,9 @@ function ExportPage() {
|
|||||||
if (selectedSessions.size === 0 || !exportFolder) return
|
if (selectedSessions.size === 0 || !exportFolder) return
|
||||||
|
|
||||||
// 先获取预估统计
|
// 先获取预估统计
|
||||||
|
const requestId = ++statsRequestIdRef.current
|
||||||
setIsLoadingStats(true)
|
setIsLoadingStats(true)
|
||||||
|
setPreExportStats(null)
|
||||||
setShowPreExportDialog(true)
|
setShowPreExportDialog(true)
|
||||||
try {
|
try {
|
||||||
const sessionList = Array.from(selectedSessions)
|
const sessionList = Array.from(selectedSessions)
|
||||||
@@ -400,16 +404,21 @@ function ExportPage() {
|
|||||||
} : null
|
} : null
|
||||||
}
|
}
|
||||||
const stats = await window.electronAPI.export.getExportStats(sessionList, exportOptions)
|
const stats = await window.electronAPI.export.getExportStats(sessionList, exportOptions)
|
||||||
|
if (statsRequestIdRef.current !== requestId) return
|
||||||
setPreExportStats(stats)
|
setPreExportStats(stats)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取导出统计失败:', e)
|
console.error('获取导出统计失败:', e)
|
||||||
|
if (statsRequestIdRef.current !== requestId) return
|
||||||
setPreExportStats(null)
|
setPreExportStats(null)
|
||||||
} finally {
|
} finally {
|
||||||
|
if (statsRequestIdRef.current !== requestId) return
|
||||||
setIsLoadingStats(false)
|
setIsLoadingStats(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmExport = () => {
|
const confirmExport = () => {
|
||||||
|
statsRequestIdRef.current++
|
||||||
|
setIsLoadingStats(false)
|
||||||
setShowPreExportDialog(false)
|
setShowPreExportDialog(false)
|
||||||
setPreExportStats(null)
|
setPreExportStats(null)
|
||||||
|
|
||||||
@@ -911,7 +920,7 @@ function ExportPage() {
|
|||||||
{isLoadingStats ? (
|
{isLoadingStats ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '24px 0', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '24px 0', justifyContent: 'center' }}>
|
||||||
<Loader2 size={20} className="spin" />
|
<Loader2 size={20} className="spin" />
|
||||||
<span style={{ fontSize: 14, color: 'var(--text-secondary)' }}>正在统计消息...</span>
|
<span style={{ fontSize: 14, color: 'var(--text-secondary)' }}>正在统计消息,可直接点击“直接导出”跳过等待</span>
|
||||||
</div>
|
</div>
|
||||||
) : preExportStats ? (
|
) : preExportStats ? (
|
||||||
<div style={{ padding: '12px 0' }}>
|
<div style={{ padding: '12px 0' }}>
|
||||||
@@ -957,11 +966,11 @@ function ExportPage() {
|
|||||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', padding: '16px 0' }}>统计信息获取失败,仍可继续导出</p>
|
<p style={{ fontSize: 14, color: 'var(--text-secondary)', padding: '16px 0' }}>统计信息获取失败,仍可继续导出</p>
|
||||||
)}
|
)}
|
||||||
<div className="layout-actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
|
<div className="layout-actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
|
||||||
<button className="layout-cancel-btn" onClick={() => { setShowPreExportDialog(false); setPreExportStats(null) }}>
|
<button className="layout-cancel-btn" onClick={() => { statsRequestIdRef.current++; setIsLoadingStats(false); setShowPreExportDialog(false); setPreExportStats(null) }}>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button className="layout-option-btn primary" onClick={confirmExport} disabled={isLoadingStats}>
|
<button className="layout-option-btn primary" onClick={confirmExport}>
|
||||||
<span className="layout-title">开始导出</span>
|
<span className="layout-title">{isLoadingStats ? '直接导出' : '开始导出'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1039,7 +1048,7 @@ function ExportPage() {
|
|||||||
|
|
||||||
{/* 日期选择弹窗 */}
|
{/* 日期选择弹窗 */}
|
||||||
{showDatePicker && (
|
{showDatePicker && (
|
||||||
<div className="export-overlay" onClick={() => setShowDatePicker(false)}>
|
<div className="export-overlay" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(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 style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: '8px 0 16px 0' }}>
|
||||||
@@ -1114,7 +1123,7 @@ function ExportPage() {
|
|||||||
>
|
>
|
||||||
<ChevronLeft size={18} />
|
<ChevronLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
<span className="calendar-month">
|
<span className="calendar-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -1124,6 +1133,32 @@ function ExportPage() {
|
|||||||
<ChevronRight size={18} />
|
<ChevronRight size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{showYearMonthPicker ? (
|
||||||
|
<div className="year-month-picker">
|
||||||
|
<div className="year-selector">
|
||||||
|
<button className="calendar-nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="year-label">{calendarDate.getFullYear()}年</span>
|
||||||
|
<button className="calendar-nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="month-grid">
|
||||||
|
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||||
|
setShowYearMonthPicker(false)
|
||||||
|
}}
|
||||||
|
>{name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="calendar-weekdays">
|
<div className="calendar-weekdays">
|
||||||
{['日', '一', '二', '三', '四', '五', '六'].map(day => (
|
{['日', '一', '二', '三', '四', '五', '六'].map(day => (
|
||||||
<div key={day} className="calendar-weekday">{day}</div>
|
<div key={day} className="calendar-weekday">{day}</div>
|
||||||
@@ -1155,12 +1190,14 @@ function ExportPage() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="date-picker-actions">
|
<div className="date-picker-actions">
|
||||||
<button className="cancel-btn" onClick={() => setShowDatePicker(false)}>
|
<button className="cancel-btn" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button className="confirm-btn" onClick={() => setShowDatePicker(false)}>
|
<button className="confirm-btn" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
|
||||||
确定
|
确定
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -777,6 +777,344 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-export-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
.member-export-empty {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
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: 30;
|
||||||
|
max-height: 280px;
|
||||||
|
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: 13px;
|
||||||
|
|
||||||
|
&: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-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-select-trigger-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-select-dropdown {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-select-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-select-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-select-empty {
|
||||||
|
padding: 10px 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-select-option {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
|
||||||
|
.member-option-main {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-option-meta {
|
||||||
|
grid-column: 2 / 3;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
.member-option-main,
|
||||||
|
.member-option-meta {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-folder {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-folder-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-options {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-chip-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-group-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-chip-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-filter-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled,
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-start-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.rankings-list {
|
.rankings-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react'
|
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown } 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'
|
||||||
import DateRangePicker from '../components/DateRangePicker'
|
import DateRangePicker from '../components/DateRangePicker'
|
||||||
|
import * as configService from '../services/config'
|
||||||
import './GroupAnalyticsPage.scss'
|
import './GroupAnalyticsPage.scss'
|
||||||
|
|
||||||
interface GroupChatInfo {
|
interface GroupChatInfo {
|
||||||
@@ -28,7 +29,26 @@ interface GroupMessageRank {
|
|||||||
messageCount: number
|
messageCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalysisFunction = 'members' | 'ranking' | 'activeHours' | 'mediaStats'
|
type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats'
|
||||||
|
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone'
|
||||||
|
|
||||||
|
interface MemberMessageExportOptions {
|
||||||
|
format: MemberExportFormat
|
||||||
|
exportAvatars: boolean
|
||||||
|
exportMedia: boolean
|
||||||
|
exportImages: boolean
|
||||||
|
exportVoices: boolean
|
||||||
|
exportVideos: boolean
|
||||||
|
exportEmojis: boolean
|
||||||
|
exportVoiceAsText: boolean
|
||||||
|
displayNamePreference: 'group-nickname' | 'remark' | 'nickname'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberExportFormatOption {
|
||||||
|
value: MemberExportFormat
|
||||||
|
label: string
|
||||||
|
desc: string
|
||||||
|
}
|
||||||
|
|
||||||
function GroupAnalyticsPage() {
|
function GroupAnalyticsPage() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -46,10 +66,31 @@ function GroupAnalyticsPage() {
|
|||||||
const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null)
|
const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null)
|
||||||
const [functionLoading, setFunctionLoading] = useState(false)
|
const [functionLoading, setFunctionLoading] = useState(false)
|
||||||
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
||||||
|
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
|
||||||
|
const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('')
|
||||||
|
const [exportFolder, setExportFolder] = useState('')
|
||||||
|
const [memberExportOptions, setMemberExportOptions] = useState<MemberMessageExportOptions>({
|
||||||
|
format: 'excel',
|
||||||
|
exportAvatars: true,
|
||||||
|
exportMedia: false,
|
||||||
|
exportImages: true,
|
||||||
|
exportVoices: true,
|
||||||
|
exportVideos: true,
|
||||||
|
exportEmojis: true,
|
||||||
|
exportVoiceAsText: false,
|
||||||
|
displayNamePreference: 'remark'
|
||||||
|
})
|
||||||
|
|
||||||
// 成员详情弹框
|
// 成员详情弹框
|
||||||
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
|
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
|
||||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||||
|
const [showMemberSelect, setShowMemberSelect] = useState(false)
|
||||||
|
const [showFormatSelect, setShowFormatSelect] = useState(false)
|
||||||
|
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
|
||||||
|
const [memberSearchKeyword, setMemberSearchKeyword] = useState('')
|
||||||
|
const memberSelectDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const formatDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// 时间范围
|
// 时间范围
|
||||||
const [startDate, setStartDate] = useState<string>('')
|
const [startDate, setStartDate] = useState<string>('')
|
||||||
@@ -74,9 +115,84 @@ function GroupAnalyticsPage() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}, [location.state])
|
}, [location.state])
|
||||||
|
|
||||||
|
const memberExportFormatOptions = useMemo<MemberExportFormatOption[]>(() => ([
|
||||||
|
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
||||||
|
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
||||||
|
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
||||||
|
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
||||||
|
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
||||||
|
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
||||||
|
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }
|
||||||
|
]), [])
|
||||||
|
const displayNameOptions = useMemo<Array<{
|
||||||
|
value: MemberMessageExportOptions['displayNamePreference']
|
||||||
|
label: string
|
||||||
|
desc: string
|
||||||
|
}>>(() => ([
|
||||||
|
{ value: 'group-nickname', label: '群昵称优先', desc: '仅群聊有效,私聊显示备注/昵称' },
|
||||||
|
{ value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' },
|
||||||
|
{ value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' }
|
||||||
|
]), [])
|
||||||
|
const selectedExportMember = useMemo(
|
||||||
|
() => members.find(member => member.username === selectedExportMemberUsername) || null,
|
||||||
|
[members, selectedExportMemberUsername]
|
||||||
|
)
|
||||||
|
const selectedFormatOption = useMemo(
|
||||||
|
() => memberExportFormatOptions.find(option => option.value === memberExportOptions.format) || memberExportFormatOptions[0],
|
||||||
|
[memberExportFormatOptions, memberExportOptions.format]
|
||||||
|
)
|
||||||
|
const selectedDisplayNameOption = useMemo(
|
||||||
|
() => displayNameOptions.find(option => option.value === memberExportOptions.displayNamePreference) || displayNameOptions[0],
|
||||||
|
[displayNameOptions, memberExportOptions.displayNamePreference]
|
||||||
|
)
|
||||||
|
const filteredMemberOptions = useMemo(() => {
|
||||||
|
const keyword = memberSearchKeyword.trim().toLowerCase()
|
||||||
|
if (!keyword) return members
|
||||||
|
return members.filter(member => {
|
||||||
|
const fields = [
|
||||||
|
member.username,
|
||||||
|
member.displayName,
|
||||||
|
member.nickname,
|
||||||
|
member.remark,
|
||||||
|
member.alias
|
||||||
|
]
|
||||||
|
return fields.some(field => String(field || '').toLowerCase().includes(keyword))
|
||||||
|
})
|
||||||
|
}, [memberSearchKeyword, members])
|
||||||
|
|
||||||
|
const loadExportPath = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const savedPath = await configService.getExportPath()
|
||||||
|
if (savedPath) {
|
||||||
|
setExportFolder(savedPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
|
||||||
|
setExportFolder(downloadsPath)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载导出路径失败:', e)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadGroups = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.groupAnalytics.getGroupChats()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setGroups(result.data)
|
||||||
|
setFilteredGroups(result.data)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadGroups()
|
loadGroups()
|
||||||
}, [])
|
loadExportPath()
|
||||||
|
}, [loadGroups, loadExportPath])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
preselectAppliedRef.current = false
|
preselectAppliedRef.current = false
|
||||||
@@ -90,6 +206,34 @@ function GroupAnalyticsPage() {
|
|||||||
}
|
}
|
||||||
}, [searchQuery, groups])
|
}, [searchQuery, groups])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (members.length === 0) {
|
||||||
|
setSelectedExportMemberUsername('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const exists = members.some(member => member.username === selectedExportMemberUsername)
|
||||||
|
if (!exists) {
|
||||||
|
setSelectedExportMemberUsername(members[0].username)
|
||||||
|
}
|
||||||
|
}, [members, selectedExportMemberUsername])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) {
|
||||||
|
setShowMemberSelect(false)
|
||||||
|
}
|
||||||
|
if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) {
|
||||||
|
setShowFormatSelect(false)
|
||||||
|
}
|
||||||
|
if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) {
|
||||||
|
setShowDisplayNameSelect(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [showDisplayNameSelect, showFormatSelect, showMemberSelect])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preselectAppliedRef.current) return
|
if (preselectAppliedRef.current) return
|
||||||
if (groups.length === 0 || preselectGroupIds.length === 0) return
|
if (groups.length === 0 || preselectGroupIds.length === 0) return
|
||||||
@@ -125,27 +269,12 @@ function GroupAnalyticsPage() {
|
|||||||
|
|
||||||
// 日期范围变化时自动刷新
|
// 日期范围变化时自动刷新
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') {
|
if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members' && selectedFunction !== 'memberExport') {
|
||||||
setDateRangeReady(false)
|
setDateRangeReady(false)
|
||||||
loadFunctionData(selectedFunction)
|
loadFunctionData(selectedFunction)
|
||||||
}
|
}
|
||||||
}, [dateRangeReady])
|
}, [dateRangeReady])
|
||||||
|
|
||||||
const loadGroups = useCallback(async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupChats()
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setGroups(result.data)
|
|
||||||
setFilteredGroups(result.data)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
setGroups([])
|
setGroups([])
|
||||||
@@ -157,15 +286,21 @@ function GroupAnalyticsPage() {
|
|||||||
setActiveHours({})
|
setActiveHours({})
|
||||||
setMediaStats(null)
|
setMediaStats(null)
|
||||||
void loadGroups()
|
void loadGroups()
|
||||||
|
void loadExportPath()
|
||||||
}
|
}
|
||||||
window.addEventListener('wxid-changed', handleChange as EventListener)
|
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||||
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||||
}, [loadGroups])
|
}, [loadExportPath, loadGroups])
|
||||||
|
|
||||||
const handleGroupSelect = (group: GroupChatInfo) => {
|
const handleGroupSelect = (group: GroupChatInfo) => {
|
||||||
if (selectedGroup?.username !== group.username) {
|
if (selectedGroup?.username !== group.username) {
|
||||||
setSelectedGroup(group)
|
setSelectedGroup(group)
|
||||||
setSelectedFunction(null)
|
setSelectedFunction(null)
|
||||||
|
setSelectedExportMemberUsername('')
|
||||||
|
setMemberSearchKeyword('')
|
||||||
|
setShowMemberSelect(false)
|
||||||
|
setShowFormatSelect(false)
|
||||||
|
setShowDisplayNameSelect(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +326,11 @@ function GroupAnalyticsPage() {
|
|||||||
if (result.success && result.data) setMembers(result.data)
|
if (result.success && result.data) setMembers(result.data)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'memberExport': {
|
||||||
|
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
|
||||||
|
if (result.success && result.data) setMembers(result.data)
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'ranking': {
|
case 'ranking': {
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
|
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
|
||||||
if (result.success && result.data) setRankings(result.data)
|
if (result.success && result.data) setRankings(result.data)
|
||||||
@@ -286,6 +426,7 @@ function GroupAnalyticsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDateRangeComplete = () => {
|
const handleDateRangeComplete = () => {
|
||||||
|
if (selectedFunction === 'memberExport') return
|
||||||
setDateRangeReady(true)
|
setDateRangeReady(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +464,86 @@ function GroupAnalyticsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMemberExportFormatChange = (format: MemberExportFormat) => {
|
||||||
|
setMemberExportOptions(prev => {
|
||||||
|
const next = { ...prev, format }
|
||||||
|
if (format === 'html') {
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
exportMedia: true,
|
||||||
|
exportImages: true,
|
||||||
|
exportVoices: true,
|
||||||
|
exportVideos: true,
|
||||||
|
exportEmojis: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChooseExportFolder = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.dialog.openDirectory({
|
||||||
|
title: '选择导出目录'
|
||||||
|
})
|
||||||
|
if (!result.canceled && result.filePaths.length > 0) {
|
||||||
|
setExportFolder(result.filePaths[0])
|
||||||
|
await configService.setExportPath(result.filePaths[0])
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('选择导出目录失败:', e)
|
||||||
|
alert(`选择导出目录失败:${String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportMemberMessages = async () => {
|
||||||
|
if (!selectedGroup || !selectedExportMemberUsername || !exportFolder || isExportingMemberMessages) return
|
||||||
|
const member = members.find(item => item.username === selectedExportMemberUsername)
|
||||||
|
if (!member) {
|
||||||
|
alert('请先选择成员')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsExportingMemberMessages(true)
|
||||||
|
try {
|
||||||
|
const hasDateRange = Boolean(startDate && endDate)
|
||||||
|
const result = await window.electronAPI.export.exportSessions(
|
||||||
|
[selectedGroup.username],
|
||||||
|
exportFolder,
|
||||||
|
{
|
||||||
|
format: memberExportOptions.format,
|
||||||
|
dateRange: hasDateRange
|
||||||
|
? {
|
||||||
|
start: Math.floor(new Date(startDate).getTime() / 1000),
|
||||||
|
end: Math.floor(new Date(`${endDate}T23:59:59`).getTime() / 1000)
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
exportAvatars: memberExportOptions.exportAvatars,
|
||||||
|
exportMedia: memberExportOptions.exportMedia,
|
||||||
|
exportImages: memberExportOptions.exportMedia && memberExportOptions.exportImages,
|
||||||
|
exportVoices: memberExportOptions.exportMedia && memberExportOptions.exportVoices,
|
||||||
|
exportVideos: memberExportOptions.exportMedia && memberExportOptions.exportVideos,
|
||||||
|
exportEmojis: memberExportOptions.exportMedia && memberExportOptions.exportEmojis,
|
||||||
|
exportVoiceAsText: memberExportOptions.exportVoiceAsText,
|
||||||
|
sessionLayout: memberExportOptions.exportMedia ? 'per-session' : 'shared',
|
||||||
|
displayNamePreference: memberExportOptions.displayNamePreference,
|
||||||
|
senderUsername: member.username,
|
||||||
|
fileNameSuffix: sanitizeFileName(member.displayName || member.username)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (result.success && (result.successCount ?? 0) > 0) {
|
||||||
|
alert(`导出成功:${member.displayName || member.username}`)
|
||||||
|
} else {
|
||||||
|
alert(`导出失败:${result.error || '未知错误'}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('导出成员消息失败:', e)
|
||||||
|
alert(`导出失败:${String(e)}`)
|
||||||
|
} finally {
|
||||||
|
setIsExportingMemberMessages(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCopy = async (text: string, field: string) => {
|
const handleCopy = async (text: string, field: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
@@ -479,6 +700,10 @@ function GroupAnalyticsPage() {
|
|||||||
<Users size={32} />
|
<Users size={32} />
|
||||||
<span>群成员查看</span>
|
<span>群成员查看</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="function-card" onClick={() => handleFunctionSelect('memberExport')}>
|
||||||
|
<Download size={32} />
|
||||||
|
<span>成员消息导出</span>
|
||||||
|
</div>
|
||||||
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
|
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
|
||||||
<BarChart3 size={32} />
|
<BarChart3 size={32} />
|
||||||
<span>群聊发言排行</span>
|
<span>群聊发言排行</span>
|
||||||
@@ -499,6 +724,7 @@ function GroupAnalyticsPage() {
|
|||||||
const getFunctionTitle = () => {
|
const getFunctionTitle = () => {
|
||||||
switch (selectedFunction) {
|
switch (selectedFunction) {
|
||||||
case 'members': return '群成员查看'
|
case 'members': return '群成员查看'
|
||||||
|
case 'memberExport': return '成员消息导出'
|
||||||
case 'ranking': return '群聊发言排行'
|
case 'ranking': return '群聊发言排行'
|
||||||
case 'activeHours': return '群聊活跃时段'
|
case 'activeHours': return '群聊活跃时段'
|
||||||
case 'mediaStats': return '媒体内容统计'
|
case 'mediaStats': return '媒体内容统计'
|
||||||
@@ -554,6 +780,234 @@ function GroupAnalyticsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{selectedFunction === 'memberExport' && (
|
||||||
|
<div className="member-export-panel">
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<div className="member-export-empty">暂无群成员数据,请先刷新。</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="member-export-grid">
|
||||||
|
<div className="member-export-field" ref={memberSelectDropdownRef}>
|
||||||
|
<span>导出成员</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showMemberSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowMemberSelect(prev => !prev)
|
||||||
|
setShowFormatSelect(false)
|
||||||
|
setShowDisplayNameSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="member-select-trigger-value">
|
||||||
|
<Avatar
|
||||||
|
src={selectedExportMember?.avatarUrl}
|
||||||
|
name={selectedExportMember?.displayName || selectedExportMember?.username || '?'}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
<span className="select-value">{selectedExportMember?.displayName || selectedExportMember?.username || '请选择成员'}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showMemberSelect && (
|
||||||
|
<div className="select-dropdown member-select-dropdown">
|
||||||
|
<div className="member-select-search">
|
||||||
|
<Search size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={memberSearchKeyword}
|
||||||
|
onChange={e => setMemberSearchKeyword(e.target.value)}
|
||||||
|
placeholder="搜索 wxid / 昵称 / 备注 / 微信号"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="member-select-options">
|
||||||
|
{filteredMemberOptions.length === 0 ? (
|
||||||
|
<div className="member-select-empty">无匹配成员</div>
|
||||||
|
) : (
|
||||||
|
filteredMemberOptions.map(member => (
|
||||||
|
<button
|
||||||
|
key={member.username}
|
||||||
|
type="button"
|
||||||
|
className={`select-option member-select-option ${selectedExportMemberUsername === member.username ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedExportMemberUsername(member.username)
|
||||||
|
setShowMemberSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar src={member.avatarUrl} name={member.displayName} size={28} />
|
||||||
|
<span className="member-option-main">{member.displayName || member.username}</span>
|
||||||
|
<span className="member-option-meta">
|
||||||
|
wxid: {member.username}
|
||||||
|
{member.alias ? ` · 微信号: ${member.alias}` : ''}
|
||||||
|
{member.remark ? ` · 备注: ${member.remark}` : ''}
|
||||||
|
{member.nickname ? ` · 昵称: ${member.nickname}` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="member-export-field" ref={formatDropdownRef}>
|
||||||
|
<span>导出格式</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowFormatSelect(prev => !prev)
|
||||||
|
setShowMemberSelect(false)
|
||||||
|
setShowDisplayNameSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="select-value">{selectedFormatOption.label}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showFormatSelect && (
|
||||||
|
<div className="select-dropdown">
|
||||||
|
{memberExportFormatOptions.map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`select-option ${memberExportOptions.format === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
handleMemberExportFormatChange(option.value)
|
||||||
|
setShowFormatSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{option.label}</span>
|
||||||
|
<span className="option-desc">{option.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="member-export-field member-export-folder">
|
||||||
|
<span>导出目录</span>
|
||||||
|
<div className="member-export-folder-row">
|
||||||
|
<input value={exportFolder} readOnly placeholder="请选择导出目录" />
|
||||||
|
<button type="button" onClick={handleChooseExportFolder}>
|
||||||
|
选择目录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="member-export-options">
|
||||||
|
<div className="member-export-chip-group">
|
||||||
|
<span className="chip-group-label">媒体导出</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`export-filter-chip ${memberExportOptions.exportMedia ? 'active' : ''}`}
|
||||||
|
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportMedia: !prev.exportMedia }))}
|
||||||
|
>
|
||||||
|
导出媒体文件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="member-export-chip-group">
|
||||||
|
<span className="chip-group-label">媒体类型</span>
|
||||||
|
<div className="member-export-chip-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`export-filter-chip ${memberExportOptions.exportImages ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||||
|
disabled={!memberExportOptions.exportMedia}
|
||||||
|
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportImages: !prev.exportImages }))}
|
||||||
|
>
|
||||||
|
图片
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`export-filter-chip ${memberExportOptions.exportVoices ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||||
|
disabled={!memberExportOptions.exportMedia}
|
||||||
|
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVoices: !prev.exportVoices }))}
|
||||||
|
>
|
||||||
|
语音
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`export-filter-chip ${memberExportOptions.exportVideos ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||||
|
disabled={!memberExportOptions.exportMedia}
|
||||||
|
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVideos: !prev.exportVideos }))}
|
||||||
|
>
|
||||||
|
视频
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`export-filter-chip ${memberExportOptions.exportEmojis ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||||
|
disabled={!memberExportOptions.exportMedia}
|
||||||
|
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportEmojis: !prev.exportEmojis }))}
|
||||||
|
>
|
||||||
|
表情
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="member-export-chip-group">
|
||||||
|
<span className="chip-group-label">附加选项</span>
|
||||||
|
<div className="member-export-chip-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`export-filter-chip ${memberExportOptions.exportVoiceAsText ? 'active' : ''}`}
|
||||||
|
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVoiceAsText: !prev.exportVoiceAsText }))}
|
||||||
|
>
|
||||||
|
语音转文字
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`export-filter-chip ${memberExportOptions.exportAvatars ? 'active' : ''}`}
|
||||||
|
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportAvatars: !prev.exportAvatars }))}
|
||||||
|
>
|
||||||
|
导出头像
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="member-export-field" ref={displayNameDropdownRef}>
|
||||||
|
<span>显示名称规则</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showDisplayNameSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowDisplayNameSelect(prev => !prev)
|
||||||
|
setShowMemberSelect(false)
|
||||||
|
setShowFormatSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="select-value">{selectedDisplayNameOption.label}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showDisplayNameSelect && (
|
||||||
|
<div className="select-dropdown">
|
||||||
|
{displayNameOptions.map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`select-option ${memberExportOptions.displayNamePreference === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setMemberExportOptions(prev => ({ ...prev, displayNamePreference: option.value }))
|
||||||
|
setShowDisplayNameSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{option.label}</span>
|
||||||
|
<span className="option-desc">{option.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="member-export-actions">
|
||||||
|
<button
|
||||||
|
className="member-export-start-btn"
|
||||||
|
onClick={handleExportMemberMessages}
|
||||||
|
disabled={isExportingMemberMessages || !selectedExportMemberUsername || !exportFolder}
|
||||||
|
>
|
||||||
|
{isExportingMemberMessages ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
|
||||||
|
<span>{isExportingMemberMessages ? '导出中...' : '开始导出'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{selectedFunction === 'ranking' && (
|
{selectedFunction === 'ranking' && (
|
||||||
<div className="rankings-list">
|
<div className="rankings-list">
|
||||||
{rankings.map((item, index) => (
|
{rankings.map((item, index) => (
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
.blob-1 {
|
.blob-1 {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
height: 400px;
|
height: 400px;
|
||||||
background: rgba(139, 115, 85, 0.25);
|
background: rgba(var(--primary-rgb), 0.25);
|
||||||
top: -100px;
|
top: -100px;
|
||||||
left: -50px;
|
left: -50px;
|
||||||
animation-duration: 25s;
|
animation-duration: 25s;
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
.blob-2 {
|
.blob-2 {
|
||||||
width: 350px;
|
width: 350px;
|
||||||
height: 350px;
|
height: 350px;
|
||||||
background: rgba(139, 115, 85, 0.15);
|
background: rgba(var(--primary-rgb), 0.15);
|
||||||
bottom: -50px;
|
bottom: -50px;
|
||||||
right: -50px;
|
right: -50px;
|
||||||
animation-duration: 30s;
|
animation-duration: 30s;
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
letter-spacing: -2px;
|
letter-spacing: -2px;
|
||||||
background: linear-gradient(135deg, var(--text-primary) 0%, rgba(139, 115, 85, 0.8) 100%);
|
background: linear-gradient(135deg, var(--primary) 0%, rgba(var(--primary-rgb), 0.6) 100%);
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
|
|||||||
@@ -46,6 +46,18 @@
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.live-play-btn {
|
||||||
|
&.active {
|
||||||
|
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
|
||||||
|
color: var(--primary, #4c84ff);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scale-text {
|
.scale-text {
|
||||||
@@ -78,14 +90,40 @@
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
.media-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
video {
|
||||||
|
display: block;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
will-change: transform;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-video {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: fill;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
will-change: opacity;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-video.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
|
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
|
||||||
|
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
||||||
import './ImageWindow.scss'
|
import './ImageWindow.scss'
|
||||||
|
|
||||||
export default function ImageWindow() {
|
export default function ImageWindow() {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const imagePath = searchParams.get('imagePath')
|
const imagePath = searchParams.get('imagePath')
|
||||||
|
const liveVideoPath = searchParams.get('liveVideoPath')
|
||||||
|
const hasLiveVideo = !!liveVideoPath
|
||||||
|
|
||||||
|
const [isPlayingLive, setIsPlayingLive] = useState(false)
|
||||||
|
const [isVideoVisible, setIsVideoVisible] = useState(false)
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const liveCleanupTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const [scale, setScale] = useState(1)
|
const [scale, setScale] = useState(1)
|
||||||
const [rotation, setRotation] = useState(0)
|
const [rotation, setRotation] = useState(0)
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||||
@@ -22,6 +30,44 @@ export default function ImageWindow() {
|
|||||||
startPosY: 0
|
startPosY: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const clearLiveCleanupTimer = useCallback(() => {
|
||||||
|
if (liveCleanupTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(liveCleanupTimerRef.current)
|
||||||
|
liveCleanupTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopLivePlayback = useCallback((immediate = false) => {
|
||||||
|
clearLiveCleanupTimer()
|
||||||
|
setIsVideoVisible(false)
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause()
|
||||||
|
videoRef.current.currentTime = 0
|
||||||
|
}
|
||||||
|
setIsPlayingLive(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
liveCleanupTimerRef.current = window.setTimeout(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause()
|
||||||
|
videoRef.current.currentTime = 0
|
||||||
|
}
|
||||||
|
setIsPlayingLive(false)
|
||||||
|
liveCleanupTimerRef.current = null
|
||||||
|
}, 300)
|
||||||
|
}, [clearLiveCleanupTimer])
|
||||||
|
|
||||||
|
const handlePlayLiveVideo = useCallback(() => {
|
||||||
|
if (!liveVideoPath || isPlayingLive) return
|
||||||
|
|
||||||
|
clearLiveCleanupTimer()
|
||||||
|
setIsPlayingLive(true)
|
||||||
|
setIsVideoVisible(false)
|
||||||
|
}, [clearLiveCleanupTimer, liveVideoPath, isPlayingLive])
|
||||||
|
|
||||||
const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10))
|
const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10))
|
||||||
const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1))
|
const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1))
|
||||||
const handleRotate = () => setRotation(prev => (prev + 90) % 360)
|
const handleRotate = () => setRotation(prev => (prev + 90) % 360)
|
||||||
@@ -51,6 +97,29 @@ export default function ImageWindow() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 视频挂载后再播放,避免点击瞬间 ref 尚未就绪导致丢播
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlayingLive || !videoRef.current) return
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video || !isPlayingLive || !video.paused) return
|
||||||
|
|
||||||
|
video.currentTime = 0
|
||||||
|
void video.play().catch(() => {
|
||||||
|
stopLivePlayback(true)
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [isPlayingLive, stopLivePlayback])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearLiveCleanupTimer()
|
||||||
|
}
|
||||||
|
}, [clearLiveCleanupTimer])
|
||||||
|
|
||||||
// 使用原生事件监听器处理拖动
|
// 使用原生事件监听器处理拖动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
@@ -106,15 +175,25 @@ export default function ImageWindow() {
|
|||||||
// 快捷键支持
|
// 快捷键支持
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') window.electronAPI.window.close()
|
if (e.key === 'Escape') {
|
||||||
|
if (isPlayingLive) {
|
||||||
|
stopLivePlayback(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.electronAPI.window.close()
|
||||||
|
}
|
||||||
if (e.key === '=' || e.key === '+') handleZoomIn()
|
if (e.key === '=' || e.key === '+') handleZoomIn()
|
||||||
if (e.key === '-') handleZoomOut()
|
if (e.key === '-') handleZoomOut()
|
||||||
if (e.key === 'r' || e.key === 'R') handleRotate()
|
if (e.key === 'r' || e.key === 'R') handleRotate()
|
||||||
if (e.key === '0') handleReset()
|
if (e.key === '0') handleReset()
|
||||||
|
if (e.key === ' ' && hasLiveVideo) {
|
||||||
|
e.preventDefault()
|
||||||
|
handlePlayLiveVideo()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [handleReset])
|
}, [handleReset, hasLiveVideo, handlePlayLiveVideo, isPlayingLive, stopLivePlayback])
|
||||||
|
|
||||||
if (!imagePath) {
|
if (!imagePath) {
|
||||||
return (
|
return (
|
||||||
@@ -131,6 +210,20 @@ export default function ImageWindow() {
|
|||||||
<div className="title-bar">
|
<div className="title-bar">
|
||||||
<div className="window-drag-area"></div>
|
<div className="window-drag-area"></div>
|
||||||
<div className="title-bar-controls">
|
<div className="title-bar-controls">
|
||||||
|
{hasLiveVideo && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handlePlayLiveVideo}
|
||||||
|
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
|
||||||
|
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
|
||||||
|
disabled={isPlayingLive}
|
||||||
|
>
|
||||||
|
<LivePhotoIcon size={16} />
|
||||||
|
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
|
||||||
|
</button>
|
||||||
|
<div className="divider"></div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
|
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
|
||||||
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
|
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
|
||||||
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
|
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
|
||||||
@@ -147,15 +240,31 @@ export default function ImageWindow() {
|
|||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
>
|
>
|
||||||
<img
|
<div
|
||||||
src={imagePath}
|
className="media-wrapper"
|
||||||
alt="Preview"
|
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`
|
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={imagePath}
|
||||||
|
alt="Preview"
|
||||||
onLoad={handleImageLoad}
|
onLoad={handleImageLoad}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
{hasLiveVideo && isPlayingLive && (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={liveVideoPath || ''}
|
||||||
|
className={`live-video ${isVideoVisible ? 'visible' : ''}`}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
preload="auto"
|
||||||
|
onPlaying={() => setIsVideoVisible(true)}
|
||||||
|
onEnded={() => stopLivePlayback(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import { NotificationToast, type NotificationData } from '../components/NotificationToast'
|
import { NotificationToast, type NotificationData } from '../components/NotificationToast'
|
||||||
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
import '../components/NotificationToast.scss'
|
import '../components/NotificationToast.scss'
|
||||||
import './NotificationWindow.scss'
|
import './NotificationWindow.scss'
|
||||||
|
|
||||||
export default function NotificationWindow() {
|
export default function NotificationWindow() {
|
||||||
|
const { currentTheme, themeMode } = useThemeStore()
|
||||||
const [notification, setNotification] = useState<NotificationData | null>(null)
|
const [notification, setNotification] = useState<NotificationData | null>(null)
|
||||||
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
||||||
|
|
||||||
@@ -17,6 +19,12 @@ export default function NotificationWindow() {
|
|||||||
|
|
||||||
const notificationRef = useRef<NotificationData | null>(null)
|
const notificationRef = useRef<NotificationData | null>(null)
|
||||||
|
|
||||||
|
// 应用主题到通知窗口
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||||
|
document.documentElement.setAttribute('data-mode', themeMode)
|
||||||
|
}, [currentTheme, themeMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
notificationRef.current = notification
|
notificationRef.current = notification
|
||||||
}, [notification])
|
}, [notification])
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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, Plug, Check, Sun, Moon,
|
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||||
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
|
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
|
||||||
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2
|
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -55,6 +55,14 @@ function SettingsPage() {
|
|||||||
|
|
||||||
const resetChatStore = useChatStore((state) => state.reset)
|
const resetChatStore = useChatStore((state) => state.reset)
|
||||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||||
|
const [systemDark, setSystemDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches)
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}, [])
|
||||||
|
const effectiveMode = themeMode === 'system' ? (systemDark ? 'dark' : 'light') : themeMode
|
||||||
const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
|
const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>('appearance')
|
const [activeTab, setActiveTab] = useState<SettingsTab>('appearance')
|
||||||
@@ -74,6 +82,7 @@ function SettingsPage() {
|
|||||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
|
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const [cachePath, setCachePath] = useState('')
|
const [cachePath, setCachePath] = useState('')
|
||||||
|
|
||||||
const [logEnabled, setLogEnabled] = useState(false)
|
const [logEnabled, setLogEnabled] = useState(false)
|
||||||
const [whisperModelName, setWhisperModelName] = useState('base')
|
const [whisperModelName, setWhisperModelName] = useState('base')
|
||||||
const [whisperModelDir, setWhisperModelDir] = useState('')
|
const [whisperModelDir, setWhisperModelDir] = useState('')
|
||||||
@@ -81,10 +90,6 @@ function SettingsPage() {
|
|||||||
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
|
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
|
||||||
const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
|
const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
|
||||||
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
||||||
const [llamaModelStatus, setLlamaModelStatus] = useState<{ exists: boolean; path?: string; size?: number } | null>(null)
|
|
||||||
const [isLlamaDownloading, setIsLlamaDownloading] = useState(false)
|
|
||||||
const [llamaDownloadProgress, setLlamaDownloadProgress] = useState(0)
|
|
||||||
const [llamaProgressData, setLlamaProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
|
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
@@ -141,12 +146,18 @@ function SettingsPage() {
|
|||||||
const [helloAvailable, setHelloAvailable] = useState(false)
|
const [helloAvailable, setHelloAvailable] = useState(false)
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [oldPassword, setOldPassword] = useState('')
|
||||||
|
const [helloPassword, setHelloPassword] = useState('')
|
||||||
|
const [disableLockPassword, setDisableLockPassword] = useState('')
|
||||||
|
const [showDisableLockInput, setShowDisableLockInput] = useState(false)
|
||||||
|
const [isLockMode, setIsLockMode] = useState(false)
|
||||||
const [isSettingHello, setIsSettingHello] = useState(false)
|
const [isSettingHello, setIsSettingHello] = useState(false)
|
||||||
|
|
||||||
// HTTP API 设置 state
|
// HTTP API 设置 state
|
||||||
const [httpApiEnabled, setHttpApiEnabled] = useState(false)
|
const [httpApiEnabled, setHttpApiEnabled] = useState(false)
|
||||||
const [httpApiPort, setHttpApiPort] = useState(5031)
|
const [httpApiPort, setHttpApiPort] = useState(5031)
|
||||||
const [httpApiRunning, setHttpApiRunning] = useState(false)
|
const [httpApiRunning, setHttpApiRunning] = useState(false)
|
||||||
|
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
|
||||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||||
const [showApiWarning, setShowApiWarning] = useState(false)
|
const [showApiWarning, setShowApiWarning] = useState(false)
|
||||||
|
|
||||||
@@ -168,6 +179,9 @@ function SettingsPage() {
|
|||||||
if (status.port) {
|
if (status.port) {
|
||||||
setHttpApiPort(status.port)
|
setHttpApiPort(status.port)
|
||||||
}
|
}
|
||||||
|
if (status.mediaExportPath) {
|
||||||
|
setHttpApiMediaExportPath(status.mediaExportPath)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('检查 API 状态失败:', e)
|
console.error('检查 API 状态失败:', e)
|
||||||
}
|
}
|
||||||
@@ -175,14 +189,6 @@ function SettingsPage() {
|
|||||||
checkApiStatus()
|
checkApiStatus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function sha256(message: string) {
|
|
||||||
const msgBuffer = new TextEncoder().encode(message)
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
|
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
|
||||||
return hashHex
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
loadAppVersion()
|
loadAppVersion()
|
||||||
@@ -249,6 +255,7 @@ function SettingsPage() {
|
|||||||
const savedPath = await configService.getDbPath()
|
const savedPath = await configService.getDbPath()
|
||||||
const savedWxid = await configService.getMyWxid()
|
const savedWxid = await configService.getMyWxid()
|
||||||
const savedCachePath = await configService.getCachePath()
|
const savedCachePath = await configService.getCachePath()
|
||||||
|
|
||||||
const savedExportPath = await configService.getExportPath()
|
const savedExportPath = await configService.getExportPath()
|
||||||
const savedLogEnabled = await configService.getLogEnabled()
|
const savedLogEnabled = await configService.getLogEnabled()
|
||||||
const savedImageXorKey = await configService.getImageXorKey()
|
const savedImageXorKey = await configService.getImageXorKey()
|
||||||
@@ -269,15 +276,18 @@ function SettingsPage() {
|
|||||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||||
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
||||||
|
|
||||||
const savedAuthEnabled = await configService.getAuthEnabled()
|
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
||||||
const savedAuthUseHello = await configService.getAuthUseHello()
|
const savedAuthUseHello = await configService.getAuthUseHello()
|
||||||
|
const savedIsLockMode = await window.electronAPI.auth.isLockMode()
|
||||||
setAuthEnabled(savedAuthEnabled)
|
setAuthEnabled(savedAuthEnabled)
|
||||||
setAuthUseHello(savedAuthUseHello)
|
setAuthUseHello(savedAuthUseHello)
|
||||||
|
setIsLockMode(savedIsLockMode)
|
||||||
|
|
||||||
if (savedPath) setDbPath(savedPath)
|
if (savedPath) setDbPath(savedPath)
|
||||||
if (savedWxid) setWxid(savedWxid)
|
if (savedWxid) setWxid(savedWxid)
|
||||||
if (savedCachePath) setCachePath(savedCachePath)
|
if (savedCachePath) setCachePath(savedCachePath)
|
||||||
|
|
||||||
|
|
||||||
const wxidConfig = savedWxid ? await configService.getWxidConfig(savedWxid) : null
|
const wxidConfig = savedWxid ? await configService.getWxidConfig(savedWxid) : null
|
||||||
const decryptKeyToUse = wxidConfig?.decryptKey ?? savedKey ?? ''
|
const decryptKeyToUse = wxidConfig?.decryptKey ?? savedKey ?? ''
|
||||||
const imageXorKeyToUse = typeof wxidConfig?.imageXorKey === 'number'
|
const imageXorKeyToUse = typeof wxidConfig?.imageXorKey === 'number'
|
||||||
@@ -321,8 +331,7 @@ function SettingsPage() {
|
|||||||
|
|
||||||
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||||
|
|
||||||
// Load Llama status after config
|
|
||||||
void checkLlamaModelStatus()
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('加载配置失败:', e)
|
console.error('加载配置失败:', e)
|
||||||
}
|
}
|
||||||
@@ -613,6 +622,7 @@ function SettingsPage() {
|
|||||||
await applyWxidSelection(selectedWxid)
|
await applyWxidSelection(selectedWxid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const handleSelectCachePath = async () => {
|
const handleSelectCachePath = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
|
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
|
||||||
@@ -637,7 +647,6 @@ function SettingsPage() {
|
|||||||
setWhisperModelDir(dir)
|
setWhisperModelDir(dir)
|
||||||
await configService.setWhisperModelDir(dir)
|
await configService.setWhisperModelDir(dir)
|
||||||
showMessage('已选择 Whisper 模型目录', true)
|
showMessage('已选择 Whisper 模型目录', true)
|
||||||
await checkLlamaModelStatus()
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showMessage('选择目录失败', false)
|
showMessage('选择目录失败', false)
|
||||||
@@ -673,68 +682,6 @@ function SettingsPage() {
|
|||||||
const handleResetWhisperModelDir = async () => {
|
const handleResetWhisperModelDir = async () => {
|
||||||
setWhisperModelDir('')
|
setWhisperModelDir('')
|
||||||
await configService.setWhisperModelDir('')
|
await configService.setWhisperModelDir('')
|
||||||
await checkLlamaModelStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkLlamaModelStatus = async () => {
|
|
||||||
try {
|
|
||||||
// @ts-ignore
|
|
||||||
const modelsPath = await window.electronAPI.llama?.getModelsPath()
|
|
||||||
if (!modelsPath) return
|
|
||||||
const modelName = "Qwen3-4B-Q4_K_M.gguf" // Hardcoded preset for now
|
|
||||||
const fullPath = `${modelsPath}\\${modelName}`
|
|
||||||
// @ts-ignore
|
|
||||||
const status = await window.electronAPI.llama?.getModelStatus(fullPath)
|
|
||||||
if (status) {
|
|
||||||
setLlamaModelStatus({
|
|
||||||
exists: status.exists,
|
|
||||||
path: status.path,
|
|
||||||
size: status.size
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Check llama model status failed", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleLlamaProgress = (payload: { downloaded: number; total: number; speed: number }) => {
|
|
||||||
setLlamaProgressData(payload)
|
|
||||||
if (payload.total > 0) {
|
|
||||||
setLlamaDownloadProgress((payload.downloaded / payload.total) * 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
const removeListener = window.electronAPI.llama?.onDownloadProgress(handleLlamaProgress)
|
|
||||||
return () => {
|
|
||||||
if (typeof removeListener === 'function') removeListener()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleDownloadLlamaModel = async () => {
|
|
||||||
if (isLlamaDownloading) return
|
|
||||||
setIsLlamaDownloading(true)
|
|
||||||
setLlamaDownloadProgress(0)
|
|
||||||
try {
|
|
||||||
const modelUrl = "https://www.modelscope.cn/models/Qwen/Qwen3-4B-GGUF/resolve/master/Qwen3-4B-Q4_K_M.gguf"
|
|
||||||
// @ts-ignore
|
|
||||||
const modelsPath = await window.electronAPI.llama?.getModelsPath()
|
|
||||||
const modelName = "Qwen3-4B-Q4_K_M.gguf"
|
|
||||||
const fullPath = `${modelsPath}\\${modelName}`
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const result = await window.electronAPI.llama?.downloadModel(modelUrl, fullPath)
|
|
||||||
if (result?.success) {
|
|
||||||
showMessage('Qwen3 模型下载完成', true)
|
|
||||||
await checkLlamaModelStatus()
|
|
||||||
} else {
|
|
||||||
showMessage(`模型下载失败: ${result?.error || '未知错误'}`, false)
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
showMessage(`模型下载失败: ${e}`, false)
|
|
||||||
} finally {
|
|
||||||
setIsLlamaDownloading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAutoGetDbKey = async () => {
|
const handleAutoGetDbKey = async () => {
|
||||||
@@ -985,12 +932,23 @@ function SettingsPage() {
|
|||||||
<button className={`mode-btn ${themeMode === 'dark' ? 'active' : ''}`} onClick={() => setThemeMode('dark')}>
|
<button className={`mode-btn ${themeMode === 'dark' ? 'active' : ''}`} onClick={() => setThemeMode('dark')}>
|
||||||
<Moon size={16} /> 深色
|
<Moon size={16} /> 深色
|
||||||
</button>
|
</button>
|
||||||
|
<button className={`mode-btn ${themeMode === 'system' ? 'active' : ''}`} onClick={() => setThemeMode('system')}>
|
||||||
|
<Monitor size={16} /> 跟随系统
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="theme-grid">
|
<div className="theme-grid">
|
||||||
{themes.map((theme) => (
|
{themes.map((theme) => (
|
||||||
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
|
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
|
||||||
<div className="theme-preview" style={{ background: themeMode === 'dark' ? 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)' : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)` }}>
|
<div className="theme-preview" style={{
|
||||||
<div className="theme-accent" style={{ background: theme.primaryColor }} />
|
background: effectiveMode === 'dark'
|
||||||
|
? (theme.id === 'blossom-dream' ? 'linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%)' : 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)')
|
||||||
|
: (theme.id === 'blossom-dream' ? `linear-gradient(150deg, ${theme.bgColor} 0%, #F8F2F8 45%, #F2F6FB 100%)` : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)`)
|
||||||
|
}}>
|
||||||
|
<div className="theme-accent" style={{
|
||||||
|
background: theme.accentColor
|
||||||
|
? `linear-gradient(135deg, ${theme.primaryColor} 0%, ${theme.accentColor} 100%)`
|
||||||
|
: theme.primaryColor
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div className="theme-info">
|
<div className="theme-info">
|
||||||
<span className="theme-name">{theme.name}</span>
|
<span className="theme-name">{theme.name}</span>
|
||||||
@@ -1306,6 +1264,8 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>账号 wxid</label>
|
<label>账号 wxid</label>
|
||||||
<span className="form-hint">微信账号标识</span>
|
<span className="form-hint">微信账号标识</span>
|
||||||
@@ -1431,7 +1391,7 @@ function SettingsPage() {
|
|||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>模型管理</label>
|
<label>模型管理</label>
|
||||||
<span className="form-hint">管理语音识别和 AI 对话模型</span>
|
<span className="form-hint">管理语音识别模型</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -1501,50 +1461,6 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>AI 对话模型 (Llama)</label>
|
|
||||||
<span className="form-hint">用于 AI 助手对话功能</span>
|
|
||||||
<div className="setting-control vertical has-border">
|
|
||||||
<div className="model-status-card">
|
|
||||||
<div className="model-info">
|
|
||||||
<div className="model-name">Qwen3 4B (Preset) (~2.6GB)</div>
|
|
||||||
<div className="model-path">
|
|
||||||
{llamaModelStatus?.exists ? (
|
|
||||||
<span className="status-indicator success"><Check size={14} /> 已安装</span>
|
|
||||||
) : (
|
|
||||||
<span className="status-indicator warning">未安装</span>
|
|
||||||
)}
|
|
||||||
{llamaModelStatus?.path && <div className="path-text" title={llamaModelStatus.path}>{llamaModelStatus.path}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="model-actions">
|
|
||||||
{!llamaModelStatus?.exists && !isLlamaDownloading && (
|
|
||||||
<button
|
|
||||||
className="btn-download"
|
|
||||||
onClick={handleDownloadLlamaModel}
|
|
||||||
>
|
|
||||||
<Download size={16} /> 下载模型
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isLlamaDownloading && (
|
|
||||||
<div className="download-status">
|
|
||||||
<div className="status-header">
|
|
||||||
<span className="percent">{Math.floor(llamaDownloadProgress)}%</span>
|
|
||||||
<span className="metrics">
|
|
||||||
{formatBytes(llamaProgressData.downloaded)} / {formatBytes(llamaProgressData.total)}
|
|
||||||
<span className="speed">({formatBytes(llamaProgressData.speed)}/s)</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="progress-bar-mini">
|
|
||||||
<div className="fill" style={{ width: `${llamaDownloadProgress}%` }}></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>自动转文字</label>
|
<label>自动转文字</label>
|
||||||
<span className="form-hint">收到语音消息时自动转换为文字</span>
|
<span className="form-hint">收到语音消息时自动转换为文字</span>
|
||||||
@@ -1972,6 +1888,17 @@ function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* API 安全警告弹窗 */}
|
{/* API 安全警告弹窗 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>默认媒体导出目录</label>
|
||||||
|
<span className="form-hint">`/api/v1/messages` 在开启 `media=1` 时会把媒体保存到这里</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input"
|
||||||
|
value={httpApiMediaExportPath || '未获取到目录'}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showApiWarning && (
|
{showApiWarning && (
|
||||||
<div className="modal-overlay" onClick={() => setShowApiWarning(false)}>
|
<div className="modal-overlay" onClick={() => setShowApiWarning(false)}>
|
||||||
<div className="api-warning-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="api-warning-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -2011,6 +1938,10 @@ function SettingsPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleSetupHello = async () => {
|
const handleSetupHello = async () => {
|
||||||
|
if (!helloPassword) {
|
||||||
|
showMessage('请输入当前密码以开启 Hello', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
setIsSettingHello(true)
|
setIsSettingHello(true)
|
||||||
try {
|
try {
|
||||||
const challenge = new Uint8Array(32)
|
const challenge = new Uint8Array(32)
|
||||||
@@ -2028,8 +1959,10 @@ function SettingsPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (credential) {
|
if (credential) {
|
||||||
|
// 存储密码作为 Hello Secret,以便 Hello 解锁时能派生密钥
|
||||||
|
await window.electronAPI.auth.setHelloSecret(helloPassword)
|
||||||
setAuthUseHello(true)
|
setAuthUseHello(true)
|
||||||
await configService.setAuthUseHello(true)
|
setHelloPassword('')
|
||||||
showMessage('Windows Hello 设置成功', true)
|
showMessage('Windows Hello 设置成功', true)
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -2047,18 +1980,40 @@ function SettingsPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 简单的保存逻辑,实际上应该先验证旧密码,但为了简化流程,这里直接允许覆盖
|
|
||||||
// 因为能进入设置页面说明已经解锁了
|
|
||||||
try {
|
try {
|
||||||
const hash = await sha256(newPassword)
|
const lockMode = await window.electronAPI.auth.isLockMode()
|
||||||
await configService.setAuthPassword(hash)
|
|
||||||
await configService.setAuthEnabled(true)
|
if (authEnabled && lockMode) {
|
||||||
setAuthEnabled(true)
|
// 已开启应用锁且已是 lock: 模式 → 修改密码
|
||||||
|
if (!oldPassword) {
|
||||||
|
showMessage('请输入旧密码', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await window.electronAPI.auth.changePassword(oldPassword, newPassword)
|
||||||
|
if (result.success) {
|
||||||
setNewPassword('')
|
setNewPassword('')
|
||||||
setConfirmPassword('')
|
setConfirmPassword('')
|
||||||
|
setOldPassword('')
|
||||||
showMessage('密码已更新', true)
|
showMessage('密码已更新', true)
|
||||||
|
} else {
|
||||||
|
showMessage(result.error || '密码更新失败', false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 未开启应用锁,或旧版 safe: 模式 → 开启/升级为 lock: 模式
|
||||||
|
const result = await window.electronAPI.auth.enableLock(newPassword)
|
||||||
|
if (result.success) {
|
||||||
|
setAuthEnabled(true)
|
||||||
|
setIsLockMode(true)
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
setOldPassword('')
|
||||||
|
showMessage('应用锁已开启', true)
|
||||||
|
} else {
|
||||||
|
showMessage(result.error || '开启失败', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showMessage('密码更新失败', false)
|
showMessage('操作失败', false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2117,31 +2072,73 @@ function SettingsPage() {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<div>
|
<div>
|
||||||
<label>启用应用锁</label>
|
<label>应用锁状态</label>
|
||||||
<span className="form-hint">每次启动应用时需要验证密码</span>
|
<span className="form-hint">{
|
||||||
|
isLockMode ? '已开启' :
|
||||||
|
authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' :
|
||||||
|
'未开启 — 请设置密码以开启'
|
||||||
|
}</span>
|
||||||
</div>
|
</div>
|
||||||
<label className="switch">
|
{authEnabled && !showDisableLockInput && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => setShowDisableLockInput(true)}
|
||||||
|
>
|
||||||
|
关闭应用锁
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showDisableLockInput && (
|
||||||
|
<div style={{ marginTop: 10, display: 'flex', gap: 10 }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="password"
|
||||||
checked={authEnabled}
|
className="field-input"
|
||||||
onChange={async (e) => {
|
placeholder="输入当前密码以关闭"
|
||||||
const enabled = e.target.checked
|
value={disableLockPassword}
|
||||||
setAuthEnabled(enabled)
|
onChange={e => setDisableLockPassword(e.target.value)}
|
||||||
await configService.setAuthEnabled(enabled)
|
style={{ flex: 1 }}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<span className="switch-slider" />
|
<button
|
||||||
</label>
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={!disableLockPassword}
|
||||||
|
onClick={async () => {
|
||||||
|
const result = await window.electronAPI.auth.disableLock(disableLockPassword)
|
||||||
|
if (result.success) {
|
||||||
|
setAuthEnabled(false)
|
||||||
|
setAuthUseHello(false)
|
||||||
|
setIsLockMode(false)
|
||||||
|
setShowDisableLockInput(false)
|
||||||
|
setDisableLockPassword('')
|
||||||
|
showMessage('应用锁已关闭', true)
|
||||||
|
} else {
|
||||||
|
showMessage(result.error || '关闭失败', false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>确认</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => { setShowDisableLockInput(false); setDisableLockPassword('') }}
|
||||||
|
>取消</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>重置密码</label>
|
<label>{isLockMode ? '修改密码' : '设置密码并开启应用锁'}</label>
|
||||||
<span className="form-hint">设置新的启动密码</span>
|
<span className="form-hint">{isLockMode ? '修改应用锁密码(需要旧密码验证)' : '设置密码后将自动开启应用锁'}</span>
|
||||||
|
|
||||||
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{isLockMode && (
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="field-input"
|
||||||
|
placeholder="旧密码"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={e => setOldPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
@@ -2158,7 +2155,9 @@ function SettingsPage() {
|
|||||||
onChange={e => setConfirmPassword(e.target.value)}
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-primary" onClick={handleUpdatePassword} disabled={!newPassword}>更新</button>
|
<button className="btn btn-primary" onClick={handleUpdatePassword} disabled={!newPassword}>
|
||||||
|
{isLockMode ? '更新' : '开启'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2170,23 +2169,39 @@ function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label>Windows Hello</label>
|
<label>Windows Hello</label>
|
||||||
<span className="form-hint">使用面容、指纹快速解锁</span>
|
<span className="form-hint">使用面容、指纹快速解锁</span>
|
||||||
{!helloAvailable && <div className="form-hint warning" style={{ color: '#ff4d4f' }}> 当前设备不支持 Windows Hello</div>}
|
{!authEnabled && <div className="form-hint warning" style={{ color: '#ff4d4f' }}>请先开启应用锁</div>}
|
||||||
|
{!helloAvailable && authEnabled && <div className="form-hint warning" style={{ color: '#ff4d4f' }}>当前设备不支持 Windows Hello</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{authUseHello ? (
|
{authUseHello ? (
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => setAuthUseHello(false)}>关闭</button>
|
<button className="btn btn-secondary btn-sm" onClick={async () => {
|
||||||
|
await window.electronAPI.auth.clearHelloSecret()
|
||||||
|
setAuthUseHello(false)
|
||||||
|
showMessage('Windows Hello 已关闭', true)
|
||||||
|
}}>关闭</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-sm"
|
||||||
onClick={handleSetupHello}
|
onClick={handleSetupHello}
|
||||||
disabled={!helloAvailable || isSettingHello}
|
disabled={!helloAvailable || isSettingHello || !authEnabled || !helloPassword}
|
||||||
>
|
>
|
||||||
{isSettingHello ? '设置中...' : '开启与设置'}
|
{isSettingHello ? '设置中...' : '开启与设置'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!authUseHello && authEnabled && (
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="field-input"
|
||||||
|
placeholder="输入当前密码以开启 Hello"
|
||||||
|
value={helloPassword}
|
||||||
|
onChange={e => setHelloPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,108 +0,0 @@
|
|||||||
|
|
||||||
export interface ModelInfo {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
downloadUrl?: string; // If it's a known preset
|
|
||||||
size?: number;
|
|
||||||
downloaded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PRESET_MODELS: ModelInfo[] = [
|
|
||||||
{
|
|
||||||
name: "Qwen3 4B (Preset)",
|
|
||||||
path: "Qwen3-4B-Q4_K_M.gguf",
|
|
||||||
downloadUrl: "https://www.modelscope.cn/models/Qwen/Qwen3-4B-GGUF/resolve/master/Qwen3-4B-Q4_K_M.gguf",
|
|
||||||
downloaded: false
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
class EngineService {
|
|
||||||
private onTokenCallback: ((token: string) => void) | null = null;
|
|
||||||
private onProgressCallback: ((percent: number) => void) | null = null;
|
|
||||||
private _removeTokenListener: (() => void) | null = null;
|
|
||||||
private _removeProgressListener: (() => void) | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Initialize listeners
|
|
||||||
this._removeTokenListener = window.electronAPI.llama.onToken((token: string) => {
|
|
||||||
if (this.onTokenCallback) {
|
|
||||||
this.onTokenCallback(token);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this._removeProgressListener = window.electronAPI.llama.onDownloadProgress((percent: number) => {
|
|
||||||
if (this.onProgressCallback) {
|
|
||||||
this.onProgressCallback(percent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async checkModelExists(filename: string): Promise<boolean> {
|
|
||||||
const modelsPath = await window.electronAPI.llama.getModelsPath();
|
|
||||||
const fullPath = `${modelsPath}\\${filename}`; // Windows path separator
|
|
||||||
// We might need to handle path separator properly or let main process handle it
|
|
||||||
// Updated preload to take full path or handling in main?
|
|
||||||
// Let's rely on main process exposing join or just checking relative to models dir if implemented
|
|
||||||
// Actually main process `checkFileExists` takes a path.
|
|
||||||
// Let's assume we construct path here or Main helps.
|
|
||||||
// Better: getModelsPath returns the directory.
|
|
||||||
return await window.electronAPI.llama.checkFileExists(fullPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getModelsPath(): Promise<string> {
|
|
||||||
return await window.electronAPI.llama.getModelsPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async loadModel(filename: string) {
|
|
||||||
const modelsPath = await this.getModelsPath();
|
|
||||||
const fullPath = `${modelsPath}\\${filename}`;
|
|
||||||
console.log("Loading model:", fullPath);
|
|
||||||
return await window.electronAPI.llama.loadModel(fullPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async createSession(systemPrompt?: string) {
|
|
||||||
return await window.electronAPI.llama.createSession(systemPrompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async chat(message: string, onToken: (token: string) => void, options?: { thinking?: boolean }) {
|
|
||||||
this.onTokenCallback = onToken;
|
|
||||||
return await window.electronAPI.llama.chat(message, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async downloadModel(url: string, filename: string, onProgress: (percent: number) => void) {
|
|
||||||
const modelsPath = await this.getModelsPath();
|
|
||||||
const fullPath = `${modelsPath}\\${filename}`;
|
|
||||||
this.onProgressCallback = onProgress;
|
|
||||||
return await window.electronAPI.llama.downloadModel(url, fullPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除当前的回调函数引用
|
|
||||||
* 用于避免内存泄漏
|
|
||||||
*/
|
|
||||||
public clearCallbacks() {
|
|
||||||
this.onTokenCallback = null;
|
|
||||||
this.onProgressCallback = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 释放所有资源
|
|
||||||
* 包括事件监听器和回调引用
|
|
||||||
*/
|
|
||||||
public dispose() {
|
|
||||||
// 清除回调
|
|
||||||
this.clearCallbacks();
|
|
||||||
|
|
||||||
// 移除事件监听器
|
|
||||||
if (this._removeTokenListener) {
|
|
||||||
this._removeTokenListener();
|
|
||||||
this._removeTokenListener = null;
|
|
||||||
}
|
|
||||||
if (this._removeProgressListener) {
|
|
||||||
this._removeProgressListener();
|
|
||||||
this._removeProgressListener = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const engineService = new EngineService();
|
|
||||||
@@ -12,6 +12,7 @@ export const CONFIG_KEYS = {
|
|||||||
LAST_SESSION: 'lastSession',
|
LAST_SESSION: 'lastSession',
|
||||||
WINDOW_BOUNDS: 'windowBounds',
|
WINDOW_BOUNDS: 'windowBounds',
|
||||||
CACHE_PATH: 'cachePath',
|
CACHE_PATH: 'cachePath',
|
||||||
|
|
||||||
EXPORT_PATH: 'exportPath',
|
EXPORT_PATH: 'exportPath',
|
||||||
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
||||||
LOG_ENABLED: 'logEnabled',
|
LOG_ENABLED: 'logEnabled',
|
||||||
@@ -117,13 +118,13 @@ export async function setWxidConfig(wxid: string, configValue: WxidConfig): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取主题
|
// 获取主题
|
||||||
export async function getTheme(): Promise<'light' | 'dark'> {
|
export async function getTheme(): Promise<'light' | 'dark' | 'system'> {
|
||||||
const value = await config.get(CONFIG_KEYS.THEME)
|
const value = await config.get(CONFIG_KEYS.THEME)
|
||||||
return (value as 'light' | 'dark') || 'light'
|
return (value as 'light' | 'dark' | 'system') || 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置主题
|
// 设置主题
|
||||||
export async function setTheme(theme: 'light' | 'dark'): Promise<void> {
|
export async function setTheme(theme: 'light' | 'dark' | 'system'): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.THEME, theme)
|
await config.set(CONFIG_KEYS.THEME, theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +163,8 @@ export async function setCachePath(path: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 获取导出路径
|
// 获取导出路径
|
||||||
export async function getExportPath(): Promise<string | null> {
|
export async function getExportPath(): Promise<string | null> {
|
||||||
const value = await config.get(CONFIG_KEYS.EXPORT_PATH)
|
const value = await config.get(CONFIG_KEYS.EXPORT_PATH)
|
||||||
|
|||||||
64
src/stores/batchImageDecryptStore.ts
Normal file
64
src/stores/batchImageDecryptStore.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export interface BatchImageDecryptState {
|
||||||
|
isBatchDecrypting: boolean
|
||||||
|
progress: { current: number; total: number }
|
||||||
|
showToast: boolean
|
||||||
|
showResultToast: boolean
|
||||||
|
result: { success: number; fail: number }
|
||||||
|
startTime: number
|
||||||
|
sessionName: string
|
||||||
|
|
||||||
|
startDecrypt: (total: number, sessionName: string) => void
|
||||||
|
updateProgress: (current: number, total: number) => void
|
||||||
|
finishDecrypt: (success: number, fail: number) => void
|
||||||
|
setShowToast: (show: boolean) => void
|
||||||
|
setShowResultToast: (show: boolean) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) => ({
|
||||||
|
isBatchDecrypting: false,
|
||||||
|
progress: { current: 0, total: 0 },
|
||||||
|
showToast: false,
|
||||||
|
showResultToast: false,
|
||||||
|
result: { success: 0, fail: 0 },
|
||||||
|
startTime: 0,
|
||||||
|
sessionName: '',
|
||||||
|
|
||||||
|
startDecrypt: (total, sessionName) => set({
|
||||||
|
isBatchDecrypting: true,
|
||||||
|
progress: { current: 0, total },
|
||||||
|
showToast: true,
|
||||||
|
showResultToast: false,
|
||||||
|
result: { success: 0, fail: 0 },
|
||||||
|
startTime: Date.now(),
|
||||||
|
sessionName
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateProgress: (current, total) => set({
|
||||||
|
progress: { current, total }
|
||||||
|
}),
|
||||||
|
|
||||||
|
finishDecrypt: (success, fail) => set({
|
||||||
|
isBatchDecrypting: false,
|
||||||
|
showToast: false,
|
||||||
|
showResultToast: true,
|
||||||
|
result: { success, fail },
|
||||||
|
startTime: 0
|
||||||
|
}),
|
||||||
|
|
||||||
|
setShowToast: (show) => set({ showToast: show }),
|
||||||
|
setShowResultToast: (show) => set({ showResultToast: show }),
|
||||||
|
|
||||||
|
reset: () => set({
|
||||||
|
isBatchDecrypting: false,
|
||||||
|
progress: { current: 0, total: 0 },
|
||||||
|
showToast: false,
|
||||||
|
showResultToast: false,
|
||||||
|
result: { success: 0, fail: 0 },
|
||||||
|
startTime: 0,
|
||||||
|
sessionName: ''
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water'
|
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water' | 'blossom-dream'
|
||||||
export type ThemeMode = 'light' | 'dark'
|
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
export interface ThemeInfo {
|
export interface ThemeInfo {
|
||||||
id: ThemeId
|
id: ThemeId
|
||||||
@@ -10,6 +10,8 @@ export interface ThemeInfo {
|
|||||||
description: string
|
description: string
|
||||||
primaryColor: string
|
primaryColor: string
|
||||||
bgColor: string
|
bgColor: string
|
||||||
|
// 可选副色,用于多彩主题的渐变预览
|
||||||
|
accentColor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const themes: ThemeInfo[] = [
|
export const themes: ThemeInfo[] = [
|
||||||
@@ -20,6 +22,14 @@ export const themes: ThemeInfo[] = [
|
|||||||
primaryColor: '#8B7355',
|
primaryColor: '#8B7355',
|
||||||
bgColor: '#F0EEE9'
|
bgColor: '#F0EEE9'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'blossom-dream',
|
||||||
|
name: '繁花如梦',
|
||||||
|
description: '晨曦花境 · 夜阑幽梦',
|
||||||
|
primaryColor: '#D4849A',
|
||||||
|
bgColor: '#FCF9FB',
|
||||||
|
accentColor: '#FFBE98'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'corundum-blue',
|
id: 'corundum-blue',
|
||||||
name: '刚玉蓝',
|
name: '刚玉蓝',
|
||||||
|
|||||||
@@ -167,6 +167,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-inline-result-toast {
|
||||||
|
.batch-progress-toast-title {
|
||||||
|
svg {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-inline-result-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-inline-result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
color: #16a34a;
|
||||||
|
svg { color: #16a34a; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fail {
|
||||||
|
color: #dc2626;
|
||||||
|
svg { color: #dc2626; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.muted {
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
svg { color: var(--text-tertiary, #999); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 批量转写结果对话框
|
// 批量转写结果对话框
|
||||||
.batch-result-modal {
|
.batch-result-modal {
|
||||||
width: 420px;
|
width: 420px;
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
|
|
||||||
// 卡片背景
|
// 卡片背景
|
||||||
--card-bg: rgba(255, 255, 255, 0.7);
|
--card-bg: rgba(255, 255, 255, 0.7);
|
||||||
|
--card-inner-bg: #FAFAF7;
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 浅色主题 ====================
|
// ==================== 浅色主题 ====================
|
||||||
@@ -59,6 +61,8 @@
|
|||||||
--bg-gradient: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%);
|
--bg-gradient: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%);
|
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%);
|
||||||
--card-bg: rgba(255, 255, 255, 0.7);
|
--card-bg: rgba(255, 255, 255, 0.7);
|
||||||
|
--card-inner-bg: #FAFAF7;
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刚玉蓝主题
|
// 刚玉蓝主题
|
||||||
@@ -79,6 +83,8 @@
|
|||||||
--bg-gradient: linear-gradient(135deg, #E8EEF0 0%, #D8E4E8 100%);
|
--bg-gradient: linear-gradient(135deg, #E8EEF0 0%, #D8E4E8 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #5A7A86 100%);
|
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #5A7A86 100%);
|
||||||
--card-bg: rgba(255, 255, 255, 0.7);
|
--card-bg: rgba(255, 255, 255, 0.7);
|
||||||
|
--card-inner-bg: #F8FAFB;
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 冰猕猴桃汁绿主题
|
// 冰猕猴桃汁绿主题
|
||||||
@@ -99,6 +105,8 @@
|
|||||||
--bg-gradient: linear-gradient(135deg, #E8F0E4 0%, #D8E8D0 100%);
|
--bg-gradient: linear-gradient(135deg, #E8F0E4 0%, #D8E8D0 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #8AAA6C 100%);
|
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #8AAA6C 100%);
|
||||||
--card-bg: rgba(255, 255, 255, 0.7);
|
--card-bg: rgba(255, 255, 255, 0.7);
|
||||||
|
--card-inner-bg: #F8FBF6;
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 辛辣红主题
|
// 辛辣红主题
|
||||||
@@ -119,6 +127,8 @@
|
|||||||
--bg-gradient: linear-gradient(135deg, #F0E8E8 0%, #E8D8D8 100%);
|
--bg-gradient: linear-gradient(135deg, #F0E8E8 0%, #E8D8D8 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #A05058 100%);
|
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #A05058 100%);
|
||||||
--card-bg: rgba(255, 255, 255, 0.7);
|
--card-bg: rgba(255, 255, 255, 0.7);
|
||||||
|
--card-inner-bg: #FAF8F8;
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 明水鸭色主题
|
// 明水鸭色主题
|
||||||
@@ -139,6 +149,45 @@
|
|||||||
--bg-gradient: linear-gradient(135deg, #E4F0F0 0%, #D4E8E8 100%);
|
--bg-gradient: linear-gradient(135deg, #E4F0F0 0%, #D4E8E8 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #6A9A9A 100%);
|
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #6A9A9A 100%);
|
||||||
--card-bg: rgba(255, 255, 255, 0.7);
|
--card-bg: rgba(255, 255, 255, 0.7);
|
||||||
|
--card-inner-bg: #F6FBFB;
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 繁花如梦 - 浅色(晨曦花境)
|
||||||
|
[data-theme="blossom-dream"][data-mode="light"],
|
||||||
|
[data-theme="blossom-dream"]:not([data-mode]) {
|
||||||
|
// 三色定义(供伪元素光晕使用,饱和度提高以便在底色上可见)
|
||||||
|
--blossom-pink: #F0A0B8;
|
||||||
|
--blossom-peach: #FFB07A;
|
||||||
|
--blossom-blue: #90B8E0;
|
||||||
|
|
||||||
|
// 主品牌色:Pantone 粉晶 Rose Quartz
|
||||||
|
--primary: #D4849A;
|
||||||
|
--primary-rgb: 212, 132, 154;
|
||||||
|
--primary-hover: #C4748A;
|
||||||
|
--primary-light: rgba(212, 132, 154, 0.12);
|
||||||
|
|
||||||
|
// 背景三层:主背景最深(相对),面板次之,卡片最白
|
||||||
|
--bg-primary: #F5EDF2;
|
||||||
|
--bg-secondary: rgba(255, 255, 255, 0.82);
|
||||||
|
--bg-tertiary: rgba(212, 132, 154, 0.06);
|
||||||
|
--bg-hover: rgba(212, 132, 154, 0.09);
|
||||||
|
|
||||||
|
// 文字:提高对比度,主色接近纯黑只带微弱紫调
|
||||||
|
--text-primary: #1E1A22;
|
||||||
|
--text-secondary: #6B5F70;
|
||||||
|
--text-tertiary: #9A8A9E;
|
||||||
|
// 边框:粉色半透明,有存在感但不强硬
|
||||||
|
--border-color: rgba(212, 132, 154, 0.18);
|
||||||
|
|
||||||
|
--bg-gradient: linear-gradient(150deg, #F5EDF2 0%, #F0EAF6 50%, #EAF0F8 100%);
|
||||||
|
--primary-gradient: linear-gradient(135deg, #D4849A 0%, #E8A8B8 100%);
|
||||||
|
|
||||||
|
// 卡片:高不透明度白,与背景形成明显层次
|
||||||
|
--card-bg: rgba(255, 255, 255, 0.88);
|
||||||
|
--card-inner-bg: rgba(255, 255, 255, 0.95);
|
||||||
|
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 深色主题 ====================
|
// ==================== 深色主题 ====================
|
||||||
@@ -151,6 +200,7 @@
|
|||||||
--primary-light: rgba(201, 168, 108, 0.15);
|
--primary-light: rgba(201, 168, 108, 0.15);
|
||||||
--bg-primary: #1a1816;
|
--bg-primary: #1a1816;
|
||||||
--bg-secondary: rgba(40, 36, 32, 0.9);
|
--bg-secondary: rgba(40, 36, 32, 0.9);
|
||||||
|
--bg-secondary-solid: #282420;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #F0EEE9;
|
--text-primary: #F0EEE9;
|
||||||
@@ -160,6 +210,8 @@
|
|||||||
--bg-gradient: linear-gradient(135deg, #1a1816 0%, #252220 100%);
|
--bg-gradient: linear-gradient(135deg, #1a1816 0%, #252220 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #C9A86C 100%);
|
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #C9A86C 100%);
|
||||||
--card-bg: rgba(40, 36, 32, 0.9);
|
--card-bg: rgba(40, 36, 32, 0.9);
|
||||||
|
--card-inner-bg: #27231F;
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刚玉蓝 - 深色
|
// 刚玉蓝 - 深色
|
||||||
@@ -170,6 +222,7 @@
|
|||||||
--primary-light: rgba(106, 154, 170, 0.15);
|
--primary-light: rgba(106, 154, 170, 0.15);
|
||||||
--bg-primary: #141a1c;
|
--bg-primary: #141a1c;
|
||||||
--bg-secondary: rgba(30, 40, 44, 0.9);
|
--bg-secondary: rgba(30, 40, 44, 0.9);
|
||||||
|
--bg-secondary-solid: #1e282c;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #E8EEF0;
|
--text-primary: #E8EEF0;
|
||||||
@@ -179,6 +232,8 @@
|
|||||||
--bg-gradient: linear-gradient(135deg, #141a1c 0%, #1e282c 100%);
|
--bg-gradient: linear-gradient(135deg, #141a1c 0%, #1e282c 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #6A9AAA 100%);
|
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #6A9AAA 100%);
|
||||||
--card-bg: rgba(30, 40, 44, 0.9);
|
--card-bg: rgba(30, 40, 44, 0.9);
|
||||||
|
--card-inner-bg: #1D272A;
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 冰猕猴桃汁绿 - 深色
|
// 冰猕猴桃汁绿 - 深色
|
||||||
@@ -189,6 +244,7 @@
|
|||||||
--primary-light: rgba(154, 186, 124, 0.15);
|
--primary-light: rgba(154, 186, 124, 0.15);
|
||||||
--bg-primary: #161a14;
|
--bg-primary: #161a14;
|
||||||
--bg-secondary: rgba(34, 42, 30, 0.9);
|
--bg-secondary: rgba(34, 42, 30, 0.9);
|
||||||
|
--bg-secondary-solid: #222a1e;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #E8F0E4;
|
--text-primary: #E8F0E4;
|
||||||
@@ -198,6 +254,8 @@
|
|||||||
--bg-gradient: linear-gradient(135deg, #161a14 0%, #222a1e 100%);
|
--bg-gradient: linear-gradient(135deg, #161a14 0%, #222a1e 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #9ABA7C 100%);
|
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #9ABA7C 100%);
|
||||||
--card-bg: rgba(34, 42, 30, 0.9);
|
--card-bg: rgba(34, 42, 30, 0.9);
|
||||||
|
--card-inner-bg: #21281D;
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 辛辣红 - 深色
|
// 辛辣红 - 深色
|
||||||
@@ -208,6 +266,7 @@
|
|||||||
--primary-light: rgba(192, 96, 104, 0.15);
|
--primary-light: rgba(192, 96, 104, 0.15);
|
||||||
--bg-primary: #1a1416;
|
--bg-primary: #1a1416;
|
||||||
--bg-secondary: rgba(42, 32, 34, 0.9);
|
--bg-secondary: rgba(42, 32, 34, 0.9);
|
||||||
|
--bg-secondary-solid: #2a2022;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #F0E8E8;
|
--text-primary: #F0E8E8;
|
||||||
@@ -217,6 +276,8 @@
|
|||||||
--bg-gradient: linear-gradient(135deg, #1a1416 0%, #2a2022 100%);
|
--bg-gradient: linear-gradient(135deg, #1a1416 0%, #2a2022 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #C06068 100%);
|
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #C06068 100%);
|
||||||
--card-bg: rgba(42, 32, 34, 0.9);
|
--card-bg: rgba(42, 32, 34, 0.9);
|
||||||
|
--card-inner-bg: #281F21;
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 明水鸭色 - 深色
|
// 明水鸭色 - 深色
|
||||||
@@ -227,6 +288,7 @@
|
|||||||
--primary-light: rgba(122, 186, 170, 0.15);
|
--primary-light: rgba(122, 186, 170, 0.15);
|
||||||
--bg-primary: #121a1a;
|
--bg-primary: #121a1a;
|
||||||
--bg-secondary: rgba(28, 42, 42, 0.9);
|
--bg-secondary: rgba(28, 42, 42, 0.9);
|
||||||
|
--bg-secondary-solid: #1c2a2a;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #E4F0F0;
|
--text-primary: #E4F0F0;
|
||||||
@@ -236,6 +298,45 @@
|
|||||||
--bg-gradient: linear-gradient(135deg, #121a1a 0%, #1c2a2a 100%);
|
--bg-gradient: linear-gradient(135deg, #121a1a 0%, #1c2a2a 100%);
|
||||||
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #7ABAAA 100%);
|
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #7ABAAA 100%);
|
||||||
--card-bg: rgba(28, 42, 42, 0.9);
|
--card-bg: rgba(28, 42, 42, 0.9);
|
||||||
|
--card-inner-bg: #1B2828;
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 繁花如梦 - 深色(夜阑幽梦)
|
||||||
|
[data-theme="blossom-dream"][data-mode="dark"] {
|
||||||
|
// 光晕色(供伪元素使用,降低饱和度避免刺眼)
|
||||||
|
--blossom-pink: #C670C3;
|
||||||
|
--blossom-purple: #5F4B8B;
|
||||||
|
--blossom-blue: #3A2A50;
|
||||||
|
|
||||||
|
// 主品牌色:藕粉/烟紫粉,降饱和度不刺眼
|
||||||
|
--primary: #D19EBB;
|
||||||
|
--primary-rgb: 209, 158, 187;
|
||||||
|
--primary-hover: #DDB0C8;
|
||||||
|
--primary-light: rgba(209, 158, 187, 0.15);
|
||||||
|
|
||||||
|
// 背景三层:极深黑灰底(去掉紫薯色),面板略浅,卡片再浅一级
|
||||||
|
--bg-primary: #151316;
|
||||||
|
--bg-secondary: rgba(34, 30, 36, 0.92);
|
||||||
|
--bg-secondary-solid: #221E24;
|
||||||
|
--bg-tertiary: rgba(255, 255, 255, 0.04);
|
||||||
|
--bg-hover: rgba(209, 158, 187, 0.1);
|
||||||
|
|
||||||
|
// 文字
|
||||||
|
--text-primary: #F0EAF4;
|
||||||
|
--text-secondary: #A898AE;
|
||||||
|
--text-tertiary: #6A5870;
|
||||||
|
// 边框:极细白色内发光,剥离层级
|
||||||
|
--border-color: rgba(255, 255, 255, 0.07);
|
||||||
|
|
||||||
|
--bg-gradient: linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%);
|
||||||
|
--primary-gradient: linear-gradient(135deg, #D19EBB 0%, #A878A8 100%);
|
||||||
|
|
||||||
|
// 卡片:比面板更亮一档,用深灰而非紫色
|
||||||
|
--card-bg: rgba(42, 38, 46, 0.92);
|
||||||
|
--card-inner-bg: rgba(52, 48, 56, 0.96);
|
||||||
|
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置样式
|
// 重置样式
|
||||||
|
|||||||
69
src/types/electron.d.ts
vendored
69
src/types/electron.d.ts
vendored
@@ -11,7 +11,7 @@ export interface ElectronAPI {
|
|||||||
setTitleBarOverlay: (options: { symbolColor: string }) => void
|
setTitleBarOverlay: (options: { symbolColor: string }) => void
|
||||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
|
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
|
||||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
||||||
openImageViewerWindow: (imagePath: string) => Promise<void>
|
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
|
||||||
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
|
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
|
||||||
}
|
}
|
||||||
config: {
|
config: {
|
||||||
@@ -19,6 +19,17 @@ export interface ElectronAPI {
|
|||||||
set: (key: string, value: unknown) => Promise<void>
|
set: (key: string, value: unknown) => Promise<void>
|
||||||
clear: () => Promise<boolean>
|
clear: () => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
auth: {
|
||||||
|
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
verifyEnabled: () => Promise<boolean>
|
||||||
|
unlock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
enableLock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
disableLock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
setHelloSecret: (password: string) => Promise<{ success: boolean }>
|
||||||
|
clearHelloSecret: () => Promise<{ success: boolean }>
|
||||||
|
isLockMode: () => Promise<boolean>
|
||||||
|
}
|
||||||
dialog: {
|
dialog: {
|
||||||
openFile: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
|
openFile: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
|
||||||
openDirectory: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
|
openDirectory: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
|
||||||
@@ -85,6 +96,8 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
getContact: (username: string) => Promise<Contact | null>
|
getContact: (username: string) => Promise<Contact | null>
|
||||||
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
|
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
|
||||||
|
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
|
||||||
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
|
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
|
||||||
getContacts: () => Promise<{
|
getContacts: () => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -113,6 +126,11 @@ export interface ElectronAPI {
|
|||||||
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, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
|
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }>
|
getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }>
|
||||||
|
getAllImageMessages: (sessionId: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: 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 }>
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||||
@@ -123,7 +141,7 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
image: {
|
image: {
|
||||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
|
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
|
||||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean>
|
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean>
|
||||||
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
|
||||||
@@ -273,6 +291,17 @@ export interface ElectronAPI {
|
|||||||
count?: number
|
count?: number
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
exportGroupMemberMessages: (
|
||||||
|
chatroomId: string,
|
||||||
|
memberUsername: string,
|
||||||
|
outputPath: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
count?: number
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
annualReport: {
|
annualReport: {
|
||||||
getAvailableYears: () => Promise<{
|
getAvailableYears: () => Promise<{
|
||||||
@@ -431,7 +460,7 @@ export interface ElectronAPI {
|
|||||||
success: boolean
|
success: boolean
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
exportContacts: (outputDir: string, options: { format: 'json' | 'csv' | 'vcf'; exportAvatars: boolean; contactTypes: { friends: boolean; groups: boolean; officials: boolean } }) => Promise<{
|
exportContacts: (outputDir: string, options: { format: 'json' | 'csv' | 'vcf'; exportAvatars: boolean; contactTypes: { friends: boolean; groups: boolean; officials: boolean }; selectedUsernames?: string[] }) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
successCount?: number
|
successCount?: number
|
||||||
error?: string
|
error?: string
|
||||||
@@ -477,28 +506,38 @@ export interface ElectronAPI {
|
|||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
|
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
|
||||||
proxyImage: (url: string) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
|
proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
|
||||||
|
downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }>
|
||||||
|
exportTimeline: (options: {
|
||||||
|
outputDir: string
|
||||||
|
format: 'json' | 'html'
|
||||||
|
usernames?: string[]
|
||||||
|
keyword?: string
|
||||||
|
exportMedia?: boolean
|
||||||
|
startTime?: number
|
||||||
|
endTime?: number
|
||||||
|
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }>
|
||||||
|
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
||||||
|
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
||||||
|
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
|
||||||
}
|
}
|
||||||
llama: {
|
http: {
|
||||||
loadModel: (modelPath: string) => Promise<boolean>
|
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
|
||||||
createSession: (systemPrompt?: string) => Promise<boolean>
|
stop: () => Promise<{ success: boolean }>
|
||||||
chat: (message: string) => Promise<{ success: boolean; response?: any; error?: string }>
|
status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }>
|
||||||
downloadModel: (url: string, savePath: string) => Promise<void>
|
|
||||||
getModelsPath: () => Promise<string>
|
|
||||||
checkFileExists: (filePath: string) => Promise<boolean>
|
|
||||||
getModelStatus: (modelPath: string) => Promise<{ exists: boolean; path?: string; size?: number; error?: string }>
|
|
||||||
onToken: (callback: (token: string) => void) => () => void
|
|
||||||
onDownloadProgress: (callback: (payload: { downloaded: number; total: number; speed: number }) => void) => () => void
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql'
|
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null
|
||||||
|
senderUsername?: string
|
||||||
|
fileNameSuffix?: string
|
||||||
exportMedia?: boolean
|
exportMedia?: boolean
|
||||||
exportAvatars?: boolean
|
exportAvatars?: boolean
|
||||||
exportImages?: boolean
|
exportImages?: boolean
|
||||||
exportVoices?: boolean
|
exportVoices?: boolean
|
||||||
|
exportVideos?: boolean
|
||||||
exportEmojis?: boolean
|
exportEmojis?: boolean
|
||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
excelCompactColumns?: boolean
|
excelCompactColumns?: boolean
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export interface ContactInfo {
|
|||||||
remark?: string
|
remark?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
type: 'friend' | 'group' | 'official' | 'other'
|
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息
|
// 消息
|
||||||
@@ -64,12 +64,39 @@ export interface Message {
|
|||||||
fileSize?: number // 文件大小
|
fileSize?: number // 文件大小
|
||||||
fileExt?: string // 文件扩展名
|
fileExt?: string // 文件扩展名
|
||||||
xmlType?: string // XML 中的 type 字段
|
xmlType?: string // XML 中的 type 字段
|
||||||
|
appMsgKind?: string // 归一化 appmsg 类型
|
||||||
|
appMsgDesc?: string
|
||||||
|
appMsgAppName?: string
|
||||||
|
appMsgSourceName?: string
|
||||||
|
appMsgSourceUsername?: string
|
||||||
|
appMsgThumbUrl?: string
|
||||||
|
appMsgMusicUrl?: string
|
||||||
|
appMsgDataUrl?: string
|
||||||
|
appMsgLocationLabel?: string
|
||||||
|
finderNickname?: string
|
||||||
|
finderUsername?: string
|
||||||
|
finderCoverUrl?: string // 视频号封面图
|
||||||
|
finderAvatar?: string // 视频号作者头像
|
||||||
|
finderDuration?: number // 视频号时长(秒)
|
||||||
|
// 位置消息
|
||||||
|
locationLat?: number // 纬度
|
||||||
|
locationLng?: number // 经度
|
||||||
|
locationPoiname?: string // 地点名称
|
||||||
|
locationLabel?: string // 详细地址
|
||||||
|
// 音乐消息
|
||||||
|
musicAlbumUrl?: string // 专辑封面
|
||||||
|
musicUrl?: string // 播放链接
|
||||||
|
// 礼物消息
|
||||||
|
giftImageUrl?: string // 礼物商品图
|
||||||
|
giftWish?: string // 祝福语
|
||||||
|
giftPrice?: string // 价格(分)
|
||||||
// 转账消息
|
// 转账消息
|
||||||
transferPayerUsername?: string // 转账付款方 wxid
|
transferPayerUsername?: string // 转账付款方 wxid
|
||||||
transferReceiverUsername?: string // 转账收款方 wxid
|
transferReceiverUsername?: string // 转账收款方 wxid
|
||||||
// 名片消息
|
// 名片消息
|
||||||
cardUsername?: string // 名片的微信ID
|
cardUsername?: string // 名片的微信ID
|
||||||
cardNickname?: string // 名片的昵称
|
cardNickname?: string // 名片的昵称
|
||||||
|
cardAvatarUrl?: string // 名片头像 URL
|
||||||
// 聊天记录
|
// 聊天记录
|
||||||
chatRecordTitle?: string // 聊天记录标题
|
chatRecordTitle?: string // 聊天记录标题
|
||||||
chatRecordList?: ChatRecordItem[] // 聊天记录列表
|
chatRecordList?: ChatRecordItem[] // 聊天记录列表
|
||||||
|
|||||||
47
src/types/sns.ts
Normal file
47
src/types/sns.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export interface SnsLivePhoto {
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
token?: string
|
||||||
|
key?: string
|
||||||
|
encIdx?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnsMedia {
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
md5?: string
|
||||||
|
token?: string
|
||||||
|
key?: string
|
||||||
|
encIdx?: string
|
||||||
|
livePhoto?: SnsLivePhoto
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnsComment {
|
||||||
|
id: string
|
||||||
|
nickname: string
|
||||||
|
content: string
|
||||||
|
refCommentId: string
|
||||||
|
refNickname?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnsPost {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
avatarUrl?: string
|
||||||
|
createTime: number
|
||||||
|
contentDesc: string
|
||||||
|
type?: number
|
||||||
|
media: SnsMedia[]
|
||||||
|
likes: string[]
|
||||||
|
comments: SnsComment[]
|
||||||
|
rawXml?: string
|
||||||
|
linkTitle?: string
|
||||||
|
linkUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnsLinkCardData {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
thumb?: string
|
||||||
|
}
|
||||||
8
src/vite-env.d.ts
vendored
8
src/vite-env.d.ts
vendored
@@ -5,6 +5,14 @@ interface Window {
|
|||||||
// ... other methods ...
|
// ... other methods ...
|
||||||
auth: {
|
auth: {
|
||||||
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
|
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
verifyEnabled: () => Promise<boolean>
|
||||||
|
unlock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
enableLock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
disableLock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
setHelloSecret: (password: string) => Promise<{ success: boolean }>
|
||||||
|
clearHelloSecret: () => Promise<{ success: boolean }>
|
||||||
|
isLockMode: () => Promise<boolean>
|
||||||
}
|
}
|
||||||
// For brevity, using 'any' for other parts or properly importing types if available.
|
// For brevity, using 'any' for other parts or properly importing types if available.
|
||||||
// In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts
|
// In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts
|
||||||
|
|||||||
Reference in New Issue
Block a user