mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-26 23:55:50 +00:00
Compare commits
257 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2797d571e4 | ||
|
|
389fd0b1b0 | ||
|
|
25630da1ce | ||
|
|
ca972d3e28 | ||
|
|
80420302c1 | ||
|
|
9759d5f64f | ||
|
|
17a9b6102e | ||
|
|
7e7503035a | ||
|
|
02a6b24517 | ||
|
|
b3fee5b56d | ||
|
|
26d38acddb | ||
|
|
8a30e9b663 | ||
|
|
46a2d04528 | ||
|
|
6a85b82643 | ||
|
|
b436bb63da | ||
|
|
b5cb4051ab | ||
|
|
01f774db54 | ||
|
|
c5a6d765ee | ||
|
|
459f23bbd6 | ||
|
|
360754737f | ||
|
|
36f1476782 | ||
|
|
ecae83f659 | ||
|
|
fbe5109ed9 | ||
|
|
4adedad0de | ||
|
|
28257ba66f | ||
|
|
3062295069 | ||
|
|
3c231a7fde | ||
|
|
0247b02f6e | ||
|
|
8aaad71784 | ||
|
|
e795474917 | ||
|
|
49f99f57c9 | ||
|
|
53398707aa | ||
|
|
1d8a7d2e63 | ||
|
|
313e2bc080 | ||
|
|
0037935280 | ||
|
|
7858b40ce4 | ||
|
|
ab6db27ea7 | ||
|
|
4568795081 | ||
|
|
43643d1a83 | ||
|
|
28e7de6ceb | ||
|
|
c204855a71 | ||
|
|
dab33c4e60 | ||
|
|
47f9c0a502 | ||
|
|
d9a6fd2a42 | ||
|
|
dcb91905ad | ||
|
|
b6fd842d4e | ||
|
|
4b57e3e350 | ||
|
|
1652ebc4ad | ||
|
|
924ff1b6fc | ||
|
|
926ca72331 | ||
|
|
cf7190aaec | ||
|
|
54d6cded53 | ||
|
|
7a7e54ea5b | ||
|
|
7b4aa23f35 | ||
|
|
ac4482bc8b | ||
|
|
0a7f2b15f1 | ||
|
|
95e0b83537 | ||
|
|
bb602af750 | ||
|
|
580242b9d2 | ||
|
|
2cc1b55cbf | ||
|
|
e1944783d0 | ||
|
|
423d760f36 | ||
|
|
16e237b698 | ||
|
|
28d68d8a8e | ||
|
|
d476fbbdae | ||
|
|
64542f2902 | ||
|
|
56a59a5355 | ||
|
|
285ddeb62e | ||
|
|
84ef51f16b | ||
|
|
fb1125136c | ||
|
|
55f7ff1842 | ||
|
|
ac1d2210da | ||
|
|
ff92f355e2 | ||
|
|
4b8c8155fa | ||
|
|
756a83191d | ||
|
|
b5eb8be15e | ||
|
|
38a023d0b6 | ||
|
|
3a878dd019 | ||
|
|
6314c0f1d6 | ||
|
|
c5eed25f06 | ||
|
|
e1243522b0 | ||
|
|
d9108ac6ed | ||
|
|
302abe3e40 | ||
|
|
b6a2191e38 | ||
|
|
84b54e43aa | ||
|
|
e9971aa6c4 | ||
|
|
91f630209c | ||
|
|
b6878aefd6 | ||
|
|
f0f70def8c | ||
|
|
81bc5aefff | ||
|
|
698d2c96d7 | ||
|
|
ce683a539d | ||
|
|
ac481c6b18 | ||
|
|
750d6ad7eb | ||
|
|
7bd801cd01 | ||
|
|
5cb364f754 | ||
|
|
04d1b0c694 | ||
|
|
35028df817 | ||
|
|
2e8f55d7a8 | ||
|
|
815a440082 | ||
|
|
2afcd528dc | ||
|
|
8d68a59799 | ||
|
|
51bc60776d | ||
|
|
43f4c966f9 | ||
|
|
98a0233c4d | ||
|
|
0545be3244 | ||
|
|
4a67b22d8d | ||
|
|
5840bf710c | ||
|
|
1b8e1c2aab | ||
|
|
60aa949cca | ||
|
|
5b05b8927c | ||
|
|
d65d6d2396 | ||
|
|
086ac8fdc9 | ||
|
|
c6c7f128a9 | ||
|
|
36ec12fd0f | ||
|
|
e9fd751578 | ||
|
|
21a97b8871 | ||
|
|
b8ede4cfd0 | ||
|
|
f47eba5764 | ||
|
|
1347136b54 | ||
|
|
89f0758fbb | ||
|
|
b5507b9f5d | ||
|
|
204baa52ab | ||
|
|
bc739dc4a0 | ||
|
|
64616b9136 | ||
|
|
983783ea95 | ||
|
|
1414a4a9cf | ||
|
|
af7639aa73 | ||
|
|
dabc6a2d0a | ||
|
|
d1ef159e87 | ||
|
|
cc5c323ccb | ||
|
|
d18a871429 | ||
|
|
0a1f55f6a6 | ||
|
|
faeda030e9 | ||
|
|
b3700c3a4c | ||
|
|
01a221831f | ||
|
|
9cb41e01e2 | ||
|
|
abdb4f62de | ||
|
|
da7d354436 | ||
|
|
794a306f89 | ||
|
|
ac61ee1833 | ||
|
|
a87d419868 | ||
|
|
abbb7a0cb1 | ||
|
|
a5ae22d2a5 | ||
|
|
22b6a07749 | ||
|
|
dbdb2e2959 | ||
|
|
5147b3f0e4 | ||
|
|
a8eb0057e3 | ||
|
|
7604ff2ae4 | ||
|
|
bf9b5ba593 | ||
|
|
d12c111684 | ||
|
|
dffd3c9138 | ||
|
|
c34f7af6de | ||
|
|
22c7048ef6 | ||
|
|
96aa9d0813 | ||
|
|
d99c0ff8b2 | ||
|
|
c6e8bde078 | ||
|
|
adff7b9e1e | ||
|
|
b62c18fd84 | ||
|
|
de7cbdf494 | ||
|
|
0444ca143e | ||
|
|
596baad296 | ||
|
|
e686bb6247 | ||
|
|
06d6f15e38 | ||
|
|
d3adae42fe | ||
|
|
39b38119c1 | ||
|
|
eace3e9467 | ||
|
|
366da8d38e | ||
|
|
a965890916 | ||
|
|
b07bbd68d7 | ||
|
|
3d4a79aac6 | ||
|
|
e30c4cc644 | ||
|
|
d317be3ad3 | ||
|
|
1b078bd2fd | ||
|
|
1d84ed1614 | ||
|
|
114476d74c | ||
|
|
fb8663fb24 | ||
|
|
3a9be771b4 | ||
|
|
b2ef8f5cd2 | ||
|
|
83d501ae9b | ||
|
|
c555566c9d | ||
|
|
264f9a380b | ||
|
|
33d5951a14 | ||
|
|
68c4e43e05 | ||
|
|
54510f1c18 | ||
|
|
940234c743 | ||
|
|
b31ab46d11 | ||
|
|
c359821844 | ||
|
|
d49cf08e21 | ||
|
|
0f4cd23989 | ||
|
|
e12451911b | ||
|
|
b26f8cc43c | ||
|
|
d63c37cd78 | ||
|
|
c88aa2c9d8 | ||
|
|
4d5c744583 | ||
|
|
5033c5c7b7 | ||
|
|
5a1f2ffac7 | ||
|
|
8eecb592e6 | ||
|
|
fb188d6aaa | ||
|
|
0d33fe8fe4 | ||
|
|
5b3b8b5bc3 | ||
|
|
17de7f2e56 | ||
|
|
03aec7a34e | ||
|
|
266d68be22 | ||
|
|
bfbdefe773 | ||
|
|
5e96cdb1d6 | ||
|
|
19ee47ceb2 | ||
|
|
2823607146 | ||
|
|
1869abd9df | ||
|
|
f070d184ea | ||
|
|
d59d552aae | ||
|
|
a370531f1d | ||
|
|
9ae1b455f4 | ||
|
|
ec0eb64ffd | ||
|
|
f31886e1ab | ||
|
|
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 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -56,8 +56,13 @@ Thumbs.db
|
|||||||
*.aps
|
*.aps
|
||||||
|
|
||||||
wcdb/
|
wcdb/
|
||||||
|
xkey/
|
||||||
|
server/
|
||||||
*info
|
*info
|
||||||
概述.md
|
|
||||||
chatlab-format.md
|
chatlab-format.md
|
||||||
*.bak
|
*.bak
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
.claude/
|
||||||
|
.agents/
|
||||||
|
resources/wx_send
|
||||||
|
概述.md
|
||||||
28
README.md
28
README.md
@@ -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">
|
||||||
@@ -40,7 +41,28 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
- 年度报告与可视化概览
|
- 年度报告与可视化概览
|
||||||
- 导出聊天记录为 HTML 等格式
|
- 导出聊天记录为 HTML 等格式
|
||||||
- HTTP API 接口(供开发者集成)
|
- HTTP API 接口(供开发者集成)
|
||||||
|
- 查看完整能力清单:[详细功能](#详细功能清单)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
若你只想使用成品版本,可前往 Release 下载并安装。
|
||||||
|
|
||||||
|
## 详细功能清单
|
||||||
|
|
||||||
|
当前版本已支持以下能力:
|
||||||
|
|
||||||
|
| 功能模块 | 说明 |
|
||||||
|
|---------|------|
|
||||||
|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
||||||
|
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
||||||
|
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
||||||
|
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
||||||
|
| **年度报告** | 生成按年统计的年度报告,或跨年度的长期历史报告 |
|
||||||
|
| **双人报告** | 选择指定好友,基于双方聊天记录生成专属分析报告 |
|
||||||
|
| **消息导出** | 将微信聊天记录导出为多种格式:JSON、HTML、TXT、Excel、CSV、PGSQL、ChatLab专属格式等 |
|
||||||
|
| **朋友圈** | 解密朋友圈图片、视频、实况;导出朋友圈内容;拦截朋友圈的删除与隐藏操作;突破时间访问限制 |
|
||||||
|
| **联系人** | 导出微信好友、群聊、公众号信息;尝试找回曾经的好友(功能尚不完善) |
|
||||||
|
| **HTTP API 映射** | 将本地消息能力映射为 HTTP API,便于对接外部系统、自动化脚本与二次开发 |
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
@@ -54,13 +76,9 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
|
|||||||
- **访问地址**:`http://127.0.0.1:5031`
|
- **访问地址**:`http://127.0.0.1:5031`
|
||||||
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
||||||
|
|
||||||
📖 完整接口文档:[点击查看](docs/HTTP-API.md)
|
完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||||
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
若你只想使用成品版本,可前往 Release 下载并安装。
|
|
||||||
|
|
||||||
## 面向开发者
|
## 面向开发者
|
||||||
|
|
||||||
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
|
|||||||
"senderUsername": "wxid_sender",
|
"senderUsername": "wxid_sender",
|
||||||
"mediaType": "image",
|
"mediaType": "image",
|
||||||
"mediaFileName": "image_123.jpg",
|
"mediaFileName": "image_123.jpg",
|
||||||
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg",
|
||||||
|
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -140,7 +141,7 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
|
|||||||
"timestamp": 1738713600000,
|
"timestamp": 1738713600000,
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"content": "消息内容",
|
"content": "消息内容",
|
||||||
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
"mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"media": {
|
"media": {
|
||||||
@@ -153,7 +154,59 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. 获取会话列表
|
### 3. 访问导出媒体文件
|
||||||
|
|
||||||
|
通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
GET /api/v1/media/{relativePath}
|
||||||
|
```
|
||||||
|
|
||||||
|
**路径参数**
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| `relativePath` | string | ✅ | 媒体文件的相对路径,如 `wxid_xxx/images/image_123.jpg` |
|
||||||
|
|
||||||
|
**支持的媒体类型**
|
||||||
|
|
||||||
|
| 扩展名 | Content-Type |
|
||||||
|
|--------|-------------|
|
||||||
|
| `.png` | image/png |
|
||||||
|
| `.jpg` / `.jpeg` | image/jpeg |
|
||||||
|
| `.gif` | image/gif |
|
||||||
|
| `.webp` | image/webp |
|
||||||
|
| `.wav` | audio/wav |
|
||||||
|
| `.mp3` | audio/mpeg |
|
||||||
|
| `.mp4` | video/mp4 |
|
||||||
|
|
||||||
|
**示例请求**
|
||||||
|
```bash
|
||||||
|
# 访问导出的图片
|
||||||
|
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg
|
||||||
|
|
||||||
|
# 访问导出的语音
|
||||||
|
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav
|
||||||
|
|
||||||
|
# 访问导出的视频
|
||||||
|
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
成功时直接返回文件内容,`Content-Type` 根据文件扩展名自动设置。
|
||||||
|
|
||||||
|
失败时返回:
|
||||||
|
```json
|
||||||
|
{ "error": "Media not found" }
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 获取会话列表
|
||||||
|
|
||||||
获取所有会话列表。
|
获取所有会话列表。
|
||||||
|
|
||||||
|
|||||||
1089
electron/main.ts
1089
electron/main.ts
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
@@ -65,6 +73,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
diagnostics: {
|
||||||
|
getExportCardLogs: (options?: { limit?: number }) =>
|
||||||
|
ipcRenderer.invoke('diagnostics:getExportCardLogs', options),
|
||||||
|
clearExportCardLogs: () =>
|
||||||
|
ipcRenderer.invoke('diagnostics:clearExportCardLogs'),
|
||||||
|
exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) =>
|
||||||
|
ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload)
|
||||||
|
},
|
||||||
|
|
||||||
// 窗口控制
|
// 窗口控制
|
||||||
window: {
|
window: {
|
||||||
minimize: () => ipcRenderer.send('window:minimize'),
|
minimize: () => ipcRenderer.send('window:minimize'),
|
||||||
@@ -78,10 +95,12 @@ 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),
|
||||||
|
openSessionChatWindow: (sessionId: string) =>
|
||||||
|
ipcRenderer.invoke('window:openSessionChatWindow', sessionId)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 数据库路径
|
// 数据库路径
|
||||||
@@ -105,7 +124,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// 密钥获取
|
// 密钥获取
|
||||||
key: {
|
key: {
|
||||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
||||||
autoGetImageKey: (manualDir?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir),
|
autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid),
|
||||||
|
scanImageKeyFromMemory: (userDir: string) => ipcRenderer.invoke('key:scanImageKeyFromMemory', userDir),
|
||||||
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
|
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
|
||||||
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
|
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
|
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
|
||||||
@@ -121,8 +141,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
chat: {
|
chat: {
|
||||||
connect: () => ipcRenderer.invoke('chat:connect'),
|
connect: () => ipcRenderer.invoke('chat:connect'),
|
||||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||||
enrichSessionsContactInfo: (usernames: string[]) =>
|
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
|
||||||
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
|
||||||
|
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
|
||||||
|
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
|
||||||
|
enrichSessionsContactInfo: (
|
||||||
|
usernames: string[],
|
||||||
|
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
|
||||||
|
) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options),
|
||||||
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
||||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
||||||
getLatestMessages: (sessionId: string, limit?: number) =>
|
getLatestMessages: (sessionId: string, limit?: number) =>
|
||||||
@@ -140,13 +166,25 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||||
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
||||||
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
||||||
|
clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) =>
|
||||||
|
ipcRenderer.invoke('chat:clearCurrentAccountData', options),
|
||||||
close: () => ipcRenderer.invoke('chat:close'),
|
close: () => ipcRenderer.invoke('chat:close'),
|
||||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||||
|
getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId),
|
||||||
|
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
|
||||||
|
getExportSessionStats: (
|
||||||
|
sessionIds: string[],
|
||||||
|
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean }
|
||||||
|
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
|
||||||
|
getGroupMyMessageCountHint: (chatroomId: string) =>
|
||||||
|
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
|
||||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
||||||
getVoiceData: (sessionId: string, msgId: string, 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),
|
||||||
|
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', 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),
|
||||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
||||||
@@ -217,6 +255,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
groupAnalytics: {
|
groupAnalytics: {
|
||||||
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
||||||
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
||||||
|
getGroupMembersPanelData: (
|
||||||
|
chatroomId: string,
|
||||||
|
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
|
||||||
|
) => ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, options),
|
||||||
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),
|
||||||
@@ -228,9 +270,29 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// 年度报告
|
// 年度报告
|
||||||
annualReport: {
|
annualReport: {
|
||||||
getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'),
|
getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'),
|
||||||
|
startAvailableYearsLoad: () => ipcRenderer.invoke('annualReport:startAvailableYearsLoad'),
|
||||||
|
cancelAvailableYearsLoad: (taskId: string) => ipcRenderer.invoke('annualReport:cancelAvailableYearsLoad', taskId),
|
||||||
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
|
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
|
||||||
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
|
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
|
||||||
ipcRenderer.invoke('annualReport:exportImages', payload),
|
ipcRenderer.invoke('annualReport:exportImages', payload),
|
||||||
|
onAvailableYearsProgress: (callback: (payload: {
|
||||||
|
taskId: string
|
||||||
|
years?: number[]
|
||||||
|
done: boolean
|
||||||
|
error?: string
|
||||||
|
canceled?: boolean
|
||||||
|
strategy?: 'cache' | 'native' | 'hybrid'
|
||||||
|
phase?: 'cache' | 'native' | 'scan' | 'done'
|
||||||
|
statusText?: string
|
||||||
|
nativeElapsedMs?: number
|
||||||
|
scanElapsedMs?: number
|
||||||
|
totalElapsedMs?: number
|
||||||
|
switched?: boolean
|
||||||
|
nativeTimedOut?: boolean
|
||||||
|
}) => void) => {
|
||||||
|
ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload))
|
||||||
|
return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress')
|
||||||
|
},
|
||||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||||
ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload))
|
ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('annualReport:progress')
|
return () => ipcRenderer.removeAllListeners('annualReport:progress')
|
||||||
@@ -255,7 +317,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||||
exportContacts: (outputDir: string, options: any) =>
|
exportContacts: (outputDir: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportContacts', outputDir, options),
|
ipcRenderer.invoke('export:exportContacts', outputDir, options),
|
||||||
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
|
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => {
|
||||||
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('export:progress')
|
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||||
}
|
}
|
||||||
@@ -276,30 +338,31 @@ 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'),
|
||||||
|
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
|
||||||
|
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
|
||||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||||
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||||
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', 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'),
|
||||||
|
installBlockDeleteTrigger: () => ipcRenderer.invoke('sns:installBlockDeleteTrigger'),
|
||||||
|
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
|
||||||
|
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
|
||||||
|
deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId),
|
||||||
|
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Llama AI
|
|
||||||
llama: {
|
// 数据收集
|
||||||
loadModel: (modelPath: string) => ipcRenderer.invoke('llama:loadModel', modelPath),
|
cloud: {
|
||||||
createSession: (systemPrompt?: string) => ipcRenderer.invoke('llama:createSession', systemPrompt),
|
init: () => ipcRenderer.invoke('cloud:init'),
|
||||||
chat: (message: string, options?: any) => ipcRenderer.invoke('llama:chat', message, options),
|
recordPage: (pageName: string) => ipcRenderer.invoke('cloud:recordPage', pageName),
|
||||||
downloadModel: (url: string, savePath: string) => ipcRenderer.invoke('llama:downloadModel', url, savePath),
|
getLogs: () => ipcRenderer.invoke('cloud:getLogs')
|
||||||
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 服务
|
||||||
|
|||||||
@@ -76,16 +76,12 @@ class AnalyticsService {
|
|||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
if (usernames.length === 0) return map
|
if (usernames.length === 0) return map
|
||||||
|
|
||||||
|
// C++ 层不支持参数绑定,直接内联转义后的字符串值
|
||||||
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(',')
|
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
|
||||||
if (!inList) continue
|
const sql = `SELECT username, alias FROM contact WHERE username IN (${inList})`
|
||||||
const sql = `
|
|
||||||
SELECT username, alias
|
|
||||||
FROM contact
|
|
||||||
WHERE username IN (${inList})
|
|
||||||
`
|
|
||||||
const result = await wcdbService.execQuery('contact', null, sql)
|
const result = await wcdbService.execQuery('contact', null, sql)
|
||||||
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>[]) {
|
||||||
|
|||||||
@@ -85,7 +85,34 @@ export interface AnnualReportData {
|
|||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AvailableYearsLoadProgress {
|
||||||
|
years: number[]
|
||||||
|
strategy: 'cache' | 'native' | 'hybrid'
|
||||||
|
phase: 'cache' | 'native' | 'scan'
|
||||||
|
statusText: string
|
||||||
|
nativeElapsedMs: number
|
||||||
|
scanElapsedMs: number
|
||||||
|
totalElapsedMs: number
|
||||||
|
switched?: boolean
|
||||||
|
nativeTimedOut?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvailableYearsLoadMeta {
|
||||||
|
strategy: 'cache' | 'native' | 'hybrid'
|
||||||
|
nativeElapsedMs: number
|
||||||
|
scanElapsedMs: number
|
||||||
|
totalElapsedMs: number
|
||||||
|
switched: boolean
|
||||||
|
nativeTimedOut: boolean
|
||||||
|
statusText: string
|
||||||
|
}
|
||||||
|
|
||||||
class AnnualReportService {
|
class AnnualReportService {
|
||||||
|
private readonly availableYearsCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private readonly availableYearsScanConcurrency = 4
|
||||||
|
private readonly availableYearsColumnCache = new Map<string, string>()
|
||||||
|
private readonly availableYearsCache = new Map<string, { years: number[]; updatedAt: number }>()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +208,234 @@ class AnnualReportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private quoteSqlIdentifier(identifier: string): string {
|
||||||
|
return `"${String(identifier || '').replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
private toUnixTimestamp(value: any): number {
|
||||||
|
const n = Number(value)
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return 0
|
||||||
|
// 兼容毫秒级时间戳
|
||||||
|
const seconds = n > 1e12 ? Math.floor(n / 1000) : Math.floor(n)
|
||||||
|
return seconds > 0 ? seconds : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private addYearsFromRange(years: Set<number>, firstTs: number, lastTs: number): boolean {
|
||||||
|
let changed = false
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const minTs = firstTs > 0 ? firstTs : lastTs
|
||||||
|
const maxTs = lastTs > 0 ? lastTs : firstTs
|
||||||
|
if (minTs <= 0 || maxTs <= 0) return changed
|
||||||
|
|
||||||
|
const minYear = new Date(minTs * 1000).getFullYear()
|
||||||
|
const maxYear = new Date(maxTs * 1000).getFullYear()
|
||||||
|
for (let y = minYear; y <= maxYear; y++) {
|
||||||
|
if (y >= 2010 && y <= currentYear && !years.has(y)) {
|
||||||
|
years.add(y)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeAvailableYears(years: Iterable<number>): number[] {
|
||||||
|
return Array.from(new Set(Array.from(years)))
|
||||||
|
.filter((y) => Number.isFinite(y))
|
||||||
|
.map((y) => Math.floor(y))
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async forEachWithConcurrency<T>(
|
||||||
|
items: T[],
|
||||||
|
concurrency: number,
|
||||||
|
handler: (item: T, index: number) => Promise<void>,
|
||||||
|
shouldStop?: () => boolean
|
||||||
|
): Promise<void> {
|
||||||
|
if (!items.length) return
|
||||||
|
const workerCount = Math.max(1, Math.min(concurrency, items.length))
|
||||||
|
let nextIndex = 0
|
||||||
|
const workers: Promise<void>[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < workerCount; i++) {
|
||||||
|
workers.push((async () => {
|
||||||
|
while (true) {
|
||||||
|
if (shouldStop?.()) break
|
||||||
|
const current = nextIndex
|
||||||
|
nextIndex += 1
|
||||||
|
if (current >= items.length) break
|
||||||
|
await handler(items[current], current)
|
||||||
|
}
|
||||||
|
})())
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(workers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detectTimeColumn(dbPath: string, tableName: string): Promise<string | null> {
|
||||||
|
const cacheKey = `${dbPath}\u0001${tableName}`
|
||||||
|
if (this.availableYearsColumnCache.has(cacheKey)) {
|
||||||
|
const cached = this.availableYearsColumnCache.get(cacheKey) || ''
|
||||||
|
return cached || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`)
|
||||||
|
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) {
|
||||||
|
this.availableYearsColumnCache.set(cacheKey, '')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time']
|
||||||
|
const columns = new Set<string>()
|
||||||
|
for (const row of result.rows as Record<string, any>[]) {
|
||||||
|
const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase()
|
||||||
|
if (name) columns.add(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (columns.has(candidate)) {
|
||||||
|
this.availableYearsColumnCache.set(cacheKey, candidate)
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.availableYearsColumnCache.set(cacheKey, '')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTableTimeRange(dbPath: string, tableName: string): Promise<{ first: number; last: number } | null> {
|
||||||
|
const cacheKey = `${dbPath}\u0001${tableName}`
|
||||||
|
const cachedColumn = this.availableYearsColumnCache.get(cacheKey)
|
||||||
|
const initialColumn = cachedColumn && cachedColumn.length > 0 ? cachedColumn : 'create_time'
|
||||||
|
const tried = new Set<string>()
|
||||||
|
|
||||||
|
const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
|
||||||
|
const sql = `SELECT MIN(${this.quoteSqlIdentifier(column)}) AS first_ts, MAX(${this.quoteSqlIdentifier(column)}) AS last_ts FROM ${this.quoteSqlIdentifier(tableName)}`
|
||||||
|
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||||
|
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null
|
||||||
|
const row = result.rows[0] as Record<string, any>
|
||||||
|
const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs)
|
||||||
|
const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs)
|
||||||
|
return { first, last }
|
||||||
|
}
|
||||||
|
|
||||||
|
tried.add(initialColumn)
|
||||||
|
const quick = await queryByColumn(initialColumn)
|
||||||
|
if (quick) {
|
||||||
|
if (!cachedColumn) this.availableYearsColumnCache.set(cacheKey, initialColumn)
|
||||||
|
return quick
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectedColumn = await this.detectTimeColumn(dbPath, tableName)
|
||||||
|
if (!detectedColumn || tried.has(detectedColumn)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryByColumn(detectedColumn)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAvailableYearsByTableScan(
|
||||||
|
sessionIds: string[],
|
||||||
|
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
|
||||||
|
): Promise<number[]> {
|
||||||
|
const years = new Set<number>()
|
||||||
|
let lastEmittedSize = 0
|
||||||
|
|
||||||
|
const emitIfChanged = (force = false) => {
|
||||||
|
if (!options?.onProgress) return
|
||||||
|
const next = this.normalizeAvailableYears(years)
|
||||||
|
if (!force && next.length === lastEmittedSize) return
|
||||||
|
options.onProgress(next)
|
||||||
|
lastEmittedSize = next.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldCancel = () => options?.shouldCancel?.() === true
|
||||||
|
|
||||||
|
await this.forEachWithConcurrency(sessionIds, this.availableYearsScanConcurrency, async (sessionId) => {
|
||||||
|
if (shouldCancel()) return
|
||||||
|
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||||
|
if (!tableStats.success || !Array.isArray(tableStats.tables) || tableStats.tables.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const table of tableStats.tables as Record<string, any>[]) {
|
||||||
|
if (shouldCancel()) return
|
||||||
|
const tableName = String(table.table_name || table.name || '').trim()
|
||||||
|
const dbPath = String(table.db_path || table.dbPath || '').trim()
|
||||||
|
if (!tableName || !dbPath) continue
|
||||||
|
|
||||||
|
const range = await this.getTableTimeRange(dbPath, tableName)
|
||||||
|
if (!range) continue
|
||||||
|
const changed = this.addYearsFromRange(years, range.first, range.last)
|
||||||
|
if (changed) emitIfChanged()
|
||||||
|
}
|
||||||
|
}, shouldCancel)
|
||||||
|
|
||||||
|
emitIfChanged(true)
|
||||||
|
return this.normalizeAvailableYears(years)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAvailableYearsByEdgeScan(
|
||||||
|
sessionIds: string[],
|
||||||
|
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
|
||||||
|
): Promise<number[]> {
|
||||||
|
const years = new Set<number>()
|
||||||
|
let lastEmittedSize = 0
|
||||||
|
const shouldCancel = () => options?.shouldCancel?.() === true
|
||||||
|
|
||||||
|
const emitIfChanged = (force = false) => {
|
||||||
|
if (!options?.onProgress) return
|
||||||
|
const next = this.normalizeAvailableYears(years)
|
||||||
|
if (!force && next.length === lastEmittedSize) return
|
||||||
|
options.onProgress(next)
|
||||||
|
lastEmittedSize = next.length
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
if (shouldCancel()) break
|
||||||
|
const first = await this.getEdgeMessageTime(sessionId, true)
|
||||||
|
const last = await this.getEdgeMessageTime(sessionId, false)
|
||||||
|
const changed = this.addYearsFromRange(years, first || 0, last || 0)
|
||||||
|
if (changed) emitIfChanged()
|
||||||
|
}
|
||||||
|
emitIfChanged(true)
|
||||||
|
return this.normalizeAvailableYears(years)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAvailableYearsCacheKey(dbPath: string, cleanedWxid: string): string {
|
||||||
|
return `${dbPath}\u0001${cleanedWxid}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCachedAvailableYears(cacheKey: string): number[] | null {
|
||||||
|
const cached = this.availableYearsCache.get(cacheKey)
|
||||||
|
if (!cached) return null
|
||||||
|
if (Date.now() - cached.updatedAt > this.availableYearsCacheTtlMs) {
|
||||||
|
this.availableYearsCache.delete(cacheKey)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return [...cached.years]
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCachedAvailableYears(cacheKey: string, years: number[]): void {
|
||||||
|
const normalized = this.normalizeAvailableYears(years)
|
||||||
|
|
||||||
|
this.availableYearsCache.set(cacheKey, {
|
||||||
|
years: normalized,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.availableYearsCache.size > 8) {
|
||||||
|
let oldestKey = ''
|
||||||
|
let oldestTime = Number.POSITIVE_INFINITY
|
||||||
|
for (const [key, val] of this.availableYearsCache) {
|
||||||
|
if (val.updatedAt < oldestTime) {
|
||||||
|
oldestTime = val.updatedAt
|
||||||
|
oldestKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldestKey) this.availableYearsCache.delete(oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private decodeMessageContent(messageContent: any, compressContent: any): string {
|
private decodeMessageContent(messageContent: any, compressContent: any): string {
|
||||||
let content = this.decodeMaybeCompressed(compressContent)
|
let content = this.decodeMaybeCompressed(compressContent)
|
||||||
if (!content || content.length === 0) {
|
if (!content || content.length === 0) {
|
||||||
@@ -359,38 +614,226 @@ class AnnualReportService {
|
|||||||
return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd }
|
return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd }
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailableYears(params: { dbPath: string; decryptKey: string; wxid: string }): Promise<{ success: boolean; data?: number[]; error?: string }> {
|
async getAvailableYears(params: {
|
||||||
|
dbPath: string
|
||||||
|
decryptKey: string
|
||||||
|
wxid: string
|
||||||
|
onProgress?: (payload: AvailableYearsLoadProgress) => void
|
||||||
|
shouldCancel?: () => boolean
|
||||||
|
nativeTimeoutMs?: number
|
||||||
|
}): Promise<{ success: boolean; data?: number[]; error?: string; meta?: AvailableYearsLoadMeta }> {
|
||||||
try {
|
try {
|
||||||
|
const isCancelled = () => params.shouldCancel?.() === true
|
||||||
|
const totalStartedAt = Date.now()
|
||||||
|
let nativeElapsedMs = 0
|
||||||
|
let scanElapsedMs = 0
|
||||||
|
let switched = false
|
||||||
|
let nativeTimedOut = false
|
||||||
|
let latestYears: number[] = []
|
||||||
|
|
||||||
|
const emitProgress = (payload: {
|
||||||
|
years?: number[]
|
||||||
|
strategy: 'cache' | 'native' | 'hybrid'
|
||||||
|
phase: 'cache' | 'native' | 'scan'
|
||||||
|
statusText: string
|
||||||
|
switched?: boolean
|
||||||
|
nativeTimedOut?: boolean
|
||||||
|
}) => {
|
||||||
|
if (!params.onProgress) return
|
||||||
|
if (Array.isArray(payload.years)) latestYears = payload.years
|
||||||
|
params.onProgress({
|
||||||
|
years: latestYears,
|
||||||
|
strategy: payload.strategy,
|
||||||
|
phase: payload.phase,
|
||||||
|
statusText: payload.statusText,
|
||||||
|
nativeElapsedMs,
|
||||||
|
scanElapsedMs,
|
||||||
|
totalElapsedMs: Date.now() - totalStartedAt,
|
||||||
|
switched: payload.switched ?? switched,
|
||||||
|
nativeTimedOut: payload.nativeTimedOut ?? nativeTimedOut
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildMeta = (
|
||||||
|
strategy: 'cache' | 'native' | 'hybrid',
|
||||||
|
statusText: string
|
||||||
|
): AvailableYearsLoadMeta => ({
|
||||||
|
strategy,
|
||||||
|
nativeElapsedMs,
|
||||||
|
scanElapsedMs,
|
||||||
|
totalElapsedMs: Date.now() - totalStartedAt,
|
||||||
|
switched,
|
||||||
|
nativeTimedOut,
|
||||||
|
statusText
|
||||||
|
})
|
||||||
|
|
||||||
const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid)
|
const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid)
|
||||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error, meta: buildMeta('hybrid', '连接数据库失败') }
|
||||||
|
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||||
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
|
const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid)
|
||||||
if (sessionIds.length === 0) {
|
const cached = this.getCachedAvailableYears(cacheKey)
|
||||||
return { success: false, error: '未找到消息会话' }
|
if (cached) {
|
||||||
}
|
latestYears = cached
|
||||||
|
emitProgress({
|
||||||
const fastYears = await wcdbService.getAvailableYears(sessionIds)
|
years: cached,
|
||||||
if (fastYears.success && fastYears.data) {
|
strategy: 'cache',
|
||||||
return { success: true, data: fastYears.data }
|
phase: 'cache',
|
||||||
}
|
statusText: '命中缓存,已快速加载年份数据'
|
||||||
|
})
|
||||||
const years = new Set<number>()
|
return {
|
||||||
for (const sessionId of sessionIds) {
|
success: true,
|
||||||
const first = await this.getEdgeMessageTime(sessionId, true)
|
data: cached,
|
||||||
const last = await this.getEdgeMessageTime(sessionId, false)
|
meta: buildMeta('cache', '命中缓存,已快速加载年份数据')
|
||||||
if (!first && !last) continue
|
|
||||||
|
|
||||||
const minYear = new Date((first || last || 0) * 1000).getFullYear()
|
|
||||||
const maxYear = new Date((last || first || 0) * 1000).getFullYear()
|
|
||||||
for (let y = minYear; y <= maxYear; y++) {
|
|
||||||
if (y >= 2010 && y <= new Date().getFullYear()) years.add(y)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedYears = Array.from(years).sort((a, b) => b - a)
|
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
|
||||||
return { success: true, data: sortedYears }
|
if (sessionIds.length === 0) {
|
||||||
|
return { success: false, error: '未找到消息会话', meta: buildMeta('hybrid', '未找到消息会话') }
|
||||||
|
}
|
||||||
|
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||||
|
|
||||||
|
const nativeTimeoutMs = Math.max(1000, Math.floor(params.nativeTimeoutMs || 5000))
|
||||||
|
const nativeStartedAt = Date.now()
|
||||||
|
let nativeTicker: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
emitProgress({
|
||||||
|
strategy: 'native',
|
||||||
|
phase: 'native',
|
||||||
|
statusText: '正在使用原生快速模式加载年份...'
|
||||||
|
})
|
||||||
|
nativeTicker = setInterval(() => {
|
||||||
|
nativeElapsedMs = Date.now() - nativeStartedAt
|
||||||
|
emitProgress({
|
||||||
|
strategy: 'native',
|
||||||
|
phase: 'native',
|
||||||
|
statusText: '正在使用原生快速模式加载年份...'
|
||||||
|
})
|
||||||
|
}, 120)
|
||||||
|
|
||||||
|
const nativeRace = await Promise.race([
|
||||||
|
wcdbService.getAvailableYears(sessionIds)
|
||||||
|
.then((result) => ({ kind: 'result' as const, result }))
|
||||||
|
.catch((error) => ({ kind: 'error' as const, error: String(error) })),
|
||||||
|
new Promise<{ kind: 'timeout' }>((resolve) => setTimeout(() => resolve({ kind: 'timeout' }), nativeTimeoutMs))
|
||||||
|
])
|
||||||
|
|
||||||
|
if (nativeTicker) {
|
||||||
|
clearInterval(nativeTicker)
|
||||||
|
nativeTicker = null
|
||||||
|
}
|
||||||
|
nativeElapsedMs = Math.max(nativeElapsedMs, Date.now() - nativeStartedAt)
|
||||||
|
|
||||||
|
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||||
|
|
||||||
|
if (nativeRace.kind === 'result' && nativeRace.result.success && Array.isArray(nativeRace.result.data) && nativeRace.result.data.length > 0) {
|
||||||
|
const years = this.normalizeAvailableYears(nativeRace.result.data)
|
||||||
|
latestYears = years
|
||||||
|
this.setCachedAvailableYears(cacheKey, years)
|
||||||
|
emitProgress({
|
||||||
|
years,
|
||||||
|
strategy: 'native',
|
||||||
|
phase: 'native',
|
||||||
|
statusText: '原生快速模式加载完成'
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: years,
|
||||||
|
meta: buildMeta('native', '原生快速模式加载完成')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switched = true
|
||||||
|
nativeTimedOut = nativeRace.kind === 'timeout'
|
||||||
|
emitProgress({
|
||||||
|
strategy: 'hybrid',
|
||||||
|
phase: 'native',
|
||||||
|
statusText: nativeTimedOut
|
||||||
|
? '原生快速模式超时,已自动切换到扫表兼容模式...'
|
||||||
|
: '原生快速模式不可用,已自动切换到扫表兼容模式...',
|
||||||
|
switched: true,
|
||||||
|
nativeTimedOut
|
||||||
|
})
|
||||||
|
|
||||||
|
const scanStartedAt = Date.now()
|
||||||
|
let scanTicker: ReturnType<typeof setInterval> | null = null
|
||||||
|
scanTicker = setInterval(() => {
|
||||||
|
scanElapsedMs = Date.now() - scanStartedAt
|
||||||
|
emitProgress({
|
||||||
|
strategy: 'hybrid',
|
||||||
|
phase: 'scan',
|
||||||
|
statusText: nativeTimedOut
|
||||||
|
? '原生已超时,正在使用扫表兼容模式加载年份...'
|
||||||
|
: '正在使用扫表兼容模式加载年份...',
|
||||||
|
switched: true,
|
||||||
|
nativeTimedOut
|
||||||
|
})
|
||||||
|
}, 120)
|
||||||
|
|
||||||
|
let years = await this.getAvailableYearsByTableScan(sessionIds, {
|
||||||
|
onProgress: (items) => {
|
||||||
|
latestYears = items
|
||||||
|
scanElapsedMs = Date.now() - scanStartedAt
|
||||||
|
emitProgress({
|
||||||
|
years: items,
|
||||||
|
strategy: 'hybrid',
|
||||||
|
phase: 'scan',
|
||||||
|
statusText: nativeTimedOut
|
||||||
|
? '原生已超时,正在使用扫表兼容模式加载年份...'
|
||||||
|
: '正在使用扫表兼容模式加载年份...',
|
||||||
|
switched: true,
|
||||||
|
nativeTimedOut
|
||||||
|
})
|
||||||
|
},
|
||||||
|
shouldCancel: params.shouldCancel
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isCancelled()) {
|
||||||
|
if (scanTicker) clearInterval(scanTicker)
|
||||||
|
return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||||
|
}
|
||||||
|
if (years.length === 0) {
|
||||||
|
years = await this.getAvailableYearsByEdgeScan(sessionIds, {
|
||||||
|
onProgress: (items) => {
|
||||||
|
latestYears = items
|
||||||
|
scanElapsedMs = Date.now() - scanStartedAt
|
||||||
|
emitProgress({
|
||||||
|
years: items,
|
||||||
|
strategy: 'hybrid',
|
||||||
|
phase: 'scan',
|
||||||
|
statusText: '扫表结果为空,正在执行游标兜底扫描...',
|
||||||
|
switched: true,
|
||||||
|
nativeTimedOut
|
||||||
|
})
|
||||||
|
},
|
||||||
|
shouldCancel: params.shouldCancel
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (scanTicker) {
|
||||||
|
clearInterval(scanTicker)
|
||||||
|
scanTicker = null
|
||||||
|
}
|
||||||
|
scanElapsedMs = Math.max(scanElapsedMs, Date.now() - scanStartedAt)
|
||||||
|
|
||||||
|
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||||
|
|
||||||
|
this.setCachedAvailableYears(cacheKey, years)
|
||||||
|
latestYears = years
|
||||||
|
emitProgress({
|
||||||
|
years,
|
||||||
|
strategy: 'hybrid',
|
||||||
|
phase: 'scan',
|
||||||
|
statusText: '扫表兼容模式加载完成',
|
||||||
|
switched: true,
|
||||||
|
nativeTimedOut
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: years,
|
||||||
|
meta: buildMeta('hybrid', '扫表兼容模式加载完成')
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e), meta: { strategy: 'hybrid', nativeElapsedMs: 0, scanElapsedMs: 0, totalElapsedMs: 0, switched: false, nativeTimedOut: false, statusText: '加载年度数据失败' } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
68
electron/services/cloudControlService.ts
Normal file
68
electron/services/cloudControlService.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
|
interface UsageStats {
|
||||||
|
appVersion: string
|
||||||
|
platform: string
|
||||||
|
deviceId: string
|
||||||
|
timestamp: number
|
||||||
|
online: boolean
|
||||||
|
pages: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class CloudControlService {
|
||||||
|
private deviceId: string = ''
|
||||||
|
private timer: NodeJS.Timeout | null = null
|
||||||
|
private pages: Set<string> = new Set()
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.deviceId = this.getDeviceId()
|
||||||
|
await wcdbService.cloudInit(300)
|
||||||
|
await this.reportOnline()
|
||||||
|
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
this.reportOnline()
|
||||||
|
}, 300000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDeviceId(): string {
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const os = require('os')
|
||||||
|
const machineId = os.hostname() + os.platform() + os.arch()
|
||||||
|
return crypto.createHash('md5').update(machineId).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reportOnline() {
|
||||||
|
const data: UsageStats = {
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
platform: process.platform,
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
online: true,
|
||||||
|
pages: Array.from(this.pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
await wcdbService.cloudReport(JSON.stringify(data))
|
||||||
|
this.pages.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
recordPage(pageName: string) {
|
||||||
|
this.pages.add(pageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
wcdbService.cloudStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs() {
|
||||||
|
return wcdbService.getLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cloudControlService = new CloudControlService()
|
||||||
|
|
||||||
|
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { app } from 'electron'
|
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
|
||||||
@@ -14,7 +19,6 @@ interface ConfigSchema {
|
|||||||
|
|
||||||
// 缓存相关
|
// 缓存相关
|
||||||
cachePath: string
|
cachePath: string
|
||||||
|
|
||||||
lastOpenedDb: string
|
lastOpenedDb: string
|
||||||
lastSession: string
|
lastSession: string
|
||||||
|
|
||||||
@@ -34,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
|
||||||
@@ -48,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()
|
||||||
@@ -75,7 +93,6 @@ export class ConfigService {
|
|||||||
imageAesKey: '',
|
imageAesKey: '',
|
||||||
wxidConfigs: {},
|
wxidConfigs: {},
|
||||||
cachePath: '',
|
cachePath: '',
|
||||||
|
|
||||||
lastOpenedDb: '',
|
lastOpenedDb: '',
|
||||||
lastSession: '',
|
lastSession: '',
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
@@ -88,13 +105,12 @@ export class ConfigService {
|
|||||||
whisperDownloadSource: 'tsinghua',
|
whisperDownloadSource: 'tsinghua',
|
||||||
autoTranscribeVoice: false,
|
autoTranscribeVoice: false,
|
||||||
transcribeLanguages: ['zh'],
|
transcribeLanguages: ['zh'],
|
||||||
exportDefaultConcurrency: 2,
|
exportDefaultConcurrency: 4,
|
||||||
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',
|
||||||
@@ -103,29 +119,556 @@ 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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 工具方法 ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局配置
|
||||||
|
*/
|
||||||
|
getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } {
|
||||||
|
const wxid = this.get('myWxid')
|
||||||
|
if (wxid) {
|
||||||
|
const wxidConfigs = this.get('wxidConfigs')
|
||||||
|
const cfg = wxidConfigs?.[wxid]
|
||||||
|
if (cfg && (cfg.imageXorKey !== undefined || cfg.imageAesKey)) {
|
||||||
|
return {
|
||||||
|
xorKey: cfg.imageXorKey ?? this.get('imageXorKey'),
|
||||||
|
aesKey: cfg.imageAesKey ?? this.get('imageAesKey')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
xorKey: this.get('imageXorKey'),
|
||||||
|
aesKey: this.get('imageAesKey')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getCacheBasePath(): string {
|
getCacheBasePath(): string {
|
||||||
const configured = this.get('cachePath')
|
return join(app.getPath('userData'), 'cache')
|
||||||
if (configured && configured.trim().length > 0) {
|
|
||||||
return configured
|
|
||||||
}
|
|
||||||
return join(app.getPath('documents'), 'WeFlow')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAll(): ConfigSchema {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
354
electron/services/exportCardDiagnosticsService.ts
Normal file
354
electron/services/exportCardDiagnosticsService.ts
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import { mkdir, writeFile } from 'fs/promises'
|
||||||
|
import { basename, dirname, extname, join } from 'path'
|
||||||
|
|
||||||
|
export type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker'
|
||||||
|
export type ExportCardDiagLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||||
|
export type ExportCardDiagStatus = 'running' | 'done' | 'failed' | 'timeout'
|
||||||
|
|
||||||
|
export interface ExportCardDiagLogEntry {
|
||||||
|
id: string
|
||||||
|
ts: number
|
||||||
|
source: ExportCardDiagSource
|
||||||
|
level: ExportCardDiagLevel
|
||||||
|
message: string
|
||||||
|
traceId?: string
|
||||||
|
stepId?: string
|
||||||
|
stepName?: string
|
||||||
|
status?: ExportCardDiagStatus
|
||||||
|
durationMs?: number
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveStepState {
|
||||||
|
key: string
|
||||||
|
traceId: string
|
||||||
|
stepId: string
|
||||||
|
stepName: string
|
||||||
|
source: ExportCardDiagSource
|
||||||
|
startedAt: number
|
||||||
|
lastUpdatedAt: number
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepStartInput {
|
||||||
|
traceId: string
|
||||||
|
stepId: string
|
||||||
|
stepName: string
|
||||||
|
source: ExportCardDiagSource
|
||||||
|
level?: ExportCardDiagLevel
|
||||||
|
message?: string
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepEndInput {
|
||||||
|
traceId: string
|
||||||
|
stepId: string
|
||||||
|
stepName: string
|
||||||
|
source: ExportCardDiagSource
|
||||||
|
status?: Extract<ExportCardDiagStatus, 'done' | 'failed' | 'timeout'>
|
||||||
|
level?: ExportCardDiagLevel
|
||||||
|
message?: string
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
durationMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogInput {
|
||||||
|
ts?: number
|
||||||
|
source: ExportCardDiagSource
|
||||||
|
level?: ExportCardDiagLevel
|
||||||
|
message: string
|
||||||
|
traceId?: string
|
||||||
|
stepId?: string
|
||||||
|
stepName?: string
|
||||||
|
status?: ExportCardDiagStatus
|
||||||
|
durationMs?: number
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportCardDiagSnapshot {
|
||||||
|
logs: ExportCardDiagLogEntry[]
|
||||||
|
activeSteps: Array<{
|
||||||
|
traceId: string
|
||||||
|
stepId: string
|
||||||
|
stepName: string
|
||||||
|
source: ExportCardDiagSource
|
||||||
|
elapsedMs: number
|
||||||
|
stallMs: number
|
||||||
|
startedAt: number
|
||||||
|
lastUpdatedAt: number
|
||||||
|
message?: string
|
||||||
|
}>
|
||||||
|
summary: {
|
||||||
|
totalLogs: number
|
||||||
|
activeStepCount: number
|
||||||
|
errorCount: number
|
||||||
|
warnCount: number
|
||||||
|
timeoutCount: number
|
||||||
|
lastUpdatedAt: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExportCardDiagnosticsService {
|
||||||
|
private readonly maxLogs = 6000
|
||||||
|
private logs: ExportCardDiagLogEntry[] = []
|
||||||
|
private activeSteps = new Map<string, ActiveStepState>()
|
||||||
|
private seq = 0
|
||||||
|
|
||||||
|
private nextId(ts: number): string {
|
||||||
|
this.seq += 1
|
||||||
|
return `export-card-diag-${ts}-${this.seq}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimLogs() {
|
||||||
|
if (this.logs.length <= this.maxLogs) return
|
||||||
|
const drop = this.logs.length - this.maxLogs
|
||||||
|
this.logs.splice(0, drop)
|
||||||
|
}
|
||||||
|
|
||||||
|
log(input: LogInput): ExportCardDiagLogEntry {
|
||||||
|
const ts = Number.isFinite(input.ts) ? Math.max(0, Math.floor(input.ts as number)) : Date.now()
|
||||||
|
const entry: ExportCardDiagLogEntry = {
|
||||||
|
id: this.nextId(ts),
|
||||||
|
ts,
|
||||||
|
source: input.source,
|
||||||
|
level: input.level || 'info',
|
||||||
|
message: input.message,
|
||||||
|
traceId: input.traceId,
|
||||||
|
stepId: input.stepId,
|
||||||
|
stepName: input.stepName,
|
||||||
|
status: input.status,
|
||||||
|
durationMs: Number.isFinite(input.durationMs) ? Math.max(0, Math.floor(input.durationMs as number)) : undefined,
|
||||||
|
data: input.data
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logs.push(entry)
|
||||||
|
this.trimLogs()
|
||||||
|
|
||||||
|
if (entry.traceId && entry.stepId && entry.stepName) {
|
||||||
|
const key = `${entry.traceId}::${entry.stepId}`
|
||||||
|
if (entry.status === 'running') {
|
||||||
|
const previous = this.activeSteps.get(key)
|
||||||
|
this.activeSteps.set(key, {
|
||||||
|
key,
|
||||||
|
traceId: entry.traceId,
|
||||||
|
stepId: entry.stepId,
|
||||||
|
stepName: entry.stepName,
|
||||||
|
source: entry.source,
|
||||||
|
startedAt: previous?.startedAt || entry.ts,
|
||||||
|
lastUpdatedAt: entry.ts,
|
||||||
|
message: entry.message
|
||||||
|
})
|
||||||
|
} else if (entry.status === 'done' || entry.status === 'failed' || entry.status === 'timeout') {
|
||||||
|
this.activeSteps.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
stepStart(input: StepStartInput): ExportCardDiagLogEntry {
|
||||||
|
return this.log({
|
||||||
|
source: input.source,
|
||||||
|
level: input.level || 'info',
|
||||||
|
message: input.message || `${input.stepName} 开始`,
|
||||||
|
traceId: input.traceId,
|
||||||
|
stepId: input.stepId,
|
||||||
|
stepName: input.stepName,
|
||||||
|
status: 'running',
|
||||||
|
data: input.data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
stepEnd(input: StepEndInput): ExportCardDiagLogEntry {
|
||||||
|
return this.log({
|
||||||
|
source: input.source,
|
||||||
|
level: input.level || (input.status === 'done' ? 'info' : 'warn'),
|
||||||
|
message: input.message || `${input.stepName} ${input.status === 'done' ? '完成' : '结束'}`,
|
||||||
|
traceId: input.traceId,
|
||||||
|
stepId: input.stepId,
|
||||||
|
stepName: input.stepName,
|
||||||
|
status: input.status || 'done',
|
||||||
|
durationMs: input.durationMs,
|
||||||
|
data: input.data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.logs = []
|
||||||
|
this.activeSteps.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(limit = 1200): ExportCardDiagSnapshot {
|
||||||
|
const capped = Number.isFinite(limit) ? Math.max(100, Math.min(5000, Math.floor(limit))) : 1200
|
||||||
|
const logs = this.logs.slice(-capped)
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
const activeSteps = Array.from(this.activeSteps.values())
|
||||||
|
.map(step => ({
|
||||||
|
traceId: step.traceId,
|
||||||
|
stepId: step.stepId,
|
||||||
|
stepName: step.stepName,
|
||||||
|
source: step.source,
|
||||||
|
startedAt: step.startedAt,
|
||||||
|
lastUpdatedAt: step.lastUpdatedAt,
|
||||||
|
elapsedMs: Math.max(0, now - step.startedAt),
|
||||||
|
stallMs: Math.max(0, now - step.lastUpdatedAt),
|
||||||
|
message: step.message
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt)
|
||||||
|
|
||||||
|
let errorCount = 0
|
||||||
|
let warnCount = 0
|
||||||
|
let timeoutCount = 0
|
||||||
|
for (const item of logs) {
|
||||||
|
if (item.level === 'error') errorCount += 1
|
||||||
|
if (item.level === 'warn') warnCount += 1
|
||||||
|
if (item.status === 'timeout') timeoutCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs,
|
||||||
|
activeSteps,
|
||||||
|
summary: {
|
||||||
|
totalLogs: this.logs.length,
|
||||||
|
activeStepCount: activeSteps.length,
|
||||||
|
errorCount,
|
||||||
|
warnCount,
|
||||||
|
timeoutCount,
|
||||||
|
lastUpdatedAt: logs.length > 0 ? logs[logs.length - 1].ts : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeExternalLogs(value: unknown[]): ExportCardDiagLogEntry[] {
|
||||||
|
const result: ExportCardDiagLogEntry[] = []
|
||||||
|
for (const item of value) {
|
||||||
|
if (!item || typeof item !== 'object') continue
|
||||||
|
const row = item as Record<string, unknown>
|
||||||
|
const tsRaw = row.ts ?? row.timestamp
|
||||||
|
const tsNum = Number(tsRaw)
|
||||||
|
const ts = Number.isFinite(tsNum) && tsNum > 0 ? Math.floor(tsNum) : Date.now()
|
||||||
|
|
||||||
|
const sourceRaw = String(row.source || 'frontend')
|
||||||
|
const source: ExportCardDiagSource = sourceRaw === 'main' || sourceRaw === 'backend' || sourceRaw === 'worker'
|
||||||
|
? sourceRaw
|
||||||
|
: 'frontend'
|
||||||
|
const levelRaw = String(row.level || 'info')
|
||||||
|
const level: ExportCardDiagLevel = levelRaw === 'debug' || levelRaw === 'warn' || levelRaw === 'error'
|
||||||
|
? levelRaw
|
||||||
|
: 'info'
|
||||||
|
|
||||||
|
const statusRaw = String(row.status || '')
|
||||||
|
const status: ExportCardDiagStatus | undefined = statusRaw === 'running' || statusRaw === 'done' || statusRaw === 'failed' || statusRaw === 'timeout'
|
||||||
|
? statusRaw
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const durationRaw = Number(row.durationMs)
|
||||||
|
result.push({
|
||||||
|
id: String(row.id || this.nextId(ts)),
|
||||||
|
ts,
|
||||||
|
source,
|
||||||
|
level,
|
||||||
|
message: String(row.message || ''),
|
||||||
|
traceId: typeof row.traceId === 'string' ? row.traceId : undefined,
|
||||||
|
stepId: typeof row.stepId === 'string' ? row.stepId : undefined,
|
||||||
|
stepName: typeof row.stepName === 'string' ? row.stepName : undefined,
|
||||||
|
status,
|
||||||
|
durationMs: Number.isFinite(durationRaw) ? Math.max(0, Math.floor(durationRaw)) : undefined,
|
||||||
|
data: row.data && typeof row.data === 'object' ? row.data as Record<string, unknown> : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeLogEntry(log: ExportCardDiagLogEntry): string {
|
||||||
|
return JSON.stringify(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSummaryText(logs: ExportCardDiagLogEntry[], activeSteps: ExportCardDiagSnapshot['activeSteps']): string {
|
||||||
|
const total = logs.length
|
||||||
|
let errorCount = 0
|
||||||
|
let warnCount = 0
|
||||||
|
let timeoutCount = 0
|
||||||
|
let frontendCount = 0
|
||||||
|
let backendCount = 0
|
||||||
|
let mainCount = 0
|
||||||
|
let workerCount = 0
|
||||||
|
|
||||||
|
for (const item of logs) {
|
||||||
|
if (item.level === 'error') errorCount += 1
|
||||||
|
if (item.level === 'warn') warnCount += 1
|
||||||
|
if (item.status === 'timeout') timeoutCount += 1
|
||||||
|
if (item.source === 'frontend') frontendCount += 1
|
||||||
|
if (item.source === 'backend') backendCount += 1
|
||||||
|
if (item.source === 'main') mainCount += 1
|
||||||
|
if (item.source === 'worker') workerCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = []
|
||||||
|
lines.push('WeFlow 导出卡片诊断摘要')
|
||||||
|
lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`)
|
||||||
|
lines.push(`日志总数: ${total}`)
|
||||||
|
lines.push(`来源统计: frontend=${frontendCount}, main=${mainCount}, backend=${backendCount}, worker=${workerCount}`)
|
||||||
|
lines.push(`级别统计: warn=${warnCount}, error=${errorCount}, timeout=${timeoutCount}`)
|
||||||
|
lines.push(`当前活跃步骤: ${activeSteps.length}`)
|
||||||
|
|
||||||
|
if (activeSteps.length > 0) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('活跃步骤:')
|
||||||
|
for (const step of activeSteps.slice(0, 12)) {
|
||||||
|
lines.push(`- [${step.source}] ${step.stepName} trace=${step.traceId} elapsed=${step.elapsedMs}ms stall=${step.stallMs}ms`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestErrors = logs.filter(item => item.level === 'error' || item.status === 'failed' || item.status === 'timeout').slice(-12)
|
||||||
|
if (latestErrors.length > 0) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('最近异常:')
|
||||||
|
for (const item of latestErrors) {
|
||||||
|
lines.push(`- ${new Date(item.ts).toLocaleTimeString('zh-CN')} [${item.source}] ${item.stepName || item.stepId || 'unknown'} ${item.status || item.level} ${item.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportCombinedLogs(filePath: string, frontendLogs: unknown[] = []): Promise<{
|
||||||
|
success: boolean
|
||||||
|
filePath?: string
|
||||||
|
summaryPath?: string
|
||||||
|
count?: number
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const normalizedFrontend = this.normalizeExternalLogs(Array.isArray(frontendLogs) ? frontendLogs : [])
|
||||||
|
const merged = [...this.logs, ...normalizedFrontend]
|
||||||
|
.sort((a, b) => (a.ts - b.ts) || a.id.localeCompare(b.id))
|
||||||
|
|
||||||
|
const lines = merged.map(item => this.serializeLogEntry(item)).join('\n')
|
||||||
|
await mkdir(dirname(filePath), { recursive: true })
|
||||||
|
await writeFile(filePath, lines ? `${lines}\n` : '', 'utf8')
|
||||||
|
|
||||||
|
const ext = extname(filePath)
|
||||||
|
const baseName = ext ? basename(filePath, ext) : basename(filePath)
|
||||||
|
const summaryPath = join(dirname(filePath), `${baseName}.txt`)
|
||||||
|
const snapshot = this.snapshot(1500)
|
||||||
|
const summaryText = this.buildSummaryText(merged, snapshot.activeSteps)
|
||||||
|
await writeFile(summaryPath, summaryText, 'utf8')
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath,
|
||||||
|
summaryPath,
|
||||||
|
count: merged.length
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exportCardDiagnosticsService = new ExportCardDiagnosticsService()
|
||||||
229
electron/services/exportContentStatsCacheService.ts
Normal file
229
electron/services/exportContentStatsCacheService.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
|
const CACHE_VERSION = 1
|
||||||
|
const MAX_SCOPE_ENTRIES = 12
|
||||||
|
const MAX_SESSION_ENTRIES_PER_SCOPE = 6000
|
||||||
|
|
||||||
|
export interface ExportContentSessionStatsEntry {
|
||||||
|
updatedAt: number
|
||||||
|
hasAny: boolean
|
||||||
|
hasVoice: boolean
|
||||||
|
hasImage: boolean
|
||||||
|
hasVideo: boolean
|
||||||
|
hasEmoji: boolean
|
||||||
|
mediaReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportContentScopeStatsEntry {
|
||||||
|
updatedAt: number
|
||||||
|
sessions: Record<string, ExportContentSessionStatsEntry>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportContentStatsStore {
|
||||||
|
version: number
|
||||||
|
scopes: Record<string, ExportContentScopeStatsEntry>
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNonNegativeInt(value: unknown): number | undefined {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||||
|
return Math.max(0, Math.floor(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBoolean(value: unknown, fallback = false): boolean {
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSessionStatsEntry(raw: unknown): ExportContentSessionStatsEntry | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||||
|
if (updatedAt === undefined) return null
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
hasAny: toBoolean(source.hasAny, false),
|
||||||
|
hasVoice: toBoolean(source.hasVoice, false),
|
||||||
|
hasImage: toBoolean(source.hasImage, false),
|
||||||
|
hasVideo: toBoolean(source.hasVideo, false),
|
||||||
|
hasEmoji: toBoolean(source.hasEmoji, false),
|
||||||
|
mediaReady: toBoolean(source.mediaReady, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScopeStatsEntry(raw: unknown): ExportContentScopeStatsEntry | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||||
|
if (updatedAt === undefined) return null
|
||||||
|
|
||||||
|
const sessionsRaw = source.sessions
|
||||||
|
if (!sessionsRaw || typeof sessionsRaw !== 'object') {
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
sessions: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions: Record<string, ExportContentSessionStatsEntry> = {}
|
||||||
|
for (const [sessionId, entryRaw] of Object.entries(sessionsRaw as Record<string, unknown>)) {
|
||||||
|
const normalized = normalizeSessionStatsEntry(entryRaw)
|
||||||
|
if (!normalized) continue
|
||||||
|
sessions[sessionId] = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
sessions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneScope(scope: ExportContentScopeStatsEntry): ExportContentScopeStatsEntry {
|
||||||
|
return {
|
||||||
|
updatedAt: scope.updatedAt,
|
||||||
|
sessions: Object.fromEntries(
|
||||||
|
Object.entries(scope.sessions).map(([sessionId, entry]) => [sessionId, { ...entry }])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExportContentStatsCacheService {
|
||||||
|
private readonly cacheFilePath: string
|
||||||
|
private store: ExportContentStatsStore = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
scopes: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(cacheBasePath?: string) {
|
||||||
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
|
? cacheBasePath
|
||||||
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
|
this.cacheFilePath = join(basePath, 'export-content-stats.json')
|
||||||
|
this.ensureCacheDir()
|
||||||
|
this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCacheDir(): void {
|
||||||
|
const dir = dirname(this.cacheFilePath)
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
if (!existsSync(this.cacheFilePath)) return
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parsed as Record<string, unknown>
|
||||||
|
const scopesRaw = payload.scopes
|
||||||
|
if (!scopesRaw || typeof scopesRaw !== 'object') {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes: Record<string, ExportContentScopeStatsEntry> = {}
|
||||||
|
for (const [scopeKey, scopeRaw] of Object.entries(scopesRaw as Record<string, unknown>)) {
|
||||||
|
const normalizedScope = normalizeScopeStatsEntry(scopeRaw)
|
||||||
|
if (!normalizedScope) continue
|
||||||
|
scopes[scopeKey] = normalizedScope
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
scopes
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ExportContentStatsCacheService: 载入缓存失败', error)
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getScope(scopeKey: string): ExportContentScopeStatsEntry | undefined {
|
||||||
|
if (!scopeKey) return undefined
|
||||||
|
const rawScope = this.store.scopes[scopeKey]
|
||||||
|
if (!rawScope) return undefined
|
||||||
|
const normalizedScope = normalizeScopeStatsEntry(rawScope)
|
||||||
|
if (!normalizedScope) {
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
this.persist()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
this.store.scopes[scopeKey] = normalizedScope
|
||||||
|
return cloneScope(normalizedScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
setScope(scopeKey: string, scope: ExportContentScopeStatsEntry): void {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const normalized = normalizeScopeStatsEntry(scope)
|
||||||
|
if (!normalized) return
|
||||||
|
this.store.scopes[scopeKey] = normalized
|
||||||
|
this.trimScope(scopeKey)
|
||||||
|
this.trimScopes()
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSession(scopeKey: string, sessionId: string): void {
|
||||||
|
if (!scopeKey || !sessionId) return
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return
|
||||||
|
if (!(sessionId in scope.sessions)) return
|
||||||
|
delete scope.sessions[sessionId]
|
||||||
|
if (Object.keys(scope.sessions).length === 0) {
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
} else {
|
||||||
|
scope.updatedAt = Date.now()
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScope(scopeKey: string): void {
|
||||||
|
if (!scopeKey) return
|
||||||
|
if (!this.store.scopes[scopeKey]) return
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAll(): void {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
try {
|
||||||
|
rmSync(this.cacheFilePath, { force: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ExportContentStatsCacheService: 清理缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimScope(scopeKey: string): void {
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return
|
||||||
|
|
||||||
|
const entries = Object.entries(scope.sessions)
|
||||||
|
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
|
||||||
|
|
||||||
|
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||||
|
scope.sessions = Object.fromEntries(entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE))
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimScopes(): void {
|
||||||
|
const scopeEntries = Object.entries(this.store.scopes)
|
||||||
|
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
|
||||||
|
|
||||||
|
scopeEntries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||||
|
this.store.scopes = Object.fromEntries(scopeEntries.slice(0, MAX_SCOPE_ENTRIES))
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
try {
|
||||||
|
this.ensureCacheDir()
|
||||||
|
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ExportContentStatsCacheService: 持久化缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
electron/services/exportRecordService.ts
Normal file
95
electron/services/exportRecordService.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export interface ExportRecord {
|
||||||
|
exportTime: number
|
||||||
|
format: string
|
||||||
|
messageCount: number
|
||||||
|
sourceLatestMessageTimestamp?: number
|
||||||
|
outputPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordStore = Record<string, ExportRecord[]>
|
||||||
|
|
||||||
|
class ExportRecordService {
|
||||||
|
private filePath: string | null = null
|
||||||
|
private loaded = false
|
||||||
|
private store: RecordStore = {}
|
||||||
|
|
||||||
|
private resolveFilePath(): string {
|
||||||
|
if (this.filePath) return this.filePath
|
||||||
|
const userDataPath = app.getPath('userData')
|
||||||
|
fs.mkdirSync(userDataPath, { recursive: true })
|
||||||
|
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
|
||||||
|
return this.filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureLoaded(): void {
|
||||||
|
if (this.loaded) return
|
||||||
|
this.loaded = true
|
||||||
|
const filePath = this.resolveFilePath()
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
this.store = parsed as RecordStore
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.store = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
try {
|
||||||
|
const filePath = this.resolveFilePath()
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(this.store), 'utf-8')
|
||||||
|
} catch {
|
||||||
|
// ignore persist errors to avoid blocking export flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getLatestRecord(sessionId: string, format: string): ExportRecord | null {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const records = this.store[sessionId]
|
||||||
|
if (!records || records.length === 0) return null
|
||||||
|
for (let i = records.length - 1; i >= 0; i--) {
|
||||||
|
const record = records[i]
|
||||||
|
if (record && record.format === format) return record
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
saveRecord(
|
||||||
|
sessionId: string,
|
||||||
|
format: string,
|
||||||
|
messageCount: number,
|
||||||
|
extra?: {
|
||||||
|
sourceLatestMessageTimestamp?: number
|
||||||
|
outputPath?: string
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
|
if (!normalizedSessionId) return
|
||||||
|
if (!this.store[normalizedSessionId]) {
|
||||||
|
this.store[normalizedSessionId] = []
|
||||||
|
}
|
||||||
|
const list = this.store[normalizedSessionId]
|
||||||
|
list.push({
|
||||||
|
exportTime: Date.now(),
|
||||||
|
format,
|
||||||
|
messageCount,
|
||||||
|
sourceLatestMessageTimestamp: extra?.sourceLatestMessageTimestamp,
|
||||||
|
outputPath: extra?.outputPath
|
||||||
|
})
|
||||||
|
// keep the latest 30 records per session
|
||||||
|
if (list.length > 30) {
|
||||||
|
this.store[normalizedSessionId] = list.slice(-30)
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exportRecordService = new ExportRecordService()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,12 @@ export interface GroupMember {
|
|||||||
alias?: string
|
alias?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
groupNickname?: string
|
groupNickname?: string
|
||||||
|
isOwner?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupMembersPanelEntry extends GroupMember {
|
||||||
|
isFriend: boolean
|
||||||
|
messageCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupMessageRank {
|
export interface GroupMessageRank {
|
||||||
@@ -43,8 +49,28 @@ export interface GroupMediaStats {
|
|||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GroupMemberContactInfo {
|
||||||
|
remark: string
|
||||||
|
nickName: string
|
||||||
|
alias: string
|
||||||
|
username: string
|
||||||
|
userName: string
|
||||||
|
encryptUsername: string
|
||||||
|
encryptUserName: string
|
||||||
|
localType: number
|
||||||
|
}
|
||||||
|
|
||||||
class GroupAnalyticsService {
|
class GroupAnalyticsService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
|
private readonly groupMembersPanelCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private readonly groupMembersPanelMembersTimeoutMs = 12 * 1000
|
||||||
|
private readonly groupMembersPanelFullTimeoutMs = 25 * 1000
|
||||||
|
private readonly groupMembersPanelCache = new Map<string, { updatedAt: number; data: GroupMembersPanelEntry[] }>()
|
||||||
|
private readonly groupMembersPanelInFlight = new Map<
|
||||||
|
string,
|
||||||
|
Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }>
|
||||||
|
>()
|
||||||
|
private readonly friendExcludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
@@ -89,6 +115,128 @@ class GroupAnalyticsService {
|
|||||||
return cleaned
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveMemberUsername(
|
||||||
|
candidate: unknown,
|
||||||
|
memberLookup: Map<string, string>
|
||||||
|
): string | null {
|
||||||
|
if (typeof candidate !== 'string') return null
|
||||||
|
const raw = candidate.trim()
|
||||||
|
if (!raw) return null
|
||||||
|
if (memberLookup.has(raw)) return memberLookup.get(raw) || null
|
||||||
|
const cleaned = this.cleanAccountDirName(raw)
|
||||||
|
if (memberLookup.has(cleaned)) return memberLookup.get(cleaned) || null
|
||||||
|
|
||||||
|
const parts = raw.split(/[,\s;|]+/).filter(Boolean)
|
||||||
|
for (const part of parts) {
|
||||||
|
if (memberLookup.has(part)) return memberLookup.get(part) || null
|
||||||
|
const normalizedPart = this.cleanAccountDirName(part)
|
||||||
|
if (memberLookup.has(normalizedPart)) return memberLookup.get(normalizedPart) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((raw.startsWith('{') || raw.startsWith('[')) && raw.length < 4096) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return this.extractOwnerUsername(parsed, memberLookup, 0)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractOwnerUsername(
|
||||||
|
value: unknown,
|
||||||
|
memberLookup: Map<string, string>,
|
||||||
|
depth: number
|
||||||
|
): string | null {
|
||||||
|
if (depth > 4 || value == null) return null
|
||||||
|
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return null
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return this.resolveMemberUsername(value, memberLookup)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
const owner = this.extractOwnerUsername(item, memberLookup, depth + 1)
|
||||||
|
if (owner) return owner
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'object') return null
|
||||||
|
const row = value as Record<string, unknown>
|
||||||
|
|
||||||
|
for (const [key, entry] of Object.entries(row)) {
|
||||||
|
const keyLower = key.toLowerCase()
|
||||||
|
if (!keyLower.includes('owner') && !keyLower.includes('host') && !keyLower.includes('creator')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof entry === 'boolean') {
|
||||||
|
if (entry && typeof row.username === 'string') {
|
||||||
|
const owner = this.resolveMemberUsername(row.username, memberLookup)
|
||||||
|
if (owner) return owner
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = this.extractOwnerUsername(entry, memberLookup, depth + 1)
|
||||||
|
if (owner) return owner
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detectGroupOwnerUsername(
|
||||||
|
chatroomId: string,
|
||||||
|
members: Array<{ username: string; [key: string]: unknown }>
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const memberLookup = new Map<string, string>()
|
||||||
|
for (const member of members) {
|
||||||
|
const username = String(member.username || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
const cleaned = this.cleanAccountDirName(username)
|
||||||
|
memberLookup.set(username, username)
|
||||||
|
memberLookup.set(cleaned, username)
|
||||||
|
}
|
||||||
|
if (memberLookup.size === 0) return undefined
|
||||||
|
|
||||||
|
const tryResolve = (candidate: unknown): string | undefined => {
|
||||||
|
const owner = this.extractOwnerUsername(candidate, memberLookup, 0)
|
||||||
|
return owner || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const owner = tryResolve(member)
|
||||||
|
if (owner) return owner
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groupContact = await wcdbService.getContact(chatroomId)
|
||||||
|
if (groupContact.success && groupContact.contact) {
|
||||||
|
const owner = tryResolve(groupContact.contact)
|
||||||
|
if (owner) return owner
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
||||||
|
const roomResult = await wcdbService.execQuery('contact', null, `SELECT * FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`)
|
||||||
|
if (roomResult.success && roomResult.rows && roomResult.rows.length > 0) {
|
||||||
|
const owner = tryResolve(roomResult.rows[0])
|
||||||
|
if (owner) return owner
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
||||||
const wxid = this.configService.get('myWxid')
|
const wxid = this.configService.get('myWxid')
|
||||||
const dbPath = this.configService.get('dbPath')
|
const dbPath = this.configService.get('dbPath')
|
||||||
@@ -296,6 +444,203 @@ class GroupAnalyticsService {
|
|||||||
return Array.from(set)
|
return Array.from(set)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toNonNegativeInteger(value: unknown): number {
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (!Number.isFinite(parsed)) return 0
|
||||||
|
return Math.max(0, Math.floor(parsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickStringField(row: Record<string, unknown>, keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = row[key]
|
||||||
|
if (value == null) continue
|
||||||
|
const text = String(value).trim()
|
||||||
|
if (text) return text
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickIntegerField(row: Record<string, unknown>, keys: string[], fallback: number = 0): number {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = row[key]
|
||||||
|
if (value == null || value === '') continue
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (Number.isFinite(parsed)) return Math.floor(parsed)
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildGroupMembersPanelCacheKey(chatroomId: string, includeMessageCounts: boolean): string {
|
||||||
|
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||||
|
const wxid = this.cleanAccountDirName(String(this.configService.get('myWxid') || '').trim())
|
||||||
|
const mode = includeMessageCounts ? 'full' : 'members'
|
||||||
|
return `${dbPath}::${wxid}::${chatroomId}::${mode}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneGroupMembersPanelCache(maxEntries: number = 80): void {
|
||||||
|
if (this.groupMembersPanelCache.size <= maxEntries) return
|
||||||
|
const entries = Array.from(this.groupMembersPanelCache.entries())
|
||||||
|
.sort((a, b) => a[1].updatedAt - b[1].updatedAt)
|
||||||
|
const removeCount = this.groupMembersPanelCache.size - maxEntries
|
||||||
|
for (let i = 0; i < removeCount; i += 1) {
|
||||||
|
this.groupMembersPanelCache.delete(entries[i][0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async withPromiseTimeout<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
timeoutResult: T
|
||||||
|
): Promise<T> {
|
||||||
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const timeoutPromise = new Promise<T>((resolve) => {
|
||||||
|
timeoutTimer = setTimeout(() => {
|
||||||
|
resolve(timeoutResult)
|
||||||
|
}, timeoutMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([promise, timeoutPromise])
|
||||||
|
} finally {
|
||||||
|
if (timeoutTimer) {
|
||||||
|
clearTimeout(timeoutTimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildGroupMemberContactLookup(usernames: string[]): Promise<Map<string, GroupMemberContactInfo>> {
|
||||||
|
const lookup = new Map<string, GroupMemberContactInfo>()
|
||||||
|
const candidates = this.buildIdCandidates(usernames)
|
||||||
|
if (candidates.length === 0) return lookup
|
||||||
|
|
||||||
|
const appendContactsToLookup = (rows: Record<string, unknown>[]) => {
|
||||||
|
for (const row of rows) {
|
||||||
|
const contact: GroupMemberContactInfo = {
|
||||||
|
remark: this.pickStringField(row, ['remark', 'WCDB_CT_remark']),
|
||||||
|
nickName: this.pickStringField(row, ['nick_name', 'nickName', 'WCDB_CT_nick_name']),
|
||||||
|
alias: this.pickStringField(row, ['alias', 'WCDB_CT_alias']),
|
||||||
|
username: this.pickStringField(row, ['username', 'WCDB_CT_username']),
|
||||||
|
userName: this.pickStringField(row, ['user_name', 'userName', 'WCDB_CT_user_name']),
|
||||||
|
encryptUsername: this.pickStringField(row, ['encrypt_username', 'encryptUsername', 'WCDB_CT_encrypt_username']),
|
||||||
|
encryptUserName: this.pickStringField(row, ['encrypt_user_name', 'encryptUserName', 'WCDB_CT_encrypt_user_name']),
|
||||||
|
localType: this.pickIntegerField(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
|
||||||
|
}
|
||||||
|
const lookupKeys = this.buildIdCandidates([
|
||||||
|
contact.username,
|
||||||
|
contact.userName,
|
||||||
|
contact.encryptUsername,
|
||||||
|
contact.encryptUserName,
|
||||||
|
contact.alias
|
||||||
|
])
|
||||||
|
for (const key of lookupKeys) {
|
||||||
|
const normalized = key.toLowerCase()
|
||||||
|
if (!lookup.has(normalized)) {
|
||||||
|
lookup.set(normalized, contact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = 200
|
||||||
|
for (let i = 0; i < candidates.length; i += batchSize) {
|
||||||
|
const batch = candidates.slice(i, i + batchSize)
|
||||||
|
if (batch.length === 0) continue
|
||||||
|
|
||||||
|
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
|
||||||
|
const lightweightSql = `
|
||||||
|
SELECT username, user_name, encrypt_username, encrypt_user_name, remark, nick_name, alias, local_type
|
||||||
|
FROM contact
|
||||||
|
WHERE username IN (${inList})
|
||||||
|
`
|
||||||
|
let result = await wcdbService.execQuery('contact', null, lightweightSql)
|
||||||
|
if (!result.success || !result.rows) {
|
||||||
|
// 兼容历史/变体列名,轻查询失败时回退全字段查询,避免好友标识丢失
|
||||||
|
result = await wcdbService.execQuery('contact', null, `SELECT * FROM contact WHERE username IN (${inList})`)
|
||||||
|
}
|
||||||
|
if (!result.success || !result.rows) continue
|
||||||
|
appendContactsToLookup(result.rows as Record<string, unknown>[])
|
||||||
|
}
|
||||||
|
return lookup
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveContactByCandidates(
|
||||||
|
lookup: Map<string, GroupMemberContactInfo>,
|
||||||
|
candidates: Array<string | undefined | null>
|
||||||
|
): GroupMemberContactInfo | undefined {
|
||||||
|
const ids = this.buildIdCandidates(candidates)
|
||||||
|
for (const id of ids) {
|
||||||
|
const hit = lookup.get(id.toLowerCase())
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildGroupMessageCountLookup(chatroomId: string): Promise<Map<string, number>> {
|
||||||
|
const lookup = new Map<string, number>()
|
||||||
|
const result = await wcdbService.getGroupStats(chatroomId, 0, 0)
|
||||||
|
if (!result.success || !result.data) return lookup
|
||||||
|
|
||||||
|
const sessionData = result.data?.sessions?.[chatroomId]
|
||||||
|
if (!sessionData || !sessionData.senders) return lookup
|
||||||
|
|
||||||
|
const idMap = result.data.idMap || {}
|
||||||
|
for (const [senderId, rawCount] of Object.entries(sessionData.senders as Record<string, number>)) {
|
||||||
|
const username = String(idMap[senderId] || senderId || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
const count = this.toNonNegativeInteger(rawCount)
|
||||||
|
const keys = this.buildIdCandidates([username])
|
||||||
|
for (const key of keys) {
|
||||||
|
const normalized = key.toLowerCase()
|
||||||
|
const prev = lookup.get(normalized) || 0
|
||||||
|
if (count > prev) {
|
||||||
|
lookup.set(normalized, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lookup
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveMessageCountByCandidates(
|
||||||
|
lookup: Map<string, number>,
|
||||||
|
candidates: Array<string | undefined | null>
|
||||||
|
): number {
|
||||||
|
let maxCount = 0
|
||||||
|
const ids = this.buildIdCandidates(candidates)
|
||||||
|
for (const id of ids) {
|
||||||
|
const count = lookup.get(id.toLowerCase())
|
||||||
|
if (typeof count === 'number' && count > maxCount) {
|
||||||
|
maxCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private isFriendMember(wxid: string, contact?: GroupMemberContactInfo): boolean {
|
||||||
|
const normalizedWxid = String(wxid || '').trim().toLowerCase()
|
||||||
|
if (!normalizedWxid) return false
|
||||||
|
if (normalizedWxid.includes('@chatroom') || normalizedWxid.startsWith('gh_')) return false
|
||||||
|
if (this.friendExcludeNames.has(normalizedWxid)) return false
|
||||||
|
if (!contact) return false
|
||||||
|
return contact.localType === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortGroupMembersPanelEntries(members: GroupMembersPanelEntry[]): GroupMembersPanelEntry[] {
|
||||||
|
return members.sort((a, b) => {
|
||||||
|
const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner))
|
||||||
|
if (ownerDiff !== 0) return ownerDiff
|
||||||
|
|
||||||
|
const friendDiff = Number(Boolean(b.isFriend)) - Number(Boolean(a.isFriend))
|
||||||
|
if (friendDiff !== 0) return friendDiff
|
||||||
|
|
||||||
|
if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount
|
||||||
|
return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
|
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
|
||||||
const idCandidates = this.buildIdCandidates(candidates)
|
const idCandidates = this.buildIdCandidates(candidates)
|
||||||
if (idCandidates.length === 0) return ''
|
if (idCandidates.length === 0) return ''
|
||||||
@@ -483,6 +828,167 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadGroupMembersPanelDataFresh(
|
||||||
|
chatroomId: string,
|
||||||
|
includeMessageCounts: boolean
|
||||||
|
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string }> {
|
||||||
|
const membersResult = await wcdbService.getGroupMembers(chatroomId)
|
||||||
|
if (!membersResult.success || !membersResult.members) {
|
||||||
|
return { success: false, error: membersResult.error || '获取群成员失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = membersResult.members as Array<{
|
||||||
|
username: string
|
||||||
|
avatarUrl?: string
|
||||||
|
originalName?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}>
|
||||||
|
if (members.length === 0) return { success: true, data: [] }
|
||||||
|
|
||||||
|
const usernames = members
|
||||||
|
.map((member) => String(member.username || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (usernames.length === 0) return { success: true, data: [] }
|
||||||
|
|
||||||
|
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
|
||||||
|
const contactLookupPromise = this.buildGroupMemberContactLookup(usernames)
|
||||||
|
const ownerPromise = this.detectGroupOwnerUsername(chatroomId, members)
|
||||||
|
const messageCountLookupPromise = includeMessageCounts
|
||||||
|
? this.buildGroupMessageCountLookup(chatroomId)
|
||||||
|
: Promise.resolve(new Map<string, number>())
|
||||||
|
|
||||||
|
const [displayNames, contactLookup, ownerUsername, messageCountLookup] = await Promise.all([
|
||||||
|
displayNamesPromise,
|
||||||
|
contactLookupPromise,
|
||||||
|
ownerPromise,
|
||||||
|
messageCountLookupPromise
|
||||||
|
])
|
||||||
|
|
||||||
|
const nicknameCandidates = this.buildIdCandidates([
|
||||||
|
...members.map((member) => member.username),
|
||||||
|
...members.map((member) => member.originalName),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.username),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.userName),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUsername),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUserName),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.alias)
|
||||||
|
])
|
||||||
|
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||||
|
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||||
|
let myGroupMessageCountHint: number | undefined
|
||||||
|
|
||||||
|
const data: GroupMembersPanelEntry[] = members
|
||||||
|
.map((member) => {
|
||||||
|
const wxid = String(member.username || '').trim()
|
||||||
|
if (!wxid) return null
|
||||||
|
|
||||||
|
const contact = this.resolveContactByCandidates(contactLookup, [wxid, member.originalName])
|
||||||
|
const nickname = contact?.nickName || ''
|
||||||
|
const remark = contact?.remark || ''
|
||||||
|
const alias = contact?.alias || ''
|
||||||
|
const normalizedWxid = this.cleanAccountDirName(wxid)
|
||||||
|
const lookupCandidates = this.buildIdCandidates([
|
||||||
|
wxid,
|
||||||
|
member.originalName as string | undefined,
|
||||||
|
contact?.username,
|
||||||
|
contact?.userName,
|
||||||
|
contact?.encryptUsername,
|
||||||
|
contact?.encryptUserName,
|
||||||
|
alias
|
||||||
|
])
|
||||||
|
if (normalizedWxid === myWxid) {
|
||||||
|
lookupCandidates.push(myWxid)
|
||||||
|
}
|
||||||
|
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
|
||||||
|
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: wxid,
|
||||||
|
displayName,
|
||||||
|
nickname,
|
||||||
|
alias,
|
||||||
|
remark,
|
||||||
|
groupNickname,
|
||||||
|
avatarUrl: member.avatarUrl,
|
||||||
|
isOwner: Boolean(ownerUsername && ownerUsername === wxid),
|
||||||
|
isFriend: this.isFriendMember(wxid, contact),
|
||||||
|
messageCount: this.resolveMessageCountByCandidates(messageCountLookup, lookupCandidates)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((member): member is GroupMembersPanelEntry => Boolean(member))
|
||||||
|
|
||||||
|
if (includeMessageCounts && myWxid) {
|
||||||
|
const selfEntry = data.find((member) => this.cleanAccountDirName(member.username) === myWxid)
|
||||||
|
if (selfEntry && Number.isFinite(selfEntry.messageCount)) {
|
||||||
|
myGroupMessageCountHint = Math.max(0, Math.floor(selfEntry.messageCount))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeMessageCounts && Number.isFinite(myGroupMessageCountHint)) {
|
||||||
|
void chatService.setGroupMyMessageCountHint(chatroomId, myGroupMessageCountHint as number)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: this.sortGroupMembersPanelEntries(data) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupMembersPanelData(
|
||||||
|
chatroomId: string,
|
||||||
|
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
|
||||||
|
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> {
|
||||||
|
try {
|
||||||
|
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||||
|
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
|
||||||
|
|
||||||
|
const forceRefresh = Boolean(options?.forceRefresh)
|
||||||
|
const includeMessageCounts = options?.includeMessageCounts !== false
|
||||||
|
const cacheKey = this.buildGroupMembersPanelCacheKey(normalizedChatroomId, includeMessageCounts)
|
||||||
|
const now = Date.now()
|
||||||
|
const cached = this.groupMembersPanelCache.get(cacheKey)
|
||||||
|
if (!forceRefresh && cached && now - cached.updatedAt < this.groupMembersPanelCacheTtlMs) {
|
||||||
|
return { success: true, data: cached.data, fromCache: true, updatedAt: cached.updatedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const pending = this.groupMembersPanelInFlight.get(cacheKey)
|
||||||
|
if (pending) return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestPromise = (async () => {
|
||||||
|
const conn = await this.ensureConnected()
|
||||||
|
if (!conn.success) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const timeoutMs = includeMessageCounts
|
||||||
|
? this.groupMembersPanelFullTimeoutMs
|
||||||
|
: this.groupMembersPanelMembersTimeoutMs
|
||||||
|
const fresh = await this.withPromiseTimeout(
|
||||||
|
this.loadGroupMembersPanelDataFresh(normalizedChatroomId, includeMessageCounts),
|
||||||
|
timeoutMs,
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: includeMessageCounts
|
||||||
|
? '群成员发言统计加载超时,请稍后重试'
|
||||||
|
: '群成员列表加载超时,请稍后重试'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!fresh.success || !fresh.data) {
|
||||||
|
return { success: false, error: fresh.error || '获取群成员面板数据失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = Date.now()
|
||||||
|
this.groupMembersPanelCache.set(cacheKey, { updatedAt, data: fresh.data })
|
||||||
|
this.pruneGroupMembersPanelCache()
|
||||||
|
return { success: true, data: fresh.data, fromCache: false, updatedAt }
|
||||||
|
})().finally(() => {
|
||||||
|
this.groupMembersPanelInFlight.delete(cacheKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.groupMembersPanelInFlight.set(cacheKey, requestPromise)
|
||||||
|
return await requestPromise
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> {
|
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
@@ -497,6 +1003,7 @@ class GroupAnalyticsService {
|
|||||||
username: string
|
username: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
originalName?: string
|
originalName?: string
|
||||||
|
[key: string]: unknown
|
||||||
}>
|
}>
|
||||||
const usernames = members.map((m) => m.username).filter(Boolean)
|
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||||
|
|
||||||
@@ -543,6 +1050,7 @@ class GroupAnalyticsService {
|
|||||||
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||||
|
|
||||||
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||||
|
const ownerUsername = await this.detectGroupOwnerUsername(chatroomId, members)
|
||||||
const data: GroupMember[] = members.map((m) => {
|
const data: GroupMember[] = members.map((m) => {
|
||||||
const wxid = m.username || ''
|
const wxid = m.username || ''
|
||||||
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
|
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
|
||||||
@@ -572,7 +1080,8 @@ class GroupAnalyticsService {
|
|||||||
alias,
|
alias,
|
||||||
remark,
|
remark,
|
||||||
groupNickname,
|
groupNickname,
|
||||||
avatarUrl: m.avatarUrl
|
avatarUrl: m.avatarUrl,
|
||||||
|
isOwner: Boolean(ownerUsername && ownerUsername === wxid)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
204
electron/services/groupMyMessageCountCacheService.ts
Normal file
204
electron/services/groupMyMessageCountCacheService.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
|
const CACHE_VERSION = 1
|
||||||
|
const MAX_GROUP_ENTRIES_PER_SCOPE = 3000
|
||||||
|
const MAX_SCOPE_ENTRIES = 12
|
||||||
|
|
||||||
|
export interface GroupMyMessageCountCacheEntry {
|
||||||
|
updatedAt: number
|
||||||
|
messageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupMyMessageCountScopeMap {
|
||||||
|
[chatroomId: string]: GroupMyMessageCountCacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupMyMessageCountCacheStore {
|
||||||
|
version: number
|
||||||
|
scopes: Record<string, GroupMyMessageCountScopeMap>
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNonNegativeInt(value: unknown): number | undefined {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||||
|
return Math.max(0, Math.floor(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntry(raw: unknown): GroupMyMessageCountCacheEntry | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||||
|
const messageCount = toNonNegativeInt(source.messageCount)
|
||||||
|
if (updatedAt === undefined || messageCount === undefined) return null
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
messageCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GroupMyMessageCountCacheService {
|
||||||
|
private readonly cacheFilePath: string
|
||||||
|
private store: GroupMyMessageCountCacheStore = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
scopes: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(cacheBasePath?: string) {
|
||||||
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
|
? cacheBasePath
|
||||||
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
|
this.cacheFilePath = join(basePath, 'group-my-message-counts.json')
|
||||||
|
this.ensureCacheDir()
|
||||||
|
this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCacheDir(): void {
|
||||||
|
const dir = dirname(this.cacheFilePath)
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
if (!existsSync(this.cacheFilePath)) return
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parsed as Record<string, unknown>
|
||||||
|
const scopesRaw = payload.scopes
|
||||||
|
if (!scopesRaw || typeof scopesRaw !== 'object') {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes: Record<string, GroupMyMessageCountScopeMap> = {}
|
||||||
|
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
|
||||||
|
if (!scopeValue || typeof scopeValue !== 'object') continue
|
||||||
|
const normalizedScope: GroupMyMessageCountScopeMap = {}
|
||||||
|
for (const [chatroomId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
|
||||||
|
const entry = normalizeEntry(entryRaw)
|
||||||
|
if (!entry) continue
|
||||||
|
normalizedScope[chatroomId] = entry
|
||||||
|
}
|
||||||
|
if (Object.keys(normalizedScope).length > 0) {
|
||||||
|
scopes[scopeKey] = normalizedScope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
scopes
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GroupMyMessageCountCacheService: 载入缓存失败', error)
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(scopeKey: string, chatroomId: string): GroupMyMessageCountCacheEntry | undefined {
|
||||||
|
if (!scopeKey || !chatroomId) return undefined
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return undefined
|
||||||
|
const entry = normalizeEntry(scope[chatroomId])
|
||||||
|
if (!entry) {
|
||||||
|
delete scope[chatroomId]
|
||||||
|
if (Object.keys(scope).length === 0) {
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
set(scopeKey: string, chatroomId: string, entry: GroupMyMessageCountCacheEntry): void {
|
||||||
|
if (!scopeKey || !chatroomId) return
|
||||||
|
const normalized = normalizeEntry(entry)
|
||||||
|
if (!normalized) return
|
||||||
|
|
||||||
|
if (!this.store.scopes[scopeKey]) {
|
||||||
|
this.store.scopes[scopeKey] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = this.store.scopes[scopeKey][chatroomId]
|
||||||
|
if (existing && existing.updatedAt > normalized.updatedAt) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.scopes[scopeKey][chatroomId] = normalized
|
||||||
|
this.trimScope(scopeKey)
|
||||||
|
this.trimScopes()
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(scopeKey: string, chatroomId: string): void {
|
||||||
|
if (!scopeKey || !chatroomId) return
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return
|
||||||
|
if (!(chatroomId in scope)) return
|
||||||
|
delete scope[chatroomId]
|
||||||
|
if (Object.keys(scope).length === 0) {
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScope(scopeKey: string): void {
|
||||||
|
if (!scopeKey) return
|
||||||
|
if (!this.store.scopes[scopeKey]) return
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAll(): void {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
try {
|
||||||
|
rmSync(this.cacheFilePath, { force: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GroupMyMessageCountCacheService: 清理缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimScope(scopeKey: string): void {
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return
|
||||||
|
const entries = Object.entries(scope)
|
||||||
|
if (entries.length <= MAX_GROUP_ENTRIES_PER_SCOPE) return
|
||||||
|
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||||
|
const trimmed: GroupMyMessageCountScopeMap = {}
|
||||||
|
for (const [chatroomId, entry] of entries.slice(0, MAX_GROUP_ENTRIES_PER_SCOPE)) {
|
||||||
|
trimmed[chatroomId] = entry
|
||||||
|
}
|
||||||
|
this.store.scopes[scopeKey] = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimScopes(): void {
|
||||||
|
const scopeEntries = Object.entries(this.store.scopes)
|
||||||
|
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
|
||||||
|
scopeEntries.sort((a, b) => {
|
||||||
|
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
|
||||||
|
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
|
||||||
|
return bUpdatedAt - aUpdatedAt
|
||||||
|
})
|
||||||
|
|
||||||
|
const trimmedScopes: Record<string, GroupMyMessageCountScopeMap> = {}
|
||||||
|
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
|
||||||
|
trimmedScopes[scopeKey] = scopeMap
|
||||||
|
}
|
||||||
|
this.store.scopes = trimmedScopes
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
try {
|
||||||
|
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GroupMyMessageCountCacheService: 保存缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ 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'
|
import { videoService } from './videoService'
|
||||||
|
import { imageDecryptService } from './imageDecryptService'
|
||||||
|
|
||||||
// ChatLab 格式定义
|
// ChatLab 格式定义
|
||||||
interface ChatLabHeader {
|
interface ChatLabHeader {
|
||||||
@@ -69,6 +70,7 @@ interface ApiExportedMedia {
|
|||||||
kind: MediaKind
|
kind: MediaKind
|
||||||
fileName: string
|
fileName: string
|
||||||
fullPath: string
|
fullPath: string
|
||||||
|
relativePath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatLab 消息类型映射
|
// ChatLab 消息类型映射
|
||||||
@@ -100,6 +102,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()
|
||||||
@@ -120,9 +123,20 @@ class HttpService {
|
|||||||
|
|
||||||
// 跟踪所有连接,以便关闭时能强制断开
|
// 跟踪所有连接,以便关闭时能强制断开
|
||||||
this.server.on('connection', (socket) => {
|
this.server.on('connection', (socket) => {
|
||||||
this.connections.add(socket)
|
// 使用互斥锁防止并发修改
|
||||||
|
if (!this.connectionMutex) {
|
||||||
|
this.connectionMutex = true
|
||||||
|
this.connections.add(socket)
|
||||||
|
this.connectionMutex = false
|
||||||
|
}
|
||||||
|
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
this.connections.delete(socket)
|
// 使用互斥锁防止并发修改
|
||||||
|
if (!this.connectionMutex) {
|
||||||
|
this.connectionMutex = true
|
||||||
|
this.connections.delete(socket)
|
||||||
|
this.connectionMutex = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -150,11 +164,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
|
||||||
@@ -215,6 +238,8 @@ class HttpService {
|
|||||||
await this.handleSessions(url, res)
|
await this.handleSessions(url, res)
|
||||||
} else if (pathname === '/api/v1/contacts') {
|
} else if (pathname === '/api/v1/contacts') {
|
||||||
await this.handleContacts(url, res)
|
await this.handleContacts(url, res)
|
||||||
|
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||||
|
this.handleMediaRequest(pathname, res)
|
||||||
} else {
|
} else {
|
||||||
this.sendError(res, 404, 'Not Found')
|
this.sendError(res, 404, 'Not Found')
|
||||||
}
|
}
|
||||||
@@ -224,6 +249,40 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
|
||||||
|
const mediaBasePath = this.getApiMediaExportPath()
|
||||||
|
const relativePath = pathname.replace('/api/v1/media/', '')
|
||||||
|
const fullPath = path.join(mediaBasePath, relativePath)
|
||||||
|
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
this.sendError(res, 404, 'Media not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(fullPath).toLowerCase()
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.mp4': 'video/mp4'
|
||||||
|
}
|
||||||
|
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileBuffer = fs.readFileSync(fullPath)
|
||||||
|
res.setHeader('Content-Type', contentType)
|
||||||
|
res.setHeader('Content-Length', fileBuffer.length)
|
||||||
|
res.writeHead(200)
|
||||||
|
res.end(fileBuffer)
|
||||||
|
} catch (e) {
|
||||||
|
this.sendError(res, 500, 'Failed to read media file')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量获取消息(循环游标直到满足 limit)
|
* 批量获取消息(循环游标直到满足 limit)
|
||||||
* 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标
|
* 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标
|
||||||
@@ -359,7 +418,7 @@ class HttpService {
|
|||||||
const queryOffset = keyword ? 0 : offset
|
const queryOffset = keyword ? 0 : offset
|
||||||
const queryLimit = keyword ? 10000 : limit
|
const queryLimit = keyword ? 10000 : limit
|
||||||
|
|
||||||
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true)
|
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, false)
|
||||||
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
|
||||||
@@ -555,19 +614,44 @@ class HttpService {
|
|||||||
): Promise<ApiExportedMedia | null> {
|
): Promise<ApiExportedMedia | null> {
|
||||||
try {
|
try {
|
||||||
if (msg.localType === 3 && options.exportImages) {
|
if (msg.localType === 3 && options.exportImages) {
|
||||||
const result = await chatService.getImageData(talker, String(msg.localId))
|
const result = await imageDecryptService.decryptImage({
|
||||||
if (result.success && result.data) {
|
sessionId: talker,
|
||||||
const imageBuffer = Buffer.from(result.data, 'base64')
|
imageMd5: msg.imageMd5,
|
||||||
const ext = this.detectImageExt(imageBuffer)
|
imageDatName: msg.imageDatName,
|
||||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
force: true
|
||||||
const fileName = `${fileBase}${ext}`
|
})
|
||||||
const targetDir = path.join(sessionDir, 'images')
|
if (result.success && result.localPath) {
|
||||||
const fullPath = path.join(targetDir, fileName)
|
let imagePath = result.localPath
|
||||||
this.ensureDir(targetDir)
|
if (imagePath.startsWith('data:')) {
|
||||||
if (!fs.existsSync(fullPath)) {
|
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
|
||||||
fs.writeFileSync(fullPath, imageBuffer)
|
if (base64Match) {
|
||||||
|
const imageBuffer = Buffer.from(base64Match[1], '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)
|
||||||
|
}
|
||||||
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||||
|
return { kind: 'image', fileName, fullPath, relativePath }
|
||||||
|
}
|
||||||
|
} else if (fs.existsSync(imagePath)) {
|
||||||
|
const imageBuffer = fs.readFileSync(imagePath)
|
||||||
|
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.copyFileSync(imagePath, fullPath)
|
||||||
|
}
|
||||||
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||||
|
return { kind: 'image', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
return { kind: 'image', fileName, fullPath }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,7 +670,8 @@ class HttpService {
|
|||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
|
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
|
||||||
}
|
}
|
||||||
return { kind: 'voice', fileName, fullPath }
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/voices/${fileName}`
|
||||||
|
return { kind: 'voice', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,7 +686,8 @@ class HttpService {
|
|||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
fs.copyFileSync(info.videoUrl, fullPath)
|
fs.copyFileSync(info.videoUrl, fullPath)
|
||||||
}
|
}
|
||||||
return { kind: 'video', fileName, fullPath }
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/videos/${fileName}`
|
||||||
|
return { kind: 'video', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,7 +702,8 @@ class HttpService {
|
|||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
fs.copyFileSync(result.localPath, fullPath)
|
fs.copyFileSync(result.localPath, fullPath)
|
||||||
}
|
}
|
||||||
return { kind: 'emoji', fileName, fullPath }
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/emojis/${fileName}`
|
||||||
|
return { kind: 'emoji', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -640,7 +727,8 @@ class HttpService {
|
|||||||
parsedContent: msg.parsedContent,
|
parsedContent: msg.parsedContent,
|
||||||
mediaType: media?.kind,
|
mediaType: media?.kind,
|
||||||
mediaFileName: media?.fileName,
|
mediaFileName: media?.fileName,
|
||||||
mediaPath: media?.fullPath
|
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
||||||
|
mediaLocalPath: media?.fullPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,7 +851,7 @@ class HttpService {
|
|||||||
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
|
mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow } from 'electron'
|
import { app, BrowserWindow } from 'electron'
|
||||||
import { basename, dirname, extname, join } from 'path'
|
import { basename, dirname, extname, join } from 'path'
|
||||||
import { pathToFileURL } from 'url'
|
import { pathToFileURL } from 'url'
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
||||||
@@ -11,7 +11,29 @@ import { wcdbService } from './wcdbService'
|
|||||||
// 获取 ffmpeg-static 的路径
|
// 获取 ffmpeg-static 的路径
|
||||||
function getStaticFfmpegPath(): string | null {
|
function getStaticFfmpegPath(): string | null {
|
||||||
try {
|
try {
|
||||||
// 优先处理打包后的路径
|
// 方法1: 直接 require ffmpeg-static
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const ffmpegStatic = require('ffmpeg-static')
|
||||||
|
|
||||||
|
if (typeof ffmpegStatic === 'string') {
|
||||||
|
// 修复:如果路径包含 app.asar(打包后),自动替换为 app.asar.unpacked
|
||||||
|
let fixedPath = ffmpegStatic
|
||||||
|
if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) {
|
||||||
|
fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(fixedPath)) {
|
||||||
|
return fixedPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法2: 手动构建路径(开发环境)
|
||||||
|
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||||
|
if (existsSync(devPath)) {
|
||||||
|
return devPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法3: 打包后的路径
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
const resourcesPath = process.resourcesPath
|
const resourcesPath = process.resourcesPath
|
||||||
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||||
@@ -20,20 +42,6 @@ function getStaticFfmpegPath(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方法1: 直接 require ffmpeg-static(开发环境)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
const ffmpegStatic = require('ffmpeg-static')
|
|
||||||
|
|
||||||
if (typeof ffmpegStatic === 'string' && existsSync(ffmpegStatic)) {
|
|
||||||
return ffmpegStatic
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法2: 手动构建路径(开发环境备用)
|
|
||||||
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
|
||||||
if (existsSync(devPath)) {
|
|
||||||
return devPath
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@@ -240,7 +248,9 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const xorKeyRaw = this.configService.get('imageXorKey') as unknown
|
// 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置
|
||||||
|
const imageKeys = this.configService.getImageKeysForCurrentWxid()
|
||||||
|
const xorKeyRaw = imageKeys.xorKey
|
||||||
// 支持十六进制格式(如 0x53)和十进制格式
|
// 支持十六进制格式(如 0x53)和十进制格式
|
||||||
let xorKey: number
|
let xorKey: number
|
||||||
if (typeof xorKeyRaw === 'number') {
|
if (typeof xorKeyRaw === 'number') {
|
||||||
@@ -257,7 +267,7 @@ export class ImageDecryptService {
|
|||||||
return { success: false, error: '未配置图片解密密钥' }
|
return { success: false, error: '未配置图片解密密钥' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const aesKeyRaw = this.configService.get('imageAesKey')
|
const aesKeyRaw = imageKeys.aesKey
|
||||||
const aesKey = this.resolveAesKey(aesKeyRaw)
|
const aesKey = this.resolveAesKey(aesKeyRaw)
|
||||||
|
|
||||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
||||||
@@ -280,14 +290,14 @@ export class ImageDecryptService {
|
|||||||
await writeFile(outputPath, decrypted)
|
await writeFile(outputPath, decrypted)
|
||||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||||
|
|
||||||
// 对于 hevc 格式,返回错误提示
|
|
||||||
if (finalExt === '.hevc') {
|
if (finalExt === '.hevc') {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示',
|
error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志',
|
||||||
isThumb: this.isThumbnailPath(datPath)
|
isThumb: this.isThumbnailPath(datPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isThumb = this.isThumbnailPath(datPath)
|
const isThumb = this.isThumbnailPath(datPath)
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||||
if (!isThumb) {
|
if (!isThumb) {
|
||||||
@@ -381,7 +391,7 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
||||||
|
|
||||||
return cleaned
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,14 +405,35 @@ export class ImageDecryptService {
|
|||||||
const allowThumbnail = options?.allowThumbnail ?? true
|
const allowThumbnail = options?.allowThumbnail ?? true
|
||||||
const skipResolvedCache = options?.skipResolvedCache ?? false
|
const skipResolvedCache = options?.skipResolvedCache ?? false
|
||||||
this.logInfo('[ImageDecrypt] resolveDatPath', {
|
this.logInfo('[ImageDecrypt] resolveDatPath', {
|
||||||
accountDir,
|
|
||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
sessionId,
|
|
||||||
allowThumbnail,
|
allowThumbnail,
|
||||||
skipResolvedCache
|
skipResolvedCache
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!skipResolvedCache) {
|
||||||
|
if (imageMd5) {
|
||||||
|
const cached = this.resolvedCache.get(imageMd5)
|
||||||
|
if (cached && existsSync(cached)) return cached
|
||||||
|
}
|
||||||
|
if (imageDatName) {
|
||||||
|
const cached = this.resolvedCache.get(imageDatName)
|
||||||
|
if (cached && existsSync(cached)) return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
|
||||||
|
if (imageMd5) {
|
||||||
|
const res = await this.fastProbabilisticSearch(accountDir, imageMd5, allowThumbnail)
|
||||||
|
if (res) return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
|
||||||
|
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
||||||
|
const res = await this.fastProbabilisticSearch(accountDir, imageDatName, allowThumbnail)
|
||||||
|
if (res) return res
|
||||||
|
}
|
||||||
|
|
||||||
// 优先通过 hardlink.db 查询
|
// 优先通过 hardlink.db 查询
|
||||||
if (imageMd5) {
|
if (imageMd5) {
|
||||||
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
|
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
|
||||||
@@ -583,9 +614,7 @@ export class ImageDecryptService {
|
|||||||
}).catch(() => { })
|
}).catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
private looksLikeMd5(value: string): boolean {
|
|
||||||
return /^[a-fA-F0-9]{16,32}$/.test(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveHardlinkDbPath(accountDir: string): string | null {
|
private resolveHardlinkDbPath(accountDir: string): string | null {
|
||||||
const wxid = this.configService.get('myWxid')
|
const wxid = this.configService.get('myWxid')
|
||||||
@@ -801,7 +830,7 @@ export class ImageDecryptService {
|
|||||||
* 包含:1. 微信旧版结构 filename.substr(0, 2)/...
|
* 包含:1. 微信旧版结构 filename.substr(0, 2)/...
|
||||||
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
|
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
|
||||||
*/
|
*/
|
||||||
private async fastProbabilisticSearch(root: string, datName: string): Promise<string | null> {
|
private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise<string | null> {
|
||||||
const { promises: fs } = require('fs')
|
const { promises: fs } = require('fs')
|
||||||
const { join } = require('path')
|
const { join } = require('path')
|
||||||
|
|
||||||
@@ -837,7 +866,7 @@ export class ImageDecryptService {
|
|||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 策略 B: 新版 Session 哈希路径猜测 ---
|
// --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 ---
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(root, { withFileTypes: true })
|
const entries = await fs.readdir(root, { withFileTypes: true })
|
||||||
const sessionDirs = entries
|
const sessionDirs = entries
|
||||||
@@ -890,7 +919,7 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 在同一目录下查找高清图变体
|
* 在同一目录下查找高清图变体
|
||||||
* 缩略图: xxx_t.dat -> 高清图: xxx_h.dat 或 xxx.dat
|
* 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat
|
||||||
*/
|
*/
|
||||||
private findHdVariantInSameDir(thumbPath: string): string | null {
|
private findHdVariantInSameDir(thumbPath: string): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -972,55 +1001,6 @@ export class ImageDecryptService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private matchesDatName(fileName: string, datName: string): boolean {
|
|
||||||
const lower = fileName.toLowerCase()
|
|
||||||
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
|
||||||
const normalizedBase = this.normalizeDatBase(base)
|
|
||||||
const normalizedTarget = this.normalizeDatBase(datName.toLowerCase())
|
|
||||||
if (normalizedBase === normalizedTarget) return true
|
|
||||||
const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`, 'i')
|
|
||||||
if (pattern.test(lower)) return true
|
|
||||||
return lower.endsWith('.dat') && lower.includes(datName)
|
|
||||||
}
|
|
||||||
|
|
||||||
private scoreDatName(fileName: string): number {
|
|
||||||
if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1
|
|
||||||
if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
|
|
||||||
private isThumbnailDat(fileName: string): boolean {
|
|
||||||
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
|
|
||||||
}
|
|
||||||
|
|
||||||
private hasXVariant(baseLower: string): boolean {
|
|
||||||
return /[._][a-z]$/.test(baseLower)
|
|
||||||
}
|
|
||||||
|
|
||||||
private isThumbnailPath(filePath: string): boolean {
|
|
||||||
const lower = basename(filePath).toLowerCase()
|
|
||||||
if (this.isThumbnailDat(lower)) return true
|
|
||||||
const ext = extname(lower)
|
|
||||||
const base = ext ? lower.slice(0, -ext.length) : lower
|
|
||||||
// 支持新命名 _thumb 和旧命名 _t
|
|
||||||
return base.endsWith('_t') || base.endsWith('_thumb')
|
|
||||||
}
|
|
||||||
|
|
||||||
private isHdPath(filePath: string): boolean {
|
|
||||||
const lower = basename(filePath).toLowerCase()
|
|
||||||
const ext = extname(lower)
|
|
||||||
const base = ext ? lower.slice(0, -ext.length) : lower
|
|
||||||
return base.endsWith('_hd') || base.endsWith('_h')
|
|
||||||
}
|
|
||||||
|
|
||||||
private hasImageVariantSuffix(baseLower: string): boolean {
|
|
||||||
return /[._][a-z]$/.test(baseLower)
|
|
||||||
}
|
|
||||||
|
|
||||||
private isLikelyImageDatBase(baseLower: string): boolean {
|
|
||||||
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeDatBase(name: string): string {
|
private normalizeDatBase(name: string): string {
|
||||||
let base = name.toLowerCase()
|
let base = name.toLowerCase()
|
||||||
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
||||||
@@ -1032,27 +1012,16 @@ export class ImageDecryptService {
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
private sanitizeDirName(name: string): string {
|
private hasImageVariantSuffix(baseLower: string): boolean {
|
||||||
const trimmed = name.trim()
|
return /[._][a-z]$/.test(baseLower)
|
||||||
if (!trimmed) return 'unknown'
|
|
||||||
return trimmed.replace(/[<>:"/\\|?*]/g, '_')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveTimeDir(datPath: string): string {
|
private isLikelyImageDatBase(baseLower: string): boolean {
|
||||||
const parts = datPath.split(/[\\/]+/)
|
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
|
||||||
for (const part of parts) {
|
|
||||||
if (/^\d{4}-\d{2}$/.test(part)) return part
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const stat = statSync(datPath)
|
|
||||||
const year = stat.mtime.getFullYear()
|
|
||||||
const month = String(stat.mtime.getMonth() + 1).padStart(2, '0')
|
|
||||||
return `${year}-${month}`
|
|
||||||
} catch {
|
|
||||||
return 'unknown-time'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
|
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
|
||||||
const allRoots = this.getAllCacheRoots()
|
const allRoots = this.getAllCacheRoots()
|
||||||
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
|
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
|
||||||
@@ -1287,14 +1256,14 @@ export class ImageDecryptService {
|
|||||||
private async ensureCacheIndexed(): Promise<void> {
|
private async ensureCacheIndexed(): Promise<void> {
|
||||||
if (this.cacheIndexed) return
|
if (this.cacheIndexed) return
|
||||||
if (this.cacheIndexing) return this.cacheIndexing
|
if (this.cacheIndexing) return this.cacheIndexing
|
||||||
this.cacheIndexing = new Promise((resolve) => {
|
this.cacheIndexing = (async () => {
|
||||||
// 扫描所有可能的缓存根目录
|
// 扫描所有可能的缓存根目录
|
||||||
const allRoots = this.getAllCacheRoots()
|
const allRoots = this.getAllCacheRoots()
|
||||||
this.logInfo('开始索引缓存', { roots: allRoots.length })
|
this.logInfo('开始索引缓存', { roots: allRoots.length })
|
||||||
|
|
||||||
for (const root of allRoots) {
|
for (const root of allRoots) {
|
||||||
try {
|
try {
|
||||||
this.indexCacheDir(root, 3, 0) // 增加深度到3,支持 sessionId/YYYY-MM 结构
|
this.indexCacheDir(root, 3, 0) // 增加深度到 3,支持 sessionId/YYYY-MM 结构
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logError('索引目录失败', e, { root })
|
this.logError('索引目录失败', e, { root })
|
||||||
}
|
}
|
||||||
@@ -1303,8 +1272,7 @@ export class ImageDecryptService {
|
|||||||
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
|
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
|
||||||
this.cacheIndexed = true
|
this.cacheIndexed = true
|
||||||
this.cacheIndexing = null
|
this.cacheIndexing = null
|
||||||
resolve()
|
})()
|
||||||
})
|
|
||||||
return this.cacheIndexing
|
return this.cacheIndexing
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1507,14 +1475,14 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
private bytesToInt32(bytes: Buffer): number {
|
private bytesToInt32(bytes: Buffer): number {
|
||||||
if (bytes.length !== 4) {
|
if (bytes.length !== 4) {
|
||||||
throw new Error('需要4个字节')
|
throw new Error('需要 4 个字节')
|
||||||
}
|
}
|
||||||
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
|
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
|
||||||
}
|
}
|
||||||
|
|
||||||
asciiKey16(keyString: string): Buffer {
|
asciiKey16(keyString: string): Buffer {
|
||||||
if (keyString.length < 16) {
|
if (keyString.length < 16) {
|
||||||
throw new Error('AES密钥至少需要16个字符')
|
throw new Error('AES密钥至少需要 16 个字符')
|
||||||
}
|
}
|
||||||
return Buffer.from(keyString, 'ascii').subarray(0, 16)
|
return Buffer.from(keyString, 'ascii').subarray(0, 16)
|
||||||
}
|
}
|
||||||
@@ -1706,25 +1674,28 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
// 提取 HEVC NALU 裸流
|
// 提取 HEVC NALU 裸流
|
||||||
const hevcData = this.extractHevcNalu(buffer)
|
const hevcData = this.extractHevcNalu(buffer)
|
||||||
if (!hevcData || hevcData.length < 100) {
|
// 优先用提取的 NALU 裸流,提取失败则跳过 wxgf 头部直接用原始数据
|
||||||
return { data: buffer, isWxgf: true }
|
const feedData = (hevcData && hevcData.length >= 100) ? hevcData : buffer.subarray(4)
|
||||||
}
|
this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', {
|
||||||
|
naluExtracted: !!(hevcData && hevcData.length >= 100),
|
||||||
|
feedSize: feedData.length
|
||||||
|
})
|
||||||
|
|
||||||
// 尝试用 ffmpeg 转换
|
// 尝试用 ffmpeg 转换
|
||||||
try {
|
try {
|
||||||
const jpgData = await this.convertHevcToJpg(hevcData)
|
const jpgData = await this.convertHevcToJpg(feedData)
|
||||||
if (jpgData && jpgData.length > 0) {
|
if (jpgData && jpgData.length > 0) {
|
||||||
return { data: jpgData, isWxgf: false }
|
return { data: jpgData, isWxgf: false }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// ffmpeg 转换失败
|
this.logError('unwrapWxgf: ffmpeg 转换失败', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { data: hevcData, isWxgf: true }
|
return { data: feedData, isWxgf: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 wxgf 数据中提取 HEVC NALU 裸流
|
* 浠?wxgf 鏁版嵁涓彁鍙?HEVC NALU 瑁告祦
|
||||||
*/
|
*/
|
||||||
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
||||||
const nalUnits: Buffer[] = []
|
const nalUnits: Buffer[] = []
|
||||||
@@ -1787,53 +1758,133 @@ export class ImageDecryptService {
|
|||||||
/**
|
/**
|
||||||
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
|
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
|
||||||
*/
|
*/
|
||||||
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
private async convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
||||||
const ffmpeg = this.getFfmpegPath()
|
const ffmpeg = this.getFfmpegPath()
|
||||||
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
||||||
|
|
||||||
|
const tmpDir = join(app.getPath('temp'), 'weflow_hevc')
|
||||||
|
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
|
||||||
|
const ts = Date.now()
|
||||||
|
const tmpInput = join(tmpDir, `hevc_${ts}.hevc`)
|
||||||
|
const tmpOutput = join(tmpDir, `hevc_${ts}.jpg`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(tmpInput, hevcData)
|
||||||
|
|
||||||
|
// 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测
|
||||||
|
const attempts: { label: string; inputArgs: string[] }[] = [
|
||||||
|
{ label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] },
|
||||||
|
{ label: 'auto detect', inputArgs: ['-i', tmpInput] },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
// 清理上一轮的输出
|
||||||
|
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
|
||||||
|
|
||||||
|
const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label)
|
||||||
|
if (result) return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
this.logError('ffmpeg 转换异常', e)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
try { if (existsSync(tmpInput)) require('fs').unlinkSync(tmpInput) } catch {}
|
||||||
|
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private runFfmpegConvert(ffmpeg: string, inputArgs: string[], tmpOutput: string, label: string): Promise<Buffer | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const { spawn } = require('child_process')
|
const { spawn } = require('child_process')
|
||||||
const chunks: Buffer[] = []
|
|
||||||
const errChunks: Buffer[] = []
|
const errChunks: Buffer[] = []
|
||||||
|
|
||||||
const proc = spawn(ffmpeg, [
|
const args = [
|
||||||
'-hide_banner',
|
'-hide_banner', '-loglevel', 'error',
|
||||||
'-loglevel', 'error',
|
...inputArgs,
|
||||||
'-f', 'hevc',
|
'-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput
|
||||||
'-i', 'pipe:0',
|
]
|
||||||
'-vframes', '1',
|
this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') })
|
||||||
'-q:v', '3',
|
|
||||||
'-f', 'mjpeg',
|
const proc = spawn(ffmpeg, args, {
|
||||||
'pipe:1'
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
windowsHide: true
|
windowsHide: true
|
||||||
})
|
})
|
||||||
|
|
||||||
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
||||||
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
||||||
|
|
||||||
proc.on('close', (code: number) => {
|
const timer = setTimeout(() => {
|
||||||
if (code === 0 && chunks.length > 0) {
|
proc.kill('SIGKILL')
|
||||||
this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length })
|
this.logError(`ffmpeg [${label}] 超时(15s)`)
|
||||||
resolve(Buffer.concat(chunks))
|
resolve(null)
|
||||||
} else {
|
}, 15000)
|
||||||
const errMsg = Buffer.concat(errChunks).toString()
|
|
||||||
this.logInfo('ffmpeg 转换失败', { code, error: errMsg })
|
|
||||||
resolve(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
proc.on('error', (err: Error) => {
|
proc.on('close', (code: number) => {
|
||||||
this.logInfo('ffmpeg 进程错误', { error: err.message })
|
clearTimeout(timer)
|
||||||
|
if (code === 0 && existsSync(tmpOutput)) {
|
||||||
|
try {
|
||||||
|
const jpgBuf = readFileSync(tmpOutput)
|
||||||
|
if (jpgBuf.length > 0) {
|
||||||
|
this.logInfo(`ffmpeg [${label}] 成功`, { outputSize: jpgBuf.length })
|
||||||
|
resolve(jpgBuf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logError(`ffmpeg [${label}] 读取输出失败`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const errMsg = Buffer.concat(errChunks).toString().trim()
|
||||||
|
this.logInfo(`ffmpeg [${label}] 失败`, { code, error: errMsg })
|
||||||
resolve(null)
|
resolve(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
proc.stdin.write(hevcData)
|
proc.on('error', (err: Error) => {
|
||||||
proc.stdin.end()
|
clearTimeout(timer)
|
||||||
|
this.logError(`ffmpeg [${label}] 进程错误`, err)
|
||||||
|
resolve(null)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private looksLikeMd5(s: string): boolean {
|
||||||
|
return /^[a-f0-9]{32}$/i.test(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isThumbnailDat(name: string): boolean {
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat')
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasXVariant(base: string): boolean {
|
||||||
|
const lower = base.toLowerCase()
|
||||||
|
return lower.endsWith('_h') || lower.endsWith('_hd') || lower.endsWith('_thumb') || lower.endsWith('_t')
|
||||||
|
}
|
||||||
|
|
||||||
|
private isHdPath(p: string): boolean {
|
||||||
|
return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h')
|
||||||
|
}
|
||||||
|
|
||||||
|
private isThumbnailPath(p: string): boolean {
|
||||||
|
const lower = p.toLowerCase()
|
||||||
|
return lower.includes('_thumb') || lower.includes('_t') || lower.includes('.t.')
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeDirName(s: string): string {
|
||||||
|
return s.replace(/[<>:"/\\|?*]/g, '_').trim() || 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTimeDir(filePath: string): string {
|
||||||
|
try {
|
||||||
|
const stats = statSync(filePath)
|
||||||
|
const d = new Date(stats.mtime)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
} catch {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 保留原有的解密到文件方法(用于兼容)
|
// 保留原有的解密到文件方法(用于兼容)
|
||||||
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
|
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
|
||||||
const version = this.getDatVersion(inputPath)
|
const version = this.getDatVersion(inputPath)
|
||||||
@@ -1846,7 +1897,7 @@ export class ImageDecryptService {
|
|||||||
decrypted = this.decryptDatV4(inputPath, xorKey, key)
|
decrypted = this.decryptDatV4(inputPath, xorKey, key)
|
||||||
} else {
|
} else {
|
||||||
if (!aesKey || aesKey.length !== 16) {
|
if (!aesKey || aesKey.length !== 16) {
|
||||||
throw new Error('V4版本需要16字节AES密钥')
|
throw new Error('V4版本需要 16 字节 AES 密钥')
|
||||||
}
|
}
|
||||||
decrypted = this.decryptDatV4(inputPath, xorKey, aesKey)
|
decrypted = this.decryptDatV4(inputPath, xorKey, aesKey)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
|
||||||
293
electron/services/sessionStatsCacheService.ts
Normal file
293
electron/services/sessionStatsCacheService.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
|
const CACHE_VERSION = 2
|
||||||
|
const MAX_SESSION_ENTRIES_PER_SCOPE = 2000
|
||||||
|
const MAX_SCOPE_ENTRIES = 12
|
||||||
|
|
||||||
|
export interface SessionStatsCacheStats {
|
||||||
|
totalMessages: number
|
||||||
|
voiceMessages: number
|
||||||
|
imageMessages: number
|
||||||
|
videoMessages: number
|
||||||
|
emojiMessages: number
|
||||||
|
transferMessages: number
|
||||||
|
redPacketMessages: number
|
||||||
|
callMessages: number
|
||||||
|
firstTimestamp?: number
|
||||||
|
lastTimestamp?: number
|
||||||
|
privateMutualGroups?: number
|
||||||
|
groupMemberCount?: number
|
||||||
|
groupMyMessages?: number
|
||||||
|
groupActiveSpeakers?: number
|
||||||
|
groupMutualFriends?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionStatsCacheEntry {
|
||||||
|
updatedAt: number
|
||||||
|
includeRelations: boolean
|
||||||
|
stats: SessionStatsCacheStats
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionStatsScopeMap {
|
||||||
|
[sessionId: string]: SessionStatsCacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionStatsCacheStore {
|
||||||
|
version: number
|
||||||
|
scopes: Record<string, SessionStatsScopeMap>
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNonNegativeInt(value: unknown): number | undefined {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||||
|
return Math.max(0, Math.floor(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStats(raw: unknown): SessionStatsCacheStats | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
|
||||||
|
const totalMessages = toNonNegativeInt(source.totalMessages)
|
||||||
|
const voiceMessages = toNonNegativeInt(source.voiceMessages)
|
||||||
|
const imageMessages = toNonNegativeInt(source.imageMessages)
|
||||||
|
const videoMessages = toNonNegativeInt(source.videoMessages)
|
||||||
|
const emojiMessages = toNonNegativeInt(source.emojiMessages)
|
||||||
|
const transferMessages = toNonNegativeInt(source.transferMessages)
|
||||||
|
const redPacketMessages = toNonNegativeInt(source.redPacketMessages)
|
||||||
|
const callMessages = toNonNegativeInt(source.callMessages)
|
||||||
|
|
||||||
|
if (
|
||||||
|
totalMessages === undefined ||
|
||||||
|
voiceMessages === undefined ||
|
||||||
|
imageMessages === undefined ||
|
||||||
|
videoMessages === undefined ||
|
||||||
|
emojiMessages === undefined ||
|
||||||
|
transferMessages === undefined ||
|
||||||
|
redPacketMessages === undefined ||
|
||||||
|
callMessages === undefined
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: SessionStatsCacheStats = {
|
||||||
|
totalMessages,
|
||||||
|
voiceMessages,
|
||||||
|
imageMessages,
|
||||||
|
videoMessages,
|
||||||
|
emojiMessages,
|
||||||
|
transferMessages,
|
||||||
|
redPacketMessages,
|
||||||
|
callMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstTimestamp = toNonNegativeInt(source.firstTimestamp)
|
||||||
|
if (firstTimestamp !== undefined) normalized.firstTimestamp = firstTimestamp
|
||||||
|
|
||||||
|
const lastTimestamp = toNonNegativeInt(source.lastTimestamp)
|
||||||
|
if (lastTimestamp !== undefined) normalized.lastTimestamp = lastTimestamp
|
||||||
|
|
||||||
|
const privateMutualGroups = toNonNegativeInt(source.privateMutualGroups)
|
||||||
|
if (privateMutualGroups !== undefined) normalized.privateMutualGroups = privateMutualGroups
|
||||||
|
|
||||||
|
const groupMemberCount = toNonNegativeInt(source.groupMemberCount)
|
||||||
|
if (groupMemberCount !== undefined) normalized.groupMemberCount = groupMemberCount
|
||||||
|
|
||||||
|
const groupMyMessages = toNonNegativeInt(source.groupMyMessages)
|
||||||
|
if (groupMyMessages !== undefined) normalized.groupMyMessages = groupMyMessages
|
||||||
|
|
||||||
|
const groupActiveSpeakers = toNonNegativeInt(source.groupActiveSpeakers)
|
||||||
|
if (groupActiveSpeakers !== undefined) normalized.groupActiveSpeakers = groupActiveSpeakers
|
||||||
|
|
||||||
|
const groupMutualFriends = toNonNegativeInt(source.groupMutualFriends)
|
||||||
|
if (groupMutualFriends !== undefined) normalized.groupMutualFriends = groupMutualFriends
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||||
|
const includeRelations = typeof source.includeRelations === 'boolean' ? source.includeRelations : false
|
||||||
|
const stats = normalizeStats(source.stats)
|
||||||
|
|
||||||
|
if (updatedAt === undefined || !stats) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
includeRelations,
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionStatsCacheService {
|
||||||
|
private readonly cacheFilePath: string
|
||||||
|
private store: SessionStatsCacheStore = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
scopes: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(cacheBasePath?: string) {
|
||||||
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
|
? cacheBasePath
|
||||||
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
|
this.cacheFilePath = join(basePath, 'session-stats.json')
|
||||||
|
this.ensureCacheDir()
|
||||||
|
this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCacheDir(): void {
|
||||||
|
const dir = dirname(this.cacheFilePath)
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
if (!existsSync(this.cacheFilePath)) return
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parsed as Record<string, unknown>
|
||||||
|
const version = Number(payload.version)
|
||||||
|
if (!Number.isFinite(version) || version !== CACHE_VERSION) {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const scopesRaw = payload.scopes
|
||||||
|
if (!scopesRaw || typeof scopesRaw !== 'object') {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes: Record<string, SessionStatsScopeMap> = {}
|
||||||
|
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
|
||||||
|
if (!scopeValue || typeof scopeValue !== 'object') continue
|
||||||
|
const normalizedScope: SessionStatsScopeMap = {}
|
||||||
|
for (const [sessionId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
|
||||||
|
const entry = normalizeEntry(entryRaw)
|
||||||
|
if (!entry) continue
|
||||||
|
normalizedScope[sessionId] = entry
|
||||||
|
}
|
||||||
|
if (Object.keys(normalizedScope).length > 0) {
|
||||||
|
scopes[scopeKey] = normalizedScope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
scopes
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SessionStatsCacheService: 载入缓存失败', error)
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(scopeKey: string, sessionId: string): SessionStatsCacheEntry | undefined {
|
||||||
|
if (!scopeKey || !sessionId) return undefined
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return undefined
|
||||||
|
const entry = normalizeEntry(scope[sessionId])
|
||||||
|
if (!entry) {
|
||||||
|
delete scope[sessionId]
|
||||||
|
if (Object.keys(scope).length === 0) {
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
set(scopeKey: string, sessionId: string, entry: SessionStatsCacheEntry): void {
|
||||||
|
if (!scopeKey || !sessionId) return
|
||||||
|
const normalized = normalizeEntry(entry)
|
||||||
|
if (!normalized) return
|
||||||
|
|
||||||
|
if (!this.store.scopes[scopeKey]) {
|
||||||
|
this.store.scopes[scopeKey] = {}
|
||||||
|
}
|
||||||
|
this.store.scopes[scopeKey][sessionId] = normalized
|
||||||
|
|
||||||
|
this.trimScope(scopeKey)
|
||||||
|
this.trimScopes()
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(scopeKey: string, sessionId: string): void {
|
||||||
|
if (!scopeKey || !sessionId) return
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return
|
||||||
|
if (!(sessionId in scope)) return
|
||||||
|
|
||||||
|
delete scope[sessionId]
|
||||||
|
if (Object.keys(scope).length === 0) {
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScope(scopeKey: string): void {
|
||||||
|
if (!scopeKey) return
|
||||||
|
if (!this.store.scopes[scopeKey]) return
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAll(): void {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
try {
|
||||||
|
rmSync(this.cacheFilePath, { force: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SessionStatsCacheService: 清理缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimScope(scopeKey: string): void {
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return
|
||||||
|
const entries = Object.entries(scope)
|
||||||
|
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
|
||||||
|
|
||||||
|
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||||
|
const trimmed: SessionStatsScopeMap = {}
|
||||||
|
for (const [sessionId, entry] of entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) {
|
||||||
|
trimmed[sessionId] = entry
|
||||||
|
}
|
||||||
|
this.store.scopes[scopeKey] = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimScopes(): void {
|
||||||
|
const scopeEntries = Object.entries(this.store.scopes)
|
||||||
|
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
|
||||||
|
|
||||||
|
scopeEntries.sort((a, b) => {
|
||||||
|
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
|
||||||
|
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
|
||||||
|
return bUpdatedAt - aUpdatedAt
|
||||||
|
})
|
||||||
|
|
||||||
|
const trimmedScopes: Record<string, SessionStatsScopeMap> = {}
|
||||||
|
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
|
||||||
|
trimmedScopes[scopeKey] = scopeMap
|
||||||
|
}
|
||||||
|
this.store.scopes = trimmedScopes
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
try {
|
||||||
|
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SessionStatsCacheService: 保存缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||||
|
import { app } from 'electron'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
@@ -18,6 +19,16 @@ class VideoService {
|
|||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private log(message: string, meta?: Record<string, unknown>): void {
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||||
|
const logDir = join(app.getPath('userData'), 'logs')
|
||||||
|
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
|
||||||
|
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取数据库根目录
|
* 获取数据库根目录
|
||||||
*/
|
*/
|
||||||
@@ -69,7 +80,12 @@ class VideoService {
|
|||||||
const wxid = this.getMyWxid()
|
const wxid = this.getMyWxid()
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
|
||||||
if (!wxid) return undefined
|
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath })
|
||||||
|
|
||||||
|
if (!wxid) {
|
||||||
|
this.log('queryVideoFileName: wxid 为空')
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
||||||
if (cachePath) {
|
if (cachePath) {
|
||||||
@@ -84,20 +100,23 @@ class VideoService {
|
|||||||
for (const p of cacheDbPaths) {
|
for (const p of cacheDbPaths) {
|
||||||
if (existsSync(p)) {
|
if (existsSync(p)) {
|
||||||
try {
|
try {
|
||||||
|
this.log('尝试缓存 hardlink.db', { path: p })
|
||||||
const db = new Database(p, { readonly: true })
|
const db = new Database(p, { readonly: true })
|
||||||
const row = db.prepare(`
|
const row = db.prepare(`
|
||||||
SELECT file_name, md5 FROM video_hardlink_info_v4
|
SELECT file_name, md5 FROM video_hardlink_info_v4
|
||||||
WHERE md5 = ?
|
WHERE md5 = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`).get(md5) as { file_name: string; md5: string } | undefined
|
`).get(md5) as { file_name: string; md5: string } | undefined
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
if (row?.file_name) {
|
if (row?.file_name) {
|
||||||
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
||||||
|
this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 })
|
||||||
return realMd5
|
return realMd5
|
||||||
}
|
}
|
||||||
|
this.log('缓存 hardlink.db 未命中', { path: p })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 忽略错误
|
this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +124,6 @@ class VideoService {
|
|||||||
|
|
||||||
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||||
if (dbPath) {
|
if (dbPath) {
|
||||||
// 检查 dbPath 是否已经包含 wxid
|
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
const wxidLower = wxid.toLowerCase()
|
const wxidLower = wxid.toLowerCase()
|
||||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||||
@@ -113,10 +131,8 @@ class VideoService {
|
|||||||
|
|
||||||
const encryptedDbPaths: string[] = []
|
const encryptedDbPaths: string[] = []
|
||||||
if (dbPathContainsWxid) {
|
if (dbPathContainsWxid) {
|
||||||
// dbPath 已包含 wxid,不需要再拼接
|
|
||||||
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
|
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||||
} else {
|
} else {
|
||||||
// dbPath 不包含 wxid,需要拼接
|
|
||||||
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||||
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||||
}
|
}
|
||||||
@@ -124,27 +140,29 @@ class VideoService {
|
|||||||
for (const p of encryptedDbPaths) {
|
for (const p of encryptedDbPaths) {
|
||||||
if (existsSync(p)) {
|
if (existsSync(p)) {
|
||||||
try {
|
try {
|
||||||
|
this.log('尝试加密 hardlink.db', { path: p })
|
||||||
const escapedMd5 = md5.replace(/'/g, "''")
|
const escapedMd5 = md5.replace(/'/g, "''")
|
||||||
|
|
||||||
// 用 md5 字段查询,获取 file_name
|
|
||||||
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
||||||
|
|
||||||
const result = await wcdbService.execQuery('media', p, sql)
|
const result = await wcdbService.execQuery('media', p, sql)
|
||||||
|
|
||||||
if (result.success && result.rows && result.rows.length > 0) {
|
if (result.success && result.rows && result.rows.length > 0) {
|
||||||
const row = result.rows[0]
|
const row = result.rows[0]
|
||||||
if (row?.file_name) {
|
if (row?.file_name) {
|
||||||
// 提取不带扩展名的文件名作为实际视频 MD5
|
|
||||||
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
||||||
|
this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 })
|
||||||
return realMd5
|
return realMd5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 忽略错误
|
this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) })
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.log('加密 hardlink.db 不存在', { path: p })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,12 +188,16 @@ class VideoService {
|
|||||||
const dbPath = this.getDbPath()
|
const dbPath = this.getDbPath()
|
||||||
const wxid = this.getMyWxid()
|
const wxid = this.getMyWxid()
|
||||||
|
|
||||||
|
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
|
||||||
|
|
||||||
if (!dbPath || !wxid || !videoMd5) {
|
if (!dbPath || !wxid || !videoMd5) {
|
||||||
|
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
|
||||||
return { exists: false }
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先尝试从数据库查询真正的视频文件名
|
// 先尝试从数据库查询真正的视频文件名
|
||||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
||||||
|
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
|
||||||
|
|
||||||
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
@@ -184,50 +206,89 @@ class VideoService {
|
|||||||
|
|
||||||
let videoBaseDir: string
|
let videoBaseDir: string
|
||||||
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
|
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
|
||||||
// dbPath 已经包含 wxid,直接使用
|
|
||||||
videoBaseDir = join(dbPath, 'msg', 'video')
|
videoBaseDir = join(dbPath, 'msg', 'video')
|
||||||
} else {
|
} else {
|
||||||
// dbPath 不包含 wxid,需要拼接
|
|
||||||
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) })
|
||||||
|
|
||||||
if (!existsSync(videoBaseDir)) {
|
if (!existsSync(videoBaseDir)) {
|
||||||
|
this.log('getVideoInfo: videoBaseDir 不存在')
|
||||||
return { exists: false }
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 遍历年月目录查找视频文件
|
// 遍历年月目录查找视频文件
|
||||||
try {
|
try {
|
||||||
const allDirs = readdirSync(videoBaseDir)
|
const allDirs = readdirSync(videoBaseDir)
|
||||||
|
|
||||||
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
|
|
||||||
const yearMonthDirs = allDirs
|
const yearMonthDirs = allDirs
|
||||||
.filter(dir => {
|
.filter(dir => {
|
||||||
const dirPath = join(videoBaseDir, dir)
|
const dirPath = join(videoBaseDir, dir)
|
||||||
return statSync(dirPath).isDirectory()
|
return statSync(dirPath).isDirectory()
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
|
.sort((a, b) => b.localeCompare(a))
|
||||||
|
|
||||||
|
this.log('扫描目录', { dirs: yearMonthDirs })
|
||||||
|
|
||||||
for (const yearMonth of yearMonthDirs) {
|
for (const yearMonth of yearMonthDirs) {
|
||||||
const dirPath = join(videoBaseDir, yearMonth)
|
const dirPath = join(videoBaseDir, yearMonth)
|
||||||
|
|
||||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
||||||
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
|
|
||||||
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
|
|
||||||
|
|
||||||
// 检查视频文件是否存在
|
|
||||||
if (existsSync(videoPath)) {
|
if (existsSync(videoPath)) {
|
||||||
|
// 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw,但封面不带)
|
||||||
|
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
|
||||||
|
const coverPath = join(dirPath, `${baseMd5}.jpg`)
|
||||||
|
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
||||||
|
|
||||||
|
// 列出同目录下与该 md5 相关的所有文件,帮助排查封面命名
|
||||||
|
const allFiles = readdirSync(dirPath)
|
||||||
|
const relatedFiles = allFiles.filter(f => f.toLowerCase().startsWith(realVideoMd5.slice(0, 8).toLowerCase()))
|
||||||
|
this.log('找到视频,相关文件列表', {
|
||||||
|
videoPath,
|
||||||
|
coverExists: existsSync(coverPath),
|
||||||
|
thumbExists: existsSync(thumbPath),
|
||||||
|
relatedFiles,
|
||||||
|
coverPath,
|
||||||
|
thumbPath
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
|
videoUrl: videoPath,
|
||||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||||
exists: true
|
exists: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 没找到,列出所有目录里的 mp4 文件帮助排查(最多每目录 10 个)
|
||||||
|
this.log('未找到视频,开始全目录扫描', {
|
||||||
|
lookingForOriginal: `${videoMd5}.mp4`,
|
||||||
|
lookingForResolved: `${realVideoMd5}.mp4`,
|
||||||
|
hardlinkResolved: realVideoMd5 !== videoMd5
|
||||||
|
})
|
||||||
|
for (const yearMonth of yearMonthDirs) {
|
||||||
|
const dirPath = join(videoBaseDir, yearMonth)
|
||||||
|
try {
|
||||||
|
const allFiles = readdirSync(dirPath)
|
||||||
|
const mp4Files = allFiles.filter(f => f.endsWith('.mp4')).slice(0, 10)
|
||||||
|
// 检查原始 md5 是否部分匹配(前8位)
|
||||||
|
const partialMatch = mp4Files.filter(f => f.toLowerCase().startsWith(videoMd5.slice(0, 8).toLowerCase()))
|
||||||
|
this.log(`目录 ${yearMonth} 扫描结果`, {
|
||||||
|
totalFiles: allFiles.length,
|
||||||
|
mp4Count: allFiles.filter(f => f.endsWith('.mp4')).length,
|
||||||
|
sampleMp4: mp4Files,
|
||||||
|
partialMatchByOriginalMd5: partialMatch
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
this.log(`目录 ${yearMonth} 读取失败`, { error: String(e) })
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 忽略错误
|
this.log('getVideoInfo 遍历出错', { error: String(e) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
|
||||||
return { exists: false }
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,41 +296,59 @@ class VideoService {
|
|||||||
* 根据消息内容解析视频MD5
|
* 根据消息内容解析视频MD5
|
||||||
*/
|
*/
|
||||||
parseVideoMd5(content: string): string | undefined {
|
parseVideoMd5(content: string): string | undefined {
|
||||||
|
|
||||||
// 打印前500字符看看 XML 结构
|
|
||||||
|
|
||||||
if (!content) return undefined
|
if (!content) return undefined
|
||||||
|
|
||||||
|
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
|
||||||
|
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 提取所有可能的 md5 值进行日志
|
// 收集所有 md5 相关属性,方便对比
|
||||||
const allMd5s: string[] = []
|
const allMd5Attrs: string[] = []
|
||||||
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
|
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
|
||||||
let match
|
let match
|
||||||
while ((match = md5Regex.exec(content)) !== null) {
|
while ((match = md5Regex.exec(content)) !== null) {
|
||||||
allMd5s.push(`${match[0]}`)
|
allMd5Attrs.push(match[0])
|
||||||
|
}
|
||||||
|
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
|
||||||
|
|
||||||
|
// 方法1:从 <videomsg md5="..."> 提取(收到的视频)
|
||||||
|
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (videoMsgMd5Match) {
|
||||||
|
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
|
||||||
|
return videoMsgMd5Match[1].toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取 md5(用于查询 hardlink.db)
|
// 方法2:从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5)
|
||||||
// 注意:不是 rawmd5,rawmd5 是另一个值
|
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
// 格式: md5="xxx" 或 <md5>xxx</md5>
|
if (rawMd5Match) {
|
||||||
|
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
|
||||||
// 尝试从videomsg标签中提取md5
|
return rawMd5Match[1].toLowerCase()
|
||||||
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
|
||||||
if (videoMsgMatch) {
|
|
||||||
return videoMsgMatch[1].toLowerCase()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
// 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
|
||||||
|
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
if (attrMatch) {
|
if (attrMatch) {
|
||||||
|
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
|
||||||
return attrMatch[1].toLowerCase()
|
return attrMatch[1].toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
// 方法4:<md5>...</md5> 标签
|
||||||
if (md5Match) {
|
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||||
return md5Match[1].toLowerCase()
|
if (md5TagMatch) {
|
||||||
|
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
|
||||||
|
return md5TagMatch[1].toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 方法5:兜底取 rawmd5 属性(任意位置)
|
||||||
|
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (rawMd5Fallback) {
|
||||||
|
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
|
||||||
|
return rawMd5Fallback[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[VideoService] 解析视频MD5失败:', e)
|
this.log('parseVideoMd5 异常', { error: String(e) })
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,8 +1,50 @@
|
|||||||
import { join, dirname, basename } from 'path'
|
import { join, dirname, basename } from 'path'
|
||||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
|
|
||||||
// DLL 初始化错误信息,用于帮助用户诊断问题
|
// DLL 初始化错误信息,用于帮助用户诊断问题
|
||||||
let lastDllInitError: string | null = null
|
let lastDllInitError: string | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 extra_buffer(protobuf)中的免打扰状态
|
||||||
|
* - field 12 (tag 0x60): 值非0 = 免打扰
|
||||||
|
* 折叠状态通过 contact.flag & 0x10000000 判断
|
||||||
|
*/
|
||||||
|
function parseExtraBuffer(raw: Buffer | string | null | undefined): { isMuted: boolean } {
|
||||||
|
if (!raw) return { isMuted: false }
|
||||||
|
// execQuery 返回的 BLOB 列是十六进制字符串,需要先解码
|
||||||
|
const buf: Buffer = typeof raw === 'string' ? Buffer.from(raw, 'hex') : raw
|
||||||
|
if (buf.length === 0) return { isMuted: false }
|
||||||
|
let isMuted = false
|
||||||
|
let i = 0
|
||||||
|
const len = buf.length
|
||||||
|
|
||||||
|
const readVarint = (): number => {
|
||||||
|
let result = 0, shift = 0
|
||||||
|
while (i < len) {
|
||||||
|
const b = buf[i++]
|
||||||
|
result |= (b & 0x7f) << shift
|
||||||
|
shift += 7
|
||||||
|
if (!(b & 0x80)) break
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < len) {
|
||||||
|
const tag = readVarint()
|
||||||
|
const fieldNum = tag >>> 3
|
||||||
|
const wireType = tag & 0x07
|
||||||
|
if (wireType === 0) {
|
||||||
|
const val = readVarint()
|
||||||
|
if (fieldNum === 12 && val !== 0) isMuted = true
|
||||||
|
} else if (wireType === 2) {
|
||||||
|
const sz = readVarint()
|
||||||
|
i += sz
|
||||||
|
} else if (wireType === 5) { i += 4
|
||||||
|
} else if (wireType === 1) { i += 8
|
||||||
|
} else { break }
|
||||||
|
}
|
||||||
|
return { isMuted }
|
||||||
|
}
|
||||||
export function getLastDllInitError(): string | null {
|
export function getLastDllInitError(): string | null {
|
||||||
return lastDllInitError
|
return lastDllInitError
|
||||||
}
|
}
|
||||||
@@ -41,6 +83,7 @@ export class WcdbCore {
|
|||||||
private wcdbGetMessageTables: any = null
|
private wcdbGetMessageTables: any = null
|
||||||
private wcdbGetMessageMeta: any = null
|
private wcdbGetMessageMeta: any = null
|
||||||
private wcdbGetContact: any = null
|
private wcdbGetContact: any = null
|
||||||
|
private wcdbGetContactStatus: any = null
|
||||||
private wcdbGetMessageTableStats: any = null
|
private wcdbGetMessageTableStats: any = null
|
||||||
private wcdbGetAggregateStats: any = null
|
private wcdbGetAggregateStats: any = null
|
||||||
private wcdbGetAvailableYears: any = null
|
private wcdbGetAvailableYears: any = null
|
||||||
@@ -63,11 +106,22 @@ export class WcdbCore {
|
|||||||
private wcdbGetVoiceData: any = null
|
private wcdbGetVoiceData: any = null
|
||||||
private wcdbGetSnsTimeline: any = null
|
private wcdbGetSnsTimeline: any = null
|
||||||
private wcdbGetSnsAnnualStats: any = null
|
private wcdbGetSnsAnnualStats: any = null
|
||||||
|
private wcdbInstallSnsBlockDeleteTrigger: any = null
|
||||||
|
private wcdbUninstallSnsBlockDeleteTrigger: any = null
|
||||||
|
private wcdbCheckSnsBlockDeleteTrigger: any = null
|
||||||
|
private wcdbDeleteSnsPost: any = null
|
||||||
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 wcdbCloudInit: any = null
|
||||||
|
private wcdbCloudReport: any = null
|
||||||
|
private wcdbCloudStop: 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()
|
||||||
@@ -92,63 +146,94 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
const net = require('net')
|
// 从 DLL 获取动态管道名(含 PID)
|
||||||
const PIPE_PATH = '\\\\.\\pipe\\weflow_monitor'
|
let pipePath = '\\\\.\\pipe\\weflow_monitor'
|
||||||
|
if (this.wcdbGetMonitorPipeName) {
|
||||||
setTimeout(() => {
|
try {
|
||||||
this.monitorPipeClient = net.createConnection(PIPE_PATH, () => {
|
const namePtr = [null as any]
|
||||||
this.writeLog('Monitor pipe connected')
|
if (this.wcdbGetMonitorPipeName(namePtr) === 0 && namePtr[0]) {
|
||||||
})
|
pipePath = this.koffi.decode(namePtr[0], 'char', -1)
|
||||||
|
this.wcdbFreeString(namePtr[0])
|
||||||
let buffer = ''
|
|
||||||
this.monitorPipeClient.on('data', (data: Buffer) => {
|
|
||||||
buffer += data.toString('utf8')
|
|
||||||
const lines = buffer.split('\n')
|
|
||||||
buffer = lines.pop() || ''
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.trim()) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(line)
|
|
||||||
callback(parsed.action || 'update', line)
|
|
||||||
} catch {
|
|
||||||
callback('update', line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
this.monitorPipeClient.on('error', (err: Error) => {
|
this.connectMonitorPipe(pipePath)
|
||||||
this.writeLog(`Monitor pipe error: ${err.message}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.monitorPipeClient.on('close', () => {
|
|
||||||
this.writeLog('Monitor pipe closed')
|
|
||||||
this.monitorPipeClient = null
|
|
||||||
})
|
|
||||||
}, 100)
|
|
||||||
|
|
||||||
this.writeLog('Monitor started via named pipe IPC')
|
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('打开数据库异常:', e)
|
console.error('[wcdbCore] startMonitor exception:', e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 连接命名管道,支持断开后自动重连
|
||||||
|
private connectMonitorPipe(pipePath: string) {
|
||||||
|
this.monitorPipePath = pipePath
|
||||||
|
const net = require('net')
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.monitorCallback) return
|
||||||
|
|
||||||
|
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {
|
||||||
|
})
|
||||||
|
|
||||||
|
let buffer = ''
|
||||||
|
this.monitorPipeClient.on('data', (data: Buffer) => {
|
||||||
|
buffer += data.toString('utf8')
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line)
|
||||||
|
this.monitorCallback?.(parsed.action || 'update', line)
|
||||||
|
} catch {
|
||||||
|
this.monitorCallback?.('update', line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.monitorPipeClient.on('error', () => {
|
||||||
|
})
|
||||||
|
|
||||||
|
this.monitorPipeClient.on('close', () => {
|
||||||
|
this.monitorPipeClient = null
|
||||||
|
this.scheduleReconnect()
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定时重连
|
||||||
|
private scheduleReconnect() {
|
||||||
|
if (this.monitorReconnectTimer || !this.monitorCallback) return
|
||||||
|
this.monitorReconnectTimer = setTimeout(() => {
|
||||||
|
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
|
||||||
@@ -448,6 +533,13 @@ export class WcdbCore {
|
|||||||
// wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json)
|
// wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json)
|
||||||
this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)')
|
this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)')
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_contact_status(wcdb_handle handle, const char* usernames_json, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetContactStatus = this.lib.func('int32 wcdb_get_contact_status(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetContactStatus = null
|
||||||
|
}
|
||||||
|
|
||||||
// wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json)
|
// wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json)
|
||||||
this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
||||||
|
|
||||||
@@ -565,15 +657,45 @@ export class WcdbCore {
|
|||||||
this.wcdbGetSnsAnnualStats = null
|
this.wcdbGetSnsAnnualStats = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbInstallSnsBlockDeleteTrigger = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_uninstall_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbUninstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_uninstall_sns_block_delete_trigger(int64 handle, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbUninstallSnsBlockDeleteTrigger = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_check_sns_block_delete_trigger(wcdb_handle handle, int32_t* out_installed)
|
||||||
|
try {
|
||||||
|
this.wcdbCheckSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_check_sns_block_delete_trigger(int64 handle, _Out_ int32* outInstalled)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbCheckSnsBlockDeleteTrigger = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_delete_sns_post(wcdb_handle handle, const char* post_id, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbDeleteSnsPost = this.lib.func('int32 wcdb_delete_sns_post(int64 handle, const char* postId, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbDeleteSnsPost = null
|
||||||
|
}
|
||||||
|
|
||||||
// Named pipe IPC for monitoring (replaces callback)
|
// Named pipe IPC for monitoring (replaces callback)
|
||||||
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)
|
||||||
@@ -583,6 +705,26 @@ export class WcdbCore {
|
|||||||
this.wcdbVerifyUser = null
|
this.wcdbVerifyUser = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_cloud_init(int32_t interval_seconds)
|
||||||
|
try {
|
||||||
|
this.wcdbCloudInit = this.lib.func('int32 wcdb_cloud_init(int32 intervalSeconds)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbCloudInit = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_cloud_report(const char* stats_json)
|
||||||
|
try {
|
||||||
|
this.wcdbCloudReport = this.lib.func('int32 wcdb_cloud_report(const char* statsJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbCloudReport = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// void wcdb_cloud_stop()
|
||||||
|
try {
|
||||||
|
this.wcdbCloudStop = this.lib.func('void wcdb_cloud_stop()')
|
||||||
|
} catch {
|
||||||
|
this.wcdbCloudStop = null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
@@ -987,7 +1129,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 }
|
||||||
}
|
}
|
||||||
@@ -1025,6 +1167,40 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSessionIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
(sessionIds || [])
|
||||||
|
.map((id) => String(id || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (normalizedSessionIds.length === 0) {
|
||||||
|
return { success: true, counts: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
for (let i = 0; i < normalizedSessionIds.length; i += 1) {
|
||||||
|
const sessionId = normalizedSessionIds[i]
|
||||||
|
const outCount = [0]
|
||||||
|
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
|
||||||
|
counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0
|
||||||
|
|
||||||
|
if (i > 0 && i % 160 === 0) {
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, counts }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
@@ -1301,6 +1477,36 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 分批查询,避免 SQL 过长(execQuery 不支持参数绑定,直接拼 SQL)
|
||||||
|
const BATCH = 200
|
||||||
|
const map: Record<string, { isFolded: boolean; isMuted: boolean }> = {}
|
||||||
|
for (let i = 0; i < usernames.length; i += BATCH) {
|
||||||
|
const batch = usernames.slice(i, i + BATCH)
|
||||||
|
const inList = batch.map(u => `'${u.replace(/'/g, "''")}'`).join(',')
|
||||||
|
const sql = `SELECT username, flag, extra_buffer FROM contact WHERE username IN (${inList})`
|
||||||
|
const result = await this.execQuery('contact', null, sql)
|
||||||
|
if (!result.success || !result.rows) continue
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const uname: string = row.username
|
||||||
|
// 折叠:flag bit 28 (0x10000000)
|
||||||
|
const flag = parseInt(row.flag ?? '0', 10)
|
||||||
|
const isFolded = (flag & 0x10000000) !== 0
|
||||||
|
// 免打扰:extra_buffer field 12 非0
|
||||||
|
const { isMuted } = parseExtraBuffer(row.extra_buffer)
|
||||||
|
map[uname] = { isFolded, isMuted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, map }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
@@ -1583,12 +1789,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]) {
|
||||||
@@ -1684,8 +1898,57 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 Windows Hello
|
* 数据收集初始化
|
||||||
*/
|
*/
|
||||||
|
async cloudInit(intervalSeconds: number = 600): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
const initOk = await this.initialize()
|
||||||
|
if (!initOk) return { success: false, error: 'WCDB init failed' }
|
||||||
|
}
|
||||||
|
if (!this.wcdbCloudInit) {
|
||||||
|
return { success: false, error: 'Cloud init API not supported by DLL' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = this.wcdbCloudInit(intervalSeconds)
|
||||||
|
if (result !== 0) {
|
||||||
|
return { success: false, error: `Cloud init failed: ${result}` }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
const initOk = await this.initialize()
|
||||||
|
if (!initOk) return { success: false, error: 'WCDB init failed' }
|
||||||
|
}
|
||||||
|
if (!this.wcdbCloudReport) {
|
||||||
|
return { success: false, error: 'Cloud report API not supported by DLL' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = this.wcdbCloudReport(statsJson || '')
|
||||||
|
if (result !== 0) {
|
||||||
|
return { success: false, error: `Cloud report failed: ${result}` }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cloudStop(): { success: boolean; error?: string } {
|
||||||
|
if (!this.wcdbCloudStop) {
|
||||||
|
return { success: false, error: 'Cloud stop API not supported by DLL' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.wcdbCloudStop()
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
|
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
const initOk = await this.initialize()
|
const initOk = await this.initialize()
|
||||||
@@ -1768,6 +2031,94 @@ export class WcdbCore {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 为朋友圈安装删除
|
||||||
|
*/
|
||||||
|
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbInstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null]
|
||||||
|
const status = this.wcdbInstallSnsBlockDeleteTrigger(this.handle, outPtr)
|
||||||
|
let msg = ''
|
||||||
|
if (outPtr[0]) {
|
||||||
|
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||||
|
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||||
|
}
|
||||||
|
if (status === 1) {
|
||||||
|
// DLL 返回 1 表示已安装
|
||||||
|
return { success: true, alreadyInstalled: true }
|
||||||
|
}
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: msg || `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true, alreadyInstalled: false }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭朋友圈删除拦截
|
||||||
|
*/
|
||||||
|
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbUninstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null]
|
||||||
|
const status = this.wcdbUninstallSnsBlockDeleteTrigger(this.handle, outPtr)
|
||||||
|
let msg = ''
|
||||||
|
if (outPtr[0]) {
|
||||||
|
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||||
|
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||||
|
}
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: msg || `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询朋友圈删除拦截是否已安装
|
||||||
|
*/
|
||||||
|
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbCheckSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||||
|
try {
|
||||||
|
const outInstalled = [0]
|
||||||
|
const status = this.wcdbCheckSnsBlockDeleteTrigger(this.handle, outInstalled)
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true, installed: outInstalled[0] === 1 }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbDeleteSnsPost) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null]
|
||||||
|
const status = this.wcdbDeleteSnsPost(this.handle, postId, outPtr)
|
||||||
|
let msg = ''
|
||||||
|
if (outPtr[0]) {
|
||||||
|
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||||
|
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||||
|
}
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: msg || `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getDualReportStats(sessionId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
async getDualReportStats(sessionId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
@@ -1848,4 +2199,3 @@ export class WcdbCore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +218,10 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageCount', { sessionId })
|
return this.callWorker('getMessageCount', { sessionId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||||
|
return this.callWorker('getMessageCounts', { sessionIds })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取联系人昵称
|
* 获取联系人昵称
|
||||||
*/
|
*/
|
||||||
@@ -291,6 +294,13 @@ export class WcdbService {
|
|||||||
return this.callWorker('getContact', { username })
|
return this.callWorker('getContact', { username })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取联系人 extra_buffer 状态(isFolded/isMuted)
|
||||||
|
*/
|
||||||
|
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
|
||||||
|
return this.callWorker('getContactStatus', { usernames })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取聚合统计数据
|
* 获取聚合统计数据
|
||||||
*/
|
*/
|
||||||
@@ -362,10 +372,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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -417,6 +427,34 @@ export class WcdbService {
|
|||||||
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装朋友圈删除拦截
|
||||||
|
*/
|
||||||
|
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||||
|
return this.callWorker('installSnsBlockDeleteTrigger')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卸载朋友圈删除拦截
|
||||||
|
*/
|
||||||
|
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('uninstallSnsBlockDeleteTrigger')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询朋友圈删除拦截是否已安装
|
||||||
|
*/
|
||||||
|
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
||||||
|
return this.callWorker('checkSnsBlockDeleteTrigger')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从数据库直接删除朋友圈记录
|
||||||
|
*/
|
||||||
|
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('deleteSnsPost', { postId })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 DLL 内部日志
|
* 获取 DLL 内部日志
|
||||||
*/
|
*/
|
||||||
@@ -445,6 +483,27 @@ export class WcdbService {
|
|||||||
return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint })
|
return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据收集:初始化
|
||||||
|
*/
|
||||||
|
async cloudInit(intervalSeconds: number): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('cloudInit', { intervalSeconds })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据收集:上报数据
|
||||||
|
*/
|
||||||
|
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('cloudReport', { statsJson })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据收集:停止
|
||||||
|
*/
|
||||||
|
cloudStop(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('cloudStop', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,9 @@ if (parentPort) {
|
|||||||
case 'getMessageCount':
|
case 'getMessageCount':
|
||||||
result = await core.getMessageCount(payload.sessionId)
|
result = await core.getMessageCount(payload.sessionId)
|
||||||
break
|
break
|
||||||
|
case 'getMessageCounts':
|
||||||
|
result = await core.getMessageCounts(payload.sessionIds)
|
||||||
|
break
|
||||||
case 'getDisplayNames':
|
case 'getDisplayNames':
|
||||||
result = await core.getDisplayNames(payload.usernames)
|
result = await core.getDisplayNames(payload.usernames)
|
||||||
break
|
break
|
||||||
@@ -87,6 +90,9 @@ if (parentPort) {
|
|||||||
case 'getContact':
|
case 'getContact':
|
||||||
result = await core.getContact(payload.username)
|
result = await core.getContact(payload.username)
|
||||||
break
|
break
|
||||||
|
case 'getContactStatus':
|
||||||
|
result = await core.getContactStatus(payload.usernames)
|
||||||
|
break
|
||||||
case 'getAggregateStats':
|
case 'getAggregateStats':
|
||||||
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
@@ -118,7 +124,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)
|
||||||
@@ -144,6 +150,18 @@ if (parentPort) {
|
|||||||
case 'getSnsAnnualStats':
|
case 'getSnsAnnualStats':
|
||||||
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
|
case 'installSnsBlockDeleteTrigger':
|
||||||
|
result = await core.installSnsBlockDeleteTrigger()
|
||||||
|
break
|
||||||
|
case 'uninstallSnsBlockDeleteTrigger':
|
||||||
|
result = await core.uninstallSnsBlockDeleteTrigger()
|
||||||
|
break
|
||||||
|
case 'checkSnsBlockDeleteTrigger':
|
||||||
|
result = await core.checkSnsBlockDeleteTrigger()
|
||||||
|
break
|
||||||
|
case 'deleteSnsPost':
|
||||||
|
result = await core.deleteSnsPost(payload.postId)
|
||||||
|
break
|
||||||
case 'getLogs':
|
case 'getLogs':
|
||||||
result = await core.getLogs()
|
result = await core.getLogs()
|
||||||
break
|
break
|
||||||
@@ -156,7 +174,15 @@ if (parentPort) {
|
|||||||
case 'deleteMessage':
|
case 'deleteMessage':
|
||||||
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
|
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
|
||||||
break
|
break
|
||||||
|
case 'cloudInit':
|
||||||
|
result = await core.cloudInit(payload.intervalSeconds)
|
||||||
|
break
|
||||||
|
case 'cloudReport':
|
||||||
|
result = await core.cloudReport(payload.statsJson)
|
||||||
|
break
|
||||||
|
case 'cloudStop':
|
||||||
|
result = core.cloudStop()
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
result = { success: false, error: `Unknown method: ${type}` }
|
result = { success: false, error: `Unknown method: ${type}` }
|
||||||
}
|
}
|
||||||
|
|||||||
1917
package-lock.json
generated
1917
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
|||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"rebuild": "electron-rebuild",
|
"rebuild": "electron-rebuild",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"electron:dev": "vite --mode electron",
|
"electron:dev": "vite --mode electron",
|
||||||
@@ -32,7 +33,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",
|
||||||
|
|||||||
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.
Binary file not shown.
55
src/App.scss
55
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 {
|
||||||
@@ -27,6 +69,19 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-keepalive-page {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-route-anchor {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes appFadeIn {
|
@keyframes appFadeIn {
|
||||||
|
|||||||
139
src/App.tsx
139
src/App.tsx
@@ -22,11 +22,11 @@ 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 * as cloudControl from './services/cloudControl'
|
||||||
import { Download, X, Shield } from 'lucide-react'
|
import { Download, X, Shield } from 'lucide-react'
|
||||||
import './App.scss'
|
import './App.scss'
|
||||||
|
|
||||||
@@ -35,6 +35,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()
|
||||||
@@ -60,7 +61,9 @@ function App() {
|
|||||||
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
||||||
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
||||||
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
|
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
|
||||||
|
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||||
const isNotificationWindow = location.pathname === '/notification-window'
|
const isNotificationWindow = location.pathname === '/notification-window'
|
||||||
|
const isExportRoute = location.pathname === '/export'
|
||||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||||
|
|
||||||
// 锁定状态
|
// 锁定状态
|
||||||
@@ -75,6 +78,9 @@ function App() {
|
|||||||
const [agreementChecked, setAgreementChecked] = useState(false)
|
const [agreementChecked, setAgreementChecked] = useState(false)
|
||||||
const [agreementLoading, setAgreementLoading] = useState(true)
|
const [agreementLoading, setAgreementLoading] = useState(true)
|
||||||
|
|
||||||
|
// 数据收集同意状态
|
||||||
|
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
const body = document.body
|
const body = document.body
|
||||||
@@ -101,14 +107,27 @@ function App() {
|
|||||||
|
|
||||||
// 应用主题
|
// 应用主题
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
document.documentElement.setAttribute('data-mode', themeMode)
|
const applyMode = (mode: ThemeMode, systemDark?: boolean) => {
|
||||||
|
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
|
||||||
// 更新窗口控件颜色以适配主题
|
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||||
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
document.documentElement.setAttribute('data-mode', effectiveMode)
|
||||||
if (!isOnboardingWindow && !isNotificationWindow) {
|
const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
||||||
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
if (!isOnboardingWindow && !isNotificationWindow) {
|
||||||
|
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 +141,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) {
|
||||||
@@ -157,6 +176,12 @@ function App() {
|
|||||||
const agreed = await configService.getAgreementAccepted()
|
const agreed = await configService.getAgreementAccepted()
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setShowAgreement(true)
|
setShowAgreement(true)
|
||||||
|
} else {
|
||||||
|
// 协议已同意,检查数据收集同意状态
|
||||||
|
const consent = await configService.getAnalyticsConsent()
|
||||||
|
if (consent === null) {
|
||||||
|
setShowAnalyticsConsent(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('检查协议状态失败:', e)
|
console.error('检查协议状态失败:', e)
|
||||||
@@ -167,25 +192,55 @@ function App() {
|
|||||||
checkAgreement()
|
checkAgreement()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 初始化数据收集
|
||||||
|
useEffect(() => {
|
||||||
|
cloudControl.initCloudControl()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 记录页面访问
|
||||||
|
useEffect(() => {
|
||||||
|
const path = location.pathname
|
||||||
|
if (path && path !== '/') {
|
||||||
|
cloudControl.recordPage(path)
|
||||||
|
}
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
const handleAgree = async () => {
|
const handleAgree = async () => {
|
||||||
if (!agreementChecked) return
|
if (!agreementChecked) return
|
||||||
await configService.setAgreementAccepted(true)
|
await configService.setAgreementAccepted(true)
|
||||||
setShowAgreement(false)
|
setShowAgreement(false)
|
||||||
|
// 协议同意后,检查数据收集同意
|
||||||
|
const consent = await configService.getAnalyticsConsent()
|
||||||
|
if (consent === null) {
|
||||||
|
setShowAnalyticsConsent(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDisagree = () => {
|
const handleDisagree = () => {
|
||||||
window.electronAPI.window.close()
|
window.electronAPI.window.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAnalyticsAllow = async () => {
|
||||||
|
await configService.setAnalyticsConsent(true)
|
||||||
|
setShowAnalyticsConsent(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAnalyticsDeny = async () => {
|
||||||
|
await configService.setAnalyticsConsent(false)
|
||||||
|
window.electronAPI.window.close()
|
||||||
|
}
|
||||||
|
|
||||||
// 监听启动时的更新通知
|
// 监听启动时的更新通知
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
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 })
|
||||||
setShowUpdateDialog(true)
|
if (!useAppStore.getState().isLocked) {
|
||||||
|
setShowUpdateDialog(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
||||||
@@ -197,6 +252,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 +353,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()
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -338,6 +400,12 @@ function App() {
|
|||||||
return <ChatHistoryPage />
|
return <ChatHistoryPage />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 独立会话聊天窗口(仅显示聊天内容区域)
|
||||||
|
if (isStandaloneChatWindow) {
|
||||||
|
const sessionId = new URLSearchParams(location.search).get('sessionId') || ''
|
||||||
|
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} />
|
||||||
|
}
|
||||||
|
|
||||||
// 独立通知窗口
|
// 独立通知窗口
|
||||||
if (isNotificationWindow) {
|
if (isNotificationWindow) {
|
||||||
return <NotificationWindow />
|
return <NotificationWindow />
|
||||||
@@ -364,6 +432,7 @@ function App() {
|
|||||||
|
|
||||||
{/* 全局批量转写进度浮窗 */}
|
{/* 全局批量转写进度浮窗 */}
|
||||||
<BatchTranscribeGlobal />
|
<BatchTranscribeGlobal />
|
||||||
|
<BatchImageDecryptGlobal />
|
||||||
|
|
||||||
{/* 用户协议弹窗 */}
|
{/* 用户协议弹窗 */}
|
||||||
{showAgreement && !agreementLoading && (
|
{showAgreement && !agreementLoading && (
|
||||||
@@ -416,6 +485,42 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 数据收集同意弹窗 */}
|
||||||
|
{showAnalyticsConsent && !agreementLoading && (
|
||||||
|
<div className="agreement-overlay">
|
||||||
|
<div className="agreement-modal">
|
||||||
|
<div className="agreement-header">
|
||||||
|
<Shield size={32} />
|
||||||
|
<h2>使用数据收集说明</h2>
|
||||||
|
</div>
|
||||||
|
<div className="agreement-content">
|
||||||
|
<div className="agreement-text">
|
||||||
|
<p>为了持续改进 WeFlow 并提供更好的用户体验,我们希望收集一些匿名的使用数据。</p>
|
||||||
|
|
||||||
|
<h4>我们会收集什么?</h4>
|
||||||
|
<p>• 功能使用情况(如哪些功能被使用、使用频率)</p>
|
||||||
|
<p>• 应用性能数据(如加载时间、错误日志)</p>
|
||||||
|
<p>• 设备基本信息(如操作系统版本、应用版本)</p>
|
||||||
|
|
||||||
|
<h4>我们不会收集什么?</h4>
|
||||||
|
<p>• 你的聊天记录内容</p>
|
||||||
|
<p>• 个人身份信息</p>
|
||||||
|
<p>• 联系人信息</p>
|
||||||
|
<p>• 任何可以识别你身份的数据</p>
|
||||||
|
<p>• 一切你担心会涉及隐藏的数据</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="agreement-footer">
|
||||||
|
<div className="agreement-actions">
|
||||||
|
<button className="btn btn-secondary" onClick={handleAnalyticsDeny}>不允许</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleAnalyticsAllow}>允许</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 更新提示对话框 */}
|
{/* 更新提示对话框 */}
|
||||||
<UpdateDialog
|
<UpdateDialog
|
||||||
open={showUpdateDialog}
|
open={showUpdateDialog}
|
||||||
@@ -431,11 +536,15 @@ function App() {
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="content">
|
<main className="content">
|
||||||
<RouteGuard>
|
<RouteGuard>
|
||||||
|
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
|
||||||
|
<ExportPage />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<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 />} />
|
||||||
@@ -445,7 +554,7 @@ function App() {
|
|||||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||||
|
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/export" element={<ExportPage />} />
|
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||||
<Route path="/sns" element={<SnsPage />} />
|
<Route path="/sns" element={<SnsPage />} />
|
||||||
<Route path="/contacts" element={<ContactsPage />} />
|
<Route path="/contacts" element={<ContactsPage />} />
|
||||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||||
|
|||||||
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 {
|
||||||
@@ -97,6 +97,10 @@ export function GlobalSessionMonitor() {
|
|||||||
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
||||||
// 这是新消息事件
|
// 这是新消息事件
|
||||||
|
|
||||||
|
// 免打扰、折叠群、折叠入口不弹通知
|
||||||
|
if (newSession.isMuted || newSession.isFolded) continue
|
||||||
|
if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
||||||
|
|
||||||
// 1. 群聊过滤自己发送的消息
|
// 1. 群聊过滤自己发送的消息
|
||||||
if (newSession.username.includes('@chatroom')) {
|
if (newSession.username.includes('@chatroom')) {
|
||||||
// 如果是自己发的消息,不弹通知
|
// 如果是自己发的消息,不弹通知
|
||||||
@@ -194,11 +198,12 @@ export function GlobalSessionMonitor() {
|
|||||||
// 尝试丰富或获取联系人详情
|
// 尝试丰富或获取联系人详情
|
||||||
const contact = await window.electronAPI.chat.getContact(newSession.username)
|
const contact = await window.electronAPI.chat.getContact(newSession.username)
|
||||||
if (contact) {
|
if (contact) {
|
||||||
if (contact.remark || contact.nickname) {
|
if (contact.remark || contact.nickName) {
|
||||||
title = contact.remark || contact.nickname
|
title = contact.remark || contact.nickName
|
||||||
}
|
}
|
||||||
if (contact.avatarUrl) {
|
const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username)
|
||||||
avatarUrl = contact.avatarUrl
|
if (avatarResult?.avatarUrl) {
|
||||||
|
avatarUrl = avatarResult.avatarUrl
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 如果不在缓存/数据库中
|
// 如果不在缓存/数据库中
|
||||||
@@ -218,8 +223,11 @@ export function GlobalSessionMonitor() {
|
|||||||
if (title === newSession.username || title.startsWith('wxid_')) {
|
if (title === newSession.username || title.startsWith('wxid_')) {
|
||||||
const retried = await window.electronAPI.chat.getContact(newSession.username)
|
const retried = await window.electronAPI.chat.getContact(newSession.username)
|
||||||
if (retried) {
|
if (retried) {
|
||||||
title = retried.remark || retried.nickname || title
|
title = retried.remark || retried.nickName || title
|
||||||
avatarUrl = retried.avatarUrl || avatarUrl
|
const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username)
|
||||||
|
if (retriedAvatar?.avatarUrl) {
|
||||||
|
avatarUrl = retriedAvatar.avatarUrl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,7 +261,8 @@ export function GlobalSessionMonitor() {
|
|||||||
const handleActiveSessionRefresh = async (sessionId: string) => {
|
const handleActiveSessionRefresh = async (sessionId: string) => {
|
||||||
// 从 ChatPage 复制/调整的逻辑,以保持集中
|
// 从 ChatPage 复制/调整的逻辑,以保持集中
|
||||||
const state = useChatStore.getState()
|
const state = useChatStore.getState()
|
||||||
const lastMsg = state.messages[state.messages.length - 1]
|
const msgs = state.messages || []
|
||||||
|
const lastMsg = msgs[msgs.length - 1]
|
||||||
const minTime = lastMsg?.createTime || 0
|
const minTime = lastMsg?.createTime || 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
166
src/components/JumpToDatePopover.scss
Normal file
166
src/components/JumpToDatePopover.scss
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
.jump-date-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 10px);
|
||||||
|
right: 0;
|
||||||
|
width: 312px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: none;
|
||||||
|
background-color: var(--bg-secondary-solid, #ffffff) !important;
|
||||||
|
opacity: 1;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
isolation: isolate;
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 12px;
|
||||||
|
z-index: 1600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .calendar-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .current-month {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .nav-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
background-color: var(--bg-secondary-solid, #ffffff) !important;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .nav-btn:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .status-line {
|
||||||
|
min-height: 16px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .status-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .calendar-grid .weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .calendar-grid .weekday {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .calendar-grid .days {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-template-rows: repeat(6, 36px);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .day-cell {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .day-cell .day-number {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .day-cell.empty {
|
||||||
|
cursor: default;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .day-cell:not(.empty):not(.no-message):hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .day-cell.today {
|
||||||
|
border-color: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .day-cell.selected {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .day-cell.no-message {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .day-count {
|
||||||
|
position: static;
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #16a34a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .day-cell.selected .day-count {
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .day-count-loading {
|
||||||
|
position: static;
|
||||||
|
margin-top: 1px;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .spin {
|
||||||
|
animation: jump-date-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jump-date-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/components/JumpToDatePopover.tsx
Normal file
185
src/components/JumpToDatePopover.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||||
|
import './JumpToDatePopover.scss'
|
||||||
|
|
||||||
|
interface JumpToDatePopoverProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSelect: (date: Date) => void
|
||||||
|
className?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
currentDate?: Date
|
||||||
|
messageDates?: Set<string>
|
||||||
|
hasLoadedMessageDates?: boolean
|
||||||
|
messageDateCounts?: Record<string, number>
|
||||||
|
loadingDates?: boolean
|
||||||
|
loadingDateCounts?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
currentDate = new Date(),
|
||||||
|
messageDates,
|
||||||
|
hasLoadedMessageDates = false,
|
||||||
|
messageDateCounts,
|
||||||
|
loadingDates = false,
|
||||||
|
loadingDateCounts = false
|
||||||
|
}) => {
|
||||||
|
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const normalized = new Date(currentDate)
|
||||||
|
setCalendarDate(normalized)
|
||||||
|
setSelectedDate(normalized)
|
||||||
|
}, [isOpen, currentDate])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const getDaysInMonth = (date: Date): number => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = date.getMonth()
|
||||||
|
return new Date(year, month + 1, 0).getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFirstDayOfMonth = (date: Date): number => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = date.getMonth()
|
||||||
|
return new Date(year, month, 1).getDay()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDateKey = (day: number): string => {
|
||||||
|
const year = calendarDate.getFullYear()
|
||||||
|
const month = calendarDate.getMonth() + 1
|
||||||
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMessage = (day: number): boolean => {
|
||||||
|
if (!hasLoadedMessageDates) return true
|
||||||
|
if (!messageDates || messageDates.size === 0) return false
|
||||||
|
return messageDates.has(toDateKey(day))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isToday = (day: number): boolean => {
|
||||||
|
const today = new Date()
|
||||||
|
return day === today.getDate()
|
||||||
|
&& calendarDate.getMonth() === today.getMonth()
|
||||||
|
&& calendarDate.getFullYear() === today.getFullYear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelected = (day: number): boolean => {
|
||||||
|
return day === selectedDate.getDate()
|
||||||
|
&& calendarDate.getMonth() === selectedDate.getMonth()
|
||||||
|
&& calendarDate.getFullYear() === selectedDate.getFullYear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateCalendar = (): Array<number | null> => {
|
||||||
|
const daysInMonth = getDaysInMonth(calendarDate)
|
||||||
|
const firstDay = getFirstDayOfMonth(calendarDate)
|
||||||
|
const days: Array<number | null> = []
|
||||||
|
|
||||||
|
for (let i = 0; i < firstDay; i++) {
|
||||||
|
days.push(null)
|
||||||
|
}
|
||||||
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
|
days.push(i)
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDateClick = (day: number) => {
|
||||||
|
if (hasLoadedMessageDates && !hasMessage(day)) return
|
||||||
|
const targetDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
|
||||||
|
setSelectedDate(targetDate)
|
||||||
|
onSelect(targetDate)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDayClassName = (day: number | null): string => {
|
||||||
|
if (day === null) return 'day-cell empty'
|
||||||
|
const classes = ['day-cell']
|
||||||
|
if (isToday(day)) classes.push('today')
|
||||||
|
if (isSelected(day)) classes.push('selected')
|
||||||
|
if (hasLoadedMessageDates && !hasMessage(day)) classes.push('no-message')
|
||||||
|
return classes.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
|
const days = generateCalendar()
|
||||||
|
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
||||||
|
<div className="calendar-nav">
|
||||||
|
<button
|
||||||
|
className="nav-btn"
|
||||||
|
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||||
|
aria-label="上一月"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="current-month">{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月</span>
|
||||||
|
<button
|
||||||
|
className="nav-btn"
|
||||||
|
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||||
|
aria-label="下一月"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="status-line">
|
||||||
|
{loadingDates && (
|
||||||
|
<span className="status-item">
|
||||||
|
<Loader2 size={12} className="spin" />
|
||||||
|
<span>日期加载中</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!loadingDates && loadingDateCounts && (
|
||||||
|
<span className="status-item">
|
||||||
|
<Loader2 size={12} className="spin" />
|
||||||
|
<span>条数加载中</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="calendar-grid">
|
||||||
|
<div className="weekdays">
|
||||||
|
{weekdays.map(day => (
|
||||||
|
<div key={day} className="weekday">{day}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="days">
|
||||||
|
{days.map((day, index) => {
|
||||||
|
if (day === null) return <div key={index} className="day-cell empty" />
|
||||||
|
const dateKey = toDateKey(day)
|
||||||
|
const hasMessageOnDay = hasMessage(day)
|
||||||
|
const count = Number(messageDateCounts?.[dateKey] || 0)
|
||||||
|
const showCount = count > 0
|
||||||
|
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
className={getDayClassName(day)}
|
||||||
|
onClick={() => handleDateClick(day)}
|
||||||
|
disabled={hasLoadedMessageDates && !hasMessageOnDay}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="day-number">{day}</span>
|
||||||
|
{showCount && <span className="day-count">{count}</span>}
|
||||||
|
{showCountLoading && <Loader2 size={11} className="day-count-loading spin" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JumpToDatePopover
|
||||||
@@ -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,12 +48,26 @@
|
|||||||
backdrop-filter: none !important;
|
backdrop-filter: none !important;
|
||||||
-webkit-backdrop-filter: none !important;
|
-webkit-backdrop-filter: none !important;
|
||||||
|
|
||||||
// Ensure background is solid
|
// 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 <html> 上的主题属性
|
||||||
background: var(--bg-secondary, #2c2c2c);
|
background: #ffffff;
|
||||||
color: var(--text-primary, #ffffff);
|
color: #3d3d3d;
|
||||||
|
--text-primary: #3d3d3d;
|
||||||
|
--text-secondary: #666666;
|
||||||
|
--text-tertiary: #999999;
|
||||||
|
--border-light: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
// 深色模式覆盖
|
||||||
|
[data-mode="dark"] & {
|
||||||
|
background: var(--bg-secondary-solid, #282420);
|
||||||
|
color: var(--text-primary, #F0EEE9);
|
||||||
|
--text-primary: #F0EEE9;
|
||||||
|
--text-secondary: #b3b0aa;
|
||||||
|
--text-tertiary: #807d78;
|
||||||
|
--border-light: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|||||||
@@ -10,6 +10,19 @@
|
|||||||
&.collapsed {
|
&.collapsed {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
|
|
||||||
|
.sidebar-user-card-wrap {
|
||||||
|
margin: 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-card {
|
||||||
|
padding: 8px 0;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.user-meta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.nav-menu,
|
.nav-menu,
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
@@ -27,6 +40,119 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-user-card-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 12px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-clear-trigger {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
z-index: 12;
|
||||||
|
border: 1px solid rgba(255, 59, 48, 0.28);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: #d93025;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||||
|
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 59, 48, 0.08);
|
||||||
|
border-color: rgba(255, 59, 48, 0.46);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-card {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 56px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(99, 102, 241, 0.32);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.menu-open {
|
||||||
|
border-color: rgba(99, 102, 241, 0.44);
|
||||||
|
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--on-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-wxid {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-caret {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: inline-flex;
|
||||||
|
transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.nav-menu {
|
.nav-menu {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -57,7 +183,7 @@
|
|||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: var(--on-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +196,44 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-icon-with-badge {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-label {
|
.nav-label {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: #ff3b30;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 59, 48, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-badge.icon-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -7px;
|
||||||
|
right: -10px;
|
||||||
|
margin-left: 0;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
box-shadow: 0 0 0 2px var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
@@ -103,4 +262,107 @@
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-clear-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1100;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-clear-dialog {
|
||||||
|
width: min(460px, 100%);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
||||||
|
padding: 18px 18px 16px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-clear-options {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-clear-actions {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
border-color: #ef4444;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,335 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } 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, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, ChevronUp, Trash2 } from 'lucide-react'
|
||||||
import { useAppStore } from '../stores/appStore'
|
import { useAppStore } from '../stores/appStore'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
|
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||||
|
|
||||||
import './Sidebar.scss'
|
import './Sidebar.scss'
|
||||||
|
|
||||||
|
interface SidebarUserProfile {
|
||||||
|
wxid: string
|
||||||
|
displayName: string
|
||||||
|
alias?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||||
|
|
||||||
|
interface SidebarUserProfileCache extends SidebarUserProfile {
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw) as SidebarUserProfileCache
|
||||||
|
if (!parsed || typeof parsed !== 'object') return null
|
||||||
|
if (!parsed.wxid || !parsed.displayName) return null
|
||||||
|
return {
|
||||||
|
wxid: parsed.wxid,
|
||||||
|
displayName: parsed.displayName,
|
||||||
|
alias: parsed.alias,
|
||||||
|
avatarUrl: parsed.avatarUrl
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
|
||||||
|
if (!profile.wxid || !profile.displayName) return
|
||||||
|
try {
|
||||||
|
const payload: SidebarUserProfileCache = {
|
||||||
|
...profile,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload))
|
||||||
|
} catch {
|
||||||
|
// 忽略本地缓存失败,不影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeAccountId = (value?: string | null): string => {
|
||||||
|
const trimmed = String(value || '').trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||||
|
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||||
|
return match?.[1] || trimmed
|
||||||
|
}
|
||||||
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
|
return suffixMatch ? suffixMatch[1] : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [authEnabled, setAuthEnabled] = useState(false)
|
const [authEnabled, setAuthEnabled] = useState(false)
|
||||||
|
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
|
||||||
|
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
|
||||||
|
wxid: '',
|
||||||
|
displayName: '未识别用户'
|
||||||
|
})
|
||||||
|
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
|
||||||
|
const [showClearAccountDialog, setShowClearAccountDialog] = useState(false)
|
||||||
|
const [shouldClearCacheData, setShouldClearCacheData] = useState(false)
|
||||||
|
const [shouldClearExportData, setShouldClearExportData] = useState(false)
|
||||||
|
const [isClearingAccountData, setIsClearingAccountData] = useState(false)
|
||||||
|
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
|
||||||
const setLocked = useAppStore(state => state.setLocked)
|
const setLocked = useAppStore(state => state.setLocked)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
configService.getAuthEnabled().then(setAuthEnabled)
|
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (!isAccountMenuOpen) return
|
||||||
|
const target = event.target as Node | null
|
||||||
|
if (accountCardWrapRef.current && target && !accountCardWrapRef.current.contains(target)) {
|
||||||
|
setIsAccountMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [isAccountMenuOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = onExportSessionStatus((payload) => {
|
||||||
|
const countFromPayload = typeof payload?.activeTaskCount === 'number'
|
||||||
|
? payload.activeTaskCount
|
||||||
|
: Array.isArray(payload?.inProgressSessionIds)
|
||||||
|
? payload.inProgressSessionIds.length
|
||||||
|
: 0
|
||||||
|
const normalized = Math.max(0, Math.floor(countFromPayload))
|
||||||
|
setActiveExportTaskCount(normalized)
|
||||||
|
})
|
||||||
|
|
||||||
|
requestExportSessionStatus()
|
||||||
|
const timer = window.setTimeout(() => requestExportSessionStatus(), 120)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe()
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCurrentUser = async () => {
|
||||||
|
const patchUserProfile = (patch: Partial<SidebarUserProfile>, expectedWxid?: string) => {
|
||||||
|
setUserProfile(prev => {
|
||||||
|
if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
const next: SidebarUserProfile = {
|
||||||
|
...prev,
|
||||||
|
...patch
|
||||||
|
}
|
||||||
|
if (!next.displayName) {
|
||||||
|
next.displayName = next.wxid || '未识别用户'
|
||||||
|
}
|
||||||
|
writeSidebarUserProfileCache(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wxid = await configService.getMyWxid()
|
||||||
|
const resolvedWxidRaw = String(wxid || '').trim()
|
||||||
|
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
|
||||||
|
const resolvedWxid = cleanedWxid || resolvedWxidRaw
|
||||||
|
const wxidCandidates = new Set<string>([
|
||||||
|
resolvedWxidRaw.toLowerCase(),
|
||||||
|
resolvedWxid.trim().toLowerCase(),
|
||||||
|
cleanedWxid.trim().toLowerCase()
|
||||||
|
].filter(Boolean))
|
||||||
|
|
||||||
|
const normalizeName = (value?: string | null): string | undefined => {
|
||||||
|
if (!value) return undefined
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
const lowered = trimmed.toLowerCase()
|
||||||
|
if (lowered === 'self') return undefined
|
||||||
|
if (lowered.startsWith('wxid_')) return undefined
|
||||||
|
if (wxidCandidates.has(lowered)) return undefined
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickFirstValidName = (...candidates: Array<string | null | undefined>): string | undefined => {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalized = normalizeName(candidate)
|
||||||
|
if (normalized) return normalized
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackDisplayName = resolvedWxid || '未识别用户'
|
||||||
|
|
||||||
|
// 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。
|
||||||
|
patchUserProfile({
|
||||||
|
wxid: resolvedWxid,
|
||||||
|
displayName: fallbackDisplayName
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resolvedWxidRaw && !resolvedWxid) return
|
||||||
|
|
||||||
|
// 第二阶段:后台补齐名称(不会阻塞首屏)。
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
let myContact: Awaited<ReturnType<typeof window.electronAPI.chat.getContact>> | null = null
|
||||||
|
for (const candidate of Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))) {
|
||||||
|
const contact = await window.electronAPI.chat.getContact(candidate)
|
||||||
|
if (!contact) continue
|
||||||
|
if (!myContact) myContact = contact
|
||||||
|
if (contact.remark || contact.nickName || contact.alias) {
|
||||||
|
myContact = contact
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fromContact = pickFirstValidName(
|
||||||
|
myContact?.remark,
|
||||||
|
myContact?.nickName,
|
||||||
|
myContact?.alias
|
||||||
|
)
|
||||||
|
|
||||||
|
if (fromContact) {
|
||||||
|
patchUserProfile({ displayName: fromContact }, resolvedWxid)
|
||||||
|
// 同步补充微信号(alias)
|
||||||
|
if (myContact?.alias) {
|
||||||
|
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichTargets = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid, 'self'].filter(Boolean)))
|
||||||
|
const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo(enrichTargets)
|
||||||
|
const enrichedDisplayName = pickFirstValidName(
|
||||||
|
enrichedResult.contacts?.[resolvedWxidRaw]?.displayName,
|
||||||
|
enrichedResult.contacts?.[resolvedWxid]?.displayName,
|
||||||
|
enrichedResult.contacts?.[cleanedWxid]?.displayName,
|
||||||
|
enrichedResult.contacts?.self?.displayName,
|
||||||
|
myContact?.alias
|
||||||
|
)
|
||||||
|
const bestName = enrichedDisplayName
|
||||||
|
if (bestName) {
|
||||||
|
patchUserProfile({ displayName: bestName }, resolvedWxid)
|
||||||
|
}
|
||||||
|
// 降级分支也补充微信号
|
||||||
|
if (myContact?.alias) {
|
||||||
|
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
|
||||||
|
}
|
||||||
|
} catch (nameError) {
|
||||||
|
console.error('加载侧边栏用户昵称失败:', nameError)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// 第二阶段:后台补齐头像(不会阻塞首屏)。
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const avatarResult = await window.electronAPI.chat.getMyAvatarUrl()
|
||||||
|
if (avatarResult.success && avatarResult.avatarUrl) {
|
||||||
|
patchUserProfile({ avatarUrl: avatarResult.avatarUrl }, resolvedWxid)
|
||||||
|
}
|
||||||
|
} catch (avatarError) {
|
||||||
|
console.error('加载侧边栏用户头像失败:', avatarError)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载侧边栏用户信息失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedProfile = readSidebarUserProfileCache()
|
||||||
|
if (cachedProfile) {
|
||||||
|
setUserProfile(prev => ({
|
||||||
|
...prev,
|
||||||
|
...cachedProfile
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadCurrentUser()
|
||||||
|
const onWxidChanged = () => { void loadCurrentUser() }
|
||||||
|
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||||
|
return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getAvatarLetter = (name: string): string => {
|
||||||
|
if (!name) return '?'
|
||||||
|
return [...name][0] || '?'
|
||||||
|
}
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||||
}
|
}
|
||||||
|
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
|
||||||
|
const canConfirmClear = shouldClearCacheData || shouldClearExportData
|
||||||
|
|
||||||
|
const resetClearDialogState = () => {
|
||||||
|
setShouldClearCacheData(false)
|
||||||
|
setShouldClearExportData(false)
|
||||||
|
setShowClearAccountDialog(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openClearAccountDialog = () => {
|
||||||
|
setIsAccountMenuOpen(false)
|
||||||
|
setShouldClearCacheData(false)
|
||||||
|
setShouldClearExportData(false)
|
||||||
|
setShowClearAccountDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmClearAccountData = async () => {
|
||||||
|
if (!canConfirmClear || isClearingAccountData) return
|
||||||
|
setIsClearingAccountData(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.chat.clearCurrentAccountData({
|
||||||
|
clearCache: shouldClearCacheData,
|
||||||
|
clearExports: shouldClearExportData
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
window.alert(result.error || '清理失败,请稍后重试。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||||
|
setUserProfile({ wxid: '', displayName: '未识别用户' })
|
||||||
|
window.dispatchEvent(new Event('wxid-changed'))
|
||||||
|
|
||||||
|
const removedPaths = Array.isArray(result.removedPaths) ? result.removedPaths : []
|
||||||
|
const selectedScopes = [
|
||||||
|
shouldClearCacheData ? '缓存数据' : '',
|
||||||
|
shouldClearExportData ? '导出数据' : ''
|
||||||
|
].filter(Boolean)
|
||||||
|
const detailLines: string[] = [
|
||||||
|
`清理范围:${selectedScopes.join('、') || '未选择'}`,
|
||||||
|
`已清理项目:${removedPaths.length} 项`
|
||||||
|
]
|
||||||
|
if (removedPaths.length > 0) {
|
||||||
|
detailLines.push('', '清理明细(最多显示 8 项):')
|
||||||
|
for (const [index, path] of removedPaths.slice(0, 8).entries()) {
|
||||||
|
detailLines.push(`${index + 1}. ${path}`)
|
||||||
|
}
|
||||||
|
if (removedPaths.length > 8) {
|
||||||
|
detailLines.push(`... 其余 ${removedPaths.length - 8} 项已省略`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.warning) {
|
||||||
|
detailLines.push('', `注意:${result.warning}`)
|
||||||
|
}
|
||||||
|
const followupHint = shouldClearCacheData
|
||||||
|
? '若需再次获取数据,请手动登录微信客户端并重新在 WeFlow 完成配置。'
|
||||||
|
: '你可以继续使用当前登录状态,无需重新登录。'
|
||||||
|
window.alert(`账号数据清理完成。\n\n${detailLines.join('\n')}\n\n为保障数据安全,WeFlow 已清除该账号本地缓存/导出相关数据。${followupHint}`)
|
||||||
|
resetClearDialogState()
|
||||||
|
if (shouldClearCacheData) {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清理账号数据失败:', error)
|
||||||
|
window.alert('清理失败,请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
setIsClearingAccountData(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||||
@@ -98,14 +410,61 @@ function Sidebar() {
|
|||||||
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
|
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
|
||||||
title={collapsed ? '导出' : undefined}
|
title={collapsed ? '导出' : undefined}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><Download size={20} /></span>
|
<span className="nav-icon nav-icon-with-badge">
|
||||||
|
<Download size={20} />
|
||||||
|
{collapsed && activeExportTaskCount > 0 && (
|
||||||
|
<span className="nav-badge icon-badge">{exportTaskBadge}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="nav-label">导出</span>
|
<span className="nav-label">导出</span>
|
||||||
|
{!collapsed && activeExportTaskCount > 0 && (
|
||||||
|
<span className="nav-badge">{exportTaskBadge}</span>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
|
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
|
||||||
|
{isAccountMenuOpen && (
|
||||||
|
<button
|
||||||
|
className="sidebar-user-clear-trigger"
|
||||||
|
onClick={openClearAccountDialog}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
<span>清除此账号所有数据</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
|
||||||
|
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
|
||||||
|
onClick={() => setIsAccountMenuOpen(prev => !prev)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
setIsAccountMenuOpen(prev => !prev)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="user-avatar">
|
||||||
|
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="user-meta">
|
||||||
|
<div className="user-name">{userProfile.displayName}</div>
|
||||||
|
<div className="user-wxid">{userProfile.alias || userProfile.wxid || 'wxid 未识别'}</div>
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
|
||||||
|
<ChevronUp size={14} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{authEnabled && (
|
{authEnabled && (
|
||||||
<button
|
<button
|
||||||
className="nav-item"
|
className="nav-item"
|
||||||
@@ -136,6 +495,49 @@ function Sidebar() {
|
|||||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showClearAccountDialog && (
|
||||||
|
<div className="sidebar-clear-dialog-overlay" onClick={() => !isClearingAccountData && resetClearDialogState()}>
|
||||||
|
<div className="sidebar-clear-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<h3>清除此账号所有数据</h3>
|
||||||
|
<p>
|
||||||
|
操作后可将该账户在 weflow 下产生的所有缓存文件、导出文件等彻底清除。
|
||||||
|
清除后必须手动登录微信客户端 weflow 才能再次获取,保障你的数据安全。
|
||||||
|
</p>
|
||||||
|
<div className="sidebar-clear-options">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={shouldClearCacheData}
|
||||||
|
onChange={(event) => setShouldClearCacheData(event.target.checked)}
|
||||||
|
disabled={isClearingAccountData}
|
||||||
|
/>
|
||||||
|
缓存数据
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={shouldClearExportData}
|
||||||
|
onChange={(event) => setShouldClearExportData(event.target.checked)}
|
||||||
|
disabled={isClearingAccountData}
|
||||||
|
/>
|
||||||
|
导出数据
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-clear-actions">
|
||||||
|
<button type="button" onClick={resetClearDialogState} disabled={isClearingAccountData}>取消</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
disabled={!canConfirmClear || isClearingAccountData}
|
||||||
|
onClick={handleConfirmClearAccountData}
|
||||||
|
>
|
||||||
|
{isClearingAccountData ? '清除中...' : '确认清除'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
199
src/components/Sns/SnsFilterPanel.tsx
Normal file
199
src/components/Sns/SnsFilterPanel.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEmptyStateText = () => {
|
||||||
|
if (loading && contacts.length === 0) {
|
||||||
|
return '正在加载联系人...'
|
||||||
|
}
|
||||||
|
if (contacts.length === 0) {
|
||||||
|
return '暂无好友或曾经的好友'
|
||||||
|
}
|
||||||
|
return '没有找到联系人'
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
return (
|
||||||
|
<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" />
|
||||||
|
<div className="contact-meta">
|
||||||
|
<span className="contact-name">{contact.displayName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{filteredContacts.length === 0 && (
|
||||||
|
<div className="empty-state">{getEmptyStateText()}</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
424
src/components/Sns/SnsPostItem.tsx
Normal file
424
src/components/Sns/SnsPostItem.tsx
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
import React, { useState, useMemo, useEffect } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Heart, ChevronRight, ImageIcon, Code, 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表情包内存缓存
|
||||||
|
const emojiLocalCache = new Map<string, string>()
|
||||||
|
|
||||||
|
// 评论表情包组件
|
||||||
|
const CommentEmoji: React.FC<{
|
||||||
|
emoji: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }
|
||||||
|
onPreview?: (src: string) => void
|
||||||
|
}> = ({ emoji, onPreview }) => {
|
||||||
|
const cacheKey = emoji.encryptUrl || emoji.url
|
||||||
|
const [localSrc, setLocalSrc] = useState<string>(() => emojiLocalCache.get(cacheKey) || '')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cacheKey) return
|
||||||
|
if (emojiLocalCache.has(cacheKey)) {
|
||||||
|
setLocalSrc(emojiLocalCache.get(cacheKey)!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await window.electronAPI.sns.downloadEmoji({
|
||||||
|
url: emoji.url,
|
||||||
|
encryptUrl: emoji.encryptUrl,
|
||||||
|
aesKey: emoji.aesKey
|
||||||
|
})
|
||||||
|
if (cancelled) return
|
||||||
|
if (res.success && res.localPath) {
|
||||||
|
const fileUrl = res.localPath.startsWith('file:')
|
||||||
|
? res.localPath
|
||||||
|
: `file://${res.localPath.replace(/\\/g, '/')}`
|
||||||
|
emojiLocalCache.set(cacheKey, fileUrl)
|
||||||
|
setLocalSrc(fileUrl)
|
||||||
|
}
|
||||||
|
} catch { /* 静默失败 */ }
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [cacheKey])
|
||||||
|
|
||||||
|
if (!localSrc) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={localSrc}
|
||||||
|
alt="emoji"
|
||||||
|
className="comment-custom-emoji"
|
||||||
|
draggable={false}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPreview?.(localSrc) }}
|
||||||
|
style={{
|
||||||
|
width: Math.min(emoji.width || 24, 30),
|
||||||
|
height: Math.min(emoji.height || 24, 30),
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
marginLeft: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: onPreview ? 'pointer' : 'default'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnsPostItemProps {
|
||||||
|
post: SnsPost
|
||||||
|
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||||
|
onDebug: (post: SnsPost) => void
|
||||||
|
onDelete?: (postId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete }) => {
|
||||||
|
const [mediaDeleted, setMediaDeleted] = useState(false)
|
||||||
|
const [dbDeleted, setDbDeleted] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (deleting || dbDeleted) return
|
||||||
|
setShowDeleteConfirm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id)
|
||||||
|
if (r.success) {
|
||||||
|
setDbDeleted(true)
|
||||||
|
onDelete?.(post.id)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? '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 || dbDeleted) && (
|
||||||
|
<span className="post-deleted-badge">
|
||||||
|
<Trash2 size={12} />
|
||||||
|
<span>已删除</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="icon-btn-ghost debug-btn delete-btn"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
disabled={deleting || dbDeleted}
|
||||||
|
title="从数据库删除此条记录"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
{c.content && (
|
||||||
|
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
|
||||||
|
)}
|
||||||
|
{c.emojis && c.emojis.map((emoji, ei) => (
|
||||||
|
<CommentEmoji
|
||||||
|
key={ei}
|
||||||
|
emoji={emoji}
|
||||||
|
onPreview={(src) => onPreview(src)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 删除确认弹窗 - 用 Portal 挂到 body,避免父级 transform 影响 fixed 定位 */}
|
||||||
|
{showDeleteConfirm && createPortal(
|
||||||
|
<div className="sns-confirm-overlay" onClick={() => setShowDeleteConfirm(false)}>
|
||||||
|
<div className="sns-confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="sns-confirm-icon">
|
||||||
|
<Trash2 size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="sns-confirm-title">删除这条记录?</div>
|
||||||
|
<div className="sns-confirm-desc">将从本地数据库中永久删除,无法恢复。</div>
|
||||||
|
<div className="sns-confirm-actions">
|
||||||
|
<button className="sns-confirm-cancel" onClick={() => setShowDeleteConfirm(false)}>取消</button>
|
||||||
|
<button className="sns-confirm-ok" onClick={handleDeleteConfirm}>删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,48 @@
|
|||||||
margin: 0 0 48px;
|
margin: 0 0 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-desc.load-summary {
|
||||||
|
margin: 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc.load-summary.complete {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-telemetry {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: 0 0 28px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-telemetry.loading {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-telemetry.complete {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-telemetry.compact {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
width: min(560px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.report-sections {
|
.report-sections {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -83,6 +125,14 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.year-grid-with-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.year-grid {
|
.year-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -95,7 +145,39 @@
|
|||||||
.report-section .year-grid {
|
.report-section .year-grid {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-grid-with-status .year-grid {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-load-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-top: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-load-status.complete {
|
||||||
|
color: color-mix(in srgb, var(--primary) 80%, var(--text-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-ellipsis {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: bottom;
|
||||||
|
animation: dot-ellipsis 1.2s steps(4, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-load-status.complete .dot-ellipsis,
|
||||||
|
.page-desc.load-summary.complete .dot-ellipsis {
|
||||||
|
animation: none;
|
||||||
|
width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.year-card {
|
.year-card {
|
||||||
@@ -185,3 +267,7 @@
|
|||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes dot-ellipsis {
|
||||||
|
to { width: 1.4em; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,28 @@ import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
|||||||
import './AnnualReportPage.scss'
|
import './AnnualReportPage.scss'
|
||||||
|
|
||||||
type YearOption = number | 'all'
|
type YearOption = number | 'all'
|
||||||
|
type YearsLoadPayload = {
|
||||||
|
years?: number[]
|
||||||
|
done: boolean
|
||||||
|
error?: string
|
||||||
|
canceled?: boolean
|
||||||
|
strategy?: 'cache' | 'native' | 'hybrid'
|
||||||
|
phase?: 'cache' | 'native' | 'scan' | 'done'
|
||||||
|
statusText?: string
|
||||||
|
nativeElapsedMs?: number
|
||||||
|
scanElapsedMs?: number
|
||||||
|
totalElapsedMs?: number
|
||||||
|
switched?: boolean
|
||||||
|
nativeTimedOut?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatLoadElapsed = (ms: number) => {
|
||||||
|
const totalSeconds = Math.max(0, ms) / 1000
|
||||||
|
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
const seconds = Math.floor(totalSeconds % 60)
|
||||||
|
return `${minutes}m ${String(seconds).padStart(2, '0')}s`
|
||||||
|
}
|
||||||
|
|
||||||
function AnnualReportPage() {
|
function AnnualReportPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -11,32 +33,117 @@ function AnnualReportPage() {
|
|||||||
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
|
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
|
||||||
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
|
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isLoadingMoreYears, setIsLoadingMoreYears] = useState(false)
|
||||||
|
const [hasYearsLoadFinished, setHasYearsLoadFinished] = useState(false)
|
||||||
|
const [loadStrategy, setLoadStrategy] = useState<'cache' | 'native' | 'hybrid'>('native')
|
||||||
|
const [loadPhase, setLoadPhase] = useState<'cache' | 'native' | 'scan' | 'done'>('native')
|
||||||
|
const [loadStatusText, setLoadStatusText] = useState('准备加载年份数据...')
|
||||||
|
const [nativeElapsedMs, setNativeElapsedMs] = useState(0)
|
||||||
|
const [scanElapsedMs, setScanElapsedMs] = useState(0)
|
||||||
|
const [totalElapsedMs, setTotalElapsedMs] = useState(0)
|
||||||
|
const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false)
|
||||||
|
const [nativeTimedOut, setNativeTimedOut] = useState(false)
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAvailableYears()
|
let disposed = false
|
||||||
}, [])
|
let taskId = ''
|
||||||
|
|
||||||
const loadAvailableYears = async () => {
|
const applyLoadPayload = (payload: YearsLoadPayload) => {
|
||||||
setIsLoading(true)
|
if (payload.strategy) setLoadStrategy(payload.strategy)
|
||||||
setLoadError(null)
|
if (payload.phase) setLoadPhase(payload.phase)
|
||||||
try {
|
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
|
||||||
const result = await window.electronAPI.annualReport.getAvailableYears()
|
if (typeof payload.nativeElapsedMs === 'number' && Number.isFinite(payload.nativeElapsedMs)) {
|
||||||
if (result.success && result.data && result.data.length > 0) {
|
setNativeElapsedMs(Math.max(0, payload.nativeElapsedMs))
|
||||||
setAvailableYears(result.data)
|
}
|
||||||
setSelectedYear((prev) => prev ?? result.data[0])
|
if (typeof payload.scanElapsedMs === 'number' && Number.isFinite(payload.scanElapsedMs)) {
|
||||||
setSelectedPairYear((prev) => prev ?? result.data[0])
|
setScanElapsedMs(Math.max(0, payload.scanElapsedMs))
|
||||||
} else if (!result.success) {
|
}
|
||||||
setLoadError(result.error || '加载年度数据失败')
|
if (typeof payload.totalElapsedMs === 'number' && Number.isFinite(payload.totalElapsedMs)) {
|
||||||
|
setTotalElapsedMs(Math.max(0, payload.totalElapsedMs))
|
||||||
|
}
|
||||||
|
if (typeof payload.switched === 'boolean') setHasSwitchedStrategy(payload.switched)
|
||||||
|
if (typeof payload.nativeTimedOut === 'boolean') setNativeTimedOut(payload.nativeTimedOut)
|
||||||
|
|
||||||
|
const years = Array.isArray(payload.years) ? payload.years : []
|
||||||
|
if (years.length > 0) {
|
||||||
|
setAvailableYears(years)
|
||||||
|
setSelectedYear((prev) => {
|
||||||
|
if (prev === 'all') return prev
|
||||||
|
if (typeof prev === 'number' && years.includes(prev)) return prev
|
||||||
|
return years[0]
|
||||||
|
})
|
||||||
|
setSelectedPairYear((prev) => {
|
||||||
|
if (prev === 'all') return prev
|
||||||
|
if (typeof prev === 'number' && years.includes(prev)) return prev
|
||||||
|
return years[0]
|
||||||
|
})
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.error && !payload.canceled) {
|
||||||
|
setLoadError(payload.error || '加载年度数据失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.done) {
|
||||||
|
setIsLoading(false)
|
||||||
|
setIsLoadingMoreYears(false)
|
||||||
|
setHasYearsLoadFinished(true)
|
||||||
|
setLoadPhase('done')
|
||||||
|
} else {
|
||||||
|
setIsLoadingMoreYears(true)
|
||||||
|
setHasYearsLoadFinished(false)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
setLoadError(String(e))
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const stopListen = window.electronAPI.annualReport.onAvailableYearsProgress((payload) => {
|
||||||
|
if (disposed) return
|
||||||
|
if (taskId && payload.taskId !== taskId) return
|
||||||
|
if (!taskId) taskId = payload.taskId
|
||||||
|
applyLoadPayload(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
const startLoad = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setIsLoadingMoreYears(true)
|
||||||
|
setHasYearsLoadFinished(false)
|
||||||
|
setLoadStrategy('native')
|
||||||
|
setLoadPhase('native')
|
||||||
|
setLoadStatusText('准备使用原生快速模式加载年份...')
|
||||||
|
setNativeElapsedMs(0)
|
||||||
|
setScanElapsedMs(0)
|
||||||
|
setTotalElapsedMs(0)
|
||||||
|
setHasSwitchedStrategy(false)
|
||||||
|
setNativeTimedOut(false)
|
||||||
|
setLoadError(null)
|
||||||
|
try {
|
||||||
|
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
|
||||||
|
if (!startResult.success || !startResult.taskId) {
|
||||||
|
setLoadError(startResult.error || '加载年度数据失败')
|
||||||
|
setIsLoading(false)
|
||||||
|
setIsLoadingMoreYears(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskId = startResult.taskId
|
||||||
|
if (startResult.snapshot) {
|
||||||
|
applyLoadPayload(startResult.snapshot)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
setLoadError(String(e))
|
||||||
|
setIsLoading(false)
|
||||||
|
setIsLoadingMoreYears(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void startLoad()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
stopListen()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleGenerateReport = async () => {
|
const handleGenerateReport = async () => {
|
||||||
if (selectedYear === null) return
|
if (selectedYear === null) return
|
||||||
@@ -57,16 +164,25 @@ function AnnualReportPage() {
|
|||||||
navigate(`/dual-report?year=${yearParam}`)
|
navigate(`/dual-report?year=${yearParam}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading && availableYears.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-page">
|
<div className="annual-report-page">
|
||||||
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
|
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在加载年份数据...</p>
|
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在加载年份数据(首批)...</p>
|
||||||
|
<div className="load-telemetry compact">
|
||||||
|
<p><span className="label">加载方式:</span>{getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })}</p>
|
||||||
|
<p><span className="label">状态:</span>{loadStatusText || '正在加载年份数据...'}</p>
|
||||||
|
<p>
|
||||||
|
<span className="label">原生耗时:</span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} |{' '}
|
||||||
|
<span className="label">扫表耗时:</span>{formatLoadElapsed(scanElapsedMs)} |{' '}
|
||||||
|
<span className="label">总耗时:</span>{formatLoadElapsed(totalElapsedMs)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (availableYears.length === 0) {
|
if (availableYears.length === 0 && !isLoadingMoreYears) {
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-page">
|
<div className="annual-report-page">
|
||||||
<Calendar size={64} style={{ color: 'var(--text-tertiary)', opacity: 0.5 }} />
|
<Calendar size={64} style={{ color: 'var(--text-tertiary)', opacity: 0.5 }} />
|
||||||
@@ -87,11 +203,50 @@ function AnnualReportPage() {
|
|||||||
return value === 'all' ? '全部时间' : `${value} 年`
|
return value === 'all' ? '全部时间' : `${value} 年`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadedYearCount = availableYears.length
|
||||||
|
const isYearStatusComplete = hasYearsLoadFinished
|
||||||
|
const strategyLabel = getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })
|
||||||
|
const renderYearLoadStatus = () => (
|
||||||
|
<div className={`year-load-status ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
||||||
|
{isYearStatusComplete ? (
|
||||||
|
<>全部年份已加载完毕</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
更多年份加载中<span className="dot-ellipsis" aria-hidden="true">...</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-page">
|
<div className="annual-report-page">
|
||||||
<Sparkles size={32} className="header-icon" />
|
<Sparkles size={32} className="header-icon" />
|
||||||
<h1 className="page-title">年度报告</h1>
|
<h1 className="page-title">年度报告</h1>
|
||||||
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
|
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
|
||||||
|
{loadedYearCount > 0 && (
|
||||||
|
<p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
||||||
|
{isYearStatusComplete ? (
|
||||||
|
<>已显示 {loadedYearCount} 个年份,年份数据已全部加载完毕。总耗时 {formatLoadElapsed(totalElapsedMs)}</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
已显示 {loadedYearCount} 个年份,正在补充更多年份<span className="dot-ellipsis" aria-hidden="true">...</span>
|
||||||
|
(已耗时 {formatLoadElapsed(totalElapsedMs)})
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={`load-telemetry ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
||||||
|
<p><span className="label">加载方式:</span>{strategyLabel}</p>
|
||||||
|
<p>
|
||||||
|
<span className="label">状态:</span>
|
||||||
|
{loadStatusText || (isYearStatusComplete ? '全部年份已加载完毕' : '正在加载年份数据...')}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="label">原生耗时:</span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} |{' '}
|
||||||
|
<span className="label">扫表耗时:</span>{formatLoadElapsed(scanElapsedMs)} |{' '}
|
||||||
|
<span className="label">总耗时:</span>{formatLoadElapsed(totalElapsedMs)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="report-sections">
|
<div className="report-sections">
|
||||||
<section className="report-section">
|
<section className="report-section">
|
||||||
@@ -102,17 +257,20 @@ function AnnualReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="year-grid">
|
<div className="year-grid-with-status">
|
||||||
{yearOptions.map(option => (
|
<div className="year-grid">
|
||||||
<div
|
{yearOptions.map(option => (
|
||||||
key={option}
|
<div
|
||||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
key={option}
|
||||||
onClick={() => setSelectedYear(option)}
|
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
||||||
>
|
onClick={() => setSelectedYear(option)}
|
||||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
>
|
||||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||||
</div>
|
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||||
))}
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{renderYearLoadStatus()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -146,17 +304,20 @@ function AnnualReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="year-grid">
|
<div className="year-grid-with-status">
|
||||||
{yearOptions.map(option => (
|
<div className="year-grid">
|
||||||
<div
|
{yearOptions.map(option => (
|
||||||
key={`pair-${option}`}
|
<div
|
||||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
|
key={`pair-${option}`}
|
||||||
onClick={() => setSelectedPairYear(option)}
|
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
|
||||||
>
|
onClick={() => setSelectedPairYear(option)}
|
||||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
>
|
||||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||||
</div>
|
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||||
))}
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{renderYearLoadStatus()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -174,4 +335,23 @@ function AnnualReportPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStrategyLabel(params: {
|
||||||
|
loadStrategy: 'cache' | 'native' | 'hybrid'
|
||||||
|
loadPhase: 'cache' | 'native' | 'scan' | 'done'
|
||||||
|
hasYearsLoadFinished: boolean
|
||||||
|
hasSwitchedStrategy: boolean
|
||||||
|
nativeTimedOut: boolean
|
||||||
|
}): string {
|
||||||
|
const { loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut } = params
|
||||||
|
if (loadStrategy === 'cache') return '缓存模式(快速)'
|
||||||
|
if (hasYearsLoadFinished) {
|
||||||
|
if (loadStrategy === 'native') return '原生快速模式'
|
||||||
|
if (hasSwitchedStrategy || nativeTimedOut) return '混合策略(原生→扫表)'
|
||||||
|
return '扫表兼容模式'
|
||||||
|
}
|
||||||
|
if (loadPhase === 'native') return '原生快速模式(优先)'
|
||||||
|
if (loadPhase === 'scan') return '扫表兼容模式(回退)'
|
||||||
|
return '混合策略'
|
||||||
|
}
|
||||||
|
|
||||||
export default AnnualReportPage
|
export default AnnualReportPage
|
||||||
|
|||||||
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;
|
||||||
@@ -143,6 +148,17 @@
|
|||||||
svg {
|
svg {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-label {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-count {
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -172,6 +188,22 @@
|
|||||||
padding: 0 20px 12px;
|
padding: 0 20px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
.contacts-cache-meta {
|
||||||
|
margin-left: 10px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&.syncing {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-enrich-progress {
|
||||||
|
margin-left: 10px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-toolbar {
|
.selection-toolbar {
|
||||||
@@ -208,10 +240,103 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.load-issue-state {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 14px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-card {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg));
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
.issue-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary));
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-message {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-reason {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-hints {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-btn {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary));
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-diagnostics {
|
||||||
|
margin-top: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.contacts-list {
|
.contacts-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 12px 12px;
|
padding: 0 12px 12px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -224,15 +349,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contacts-list-virtual {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-row {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 76px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
.contact-item {
|
.contact-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
height: 72px;
|
||||||
|
box-sizing: border-box;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
margin-bottom: 4px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
@@ -242,6 +383,10 @@
|
|||||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.contact-select {
|
.contact-select {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -334,6 +479,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;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,16 @@ function DualReportWindow() {
|
|||||||
setLoadingStage('完成')
|
setLoadingStage('完成')
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setReportData(result.data)
|
const normalizedResponse = result.data.response
|
||||||
|
? {
|
||||||
|
...result.data.response,
|
||||||
|
slowest: result.data.response.slowest ?? result.data.response.avg
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
setReportData({
|
||||||
|
...result.data,
|
||||||
|
response: normalizedResponse
|
||||||
|
})
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || '生成报告失败')
|
setError(result.error || '生成报告失败')
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,7 @@ interface GroupMessageRank {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats'
|
type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats'
|
||||||
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone'
|
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
|
||||||
|
|
||||||
interface MemberMessageExportOptions {
|
interface MemberMessageExportOptions {
|
||||||
format: MemberExportFormat
|
format: MemberExportFormat
|
||||||
@@ -119,6 +119,7 @@ function GroupAnalyticsPage() {
|
|||||||
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
||||||
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
||||||
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
||||||
|
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
|
||||||
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
||||||
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
||||||
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
||||||
|
|||||||
@@ -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,18 +1,26 @@
|
|||||||
|
|
||||||
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 })
|
||||||
const [initialScale, setInitialScale] = useState(1)
|
const [initialScale, setInitialScale] = useState(1)
|
||||||
const viewportRef = useRef<HTMLDivElement>(null)
|
const viewportRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// 使用 ref 存储拖动状态,避免闭包问题
|
// 使用 ref 存储拖动状态,避免闭包问题
|
||||||
const dragStateRef = useRef({
|
const dragStateRef = useRef({
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
@@ -22,11 +30,49 @@ 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)
|
||||||
const handleRotateCcw = () => setRotation(prev => (prev - 90 + 360) % 360)
|
const handleRotateCcw = () => setRotation(prev => (prev - 90 + 360) % 360)
|
||||||
|
|
||||||
// 重置视图
|
// 重置视图
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
setScale(1)
|
setScale(1)
|
||||||
@@ -39,7 +85,7 @@ export default function ImageWindow() {
|
|||||||
const img = e.currentTarget
|
const img = e.currentTarget
|
||||||
const naturalWidth = img.naturalWidth
|
const naturalWidth = img.naturalWidth
|
||||||
const naturalHeight = img.naturalHeight
|
const naturalHeight = img.naturalHeight
|
||||||
|
|
||||||
if (viewportRef.current) {
|
if (viewportRef.current) {
|
||||||
const viewportWidth = viewportRef.current.clientWidth * 0.9
|
const viewportWidth = viewportRef.current.clientWidth * 0.9
|
||||||
const viewportHeight = viewportRef.current.clientHeight * 0.9
|
const viewportHeight = viewportRef.current.clientHeight * 0.9
|
||||||
@@ -51,14 +97,37 @@ 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) => {
|
||||||
if (!dragStateRef.current.isDragging) return
|
if (!dragStateRef.current.isDragging) return
|
||||||
|
|
||||||
const dx = e.clientX - dragStateRef.current.startX
|
const dx = e.clientX - dragStateRef.current.startX
|
||||||
const dy = e.clientY - dragStateRef.current.startY
|
const dy = e.clientY - dragStateRef.current.startY
|
||||||
|
|
||||||
setPosition({
|
setPosition({
|
||||||
x: dragStateRef.current.startPosX + dx,
|
x: dragStateRef.current.startPosX + dx,
|
||||||
y: dragStateRef.current.startPosY + dy
|
y: dragStateRef.current.startPosY + dy
|
||||||
@@ -82,7 +151,7 @@ export default function ImageWindow() {
|
|||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
dragStateRef.current = {
|
dragStateRef.current = {
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
@@ -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>
|
||||||
@@ -140,22 +233,38 @@ export default function ImageWindow() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="image-viewport"
|
className="image-viewport"
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
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)`
|
||||||
}}
|
}}
|
||||||
onLoad={handleImageLoad}
|
>
|
||||||
draggable={false}
|
<img
|
||||||
/>
|
src={imagePath}
|
||||||
|
alt="Preview"
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2172,4 +2172,71 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.brute-force-progress {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
animation: slideUp 0.3s ease;
|
||||||
|
|
||||||
|
.status-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
// 增加文字呼吸灯效果,表明正在运行
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.percent {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 60%, white) 100%);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
// 流光扫过的高亮特效
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.3),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: progress-shimmer 1.5s infinite linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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,8 @@ 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 [imageKeyProgress, setImageKeyProgress] = useState(0)
|
||||||
|
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
|
||||||
|
|
||||||
const [logEnabled, setLogEnabled] = useState(false)
|
const [logEnabled, setLogEnabled] = useState(false)
|
||||||
const [whisperModelName, setWhisperModelName] = useState('base')
|
const [whisperModelName, setWhisperModelName] = useState('base')
|
||||||
@@ -82,10 +92,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';
|
||||||
@@ -142,6 +148,11 @@ 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
|
||||||
@@ -180,14 +191,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()
|
||||||
@@ -221,8 +224,28 @@ function SettingsPage() {
|
|||||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||||
setDbKeyStatus(payload.message)
|
setDbKeyStatus(payload.message)
|
||||||
})
|
})
|
||||||
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
|
|
||||||
setImageKeyStatus(payload.message)
|
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string, percent?: number }) => {
|
||||||
|
let msg = payload.message;
|
||||||
|
let pct = payload.percent;
|
||||||
|
|
||||||
|
// 如果后端没有显式传 percent,则用正则从字符串中提取如 "(12.5%)"
|
||||||
|
if (pct === undefined) {
|
||||||
|
const match = msg.match(/\(([\d.]+)%\)/);
|
||||||
|
if (match) {
|
||||||
|
pct = parseFloat(match[1]);
|
||||||
|
// 将百分比从文本中剥离,让 UI 更清爽
|
||||||
|
msg = msg.replace(/\s*\([\d.]+%\)/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageKeyStatus(msg);
|
||||||
|
if (pct !== undefined) {
|
||||||
|
setImageKeyPercent(pct);
|
||||||
|
} else if (msg.includes('启动多核') || msg.includes('定位') || msg.includes('准备')) {
|
||||||
|
// 预热阶段
|
||||||
|
setImageKeyPercent(0);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
removeDb?.()
|
removeDb?.()
|
||||||
@@ -275,10 +298,12 @@ 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)
|
||||||
@@ -328,8 +353,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)
|
||||||
}
|
}
|
||||||
@@ -645,7 +669,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)
|
||||||
@@ -681,68 +704,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 () => {
|
||||||
@@ -806,40 +767,26 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAutoGetImageKey = async () => {
|
const handleAutoGetImageKey = async () => {
|
||||||
if (isFetchingImageKey) return
|
if (isFetchingImageKey) return;
|
||||||
if (!dbPath) {
|
if (!dbPath) { showMessage('请先选择数据库目录', false); return; }
|
||||||
showMessage('请先选择数据库目录', false)
|
setIsFetchingImageKey(true);
|
||||||
return
|
setImageKeyPercent(0)
|
||||||
}
|
setImageKeyStatus('正在初始化...');
|
||||||
setIsFetchingImageKey(true)
|
setImageKeyProgress(0);
|
||||||
setImageKeyStatus('正在准备获取图片密钥...')
|
|
||||||
try {
|
try {
|
||||||
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
|
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath;
|
||||||
const result = await window.electronAPI.key.autoGetImageKey(accountPath)
|
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
|
||||||
if (result.success && result.aesKey) {
|
if (result.success && result.aesKey) {
|
||||||
if (typeof result.xorKey === 'number') {
|
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||||
setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
|
||||||
}
|
|
||||||
setImageAesKey(result.aesKey)
|
setImageAesKey(result.aesKey)
|
||||||
setImageKeyStatus('已获取图片密钥')
|
setImageKeyStatus('已获取图片密钥')
|
||||||
showMessage('已自动获取图片密钥', true)
|
showMessage('已自动获取图片密钥', true)
|
||||||
|
|
||||||
// Auto-save after fetching keys
|
|
||||||
// We need to use the values directly because state updates are async
|
|
||||||
const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0
|
const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0
|
||||||
const newAesKey = result.aesKey
|
const newAesKey = result.aesKey
|
||||||
|
|
||||||
await configService.setImageXorKey(newXorKey)
|
await configService.setImageXorKey(newXorKey)
|
||||||
await configService.setImageAesKey(newAesKey)
|
await configService.setImageAesKey(newAesKey)
|
||||||
|
if (wxid) await configService.setWxidConfig(wxid, { decryptKey, imageXorKey: newXorKey, imageAesKey: newAesKey })
|
||||||
if (wxid) {
|
|
||||||
await configService.setWxidConfig(wxid, {
|
|
||||||
decryptKey: decryptKey, // use current state as it hasn't changed here
|
|
||||||
imageXorKey: newXorKey,
|
|
||||||
imageAesKey: newAesKey
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
showMessage(result.error || '自动获取图片密钥失败', false)
|
showMessage(result.error || '自动获取图片密钥失败', false)
|
||||||
}
|
}
|
||||||
@@ -850,6 +797,36 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleScanImageKeyFromMemory = async () => {
|
||||||
|
if (isFetchingImageKey) return;
|
||||||
|
if (!dbPath) { showMessage('请先选择数据库目录', false); return; }
|
||||||
|
setIsFetchingImageKey(true);
|
||||||
|
setImageKeyPercent(0)
|
||||||
|
setImageKeyStatus('正在扫描内存...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath;
|
||||||
|
const result = await window.electronAPI.key.scanImageKeyFromMemory(accountPath)
|
||||||
|
if (result.success && result.aesKey) {
|
||||||
|
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||||
|
setImageAesKey(result.aesKey)
|
||||||
|
setImageKeyStatus('内存扫描成功,已获取图片密钥')
|
||||||
|
showMessage('内存扫描成功,已获取图片密钥', true)
|
||||||
|
const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0
|
||||||
|
const newAesKey = result.aesKey
|
||||||
|
await configService.setImageXorKey(newXorKey)
|
||||||
|
await configService.setImageAesKey(newAesKey)
|
||||||
|
if (wxid) await configService.setWxidConfig(wxid, { decryptKey, imageXorKey: newXorKey, imageAesKey: newAesKey })
|
||||||
|
} else {
|
||||||
|
showMessage(result.error || '内存扫描获取图片密钥失败', false)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
showMessage(`内存扫描失败: ${e}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsFetchingImageKey(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
const handleTestConnection = async () => {
|
||||||
@@ -993,12 +970,27 @@ 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%)'
|
||||||
|
: theme.id === 'geist' ? 'linear-gradient(135deg, #1a1a1a 0%, #222222 100%)'
|
||||||
|
: 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)')
|
||||||
|
: (theme.id === 'blossom-dream' ? `linear-gradient(150deg, ${theme.bgColor} 0%, #F8F2F8 45%, #F2F6FB 100%)`
|
||||||
|
: theme.id === 'geist' ? 'linear-gradient(135deg, #ffffff 0%, #f0f0f0 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>
|
||||||
@@ -1398,11 +1390,27 @@ function SettingsPage() {
|
|||||||
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
|
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
<div className="form-hint" style={{ color: '#f59e0b', margin: '6px 0' }}>
|
||||||
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
⚠️ 快速获取方案基于本地缓存计算,可能因账号信息不匹配而不准确。若图片无法解密,请使用「内存扫描」方案。
|
||||||
</button>
|
</div>
|
||||||
{imageKeyStatus && <div className="form-hint status-text">{imageKeyStatus}</div>}
|
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
|
||||||
{isFetchingImageKey && <div className="form-hint status-text">正在扫描内存,请稍候...</div>}
|
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算(可能不准确)">
|
||||||
|
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '快速获取(缓存计算)'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存,准确率更高">
|
||||||
|
{isFetchingImageKey ? '扫描中...' : '内存扫描(推荐)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isFetchingImageKey ? (
|
||||||
|
<div className="brute-force-progress">
|
||||||
|
<div className="status-header">
|
||||||
|
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
|
||||||
|
)}
|
||||||
|
<span className="form-hint">内存扫描需要微信正在运行,并在微信中打开 2-3 张图片大图后再点击</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -1441,7 +1449,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">
|
||||||
@@ -1511,50 +1519,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>
|
||||||
@@ -2032,6 +1996,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)
|
||||||
@@ -2049,8 +2017,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) {
|
||||||
@@ -2068,18 +2038,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: 模式 → 修改密码
|
||||||
setNewPassword('')
|
if (!oldPassword) {
|
||||||
setConfirmPassword('')
|
showMessage('请输入旧密码', false)
|
||||||
showMessage('密码已更新', true)
|
return
|
||||||
|
}
|
||||||
|
const result = await window.electronAPI.auth.changePassword(oldPassword, newPassword)
|
||||||
|
if (result.success) {
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
setOldPassword('')
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2138,31 +2130,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 && (
|
||||||
<input
|
<button
|
||||||
type="checkbox"
|
className="btn btn-secondary btn-sm"
|
||||||
checked={authEnabled}
|
onClick={() => setShowDisableLockInput(true)}
|
||||||
onChange={async (e) => {
|
>
|
||||||
const enabled = e.target.checked
|
关闭应用锁
|
||||||
setAuthEnabled(enabled)
|
</button>
|
||||||
await configService.setAuthEnabled(enabled)
|
)}
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="switch-slider" />
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
{showDisableLockInput && (
|
||||||
|
<div style={{ marginTop: 10, display: 'flex', gap: 10 }}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="field-input"
|
||||||
|
placeholder="输入当前密码以关闭"
|
||||||
|
value={disableLockPassword}
|
||||||
|
onChange={e => setDisableLockPassword(e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
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 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"
|
||||||
@@ -2179,7 +2213,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>
|
||||||
@@ -2191,23 +2227,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
@@ -803,3 +803,79 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.brute-force-progress {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
animation: slideUp 0.3s ease;
|
||||||
|
|
||||||
|
.status-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.percent {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 60%, white) 100%);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.3),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: progress-shimmer 1.5s infinite linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress-shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
@@ -23,6 +23,18 @@ interface WelcomePageProps {
|
|||||||
standalone?: boolean
|
standalone?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
|
||||||
|
const base = String(error || '自动获取密钥失败').trim()
|
||||||
|
const tailLogs = Array.isArray(logs)
|
||||||
|
? logs
|
||||||
|
.map(item => String(item || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(-6)
|
||||||
|
: []
|
||||||
|
if (tailLogs.length === 0) return base
|
||||||
|
return `${base};最近状态:${tailLogs.join(' | ')}`
|
||||||
|
}
|
||||||
|
|
||||||
function WelcomePage({ standalone = false }: WelcomePageProps) {
|
function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
|
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
|
||||||
@@ -48,6 +60,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||||
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
||||||
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
||||||
|
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
|
||||||
|
|
||||||
// 安全相关 state
|
// 安全相关 state
|
||||||
const [enableAuth, setEnableAuth] = useState(false)
|
const [enableAuth, setEnableAuth] = useState(false)
|
||||||
@@ -111,8 +124,25 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||||
setDbKeyStatus(payload.message)
|
setDbKeyStatus(payload.message)
|
||||||
})
|
})
|
||||||
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
|
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string, percent?: number }) => {
|
||||||
setImageKeyStatus(payload.message)
|
let msg = payload.message;
|
||||||
|
let pct = payload.percent;
|
||||||
|
|
||||||
|
// 解析文本中的百分比
|
||||||
|
if (pct === undefined) {
|
||||||
|
const match = msg.match(/\(([\d.]+)%\)/);
|
||||||
|
if (match) {
|
||||||
|
pct = parseFloat(match[1]);
|
||||||
|
msg = msg.replace(/\s*\([\d.]+%\)/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageKeyStatus(msg);
|
||||||
|
if (pct !== undefined) {
|
||||||
|
setImageKeyPercent(pct);
|
||||||
|
} else if (msg.includes('启动多核') || msg.includes('定位') || msg.includes('准备')) {
|
||||||
|
setImageKeyPercent(0);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
removeDb?.()
|
removeDb?.()
|
||||||
@@ -274,7 +304,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
setIsManualStartPrompt(true)
|
setIsManualStartPrompt(true)
|
||||||
setDbKeyStatus('需要手动启动微信')
|
setDbKeyStatus('需要手动启动微信')
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || '自动获取密钥失败')
|
if (result.error?.includes('尚未完成登录')) {
|
||||||
|
setDbKeyStatus('请先在微信完成登录后重试')
|
||||||
|
}
|
||||||
|
setError(formatDbKeyFailureMessage(result.error, result.logs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -291,21 +324,16 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
|
|
||||||
const handleAutoGetImageKey = async () => {
|
const handleAutoGetImageKey = async () => {
|
||||||
if (isFetchingImageKey) return
|
if (isFetchingImageKey) return
|
||||||
if (!dbPath) {
|
if (!dbPath) { setError('请先选择数据库目录'); return }
|
||||||
setError('请先选择数据库目录')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIsFetchingImageKey(true)
|
setIsFetchingImageKey(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
setImageKeyPercent(0)
|
||||||
setImageKeyStatus('正在准备获取图片密钥...')
|
setImageKeyStatus('正在准备获取图片密钥...')
|
||||||
try {
|
try {
|
||||||
// 拼接完整的账号目录,确保 KeyService 能准确找到模板文件
|
|
||||||
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
|
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
|
||||||
const result = await window.electronAPI.key.autoGetImageKey(accountPath)
|
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
|
||||||
if (result.success && result.aesKey) {
|
if (result.success && result.aesKey) {
|
||||||
if (typeof result.xorKey === 'number') {
|
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||||
setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
|
||||||
}
|
|
||||||
setImageAesKey(result.aesKey)
|
setImageAesKey(result.aesKey)
|
||||||
setImageKeyStatus('已获取图片密钥')
|
setImageKeyStatus('已获取图片密钥')
|
||||||
} else {
|
} else {
|
||||||
@@ -318,6 +346,30 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleScanImageKeyFromMemory = async () => {
|
||||||
|
if (isFetchingImageKey) return
|
||||||
|
if (!dbPath) { setError('请先选择数据库目录'); return }
|
||||||
|
setIsFetchingImageKey(true)
|
||||||
|
setError('')
|
||||||
|
setImageKeyPercent(0)
|
||||||
|
setImageKeyStatus('正在扫描内存...')
|
||||||
|
try {
|
||||||
|
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
|
||||||
|
const result = await window.electronAPI.key.scanImageKeyFromMemory(accountPath)
|
||||||
|
if (result.success && result.aesKey) {
|
||||||
|
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||||
|
setImageAesKey(result.aesKey)
|
||||||
|
setImageKeyStatus('内存扫描成功,已获取图片密钥')
|
||||||
|
} else {
|
||||||
|
setError(result.error || '内存扫描获取图片密钥失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(`内存扫描失败: ${e}`)
|
||||||
|
} finally {
|
||||||
|
setIsFetchingImageKey(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const canGoNext = () => {
|
const canGoNext = () => {
|
||||||
if (currentStep.id === 'intro') return true
|
if (currentStep.id === 'intro') return true
|
||||||
if (currentStep.id === 'db') return Boolean(dbPath)
|
if (currentStep.id === 'db') return Boolean(dbPath)
|
||||||
@@ -728,35 +780,40 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
|
|
||||||
{currentStep.id === 'image' && (
|
{currentStep.id === 'image' && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
<div className="field-hint" style={{ color: '#f59e0b', marginBottom: '12px' }}>
|
||||||
|
⚠️ 快速获取方案基于本地缓存计算,可能因账号信息不匹配而不准确。若图片无法解密,请使用下方「内存扫描」方案。
|
||||||
|
</div>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">图片 XOR 密钥</label>
|
<label className="field-label">图片 XOR 密钥</label>
|
||||||
<input
|
<input type="text" className="field-input" placeholder="0x..." value={imageXorKey} onChange={(e) => setImageXorKey(e.target.value)} />
|
||||||
type="text"
|
|
||||||
className="field-input"
|
|
||||||
placeholder="0x..."
|
|
||||||
value={imageXorKey}
|
|
||||||
onChange={(e) => setImageXorKey(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">图片 AES 密钥</label>
|
<label className="field-label">图片 AES 密钥</label>
|
||||||
<input
|
<input type="text" className="field-input" placeholder="16位密钥" value={imageAesKey} onChange={(e) => setImageAesKey(e.target.value)} />
|
||||||
type="text"
|
|
||||||
className="field-input"
|
|
||||||
placeholder="16位密钥"
|
|
||||||
value={imageAesKey}
|
|
||||||
onChange={(e) => setImageAesKey(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||||||
{isFetchingImageKey ? '扫描中...' : '自动获取图片密钥'}
|
<button className="btn btn-secondary btn-block" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算(可能不准确)">
|
||||||
</button>
|
{isFetchingImageKey ? '获取中...' : '快速获取(缓存计算)'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary btn-block" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存,准确率更高,需要微信正在运行">
|
||||||
|
{isFetchingImageKey ? '扫描中...' : '内存扫描(推荐)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{imageKeyStatus && <div className="status-message">{imageKeyStatus}</div>}
|
{isFetchingImageKey ? (
|
||||||
<div className="field-hint">请在微信中打开几张图片后再点击获取</div>
|
<div className="brute-force-progress">
|
||||||
|
<div className="status-header">
|
||||||
|
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="field-hint" style={{ marginTop: '8px' }}>内存扫描需要微信正在运行,并在微信中打开 2-3 张图片大图后再点击</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
|
||||||
9
src/services/cloudControl.ts
Normal file
9
src/services/cloudControl.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// 数据收集服务前端接口
|
||||||
|
|
||||||
|
export async function initCloudControl() {
|
||||||
|
return window.electronAPI.cloud.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordPage(pageName: string) {
|
||||||
|
window.electronAPI.cloud.recordPage(pageName)
|
||||||
|
}
|
||||||
@@ -32,6 +32,19 @@ export const CONFIG_KEYS = {
|
|||||||
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
||||||
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
|
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
|
||||||
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
|
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
|
||||||
|
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
|
||||||
|
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
|
||||||
|
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
|
||||||
|
EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap',
|
||||||
|
EXPORT_SESSION_RECORD_MAP: 'exportSessionRecordMap',
|
||||||
|
EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount',
|
||||||
|
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
|
||||||
|
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
|
||||||
|
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
||||||
|
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
||||||
|
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
||||||
|
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
||||||
|
CONTACTS_AVATAR_CACHE_MAP: 'contactsAvatarCacheMap',
|
||||||
|
|
||||||
// 安全
|
// 安全
|
||||||
AUTH_ENABLED: 'authEnabled',
|
AUTH_ENABLED: 'authEnabled',
|
||||||
@@ -48,7 +61,10 @@ export const CONFIG_KEYS = {
|
|||||||
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
||||||
|
|
||||||
// 词云
|
// 词云
|
||||||
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords'
|
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords',
|
||||||
|
|
||||||
|
// 数据收集
|
||||||
|
ANALYTICS_CONSENT: 'analyticsConsent'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export interface WxidConfig {
|
export interface WxidConfig {
|
||||||
@@ -118,13 +134,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +402,594 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
|
|||||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExportWriteLayout = 'A' | 'B' | 'C'
|
||||||
|
|
||||||
|
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_WRITE_LAYOUT)
|
||||||
|
if (value === 'A' || value === 'B' || value === 'C') return value
|
||||||
|
return 'B'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportWriteLayout(layout: ExportWriteLayout): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_WRITE_LAYOUT, layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportSessionNamePrefixEnabled(): Promise<boolean> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_NAME_PREFIX_ENABLED)
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportSessionNamePrefixEnabled(enabled: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SESSION_NAME_PREFIX_ENABLED, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportLastSessionRunMap(): Promise<Record<string, number>> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return {}
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>)
|
||||||
|
const map: Record<string, number> = {}
|
||||||
|
for (const [sessionId, raw] of entries) {
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||||
|
map[sessionId] = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportLastSessionRunMap(map: Record<string, number>): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportLastContentRunMap(): Promise<Record<string, number>> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return {}
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>)
|
||||||
|
const map: Record<string, number> = {}
|
||||||
|
for (const [key, raw] of entries) {
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||||
|
map[key] = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportLastContentRunMap(map: Record<string, number>): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportSessionRecordEntry {
|
||||||
|
exportTime: number
|
||||||
|
content: string
|
||||||
|
outputDir: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportSessionRecordMap(): Promise<Record<string, ExportSessionRecordEntry[]>> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_RECORD_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return {}
|
||||||
|
const map: Record<string, ExportSessionRecordEntry[]> = {}
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>)
|
||||||
|
for (const [sessionId, rawList] of entries) {
|
||||||
|
if (!Array.isArray(rawList)) continue
|
||||||
|
const normalizedList: ExportSessionRecordEntry[] = []
|
||||||
|
for (const rawItem of rawList) {
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') continue
|
||||||
|
const exportTime = Number((rawItem as Record<string, unknown>).exportTime)
|
||||||
|
const content = String((rawItem as Record<string, unknown>).content || '').trim()
|
||||||
|
const outputDir = String((rawItem as Record<string, unknown>).outputDir || '').trim()
|
||||||
|
if (!Number.isFinite(exportTime) || exportTime <= 0) continue
|
||||||
|
if (!content || !outputDir) continue
|
||||||
|
normalizedList.push({
|
||||||
|
exportTime: Math.floor(exportTime),
|
||||||
|
content,
|
||||||
|
outputDir
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (normalizedList.length > 0) {
|
||||||
|
map[sessionId] = normalizedList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportSessionRecordMap(map: Record<string, ExportSessionRecordEntry[]>): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SESSION_RECORD_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportLastSnsPostCount(): Promise<number> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT)
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
||||||
|
return Math.floor(value)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportLastSnsPostCount(count: number): Promise<void> {
|
||||||
|
const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportSessionMessageCountCacheItem {
|
||||||
|
updatedAt: number
|
||||||
|
counts: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportSessionContentMetricCacheEntry {
|
||||||
|
totalMessages?: number
|
||||||
|
voiceMessages?: number
|
||||||
|
imageMessages?: number
|
||||||
|
videoMessages?: number
|
||||||
|
emojiMessages?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportSessionContentMetricCacheItem {
|
||||||
|
updatedAt: number
|
||||||
|
metrics: Record<string, ExportSessionContentMetricCacheEntry>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportSnsStatsCacheItem {
|
||||||
|
updatedAt: number
|
||||||
|
totalPosts: number
|
||||||
|
totalFriends: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnsPageOverviewCache {
|
||||||
|
totalPosts: number
|
||||||
|
totalFriends: number
|
||||||
|
myPosts: number | null
|
||||||
|
earliestTime: number | null
|
||||||
|
latestTime: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnsPageCacheItem {
|
||||||
|
updatedAt: number
|
||||||
|
overviewStats: SnsPageOverviewCache
|
||||||
|
posts: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactsListCacheContact {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
remark?: string
|
||||||
|
nickname?: string
|
||||||
|
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactsListCacheItem {
|
||||||
|
updatedAt: number
|
||||||
|
contacts: ContactsListCacheContact[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactsAvatarCacheEntry {
|
||||||
|
avatarUrl: string
|
||||||
|
updatedAt: number
|
||||||
|
checkedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactsAvatarCacheItem {
|
||||||
|
updatedAt: number
|
||||||
|
avatars: Record<string, ContactsAvatarCacheEntry>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportSessionMessageCountCache(scopeKey: string): Promise<ExportSessionMessageCountCacheItem | null> {
|
||||||
|
if (!scopeKey) return null
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const rawMap = value as Record<string, unknown>
|
||||||
|
const rawItem = rawMap[scopeKey]
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
|
||||||
|
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
|
||||||
|
const rawCounts = (rawItem as Record<string, unknown>).counts
|
||||||
|
if (!rawCounts || typeof rawCounts !== 'object') return null
|
||||||
|
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
for (const [sessionId, countRaw] of Object.entries(rawCounts as Record<string, unknown>)) {
|
||||||
|
if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) {
|
||||||
|
counts[sessionId] = Math.floor(countRaw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||||
|
counts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportSessionMessageCountCache(scopeKey: string, counts: Record<string, number>): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP)
|
||||||
|
const map = current && typeof current === 'object'
|
||||||
|
? { ...(current as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const normalized: Record<string, number> = {}
|
||||||
|
for (const [sessionId, countRaw] of Object.entries(counts || {})) {
|
||||||
|
if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) {
|
||||||
|
normalized[sessionId] = Math.floor(countRaw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map[scopeKey] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
counts: normalized
|
||||||
|
}
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportSessionContentMetricCache(scopeKey: string): Promise<ExportSessionContentMetricCacheItem | null> {
|
||||||
|
if (!scopeKey) return null
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const rawMap = value as Record<string, unknown>
|
||||||
|
const rawItem = rawMap[scopeKey]
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
|
||||||
|
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
|
||||||
|
const rawMetrics = (rawItem as Record<string, unknown>).metrics
|
||||||
|
if (!rawMetrics || typeof rawMetrics !== 'object') return null
|
||||||
|
|
||||||
|
const metrics: Record<string, ExportSessionContentMetricCacheEntry> = {}
|
||||||
|
for (const [sessionId, rawMetric] of Object.entries(rawMetrics as Record<string, unknown>)) {
|
||||||
|
if (!rawMetric || typeof rawMetric !== 'object') continue
|
||||||
|
const source = rawMetric as Record<string, unknown>
|
||||||
|
const metric: ExportSessionContentMetricCacheEntry = {}
|
||||||
|
if (typeof source.totalMessages === 'number' && Number.isFinite(source.totalMessages) && source.totalMessages >= 0) {
|
||||||
|
metric.totalMessages = Math.floor(source.totalMessages)
|
||||||
|
}
|
||||||
|
if (typeof source.voiceMessages === 'number' && Number.isFinite(source.voiceMessages) && source.voiceMessages >= 0) {
|
||||||
|
metric.voiceMessages = Math.floor(source.voiceMessages)
|
||||||
|
}
|
||||||
|
if (typeof source.imageMessages === 'number' && Number.isFinite(source.imageMessages) && source.imageMessages >= 0) {
|
||||||
|
metric.imageMessages = Math.floor(source.imageMessages)
|
||||||
|
}
|
||||||
|
if (typeof source.videoMessages === 'number' && Number.isFinite(source.videoMessages) && source.videoMessages >= 0) {
|
||||||
|
metric.videoMessages = Math.floor(source.videoMessages)
|
||||||
|
}
|
||||||
|
if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) {
|
||||||
|
metric.emojiMessages = Math.floor(source.emojiMessages)
|
||||||
|
}
|
||||||
|
if (Object.keys(metric).length === 0) continue
|
||||||
|
metrics[sessionId] = metric
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||||
|
metrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportSessionContentMetricCache(
|
||||||
|
scopeKey: string,
|
||||||
|
metrics: Record<string, ExportSessionContentMetricCacheEntry>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP)
|
||||||
|
const map = current && typeof current === 'object'
|
||||||
|
? { ...(current as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const normalized: Record<string, ExportSessionContentMetricCacheEntry> = {}
|
||||||
|
for (const [sessionId, rawMetric] of Object.entries(metrics || {})) {
|
||||||
|
if (!rawMetric || typeof rawMetric !== 'object') continue
|
||||||
|
const metric: ExportSessionContentMetricCacheEntry = {}
|
||||||
|
if (typeof rawMetric.totalMessages === 'number' && Number.isFinite(rawMetric.totalMessages) && rawMetric.totalMessages >= 0) {
|
||||||
|
metric.totalMessages = Math.floor(rawMetric.totalMessages)
|
||||||
|
}
|
||||||
|
if (typeof rawMetric.voiceMessages === 'number' && Number.isFinite(rawMetric.voiceMessages) && rawMetric.voiceMessages >= 0) {
|
||||||
|
metric.voiceMessages = Math.floor(rawMetric.voiceMessages)
|
||||||
|
}
|
||||||
|
if (typeof rawMetric.imageMessages === 'number' && Number.isFinite(rawMetric.imageMessages) && rawMetric.imageMessages >= 0) {
|
||||||
|
metric.imageMessages = Math.floor(rawMetric.imageMessages)
|
||||||
|
}
|
||||||
|
if (typeof rawMetric.videoMessages === 'number' && Number.isFinite(rawMetric.videoMessages) && rawMetric.videoMessages >= 0) {
|
||||||
|
metric.videoMessages = Math.floor(rawMetric.videoMessages)
|
||||||
|
}
|
||||||
|
if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) {
|
||||||
|
metric.emojiMessages = Math.floor(rawMetric.emojiMessages)
|
||||||
|
}
|
||||||
|
if (Object.keys(metric).length === 0) continue
|
||||||
|
normalized[sessionId] = metric
|
||||||
|
}
|
||||||
|
|
||||||
|
map[scopeKey] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
metrics: normalized
|
||||||
|
}
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportSnsStatsCache(scopeKey: string): Promise<ExportSnsStatsCacheItem | null> {
|
||||||
|
if (!scopeKey) return null
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const rawMap = value as Record<string, unknown>
|
||||||
|
const rawItem = rawMap[scopeKey]
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
|
||||||
|
const raw = rawItem as Record<string, unknown>
|
||||||
|
const totalPosts = typeof raw.totalPosts === 'number' && Number.isFinite(raw.totalPosts) && raw.totalPosts >= 0
|
||||||
|
? Math.floor(raw.totalPosts)
|
||||||
|
: 0
|
||||||
|
const totalFriends = typeof raw.totalFriends === 'number' && Number.isFinite(raw.totalFriends) && raw.totalFriends >= 0
|
||||||
|
? Math.floor(raw.totalFriends)
|
||||||
|
: 0
|
||||||
|
const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt)
|
||||||
|
? raw.updatedAt
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return { updatedAt, totalPosts, totalFriends }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportSnsStatsCache(
|
||||||
|
scopeKey: string,
|
||||||
|
stats: { totalPosts: number; totalFriends: number }
|
||||||
|
): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP)
|
||||||
|
const map = current && typeof current === 'object'
|
||||||
|
? { ...(current as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
map[scopeKey] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
totalPosts: Number.isFinite(stats.totalPosts) ? Math.max(0, Math.floor(stats.totalPosts)) : 0,
|
||||||
|
totalFriends: Number.isFinite(stats.totalFriends) ? Math.max(0, Math.floor(stats.totalFriends)) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
|
||||||
|
if (!scopeKey) return null
|
||||||
|
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const rawMap = value as Record<string, unknown>
|
||||||
|
const rawItem = rawMap[scopeKey]
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
|
||||||
|
const raw = rawItem as Record<string, unknown>
|
||||||
|
const rawOverview = raw.overviewStats
|
||||||
|
const rawPosts = raw.posts
|
||||||
|
if (!rawOverview || typeof rawOverview !== 'object' || !Array.isArray(rawPosts)) return null
|
||||||
|
|
||||||
|
const overviewObj = rawOverview as Record<string, unknown>
|
||||||
|
const normalizeNumber = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : 0)
|
||||||
|
const normalizeNullableTimestamp = (v: unknown) => {
|
||||||
|
if (v === null || v === undefined) return null
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const normalizeNullableCount = (v: unknown) => {
|
||||||
|
if (v === null || v === undefined) return null
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return Math.floor(v)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0,
|
||||||
|
overviewStats: {
|
||||||
|
totalPosts: Math.max(0, normalizeNumber(overviewObj.totalPosts)),
|
||||||
|
totalFriends: Math.max(0, normalizeNumber(overviewObj.totalFriends)),
|
||||||
|
myPosts: normalizeNullableCount(overviewObj.myPosts),
|
||||||
|
earliestTime: normalizeNullableTimestamp(overviewObj.earliestTime),
|
||||||
|
latestTime: normalizeNullableTimestamp(overviewObj.latestTime)
|
||||||
|
},
|
||||||
|
posts: rawPosts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSnsPageCache(
|
||||||
|
scopeKey: string,
|
||||||
|
payload: { overviewStats: SnsPageOverviewCache; posts: unknown[] }
|
||||||
|
): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
||||||
|
const map = current && typeof current === 'object'
|
||||||
|
? { ...(current as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const normalizeNumber = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0)
|
||||||
|
const normalizeNullableTimestamp = (v: unknown) => {
|
||||||
|
if (v === null || v === undefined) return null
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const normalizeNullableCount = (v: unknown) => {
|
||||||
|
if (v === null || v === undefined) return null
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return Math.floor(v)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
map[scopeKey] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
overviewStats: {
|
||||||
|
totalPosts: normalizeNumber(payload?.overviewStats?.totalPosts),
|
||||||
|
totalFriends: normalizeNumber(payload?.overviewStats?.totalFriends),
|
||||||
|
myPosts: normalizeNullableCount(payload?.overviewStats?.myPosts),
|
||||||
|
earliestTime: normalizeNullableTimestamp(payload?.overviewStats?.earliestTime),
|
||||||
|
latestTime: normalizeNullableTimestamp(payload?.overviewStats?.latestTime)
|
||||||
|
},
|
||||||
|
posts: Array.isArray(payload?.posts) ? payload.posts : []
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.set(CONFIG_KEYS.SNS_PAGE_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取通讯录加载超时阈值(毫秒)
|
||||||
|
export async function getContactsLoadTimeoutMs(): Promise<number> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) {
|
||||||
|
return Math.floor(value)
|
||||||
|
}
|
||||||
|
return 3000
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置通讯录加载超时阈值(毫秒)
|
||||||
|
export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> {
|
||||||
|
const normalized = Number.isFinite(timeoutMs)
|
||||||
|
? Math.min(60000, Math.max(1000, Math.floor(timeoutMs)))
|
||||||
|
: 3000
|
||||||
|
await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContactsListCache(scopeKey: string): Promise<ContactsListCacheItem | null> {
|
||||||
|
if (!scopeKey) return null
|
||||||
|
const value = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const rawMap = value as Record<string, unknown>
|
||||||
|
const rawItem = rawMap[scopeKey]
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
|
||||||
|
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
|
||||||
|
const rawContacts = (rawItem as Record<string, unknown>).contacts
|
||||||
|
if (!Array.isArray(rawContacts)) return null
|
||||||
|
|
||||||
|
const contacts: ContactsListCacheContact[] = []
|
||||||
|
for (const raw of rawContacts) {
|
||||||
|
if (!raw || typeof raw !== 'object') continue
|
||||||
|
const item = raw as Record<string, unknown>
|
||||||
|
const username = typeof item.username === 'string' ? item.username.trim() : ''
|
||||||
|
if (!username) continue
|
||||||
|
const displayName = typeof item.displayName === 'string' ? item.displayName : username
|
||||||
|
const type = typeof item.type === 'string' ? item.type : 'other'
|
||||||
|
contacts.push({
|
||||||
|
username,
|
||||||
|
displayName,
|
||||||
|
remark: typeof item.remark === 'string' ? item.remark : undefined,
|
||||||
|
nickname: typeof item.nickname === 'string' ? item.nickname : undefined,
|
||||||
|
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
|
||||||
|
? type
|
||||||
|
: 'other'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||||
|
contacts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setContactsListCache(scopeKey: string, contacts: ContactsListCacheContact[]): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP)
|
||||||
|
const map = current && typeof current === 'object'
|
||||||
|
? { ...(current as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const normalized: ContactsListCacheContact[] = []
|
||||||
|
for (const contact of contacts || []) {
|
||||||
|
const username = String(contact?.username || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
const displayName = String(contact?.displayName || username)
|
||||||
|
const type = contact?.type || 'other'
|
||||||
|
if (type !== 'friend' && type !== 'group' && type !== 'official' && type !== 'former_friend' && type !== 'other') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized.push({
|
||||||
|
username,
|
||||||
|
displayName,
|
||||||
|
remark: contact?.remark ? String(contact.remark) : undefined,
|
||||||
|
nickname: contact?.nickname ? String(contact.nickname) : undefined,
|
||||||
|
type
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
map[scopeKey] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
contacts: normalized
|
||||||
|
}
|
||||||
|
await config.set(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContactsAvatarCache(scopeKey: string): Promise<ContactsAvatarCacheItem | null> {
|
||||||
|
if (!scopeKey) return null
|
||||||
|
const value = await config.get(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const rawMap = value as Record<string, unknown>
|
||||||
|
const rawItem = rawMap[scopeKey]
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
|
||||||
|
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
|
||||||
|
const rawAvatars = (rawItem as Record<string, unknown>).avatars
|
||||||
|
if (!rawAvatars || typeof rawAvatars !== 'object') return null
|
||||||
|
|
||||||
|
const avatars: Record<string, ContactsAvatarCacheEntry> = {}
|
||||||
|
for (const [rawUsername, rawEntry] of Object.entries(rawAvatars as Record<string, unknown>)) {
|
||||||
|
const username = rawUsername.trim()
|
||||||
|
if (!username) continue
|
||||||
|
|
||||||
|
if (typeof rawEntry === 'string') {
|
||||||
|
const avatarUrl = rawEntry.trim()
|
||||||
|
if (!avatarUrl) continue
|
||||||
|
avatars[username] = {
|
||||||
|
avatarUrl,
|
||||||
|
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||||
|
checkedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawEntry || typeof rawEntry !== 'object') continue
|
||||||
|
const entry = rawEntry as Record<string, unknown>
|
||||||
|
const avatarUrl = typeof entry.avatarUrl === 'string' ? entry.avatarUrl.trim() : ''
|
||||||
|
if (!avatarUrl) continue
|
||||||
|
const updatedAt = typeof entry.updatedAt === 'number' && Number.isFinite(entry.updatedAt)
|
||||||
|
? entry.updatedAt
|
||||||
|
: 0
|
||||||
|
const checkedAt = typeof entry.checkedAt === 'number' && Number.isFinite(entry.checkedAt)
|
||||||
|
? entry.checkedAt
|
||||||
|
: updatedAt
|
||||||
|
|
||||||
|
avatars[username] = {
|
||||||
|
avatarUrl,
|
||||||
|
updatedAt,
|
||||||
|
checkedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||||
|
avatars
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setContactsAvatarCache(
|
||||||
|
scopeKey: string,
|
||||||
|
avatars: Record<string, ContactsAvatarCacheEntry>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP)
|
||||||
|
const map = current && typeof current === 'object'
|
||||||
|
? { ...(current as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const normalized: Record<string, ContactsAvatarCacheEntry> = {}
|
||||||
|
for (const [rawUsername, rawEntry] of Object.entries(avatars || {})) {
|
||||||
|
const username = String(rawUsername || '').trim()
|
||||||
|
if (!username || !rawEntry || typeof rawEntry !== 'object') continue
|
||||||
|
const avatarUrl = String(rawEntry.avatarUrl || '').trim()
|
||||||
|
if (!avatarUrl) continue
|
||||||
|
const updatedAt = Number.isFinite(rawEntry.updatedAt)
|
||||||
|
? Math.max(0, Math.floor(rawEntry.updatedAt))
|
||||||
|
: Date.now()
|
||||||
|
const checkedAt = Number.isFinite(rawEntry.checkedAt)
|
||||||
|
? Math.max(0, Math.floor(rawEntry.checkedAt))
|
||||||
|
: updatedAt
|
||||||
|
normalized[username] = {
|
||||||
|
avatarUrl,
|
||||||
|
updatedAt,
|
||||||
|
checkedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map[scopeKey] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
avatars: normalized
|
||||||
|
}
|
||||||
|
await config.set(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
// === 安全相关 ===
|
// === 安全相关 ===
|
||||||
|
|
||||||
export async function getAuthEnabled(): Promise<boolean> {
|
export async function getAuthEnabled(): Promise<boolean> {
|
||||||
@@ -482,3 +1086,15 @@ export async function getWordCloudExcludeWords(): Promise<string[]> {
|
|||||||
export async function setWordCloudExcludeWords(words: string[]): Promise<void> {
|
export async function setWordCloudExcludeWords(words: string[]): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS, words)
|
await config.set(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS, words)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取数据收集同意状态
|
||||||
|
export async function getAnalyticsConsent(): Promise<boolean | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.ANALYTICS_CONSENT)
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置数据收集同意状态
|
||||||
|
export async function setAnalyticsConsent(consent: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.ANALYTICS_CONSENT, consent)
|
||||||
|
}
|
||||||
|
|||||||
85
src/services/exportBridge.ts
Normal file
85
src/services/exportBridge.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
export interface OpenSingleExportPayload {
|
||||||
|
sessionId: string
|
||||||
|
sessionName?: string
|
||||||
|
requestId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportSessionStatusPayload {
|
||||||
|
inProgressSessionIds: string[]
|
||||||
|
activeTaskCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SingleExportDialogStatusPayload {
|
||||||
|
requestId: string
|
||||||
|
status: 'initializing' | 'opened' | 'failed'
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPEN_SINGLE_EXPORT_EVENT = 'weflow:open-single-export'
|
||||||
|
const EXPORT_SESSION_STATUS_EVENT = 'weflow:export-session-status'
|
||||||
|
const EXPORT_SESSION_STATUS_REQUEST_EVENT = 'weflow:export-session-status-request'
|
||||||
|
const SINGLE_EXPORT_DIALOG_STATUS_EVENT = 'weflow:single-export-dialog-status'
|
||||||
|
|
||||||
|
export const emitOpenSingleExport = (payload: OpenSingleExportPayload) => {
|
||||||
|
window.dispatchEvent(new CustomEvent<OpenSingleExportPayload>(OPEN_SINGLE_EXPORT_EVENT, {
|
||||||
|
detail: payload
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onOpenSingleExport = (
|
||||||
|
listener: (payload: OpenSingleExportPayload) => void
|
||||||
|
): (() => void) => {
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
const customEvent = event as CustomEvent<OpenSingleExportPayload>
|
||||||
|
listener(customEvent.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(OPEN_SINGLE_EXPORT_EVENT, handler as EventListener)
|
||||||
|
return () => window.removeEventListener(OPEN_SINGLE_EXPORT_EVENT, handler as EventListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emitExportSessionStatus = (payload: ExportSessionStatusPayload) => {
|
||||||
|
window.dispatchEvent(new CustomEvent<ExportSessionStatusPayload>(EXPORT_SESSION_STATUS_EVENT, {
|
||||||
|
detail: payload
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onExportSessionStatus = (
|
||||||
|
listener: (payload: ExportSessionStatusPayload) => void
|
||||||
|
): (() => void) => {
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
const customEvent = event as CustomEvent<ExportSessionStatusPayload>
|
||||||
|
listener(customEvent.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(EXPORT_SESSION_STATUS_EVENT, handler as EventListener)
|
||||||
|
return () => window.removeEventListener(EXPORT_SESSION_STATUS_EVENT, handler as EventListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestExportSessionStatus = () => {
|
||||||
|
window.dispatchEvent(new CustomEvent(EXPORT_SESSION_STATUS_REQUEST_EVENT))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onExportSessionStatusRequest = (listener: () => void): (() => void) => {
|
||||||
|
const handler = () => listener()
|
||||||
|
window.addEventListener(EXPORT_SESSION_STATUS_REQUEST_EVENT, handler)
|
||||||
|
return () => window.removeEventListener(EXPORT_SESSION_STATUS_REQUEST_EVENT, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emitSingleExportDialogStatus = (payload: SingleExportDialogStatusPayload) => {
|
||||||
|
window.dispatchEvent(new CustomEvent<SingleExportDialogStatusPayload>(SINGLE_EXPORT_DIALOG_STATUS_EVENT, {
|
||||||
|
detail: payload
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onSingleExportDialogStatus = (
|
||||||
|
listener: (payload: SingleExportDialogStatusPayload) => void
|
||||||
|
): (() => void) => {
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
const customEvent = event as CustomEvent<SingleExportDialogStatusPayload>
|
||||||
|
listener(customEvent.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(SINGLE_EXPORT_DIALOG_STATUS_EVENT, handler as EventListener)
|
||||||
|
return () => window.removeEventListener(SINGLE_EXPORT_DIALOG_STATUS_EVENT, handler as EventListener)
|
||||||
|
}
|
||||||
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: ''
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export interface ChatState {
|
|||||||
setConnectionError: (error: string | null) => void
|
setConnectionError: (error: string | null) => void
|
||||||
setSessions: (sessions: ChatSession[]) => void
|
setSessions: (sessions: ChatSession[]) => void
|
||||||
setFilteredSessions: (sessions: ChatSession[]) => void
|
setFilteredSessions: (sessions: ChatSession[]) => void
|
||||||
setCurrentSession: (sessionId: string | null) => void
|
setCurrentSession: (sessionId: string | null, options?: { preserveMessages?: boolean }) => void
|
||||||
setLoadingSessions: (loading: boolean) => void
|
setLoadingSessions: (loading: boolean) => void
|
||||||
setMessages: (messages: Message[]) => void
|
setMessages: (messages: Message[]) => void
|
||||||
appendMessages: (messages: Message[], prepend?: boolean) => void
|
appendMessages: (messages: Message[], prepend?: boolean) => void
|
||||||
@@ -69,12 +69,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
|
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
|
||||||
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
|
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
|
||||||
|
|
||||||
setCurrentSession: (sessionId) => set({
|
setCurrentSession: (sessionId, options) => set((state) => ({
|
||||||
currentSessionId: sessionId,
|
currentSessionId: sessionId,
|
||||||
messages: [],
|
messages: options?.preserveMessages ? state.messages : [],
|
||||||
hasMoreMessages: true,
|
hasMoreMessages: true,
|
||||||
hasMoreLater: false
|
hasMoreLater: false
|
||||||
}),
|
})),
|
||||||
|
|
||||||
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
||||||
|
|
||||||
@@ -86,15 +86,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
if (m.localId && m.localId > 0) return `l:${m.localId}`
|
if (m.localId && m.localId > 0) return `l:${m.localId}`
|
||||||
return `t:${m.createTime}:${m.sortSeq || 0}:${m.serverId || 0}`
|
return `t:${m.createTime}:${m.sortSeq || 0}:${m.serverId || 0}`
|
||||||
}
|
}
|
||||||
const existingKeys = new Set(state.messages.map(getMsgKey))
|
const currentMessages = state.messages || []
|
||||||
|
const existingKeys = new Set(currentMessages.map(getMsgKey))
|
||||||
const filtered = newMessages.filter(m => !existingKeys.has(getMsgKey(m)))
|
const filtered = newMessages.filter(m => !existingKeys.has(getMsgKey(m)))
|
||||||
|
|
||||||
if (filtered.length === 0) return state
|
if (filtered.length === 0) return state
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: prepend
|
messages: prepend
|
||||||
? [...filtered, ...state.messages]
|
? [...filtered, ...currentMessages]
|
||||||
: [...state.messages, ...filtered]
|
: [...currentMessages, ...filtered]
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
115
src/stores/contactTypeCountsStore.ts
Normal file
115
src/stores/contactTypeCountsStore.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { ContactInfo } from '../types/models'
|
||||||
|
|
||||||
|
export interface ContactTypeTabCounts {
|
||||||
|
private: number
|
||||||
|
group: number
|
||||||
|
official: number
|
||||||
|
former_friend: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactTypeCardCounts {
|
||||||
|
friends: number
|
||||||
|
groups: number
|
||||||
|
officials: number
|
||||||
|
deletedFriends: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyTabCounts: ContactTypeTabCounts = {
|
||||||
|
private: 0,
|
||||||
|
group: 0,
|
||||||
|
official: 0,
|
||||||
|
former_friend: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let inflightPromise: Promise<ContactTypeTabCounts> | null = null
|
||||||
|
|
||||||
|
const normalizeCounts = (counts?: Partial<ContactTypeTabCounts> | null): ContactTypeTabCounts => {
|
||||||
|
return {
|
||||||
|
private: Number.isFinite(counts?.private) ? Math.max(0, Math.floor(Number(counts?.private))) : 0,
|
||||||
|
group: Number.isFinite(counts?.group) ? Math.max(0, Math.floor(Number(counts?.group))) : 0,
|
||||||
|
official: Number.isFinite(counts?.official) ? Math.max(0, Math.floor(Number(counts?.official))) : 0,
|
||||||
|
former_friend: Number.isFinite(counts?.former_friend) ? Math.max(0, Math.floor(Number(counts?.former_friend))) : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toContactTypeTabCountsFromContacts = (contacts: ContactInfo[]): ContactTypeTabCounts => {
|
||||||
|
const next = { ...emptyTabCounts }
|
||||||
|
for (const contact of contacts || []) {
|
||||||
|
if (contact.type === 'friend') next.private += 1
|
||||||
|
if (contact.type === 'group') next.group += 1
|
||||||
|
if (contact.type === 'official') next.official += 1
|
||||||
|
if (contact.type === 'former_friend') next.former_friend += 1
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toContactTypeCardCounts = (counts: ContactTypeTabCounts): ContactTypeCardCounts => {
|
||||||
|
return {
|
||||||
|
friends: counts.private,
|
||||||
|
groups: counts.group,
|
||||||
|
officials: counts.official,
|
||||||
|
deletedFriends: counts.former_friend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactTypeCountsState {
|
||||||
|
tabCounts: ContactTypeTabCounts
|
||||||
|
isLoading: boolean
|
||||||
|
isReady: boolean
|
||||||
|
updatedAt: number
|
||||||
|
setTabCounts: (counts: ContactTypeTabCounts) => void
|
||||||
|
syncFromContacts: (contacts: ContactInfo[]) => void
|
||||||
|
ensureLoaded: (options?: { force?: boolean }) => Promise<ContactTypeTabCounts>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useContactTypeCountsStore = create<ContactTypeCountsState>((set, get) => ({
|
||||||
|
tabCounts: { ...emptyTabCounts },
|
||||||
|
isLoading: false,
|
||||||
|
isReady: false,
|
||||||
|
updatedAt: 0,
|
||||||
|
setTabCounts: (counts) => {
|
||||||
|
const normalized = normalizeCounts(counts)
|
||||||
|
set({
|
||||||
|
tabCounts: normalized,
|
||||||
|
isReady: true,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
syncFromContacts: (contacts) => {
|
||||||
|
const fromContacts = toContactTypeTabCountsFromContacts(contacts || [])
|
||||||
|
get().setTabCounts(fromContacts)
|
||||||
|
},
|
||||||
|
ensureLoaded: async (options) => {
|
||||||
|
if (!options?.force && get().isReady) {
|
||||||
|
return get().tabCounts
|
||||||
|
}
|
||||||
|
if (inflightPromise) {
|
||||||
|
return inflightPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true })
|
||||||
|
inflightPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.chat.getContactTypeCounts()
|
||||||
|
if (result?.success && result.counts) {
|
||||||
|
const normalized = normalizeCounts(result.counts)
|
||||||
|
set({
|
||||||
|
tabCounts: normalized,
|
||||||
|
isReady: true,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载联系人类型计数失败:', error)
|
||||||
|
}
|
||||||
|
return get().tabCounts
|
||||||
|
})().finally(() => {
|
||||||
|
inflightPromise = null
|
||||||
|
set({ isLoading: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
return inflightPromise
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -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' | 'geist'
|
||||||
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: '刚玉蓝',
|
||||||
@@ -47,6 +57,13 @@ export const themes: ThemeInfo[] = [
|
|||||||
description: 'RAL 180 80 10',
|
description: 'RAL 180 80 10',
|
||||||
primaryColor: '#5A8A8A',
|
primaryColor: '#5A8A8A',
|
||||||
bgColor: '#E4F0F0'
|
bgColor: '#E4F0F0'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'geist',
|
||||||
|
name: 'Geist',
|
||||||
|
description: 'Vercel · 极简黑白',
|
||||||
|
primaryColor: '#000000',
|
||||||
|
bgColor: '#ffffff'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -293,4 +337,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
|
|
||||||
// 卡片背景
|
// 卡片背景
|
||||||
--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);
|
||||||
|
|
||||||
|
// primary 色上方的前景文字色(大多数主题为白色)
|
||||||
|
--on-primary: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 浅色主题 ====================
|
// ==================== 浅色主题 ====================
|
||||||
@@ -59,6 +64,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 +86,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 +108,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 +130,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 +152,70 @@
|
|||||||
--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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geist · 极简黑白 - 浅色
|
||||||
|
[data-theme="geist"][data-mode="light"],
|
||||||
|
[data-theme="geist"]:not([data-mode]) {
|
||||||
|
--primary: #444444;
|
||||||
|
--primary-rgb: 68, 68, 68;
|
||||||
|
--primary-hover: #333333;
|
||||||
|
--primary-light: rgba(68, 68, 68, 0.08);
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: rgba(250, 250, 250, 0.95);
|
||||||
|
--bg-tertiary: rgba(0, 0, 0, 0.03);
|
||||||
|
--bg-hover: rgba(0, 0, 0, 0.05);
|
||||||
|
--text-primary: #111111;
|
||||||
|
--text-secondary: #666666;
|
||||||
|
--text-tertiary: #999999;
|
||||||
|
--border-color: #eaeaea;
|
||||||
|
--border-radius: 6px;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
--bg-gradient: linear-gradient(135deg, #ffffff 0%, #fafafa 100%);
|
||||||
|
--primary-gradient: linear-gradient(135deg, #444444 0%, #666666 100%);
|
||||||
|
--card-bg: rgba(250, 250, 250, 0.95);
|
||||||
|
--card-inner-bg: #f5f5f5;
|
||||||
|
--sent-card-bg: #444444;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 深色主题 ====================
|
// ==================== 深色主题 ====================
|
||||||
@@ -151,6 +228,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 +238,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 +250,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 +260,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 +272,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 +282,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 +294,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 +304,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 +316,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 +326,72 @@
|
|||||||
--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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geist · 极简黑白 - 深色
|
||||||
|
[data-theme="geist"][data-mode="dark"] {
|
||||||
|
--primary: #ededed;
|
||||||
|
--primary-rgb: 237, 237, 237;
|
||||||
|
--primary-hover: #d5d5d5;
|
||||||
|
--primary-light: rgba(237, 237, 237, 0.1);
|
||||||
|
--bg-primary: #1a1a1a;
|
||||||
|
--bg-secondary: rgba(34, 34, 34, 0.95);
|
||||||
|
--bg-secondary-solid: #222222;
|
||||||
|
--bg-tertiary: rgba(255, 255, 255, 0.04);
|
||||||
|
--bg-hover: rgba(255, 255, 255, 0.07);
|
||||||
|
--text-primary: #ededed;
|
||||||
|
--text-secondary: #999999;
|
||||||
|
--text-tertiary: #666666;
|
||||||
|
--border-color: #2e2e2e;
|
||||||
|
--border-radius: 6px;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
--bg-gradient: linear-gradient(135deg, #1a1a1a 0%, #222222 100%);
|
||||||
|
--primary-gradient: linear-gradient(135deg, #ededed 0%, #cccccc 100%);
|
||||||
|
--card-bg: rgba(34, 34, 34, 0.95);
|
||||||
|
--card-inner-bg: #2a2a2a;
|
||||||
|
--sent-card-bg: #3a3a3a;
|
||||||
|
// primary 是浅灰色,上方文字需要用深色
|
||||||
|
--on-primary: #111111;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置样式
|
// 重置样式
|
||||||
@@ -294,7 +450,7 @@ body {
|
|||||||
|
|
||||||
&-primary {
|
&-primary {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: var(--on-primary);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--primary-hover);
|
background: var(--primary-hover);
|
||||||
|
|||||||
327
src/types/electron.d.ts
vendored
327
src/types/electron.d.ts
vendored
@@ -11,14 +11,26 @@ 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>
|
||||||
|
openSessionChatWindow: (sessionId: string) => Promise<boolean>
|
||||||
}
|
}
|
||||||
config: {
|
config: {
|
||||||
get: (key: string) => Promise<unknown>
|
get: (key: string) => Promise<unknown>
|
||||||
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>
|
||||||
@@ -37,9 +49,65 @@ export interface ElectronAPI {
|
|||||||
onDownloadProgress: (callback: (progress: number) => void) => () => void
|
onDownloadProgress: (callback: (progress: number) => void) => () => void
|
||||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
|
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
|
||||||
}
|
}
|
||||||
|
notification: {
|
||||||
|
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void>
|
||||||
|
close: () => Promise<void>
|
||||||
|
click: (sessionId: string) => void
|
||||||
|
ready: () => void
|
||||||
|
resize: (width: number, height: number) => void
|
||||||
|
onShow: (callback: (event: any, data: any) => void) => () => void
|
||||||
|
}
|
||||||
log: {
|
log: {
|
||||||
getPath: () => Promise<string>
|
getPath: () => Promise<string>
|
||||||
read: () => Promise<{ success: boolean; content?: string; error?: string }>
|
read: () => Promise<{ success: boolean; content?: string; error?: string }>
|
||||||
|
debug: (data: any) => void
|
||||||
|
}
|
||||||
|
diagnostics: {
|
||||||
|
getExportCardLogs: (options?: { limit?: number }) => Promise<{
|
||||||
|
logs: Array<{
|
||||||
|
id: string
|
||||||
|
ts: number
|
||||||
|
source: 'frontend' | 'main' | 'backend' | 'worker'
|
||||||
|
level: 'debug' | 'info' | 'warn' | 'error'
|
||||||
|
message: string
|
||||||
|
traceId?: string
|
||||||
|
stepId?: string
|
||||||
|
stepName?: string
|
||||||
|
status?: 'running' | 'done' | 'failed' | 'timeout'
|
||||||
|
durationMs?: number
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}>
|
||||||
|
activeSteps: Array<{
|
||||||
|
traceId: string
|
||||||
|
stepId: string
|
||||||
|
stepName: string
|
||||||
|
source: 'frontend' | 'main' | 'backend' | 'worker'
|
||||||
|
elapsedMs: number
|
||||||
|
stallMs: number
|
||||||
|
startedAt: number
|
||||||
|
lastUpdatedAt: number
|
||||||
|
message?: string
|
||||||
|
}>
|
||||||
|
summary: {
|
||||||
|
totalLogs: number
|
||||||
|
activeStepCount: number
|
||||||
|
errorCount: number
|
||||||
|
warnCount: number
|
||||||
|
timeoutCount: number
|
||||||
|
lastUpdatedAt: number
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
clearExportCardLogs: () => Promise<{ success: boolean }>
|
||||||
|
exportExportCardLogs: (payload: {
|
||||||
|
filePath: string
|
||||||
|
frontendLogs?: unknown[]
|
||||||
|
}) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
filePath?: string
|
||||||
|
summaryPath?: string
|
||||||
|
count?: number
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
dbPath: {
|
dbPath: {
|
||||||
autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }>
|
autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }>
|
||||||
@@ -55,14 +123,48 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
key: {
|
key: {
|
||||||
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
|
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
|
||||||
autoGetImageKey: (manualDir?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
|
autoGetImageKey: (manualDir?: string, wxid?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
|
||||||
|
scanImageKeyFromMemory: (userDir: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
|
||||||
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => () => void
|
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => () => void
|
||||||
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||||
}
|
}
|
||||||
chat: {
|
chat: {
|
||||||
connect: () => Promise<{ success: boolean; error?: string }>
|
connect: () => Promise<{ success: boolean; error?: string }>
|
||||||
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
|
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
|
||||||
enrichSessionsContactInfo: (usernames: string[]) => Promise<{
|
getSessionStatuses: (usernames: string[]) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
map?: Record<string, { isFolded?: boolean; isMuted?: boolean }>
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
getExportTabCounts: () => Promise<{
|
||||||
|
success: boolean
|
||||||
|
counts?: {
|
||||||
|
private: number
|
||||||
|
group: number
|
||||||
|
official: number
|
||||||
|
former_friend: number
|
||||||
|
}
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
getContactTypeCounts: () => Promise<{
|
||||||
|
success: boolean
|
||||||
|
counts?: {
|
||||||
|
private: number
|
||||||
|
group: number
|
||||||
|
official: number
|
||||||
|
former_friend: number
|
||||||
|
}
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
getSessionMessageCounts: (sessionIds: string[]) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
counts?: Record<string, number>
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
enrichSessionsContactInfo: (
|
||||||
|
usernames: string[],
|
||||||
|
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
|
||||||
|
) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
||||||
error?: string
|
error?: string
|
||||||
@@ -76,6 +178,7 @@ export interface ElectronAPI {
|
|||||||
getLatestMessages: (sessionId: string, limit?: number) => Promise<{
|
getLatestMessages: (sessionId: string, limit?: number) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
messages?: Message[]
|
messages?: Message[]
|
||||||
|
hasMore?: boolean
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
|
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
|
||||||
@@ -83,6 +186,17 @@ export interface ElectronAPI {
|
|||||||
messages?: Message[]
|
messages?: Message[]
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
getCachedMessages: (sessionId: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
messages?: Message[]
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
removedPaths?: string[]
|
||||||
|
warning?: string
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
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 }>
|
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
|
||||||
@@ -112,9 +226,76 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
getSessionDetailFast: (sessionId: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
detail?: {
|
||||||
|
wxid: string
|
||||||
|
displayName: string
|
||||||
|
remark?: string
|
||||||
|
nickName?: string
|
||||||
|
alias?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
messageCount: number
|
||||||
|
}
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
getSessionDetailExtra: (sessionId: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
detail?: {
|
||||||
|
firstMessageTime?: number
|
||||||
|
latestMessageTime?: number
|
||||||
|
messageTables: { dbName: string; tableName: string; count: number }[]
|
||||||
|
}
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
getExportSessionStats: (
|
||||||
|
sessionIds: string[],
|
||||||
|
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean }
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: Record<string, {
|
||||||
|
totalMessages: number
|
||||||
|
voiceMessages: number
|
||||||
|
imageMessages: number
|
||||||
|
videoMessages: number
|
||||||
|
emojiMessages: number
|
||||||
|
transferMessages: number
|
||||||
|
redPacketMessages: number
|
||||||
|
callMessages: number
|
||||||
|
firstTimestamp?: number
|
||||||
|
lastTimestamp?: number
|
||||||
|
privateMutualGroups?: number
|
||||||
|
groupMemberCount?: number
|
||||||
|
groupMyMessages?: number
|
||||||
|
groupActiveSpeakers?: number
|
||||||
|
groupMutualFriends?: number
|
||||||
|
}>
|
||||||
|
cache?: Record<string, {
|
||||||
|
updatedAt: number
|
||||||
|
stale: boolean
|
||||||
|
includeRelations: boolean
|
||||||
|
source: 'memory' | 'disk' | 'fresh'
|
||||||
|
}>
|
||||||
|
needsRefresh?: string[]
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
getGroupMyMessageCountHint: (chatroomId: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
count?: number
|
||||||
|
updatedAt?: number
|
||||||
|
source?: 'memory' | 'disk'
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
getVoiceData: (sessionId: string, msgId: string, 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
|
||||||
|
}>
|
||||||
|
getMessageDates: (sessionId: string) => Promise<{ success: boolean; dates?: string[]; error?: string }>
|
||||||
|
getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, 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
|
||||||
@@ -124,8 +305,8 @@ 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; liveVideoPath?: 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
|
||||||
@@ -236,9 +417,31 @@ export interface ElectronAPI {
|
|||||||
alias?: string
|
alias?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
groupNickname?: string
|
groupNickname?: string
|
||||||
|
isOwner?: boolean
|
||||||
}>
|
}>
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
getGroupMembersPanelData: (
|
||||||
|
chatroomId: string,
|
||||||
|
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: Array<{
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
nickname?: string
|
||||||
|
alias?: string
|
||||||
|
remark?: string
|
||||||
|
groupNickname?: string
|
||||||
|
isOwner?: boolean
|
||||||
|
isFriend: boolean
|
||||||
|
messageCount: number
|
||||||
|
}>
|
||||||
|
fromCache?: boolean
|
||||||
|
updatedAt?: number
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => Promise<{
|
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: Array<{
|
data?: Array<{
|
||||||
@@ -293,6 +496,30 @@ export interface ElectronAPI {
|
|||||||
data?: number[]
|
data?: number[]
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
startAvailableYearsLoad: () => Promise<{
|
||||||
|
success: boolean
|
||||||
|
taskId?: string
|
||||||
|
reused?: boolean
|
||||||
|
snapshot?: {
|
||||||
|
years?: number[]
|
||||||
|
done: boolean
|
||||||
|
error?: string
|
||||||
|
canceled?: boolean
|
||||||
|
strategy?: 'cache' | 'native' | 'hybrid'
|
||||||
|
phase?: 'cache' | 'native' | 'scan' | 'done'
|
||||||
|
statusText?: string
|
||||||
|
nativeElapsedMs?: number
|
||||||
|
scanElapsedMs?: number
|
||||||
|
totalElapsedMs?: number
|
||||||
|
switched?: boolean
|
||||||
|
nativeTimedOut?: boolean
|
||||||
|
}
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
cancelAvailableYearsLoad: (taskId: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
generateReport: (year: number) => Promise<{
|
generateReport: (year: number) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: {
|
data?: {
|
||||||
@@ -355,6 +582,20 @@ export interface ElectronAPI {
|
|||||||
phrase: string
|
phrase: string
|
||||||
count: number
|
count: number
|
||||||
}>
|
}>
|
||||||
|
snsStats?: {
|
||||||
|
totalPosts: number
|
||||||
|
typeCounts?: Record<string, number>
|
||||||
|
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
}
|
||||||
|
lostFriend: {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
earlyCount: number
|
||||||
|
lateCount: number
|
||||||
|
periodDesc: string
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
@@ -363,6 +604,21 @@ export interface ElectronAPI {
|
|||||||
dir?: string
|
dir?: string
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
onAvailableYearsProgress: (callback: (payload: {
|
||||||
|
taskId: string
|
||||||
|
years?: number[]
|
||||||
|
done: boolean
|
||||||
|
error?: string
|
||||||
|
canceled?: boolean
|
||||||
|
strategy?: 'cache' | 'native' | 'hybrid'
|
||||||
|
phase?: 'cache' | 'native' | 'scan' | 'done'
|
||||||
|
statusText?: string
|
||||||
|
nativeElapsedMs?: number
|
||||||
|
scanElapsedMs?: number
|
||||||
|
totalElapsedMs?: number
|
||||||
|
switched?: boolean
|
||||||
|
nativeTimedOut?: boolean
|
||||||
|
}) => void) => () => void
|
||||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||||
}
|
}
|
||||||
dualReport: {
|
dualReport: {
|
||||||
@@ -410,15 +666,26 @@ export interface ElectronAPI {
|
|||||||
myTopEmojiMd5?: string
|
myTopEmojiMd5?: string
|
||||||
friendTopEmojiMd5?: string
|
friendTopEmojiMd5?: string
|
||||||
myTopEmojiUrl?: string
|
myTopEmojiUrl?: string
|
||||||
|
friendTopEmojiUrl?: string
|
||||||
|
myTopEmojiCount?: number
|
||||||
|
friendTopEmojiCount?: number
|
||||||
topPhrases: Array<{ phrase: string; count: number }>
|
topPhrases: Array<{ phrase: string; count: number }>
|
||||||
myExclusivePhrases: Array<{ phrase: string; count: number }>
|
myExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
heatmap?: number[][]
|
heatmap?: number[][]
|
||||||
initiative?: { initiated: number; received: number }
|
initiative?: { initiated: number; received: number }
|
||||||
response?: { avg: number; fastest: number; count: number }
|
response?: { avg: number; fastest: number; slowest?: number; count: number }
|
||||||
monthly?: Record<string, number>
|
monthly?: Record<string, number>
|
||||||
streak?: { days: number; startDate: string; endDate: string }
|
streak?: { days: number; startDate: string; endDate: string }
|
||||||
}
|
}
|
||||||
|
topPhrases: Array<{ phrase: string; count: number }>
|
||||||
|
myExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
|
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
|
heatmap?: number[][]
|
||||||
|
initiative?: { initiated: number; received: number }
|
||||||
|
response?: { avg: number; fastest: number; slowest?: number; count: number }
|
||||||
|
monthly?: Record<string, number>
|
||||||
|
streak?: { days: number; startDate: string; endDate: string }
|
||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
@@ -438,6 +705,9 @@ export interface ElectronAPI {
|
|||||||
success: boolean
|
success: boolean
|
||||||
successCount?: number
|
successCount?: number
|
||||||
failCount?: number
|
failCount?: number
|
||||||
|
pendingSessionIds?: string[]
|
||||||
|
successSessionIds?: string[]
|
||||||
|
failedSessionIds?: string[]
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
|
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
|
||||||
@@ -484,24 +754,40 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
likes: Array<string>
|
likes: Array<string>
|
||||||
comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }>
|
comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> }>
|
||||||
rawXml?: string
|
rawXml?: string
|
||||||
}>
|
}>
|
||||||
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: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
|
proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; videoPath?: 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' | 'arkmejson'
|
||||||
|
usernames?: string[]
|
||||||
|
keyword?: string
|
||||||
|
exportImages?: boolean
|
||||||
|
exportLivePhotos?: boolean
|
||||||
|
exportVideos?: 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 }>
|
||||||
|
getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
|
||||||
|
getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
|
||||||
|
installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }>
|
||||||
|
uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>
|
||||||
|
deleteSnsPost: (postId: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||||
}
|
}
|
||||||
llama: {
|
cloud: {
|
||||||
loadModel: (modelPath: string) => Promise<boolean>
|
init: () => Promise<void>
|
||||||
createSession: (systemPrompt?: string) => Promise<boolean>
|
recordPage: (pageName: string) => Promise<void>
|
||||||
chat: (message: string) => Promise<{ success: boolean; response?: any; error?: string }>
|
getLogs: () => Promise<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
|
|
||||||
}
|
}
|
||||||
http: {
|
http: {
|
||||||
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
|
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
|
||||||
@@ -511,7 +797,8 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||||
|
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji'
|
||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null
|
||||||
senderUsername?: string
|
senderUsername?: string
|
||||||
fileNameSuffix?: string
|
fileNameSuffix?: string
|
||||||
@@ -525,6 +812,7 @@ export interface ExportOptions {
|
|||||||
excelCompactColumns?: boolean
|
excelCompactColumns?: boolean
|
||||||
txtColumns?: string[]
|
txtColumns?: string[]
|
||||||
sessionLayout?: 'shared' | 'per-session'
|
sessionLayout?: 'shared' | 'per-session'
|
||||||
|
sessionNameWithTypePrefix?: boolean
|
||||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||||
exportConcurrency?: number
|
exportConcurrency?: number
|
||||||
}
|
}
|
||||||
@@ -533,6 +821,7 @@ export interface ExportProgress {
|
|||||||
current: number
|
current: number
|
||||||
total: number
|
total: number
|
||||||
currentSession: string
|
currentSession: string
|
||||||
|
currentSessionId?: string
|
||||||
phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
|
phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
|
||||||
phaseProgress?: number
|
phaseProgress?: number
|
||||||
phaseTotal?: number
|
phaseTotal?: number
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ export interface ChatSession {
|
|||||||
sortTimestamp: number // 用于排序
|
sortTimestamp: number // 用于排序
|
||||||
lastTimestamp: number // 用于显示时间
|
lastTimestamp: number // 用于显示时间
|
||||||
lastMsgType: number
|
lastMsgType: number
|
||||||
|
messageCountHint?: number // 会话总消息数提示(若底层直接可取)
|
||||||
displayName?: string
|
displayName?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
lastMsgSender?: string
|
lastMsgSender?: string
|
||||||
lastSenderDisplayName?: string
|
lastSenderDisplayName?: string
|
||||||
selfWxid?: string // Helper field to avoid extra API calls
|
selfWxid?: string // Helper field to avoid extra API calls
|
||||||
|
isFolded?: boolean // 是否已折叠进"折叠的群聊"
|
||||||
|
isMuted?: boolean // 是否开启免打扰
|
||||||
}
|
}
|
||||||
|
|
||||||
// 联系人
|
// 联系人
|
||||||
@@ -31,8 +34,9 @@ export interface ContactInfo {
|
|||||||
displayName: string
|
displayName: string
|
||||||
remark?: string
|
remark?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
|
alias?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
type: 'friend' | 'group' | 'official' | 'other'
|
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息
|
// 消息
|
||||||
@@ -51,6 +55,7 @@ export interface Message {
|
|||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
emojiCdnUrl?: string
|
emojiCdnUrl?: string
|
||||||
emojiMd5?: string
|
emojiMd5?: string
|
||||||
|
emojiLocalPath?: string // 本地缓存路径(转发表情包无 CDN URL 时使用)
|
||||||
voiceDurationSeconds?: number
|
voiceDurationSeconds?: number
|
||||||
videoMd5?: string
|
videoMd5?: string
|
||||||
// 引用消息
|
// 引用消息
|
||||||
@@ -64,12 +69,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[] // 聊天记录列表
|
||||||
|
|||||||
59
src/types/sns.ts
Normal file
59
src/types/sns.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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 SnsCommentEmoji {
|
||||||
|
url: string
|
||||||
|
md5: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
encryptUrl?: string
|
||||||
|
aesKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnsComment {
|
||||||
|
id: string
|
||||||
|
nickname: string
|
||||||
|
content: string
|
||||||
|
refCommentId: string
|
||||||
|
refNickname?: string
|
||||||
|
emojis?: SnsCommentEmoji[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnsPost {
|
||||||
|
id: string
|
||||||
|
tid?: string // 数据库主键(雪花 ID),用于精确删除
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
avatarUrl?: string
|
||||||
|
createTime: number
|
||||||
|
contentDesc: string
|
||||||
|
type?: number
|
||||||
|
media: SnsMedia[]
|
||||||
|
likes: string[]
|
||||||
|
comments: SnsComment[]
|
||||||
|
rawXml?: string
|
||||||
|
linkTitle?: string
|
||||||
|
linkUrl?: string
|
||||||
|
isProtected?: boolean // 是否受保护(已安装时标记)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnsLinkCardData {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
thumb?: string
|
||||||
|
}
|
||||||
13
src/vite-env.d.ts
vendored
13
src/vite-env.d.ts
vendored
@@ -1,14 +1 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface Window {
|
|
||||||
electronAPI: {
|
|
||||||
// ... other methods ...
|
|
||||||
auth: {
|
|
||||||
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
|
|
||||||
}
|
|
||||||
// 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
|
|
||||||
// or import a shared type definition.
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user