mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfa335564a | ||
|
|
3d1493b0a6 | ||
|
|
a46b52e603 | ||
|
|
3c0683b9f8 | ||
|
|
3214c2804e | ||
|
|
83f50cbaee | ||
|
|
61ef10de9b | ||
|
|
73f36d6b29 | ||
|
|
666a1a3296 | ||
|
|
acec2e95a2 | ||
|
|
d26e7e78a1 | ||
|
|
77e5c44673 | ||
|
|
619cc84d15 | ||
|
|
22b85439d3 | ||
|
|
b5a371da87 |
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -190,7 +190,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
||||||
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
||||||
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
|
|
||||||
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
|
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
|
||||||
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
|
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
|
||||||
|
|
||||||
@@ -204,7 +203,6 @@ jobs:
|
|||||||
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||||
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||||
MAC_URL="$(build_link "$MAC_ASSET")"
|
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||||
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
|
|
||||||
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||||
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||||
|
|
||||||
@@ -219,7 +217,6 @@ jobs:
|
|||||||
- Windows x64(Win10+): ${WINDOWS_URL:-$RELEASE_PAGE}
|
- Windows x64(Win10+): ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||||
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
||||||
- macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE}
|
- macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE}
|
||||||
- Linux (.deb) (即将废弃): ${LINUX_DEB_URL:-$RELEASE_PAGE}
|
|
||||||
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
||||||
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
||||||
|
|
||||||
|
|||||||
@@ -50,13 +50,15 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
|------|----------|--------|
|
|------|----------|--------|
|
||||||
| Windows | Windows10+、x64(amd64) | `.exe` |
|
| Windows | Windows10+、x64(amd64) | `.exe` |
|
||||||
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
||||||
| Linux | x64 设备(amd64) | `.deb`、`.tar.gz` |
|
| Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` |
|
||||||
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
||||||
|
|
||||||
|
> ArchLinux 用户可以选择 `yay -S weflow` 快速安装
|
||||||
|
|
||||||
## 详细功能清单
|
## 详细功能清单
|
||||||
|
|
||||||
当前版本已支持以下能力:
|
当前版本已支持以下能力:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WeFlow HTTP API / Push 文档
|
# WeFlow HTTP API / Push 文档
|
||||||
|
|
||||||
WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
WeFlow 提供本地 HTTP API(已支持GET 和 POST请求),便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
||||||
|
|
||||||
## 启用方式
|
## 启用方式
|
||||||
|
|
||||||
@@ -11,17 +11,27 @@ WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、
|
|||||||
- 基础地址:`http://127.0.0.1:5031`
|
- 基础地址:`http://127.0.0.1:5031`
|
||||||
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
||||||
|
|
||||||
|
**状态记忆**:API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。
|
||||||
|
|
||||||
|
## 鉴权规范
|
||||||
|
|
||||||
|
**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一):
|
||||||
|
|
||||||
|
1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>`
|
||||||
|
2. **Query 参数**: `?access_token=<您的Token>`(SSE 长连接推荐此方式)
|
||||||
|
3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求)
|
||||||
|
|
||||||
## 接口列表
|
## 接口列表
|
||||||
|
|
||||||
- `GET /health`
|
- `GET|POST /health`
|
||||||
- `GET /api/v1/health`
|
- `GET|POST /api/v1/health`
|
||||||
- `GET /api/v1/push/messages`
|
- `GET|POST /api/v1/push/messages`
|
||||||
- `GET /api/v1/messages`
|
- `GET|POST /api/v1/messages`
|
||||||
- `GET /api/v1/messages/new`
|
- `GET|POST /api/v1/messages/new`
|
||||||
- `GET /api/v1/sessions`
|
- `GET|POST /api/v1/sessions`
|
||||||
- `GET /api/v1/contacts`
|
- `GET|POST /api/v1/contacts`
|
||||||
- `GET /api/v1/group-members`
|
- `GET|POST /api/v1/group-members`
|
||||||
- `GET /api/v1/media/*`
|
- `GET|POST /api/v1/media/*`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -80,7 +90,7 @@ GET /api/v1/push/messages
|
|||||||
### 示例
|
### 示例
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -N "http://127.0.0.1:5031/api/v1/push/messages"
|
curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
示例事件:
|
示例事件:
|
||||||
@@ -94,6 +104,8 @@ data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123
|
|||||||
|
|
||||||
## 3. 获取消息
|
## 3. 获取消息
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
@@ -231,6 +243,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
|||||||
|
|
||||||
## 4. 获取会话列表
|
## 4. 获取会话列表
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -276,6 +290,8 @@ GET /api/v1/sessions
|
|||||||
|
|
||||||
## 5. 获取联系人列表
|
## 5. 获取联系人列表
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -325,6 +341,8 @@ GET /api/v1/contacts
|
|||||||
|
|
||||||
## 6. 获取群成员列表
|
## 6. 获取群成员列表
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
@@ -417,6 +435,8 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include
|
|||||||
|
|
||||||
## 7. 访问导出媒体
|
## 7. 访问导出媒体
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
|
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
@@ -461,19 +481,23 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
|||||||
### PowerShell
|
### PowerShell
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" }
|
||||||
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
|
$body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json
|
||||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
|
||||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
|
Invoke-RestMethod -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json"
|
||||||
```
|
```
|
||||||
|
|
||||||
### cURL
|
### cURL
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:5031/health
|
# GET 带 Token Header
|
||||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
|
curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx"
|
||||||
curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三"
|
|
||||||
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
# POST 带 JSON Body
|
||||||
|
curl -X POST http://127.0.0.1:5031/api/v1/messages \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"talker": "xxx@chatroom", "chatlab": true}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Python
|
### Python
|
||||||
@@ -482,19 +506,21 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
BASE_URL = "http://127.0.0.1:5031"
|
BASE_URL = "http://127.0.0.1:5031"
|
||||||
|
headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
|
||||||
|
|
||||||
messages = requests.get(
|
# POST 方式获取消息
|
||||||
f"{BASE_URL}/api/v1/messages",
|
messages = requests.post(
|
||||||
params={"talker": "xxx@chatroom", "limit": 50}
|
f"{BASE_URL}/api/v1/messages",
|
||||||
|
json={"talker": "xxx@chatroom", "limit": 50},
|
||||||
|
headers=headers
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
|
# GET 方式获取群成员
|
||||||
members = requests.get(
|
members = requests.get(
|
||||||
f"{BASE_URL}/api/v1/group-members",
|
f"{BASE_URL}/api/v1/group-members",
|
||||||
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}
|
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1},
|
||||||
|
headers=headers
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
print(messages)
|
|
||||||
print(members)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2157,6 +2157,13 @@ function registerIpcHandlers() {
|
|||||||
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
|
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'groupAnalytics:getGroupMemberAnalytics',
|
||||||
|
async (_, chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => {
|
||||||
|
return groupAnalyticsService.getGroupMemberAnalytics(chatroomId, memberUsername, startTime, endTime)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'groupAnalytics:getGroupMemberMessages',
|
'groupAnalytics:getGroupMemberMessages',
|
||||||
async (
|
async (
|
||||||
@@ -2605,8 +2612,9 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// HTTP API 服务
|
// HTTP API 服务
|
||||||
ipcMain.handle('http:start', async (_, port?: number) => {
|
ipcMain.handle('http:start', async (_, port?: number, host?: string) => {
|
||||||
return httpService.start(port || 5031)
|
const bindHost = typeof host === 'string' && host.trim() ? host.trim() : '127.0.0.1'
|
||||||
|
return httpService.start(port || 5031, bindHost)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('http:stop', async () => {
|
ipcMain.handle('http:stop', async () => {
|
||||||
@@ -2825,6 +2833,8 @@ app.whenReady().then(async () => {
|
|||||||
// 启动时检测更新(不阻塞启动)
|
// 启动时检测更新(不阻塞启动)
|
||||||
checkForUpdatesOnStartup()
|
checkForUpdatesOnStartup()
|
||||||
|
|
||||||
|
await httpService.autoStart()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
mainWindow = createWindow()
|
mainWindow = createWindow()
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||||
|
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime),
|
||||||
getGroupMemberMessages: (
|
getGroupMemberMessages: (
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
memberUsername: string,
|
memberUsername: string,
|
||||||
@@ -422,7 +423,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// HTTP API 服务
|
// HTTP API 服务
|
||||||
http: {
|
http: {
|
||||||
start: (port?: number) => ipcRenderer.invoke('http:start', port),
|
start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
|
||||||
stop: () => ipcRenderer.invoke('http:stop'),
|
stop: () => ipcRenderer.invoke('http:stop'),
|
||||||
status: () => ipcRenderer.invoke('http:status')
|
status: () => ipcRenderer.invoke('http:status')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,13 +52,17 @@ interface ConfigSchema {
|
|||||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
notificationFilterList: string[]
|
notificationFilterList: string[]
|
||||||
messagePushEnabled: boolean
|
messagePushEnabled: boolean
|
||||||
|
httpApiEnabled: boolean
|
||||||
|
httpApiPort: number
|
||||||
|
httpApiHost: string
|
||||||
|
httpApiToken: string
|
||||||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||||
quoteLayout: 'quote-top' | 'quote-bottom'
|
quoteLayout: 'quote-top' | 'quote-bottom'
|
||||||
wordCloudExcludeWords: string[]
|
wordCloudExcludeWords: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要 safeStorage 加密的字段(普通模式)
|
// 需要 safeStorage 加密的字段(普通模式)
|
||||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
|
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken'])
|
||||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||||
|
|
||||||
@@ -119,6 +123,10 @@ export class ConfigService {
|
|||||||
notificationPosition: 'top-right',
|
notificationPosition: 'top-right',
|
||||||
notificationFilterMode: 'all',
|
notificationFilterMode: 'all',
|
||||||
notificationFilterList: [],
|
notificationFilterList: [],
|
||||||
|
httpApiToken: '',
|
||||||
|
httpApiEnabled: false,
|
||||||
|
httpApiPort: 5031,
|
||||||
|
httpApiHost: '127.0.0.1',
|
||||||
messagePushEnabled: false,
|
messagePushEnabled: false,
|
||||||
windowCloseBehavior: 'ask',
|
windowCloseBehavior: 'ask',
|
||||||
quoteLayout: 'quote-top',
|
quoteLayout: 'quote-top',
|
||||||
@@ -662,11 +670,9 @@ export class ConfigService {
|
|||||||
|
|
||||||
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
||||||
const rawDecryptKey: any = this.store.get('decryptKey')
|
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||||
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX);
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 工具方法 ===
|
// === 工具方法 ===
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ConfigService } from './config'
|
|||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { chatService } from './chatService'
|
import { chatService } from './chatService'
|
||||||
import type { Message } from './chatService'
|
import type { Message } from './chatService'
|
||||||
|
import type { ChatStatistics } from './analyticsService'
|
||||||
|
|
||||||
export interface GroupChatInfo {
|
export interface GroupChatInfo {
|
||||||
username: string
|
username: string
|
||||||
@@ -49,6 +50,13 @@ export interface GroupMediaStats {
|
|||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupMemberAnalytics {
|
||||||
|
statistics: ChatStatistics
|
||||||
|
timeDistribution: Record<number, number>
|
||||||
|
commonPhrases?: Array<{ phrase: string; count: number }>
|
||||||
|
commonEmojis?: Array<{ emoji: string; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupMemberMessagesPage {
|
export interface GroupMemberMessagesPage {
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
@@ -797,7 +805,12 @@ class GroupAnalyticsService {
|
|||||||
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
|
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractRowSenderUsername(row: Record<string, any>): string {
|
private extractRowSenderUsername(row: Record<string, any>, myWxid?: string): string {
|
||||||
|
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
|
||||||
|
if (isSendRaw != null && parseInt(isSendRaw, 10) === 1 && myWxid) {
|
||||||
|
return myWxid
|
||||||
|
}
|
||||||
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
row.sender_username,
|
row.sender_username,
|
||||||
row.senderUsername,
|
row.senderUsername,
|
||||||
@@ -820,13 +833,33 @@ class GroupAnalyticsService {
|
|||||||
if (normalizedValue) return normalizedValue
|
if (normalizedValue) return normalizedValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: fast extract from raw content to avoid full parse
|
||||||
|
const rawContent = String(row.StrContent || row.message_content || row.content || row.msg_content || '').trim()
|
||||||
|
if (rawContent) {
|
||||||
|
const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i.exec(rawContent)
|
||||||
|
if (match && match[1]) {
|
||||||
|
return match[1].trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
||||||
try {
|
try {
|
||||||
const mapped = chatService.mapRowsToMessagesForApi([row])
|
const mapped = chatService.mapRowsToMessagesForApi([row])
|
||||||
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
|
if (Array.isArray(mapped) && mapped.length > 0) {
|
||||||
|
const msg = mapped[0]
|
||||||
|
if (!msg.localType) {
|
||||||
|
msg.localType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
|
||||||
|
}
|
||||||
|
if (!msg.createTime) {
|
||||||
|
msg.createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -881,7 +914,7 @@ class GroupAnalyticsService {
|
|||||||
if (rows.length === 0) break
|
if (rows.length === 0) break
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const senderFromRow = this.extractRowSenderUsername(row)
|
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
|
||||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -987,7 +1020,7 @@ class GroupAnalyticsService {
|
|||||||
const row = rows[index]
|
const row = rows[index]
|
||||||
consumedRows += 1
|
consumedRows += 1
|
||||||
|
|
||||||
const senderFromRow = this.extractRowSenderUsername(row)
|
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
|
||||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1467,6 +1500,154 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getGroupMemberAnalytics(
|
||||||
|
chatroomId: string,
|
||||||
|
memberUsername: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
): Promise<{ success: boolean; data?: GroupMemberAnalytics; error?: string }> {
|
||||||
|
try {
|
||||||
|
const conn = await this.ensureConnected()
|
||||||
|
if (!conn.success) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||||
|
const normalizedMemberUsername = String(memberUsername || '').trim()
|
||||||
|
|
||||||
|
const batchSize = 10000
|
||||||
|
const senderMatchCache = new Map<string, boolean>()
|
||||||
|
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||||
|
const key = String(sender || '').trim().toLowerCase()
|
||||||
|
if (!key) return false
|
||||||
|
const cached = senderMatchCache.get(key)
|
||||||
|
if (typeof cached === 'boolean') return cached
|
||||||
|
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
|
||||||
|
senderMatchCache.set(key, matched)
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorResult = await this.openMemberMessageCursor(normalizedChatroomId, batchSize, true, startTime || 0, endTime || 0)
|
||||||
|
if (!cursorResult.success || !cursorResult.cursor) {
|
||||||
|
return { success: false, error: cursorResult.error || '创建游标失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = cursorResult.cursor
|
||||||
|
const stats: ChatStatistics = {
|
||||||
|
totalMessages: 0,
|
||||||
|
textMessages: 0,
|
||||||
|
imageMessages: 0,
|
||||||
|
voiceMessages: 0,
|
||||||
|
videoMessages: 0,
|
||||||
|
emojiMessages: 0,
|
||||||
|
otherMessages: 0,
|
||||||
|
sentMessages: 0, // In group, we only fetch messages of this member, so sentMessages = totalMessages
|
||||||
|
receivedMessages: 0, // No meaning here
|
||||||
|
firstMessageTime: null,
|
||||||
|
lastMessageTime: null,
|
||||||
|
activeDays: 0,
|
||||||
|
messageTypeCounts: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hourlyDistribution: Record<number, number> = {}
|
||||||
|
for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0
|
||||||
|
const dailySet = new Set<string>()
|
||||||
|
const textTypes = [1, 244813135921]
|
||||||
|
|
||||||
|
const phraseCounts = new Map<string, number>()
|
||||||
|
const emojiCounts = new Map<string, number>()
|
||||||
|
|
||||||
|
const myWxid = String(this.configService.get('myWxid') || '').trim()
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||||
|
if (!batch.success) {
|
||||||
|
return { success: false, error: batch.error || '获取分析数据失败' }
|
||||||
|
}
|
||||||
|
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||||
|
if (rows.length === 0) break
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
let senderFromRow = this.extractRowSenderUsername(row, myWxid)
|
||||||
|
|
||||||
|
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
|
||||||
|
const isSend = isSendRaw != null ? parseInt(isSendRaw, 10) === 1 : false
|
||||||
|
|
||||||
|
if (isSend) {
|
||||||
|
senderFromRow = myWxid
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!senderFromRow || !matchesTargetSender(senderFromRow)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
|
||||||
|
const createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
|
||||||
|
|
||||||
|
let content = String(row.StrContent || row.message_content || row.content || row.msg_content || '')
|
||||||
|
if (content) {
|
||||||
|
content = content.replace(/^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.totalMessages++
|
||||||
|
if (textTypes.includes(msgType)) {
|
||||||
|
stats.textMessages++
|
||||||
|
if (content) {
|
||||||
|
const text = content.trim()
|
||||||
|
if (text && text.length <= 20) {
|
||||||
|
phraseCounts.set(text, (phraseCounts.get(text) || 0) + 1)
|
||||||
|
}
|
||||||
|
const emojiMatches = text.match(/\[.*?\]/g)
|
||||||
|
if (emojiMatches) {
|
||||||
|
for (const em of emojiMatches) {
|
||||||
|
emojiCounts.set(em, (emojiCounts.get(em) || 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (msgType === 3) stats.imageMessages++
|
||||||
|
else if (msgType === 34) stats.voiceMessages++
|
||||||
|
else if (msgType === 43) stats.videoMessages++
|
||||||
|
else if (msgType === 47) stats.emojiMessages++
|
||||||
|
else stats.otherMessages++
|
||||||
|
|
||||||
|
stats.sentMessages++
|
||||||
|
|
||||||
|
stats.messageTypeCounts[msgType] = (stats.messageTypeCounts[msgType] || 0) + 1
|
||||||
|
|
||||||
|
if (createTime > 0) {
|
||||||
|
if (stats.firstMessageTime === null || createTime < stats.firstMessageTime) stats.firstMessageTime = createTime
|
||||||
|
if (stats.lastMessageTime === null || createTime > stats.lastMessageTime) stats.lastMessageTime = createTime
|
||||||
|
|
||||||
|
const d = new Date(createTime * 1000)
|
||||||
|
const hour = d.getHours()
|
||||||
|
hourlyDistribution[hour]++
|
||||||
|
dailySet.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!batch.hasMore) break
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await wcdbService.closeMessageCursor(cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.activeDays = dailySet.size
|
||||||
|
|
||||||
|
const commonPhrases = Array.from(phraseCounts.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([phrase, count]) => ({ phrase, count }))
|
||||||
|
|
||||||
|
const commonEmojis = Array.from(emojiCounts.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([emoji, count]) => ({ emoji, count }))
|
||||||
|
|
||||||
|
return { success: true, data: { statistics: stats, timeDistribution: hourlyDistribution, commonPhrases, commonEmojis } }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async exportGroupMemberMessages(
|
async exportGroupMemberMessages(
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
memberUsername: string,
|
memberUsername: string,
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class HttpService {
|
|||||||
private server: http.Server | null = null
|
private server: http.Server | null = null
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private port: number = 5031
|
private port: number = 5031
|
||||||
|
private host: string = '127.0.0.1'
|
||||||
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 messagePushClients: Set<http.ServerResponse> = new Set()
|
private messagePushClients: Set<http.ServerResponse> = new Set()
|
||||||
@@ -114,12 +115,13 @@ class HttpService {
|
|||||||
/**
|
/**
|
||||||
* 启动 HTTP 服务
|
* 启动 HTTP 服务
|
||||||
*/
|
*/
|
||||||
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> {
|
async start(port: number = 5031, host: string = '127.0.0.1'): Promise<{ success: boolean; port?: number; error?: string }> {
|
||||||
if (this.running && this.server) {
|
if (this.running && this.server) {
|
||||||
return { success: true, port: this.port }
|
return { success: true, port: this.port }
|
||||||
}
|
}
|
||||||
|
|
||||||
this.port = port
|
this.port = port
|
||||||
|
this.host = host
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.server = http.createServer((req, res) => this.handleRequest(req, res))
|
this.server = http.createServer((req, res) => this.handleRequest(req, res))
|
||||||
@@ -153,10 +155,10 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.server.listen(this.port, '127.0.0.1', () => {
|
this.server.listen(this.port, this.host, () => {
|
||||||
this.running = true
|
this.running = true
|
||||||
this.startMessagePushHeartbeat()
|
this.startMessagePushHeartbeat()
|
||||||
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
|
console.log(`[HttpService] HTTP API server started on http://${this.host}:${this.port}`)
|
||||||
resolve({ success: true, port: this.port })
|
resolve({ success: true, port: this.port })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -225,7 +227,7 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getMessagePushStreamUrl(): string {
|
getMessagePushStreamUrl(): string {
|
||||||
return `http://127.0.0.1:${this.port}/api/v1/push/messages`
|
return `http://${this.host}:${this.port}/api/v1/push/messages`
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastMessagePush(payload: Record<string, unknown>): void {
|
broadcastMessagePush(payload: Record<string, unknown>): void {
|
||||||
@@ -246,49 +248,116 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async autoStart(): Promise<void> {
|
||||||
* 处理 HTTP 请求
|
const enabled = this.configService.get('httpApiEnabled')
|
||||||
*/
|
if (enabled) {
|
||||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
const port = Number(this.configService.get('httpApiPort')) || 5031
|
||||||
// 设置 CORS 头
|
const host = String(this.configService.get('httpApiHost') || '127.0.0.1').trim() || '127.0.0.1'
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
try {
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
await this.start(port, host)
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
console.log(`[HttpService] Auto-started on port ${port}`)
|
||||||
|
} catch (err) {
|
||||||
if (req.method === 'OPTIONS') {
|
console.error('[HttpService] Auto-start failed:', err)
|
||||||
res.writeHead(204)
|
|
||||||
res.end()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
|
|
||||||
const pathname = url.pathname
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 路由处理
|
|
||||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
|
||||||
this.sendJson(res, { status: 'ok' })
|
|
||||||
} else if (pathname === '/api/v1/push/messages') {
|
|
||||||
this.handleMessagePushStream(req, res)
|
|
||||||
} else if (pathname === '/api/v1/messages') {
|
|
||||||
await this.handleMessages(url, res)
|
|
||||||
} else if (pathname === '/api/v1/sessions') {
|
|
||||||
await this.handleSessions(url, res)
|
|
||||||
} else if (pathname === '/api/v1/contacts') {
|
|
||||||
await this.handleContacts(url, res)
|
|
||||||
} else if (pathname === '/api/v1/group-members') {
|
|
||||||
await this.handleGroupMembers(url, res)
|
|
||||||
} else if (pathname.startsWith('/api/v1/media/')) {
|
|
||||||
this.handleMediaRequest(pathname, res)
|
|
||||||
} else {
|
|
||||||
this.sendError(res, 404, 'Not Found')
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('[HttpService] Request error:', error)
|
|
||||||
this.sendError(res, 500, String(error))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 POST 请求的 JSON Body
|
||||||
|
*/
|
||||||
|
private async parseBody(req: http.IncomingMessage): Promise<Record<string, any>> {
|
||||||
|
if (req.method !== 'POST') return {}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let body = ''
|
||||||
|
req.on('data', chunk => { body += chunk.toString() })
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(body))
|
||||||
|
} catch {
|
||||||
|
resolve({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
req.on('error', () => resolve({}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 鉴权拦截器
|
||||||
|
*/
|
||||||
|
private verifyToken(req: http.IncomingMessage, url: URL, body: Record<string, any>): boolean {
|
||||||
|
const expectedToken = String(this.configService.get('httpApiToken') || '').trim()
|
||||||
|
if (!expectedToken) return true
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization
|
||||||
|
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
|
||||||
|
const token = authHeader.substring(7).trim()
|
||||||
|
if (token === expectedToken) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryToken = url.searchParams.get('access_token')
|
||||||
|
if (queryToken && queryToken.trim() === expectedToken) return true
|
||||||
|
|
||||||
|
const bodyToken = body['access_token']
|
||||||
|
return !!(bodyToken && String(bodyToken).trim() === expectedToken);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 HTTP 请求 (重构后)
|
||||||
|
*/
|
||||||
|
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(204)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url || '/', `http://${this.host}:${this.port}`)
|
||||||
|
const pathname = url.pathname
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bodyParams = await this.parseBody(req)
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(bodyParams)) {
|
||||||
|
if (!url.searchParams.has(key)) {
|
||||||
|
url.searchParams.set(key, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname !== '/health' && pathname !== '/api/v1/health') {
|
||||||
|
if (!this.verifyToken(req, url, bodyParams)) {
|
||||||
|
this.sendError(res, 401, 'Unauthorized: Invalid or missing access_token')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||||
|
this.sendJson(res, { status: 'ok' })
|
||||||
|
} else if (pathname === '/api/v1/push/messages') {
|
||||||
|
this.handleMessagePushStream(req, res)
|
||||||
|
} else if (pathname === '/api/v1/messages') {
|
||||||
|
await this.handleMessages(url, res)
|
||||||
|
} else if (pathname === '/api/v1/sessions') {
|
||||||
|
await this.handleSessions(url, res)
|
||||||
|
} else if (pathname === '/api/v1/contacts') {
|
||||||
|
await this.handleContacts(url, res)
|
||||||
|
} else if (pathname === '/api/v1/group-members') {
|
||||||
|
await this.handleGroupMembers(url, res)
|
||||||
|
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||||
|
this.handleMediaRequest(pathname, res)
|
||||||
|
} else {
|
||||||
|
this.sendError(res, 404, 'Not Found')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HttpService] Request error:', error)
|
||||||
|
this.sendError(res, 500, String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
private startMessagePushHeartbeat(): void {
|
private startMessagePushHeartbeat(): void {
|
||||||
if (this.messagePushHeartbeatTimer) return
|
if (this.messagePushHeartbeatTimer) return
|
||||||
this.messagePushHeartbeatTimer = setInterval(() => {
|
this.messagePushHeartbeatTimer = setInterval(() => {
|
||||||
@@ -895,7 +964,7 @@ class HttpService {
|
|||||||
parsedContent: msg.parsedContent,
|
parsedContent: msg.parsedContent,
|
||||||
mediaType: media?.kind,
|
mediaType: media?.kind,
|
||||||
mediaFileName: media?.fileName,
|
mediaFileName: media?.fileName,
|
||||||
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
mediaUrl: media ? `http://${this.host}:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
||||||
mediaLocalPath: media?.fullPath
|
mediaLocalPath: media?.fullPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1165,7 +1234,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) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ export class VoiceTranscribeService {
|
|||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||||
}
|
}
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
// Windows: 把 sherpa-onnx DLL 所在目录加到 PATH,否则 native module 找不到依赖
|
||||||
|
const existing = env['PATH'] || ''
|
||||||
|
const merged = [...candidates, ...existing.split(';').filter(Boolean)]
|
||||||
|
env['PATH'] = Array.from(new Set(merged)).join(';')
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return env
|
return env
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -20,7 +20,8 @@
|
|||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"electron:dev": "vite --mode electron",
|
"electron:dev": "vite --mode electron",
|
||||||
"electron:build": "npm run build"
|
"electron:build": "npm run build",
|
||||||
|
"preinstall": "node preinstall.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"echarts": "^5.5.1",
|
"echarts": "^5.5.1",
|
||||||
@@ -96,12 +97,17 @@
|
|||||||
"icon": "public/icon.png",
|
"icon": "public/icon.png",
|
||||||
"target": [
|
"target": [
|
||||||
"appimage",
|
"appimage",
|
||||||
"deb",
|
|
||||||
"tar.gz"
|
"tar.gz"
|
||||||
],
|
],
|
||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"executableName": "weflow",
|
"executableName": "weflow",
|
||||||
"synopsis": "WeFlow for Linux"
|
"synopsis": "WeFlow for Linux",
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "resources/linux/install.sh",
|
||||||
|
"to": "install.sh"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
@@ -173,4 +179,4 @@
|
|||||||
],
|
],
|
||||||
"icon": "resources/icon.icns"
|
"icon": "resources/icon.icns"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
20
preinstall.js
Normal file
20
preinstall.js
Normal file
File diff suppressed because one or more lines are too long
BIN
public/icon.ico
BIN
public/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 364 KiB |
BIN
public/icon.png
BIN
public/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 570 KiB |
BIN
public/logo.png
BIN
public/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 570 KiB |
59
resources/linux/install.sh
Normal file
59
resources/linux/install.sh
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_NAME="weflow"
|
||||||
|
APP_EXEC="weflow"
|
||||||
|
OPT_DIR="/opt/$APP_NAME"
|
||||||
|
BIN_LINK="/usr/bin/$APP_NAME"
|
||||||
|
DESKTOP_DIR="/usr/share/applications"
|
||||||
|
ICON_DIR="/usr/share/pixmaps"
|
||||||
|
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "❌ 请使用 root 权限运行此脚本 (例如: sudo ./install.sh)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 开始安装 $APP_NAME..."
|
||||||
|
|
||||||
|
echo "📦 正在复制文件到 $OPT_DIR..."
|
||||||
|
rm -rf "$OPT_DIR"
|
||||||
|
mkdir -p "$OPT_DIR"
|
||||||
|
cp -r ./* "$OPT_DIR/"
|
||||||
|
chmod -R 755 "$OPT_DIR"
|
||||||
|
chmod +x "$OPT_DIR/$APP_EXEC"
|
||||||
|
|
||||||
|
echo "🔗 正在创建软链接 $BIN_LINK..."
|
||||||
|
ln -sf "$OPT_DIR/$APP_EXEC" "$BIN_LINK"
|
||||||
|
|
||||||
|
echo "📝 正在创建桌面快捷方式..."
|
||||||
|
cat <<EOF >"$DESKTOP_DIR/${APP_NAME}.desktop"
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=WeFlow
|
||||||
|
Exec=$OPT_DIR/$APP_EXEC %U
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Icon=$APP_NAME
|
||||||
|
StartupWMClass=WeFlow
|
||||||
|
Comment=A local WeChat database decryption and analysis tool
|
||||||
|
Categories=Utility;
|
||||||
|
EOF
|
||||||
|
chmod 644 "$DESKTOP_DIR/${APP_NAME}.desktop"
|
||||||
|
|
||||||
|
echo "🖼️ 正在安装图标..."
|
||||||
|
if [ -f "$OPT_DIR/resources/icon.png" ]; then
|
||||||
|
cp "$OPT_DIR/resources/icon.png" "$ICON_DIR/${APP_NAME}.png"
|
||||||
|
chmod 644 "$ICON_DIR/${APP_NAME}.png"
|
||||||
|
elif [ -f "$OPT_DIR/icon.png" ]; then
|
||||||
|
cp "$OPT_DIR/icon.png" "$ICON_DIR/${APP_NAME}.png"
|
||||||
|
chmod 644 "$ICON_DIR/${APP_NAME}.png"
|
||||||
|
else
|
||||||
|
echo "⚠️ 警告: 未找到图标文件,跳过图标安装。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
echo "🔄 更新桌面数据库..."
|
||||||
|
update-desktop-database "$DESKTOP_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ 安装完成!你现在可以在应用菜单中找到 WeFlow,或者在终端输入 'weflow' 启动。"
|
||||||
Binary file not shown.
@@ -834,11 +834,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.member-export-panel,
|
.member-export-panel,
|
||||||
.member-messages-panel {
|
.member-messages-panel,
|
||||||
|
.member-analytics-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
.member-export-empty {
|
.member-export-empty {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -1521,29 +1523,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-cards {
|
.stats-overview {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 24px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: transparent;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--primary-light);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px;
|
color: var(--primary);
|
||||||
text-align: center;
|
}
|
||||||
|
|
||||||
.value {
|
.stat-info {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.stat-label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
&.wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare } from 'lucide-react'
|
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare, Calendar, PieChart, Hash, Smile } from 'lucide-react'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
import DateRangePicker from '../components/DateRangePicker'
|
import DateRangePicker from '../components/DateRangePicker'
|
||||||
@@ -37,7 +37,7 @@ interface GroupMessageRank {
|
|||||||
messageCount: number
|
messageCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalysisFunction = 'members' | 'memberMessages' | 'ranking' | 'activeHours' | 'mediaStats'
|
type AnalysisFunction = 'members' | 'memberMessages' | 'memberAnalytics' | 'ranking' | 'activeHours' | 'mediaStats'
|
||||||
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
|
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
|
||||||
|
|
||||||
interface MemberMessageExportOptions {
|
interface MemberMessageExportOptions {
|
||||||
@@ -167,6 +167,8 @@ function GroupAnalyticsPage() {
|
|||||||
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
||||||
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
|
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
|
||||||
const [memberMessages, setMemberMessages] = useState<Message[]>([])
|
const [memberMessages, setMemberMessages] = useState<Message[]>([])
|
||||||
|
const [memberAnalyticsData, setMemberAnalyticsData] = useState<any | null>(null)
|
||||||
|
const [analyticsError, setAnalyticsError] = useState<string | null>(null)
|
||||||
const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false)
|
const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false)
|
||||||
const [memberMessagesCursor, setMemberMessagesCursor] = useState(0)
|
const [memberMessagesCursor, setMemberMessagesCursor] = useState(0)
|
||||||
const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false)
|
const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false)
|
||||||
@@ -524,6 +526,7 @@ function GroupAnalyticsPage() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'memberMessages': {
|
case 'memberMessages': {
|
||||||
|
resetMemberMessageState(false)
|
||||||
updateBackgroundTask(taskId, {
|
updateBackgroundTask(taskId, {
|
||||||
detail: '正在读取成员列表与消息',
|
detail: '正在读取成员列表与消息',
|
||||||
progressText: '成员消息'
|
progressText: '成员消息'
|
||||||
@@ -566,7 +569,57 @@ function GroupAnalyticsPage() {
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'memberAnalytics': {
|
||||||
|
setMemberAnalyticsData(null)
|
||||||
|
setAnalyticsError(null)
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在读取成员列表与消息分析',
|
||||||
|
progressText: '成员分析'
|
||||||
|
})
|
||||||
|
const result = await window.electronAPI.groupAnalytics.getGroupMembers(targetGroup.username)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员分析未继续写入' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
finishBackgroundTask(taskId, 'failed', { detail: result.error || '获取成员列表失败' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMembers(result.data)
|
||||||
|
let targetMember = preferredMemberUsername
|
||||||
|
? result.data.find(m => m.username === preferredMemberUsername)
|
||||||
|
: result.data.find(m => m.username === selectedMessageMemberUsername)
|
||||||
|
if (!targetMember && result.data.length > 0) {
|
||||||
|
targetMember = result.data[0]
|
||||||
|
setSelectedMessageMemberUsername(targetMember.username)
|
||||||
|
}
|
||||||
|
if (!targetMember) {
|
||||||
|
finishBackgroundTask(taskId, 'failed', { detail: '找不到目标成员' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: `正在分析 ${targetMember.displayName || targetMember.username} 的发言记录`,
|
||||||
|
progressText: '统计分析'
|
||||||
|
})
|
||||||
|
const analyticsResult = await window.electronAPI.groupAnalytics.getGroupMemberAnalytics(targetGroup.username, targetMember.username, startTime, endTime)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员分析未继续写入' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (analyticsResult.success && analyticsResult.data) {
|
||||||
|
setMemberAnalyticsData(analyticsResult.data)
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: `分析完成,共计 ${analyticsResult.data.statistics?.totalMessages || 0} 条消息`,
|
||||||
|
progressText: '已完成'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setAnalyticsError(analyticsResult.error || '分析失败')
|
||||||
|
finishBackgroundTask(taskId, 'failed', { detail: analyticsResult.error || '分析失败' })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'ranking': {
|
case 'ranking': {
|
||||||
|
setRankings([])
|
||||||
updateBackgroundTask(taskId, {
|
updateBackgroundTask(taskId, {
|
||||||
detail: '正在计算群消息排行',
|
detail: '正在计算群消息排行',
|
||||||
progressText: '消息排行'
|
progressText: '消息排行'
|
||||||
@@ -584,6 +637,7 @@ function GroupAnalyticsPage() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'activeHours': {
|
case 'activeHours': {
|
||||||
|
setActiveHours({})
|
||||||
updateBackgroundTask(taskId, {
|
updateBackgroundTask(taskId, {
|
||||||
detail: '正在计算群活跃时段',
|
detail: '正在计算群活跃时段',
|
||||||
progressText: '活跃时段'
|
progressText: '活跃时段'
|
||||||
@@ -601,6 +655,7 @@ function GroupAnalyticsPage() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'mediaStats': {
|
case 'mediaStats': {
|
||||||
|
setMediaStats(null)
|
||||||
updateBackgroundTask(taskId, {
|
updateBackgroundTask(taskId, {
|
||||||
detail: '正在统计群消息类型',
|
detail: '正在统计群消息类型',
|
||||||
progressText: '消息类型'
|
progressText: '消息类型'
|
||||||
@@ -633,6 +688,12 @@ function GroupAnalyticsPage() {
|
|||||||
return num.toLocaleString()
|
return num.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number | null) => {
|
||||||
|
if (!timestamp) return '-'
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
const sanitizeFileName = (name: string) => {
|
const sanitizeFileName = (name: string) => {
|
||||||
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
|
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
|
||||||
}
|
}
|
||||||
@@ -764,6 +825,16 @@ function GroupAnalyticsPage() {
|
|||||||
await loadFunctionData('memberMessages', selectedGroup, member.username)
|
await loadFunctionData('memberMessages', selectedGroup, member.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleViewMemberAnalyticsFromModal = async (member: GroupMember) => {
|
||||||
|
if (!selectedGroup) return
|
||||||
|
setSelectedMember(null)
|
||||||
|
setSelectedFunction('memberAnalytics')
|
||||||
|
setSelectedMessageMemberUsername(member.username)
|
||||||
|
setMessageMemberSearchKeyword('')
|
||||||
|
setShowMessageMemberSelect(false)
|
||||||
|
await loadFunctionData('memberAnalytics', selectedGroup, member.username)
|
||||||
|
}
|
||||||
|
|
||||||
const handleOpenMemberExportModal = () => {
|
const handleOpenMemberExportModal = () => {
|
||||||
setShowMessageMemberSelect(false)
|
setShowMessageMemberSelect(false)
|
||||||
setShowFormatSelect(false)
|
setShowFormatSelect(false)
|
||||||
@@ -982,6 +1053,14 @@ function GroupAnalyticsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="member-modal-primary-btn"
|
className="member-modal-primary-btn"
|
||||||
|
onClick={() => void handleViewMemberAnalyticsFromModal(selectedMember)}
|
||||||
|
>
|
||||||
|
<BarChart3 size={16} />
|
||||||
|
<span>分析该成员聊天</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="member-modal-secondary-btn"
|
||||||
onClick={() => void handleViewMemberMessagesFromModal(selectedMember)}
|
onClick={() => void handleViewMemberMessagesFromModal(selectedMember)}
|
||||||
>
|
>
|
||||||
<MessageSquare size={16} />
|
<MessageSquare size={16} />
|
||||||
@@ -1080,6 +1159,11 @@ function GroupAnalyticsPage() {
|
|||||||
<span>成员消息筛选与导出</span>
|
<span>成员消息筛选与导出</span>
|
||||||
<small>按成员查看群聊消息,并支持导出当前成员记录</small>
|
<small>按成员查看群聊消息,并支持导出当前成员记录</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="function-card" onClick={() => handleFunctionSelect('memberAnalytics')}>
|
||||||
|
<PieChart size={32} />
|
||||||
|
<span>群成员详细分析</span>
|
||||||
|
<small>针对群聊内某一用户的群聊记录进行详细分析,如发信数量、活跃周期等</small>
|
||||||
|
</div>
|
||||||
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
|
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
|
||||||
<BarChart3 size={32} />
|
<BarChart3 size={32} />
|
||||||
<span>群聊发言排行</span>
|
<span>群聊发言排行</span>
|
||||||
@@ -1104,6 +1188,7 @@ function GroupAnalyticsPage() {
|
|||||||
switch (selectedFunction) {
|
switch (selectedFunction) {
|
||||||
case 'members': return '群成员查看'
|
case 'members': return '群成员查看'
|
||||||
case 'memberMessages': return '成员消息筛选与导出'
|
case 'memberMessages': return '成员消息筛选与导出'
|
||||||
|
case 'memberAnalytics': return '群成员详细分析'
|
||||||
case 'ranking': return '群聊发言排行'
|
case 'ranking': return '群聊发言排行'
|
||||||
case 'activeHours': return '群聊活跃时段'
|
case 'activeHours': return '群聊活跃时段'
|
||||||
case 'mediaStats': return '媒体内容统计'
|
case 'mediaStats': return '媒体内容统计'
|
||||||
@@ -1282,6 +1367,187 @@ function GroupAnalyticsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{selectedFunction === 'memberAnalytics' && (
|
||||||
|
<div className="member-analytics-panel">
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<div className="member-message-empty">暂无群成员数据,请先刷新。</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="member-message-toolbar" style={{ marginBottom: 20 }}>
|
||||||
|
<div className="member-export-field" ref={messageMemberSelectDropdownRef}>
|
||||||
|
<span>分析成员</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger member-message-select-trigger ${showMessageMemberSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => setShowMessageMemberSelect(prev => !prev)}
|
||||||
|
>
|
||||||
|
<div className="member-select-trigger-value">
|
||||||
|
<Avatar
|
||||||
|
src={selectedMessageMember?.avatarUrl}
|
||||||
|
name={selectedMessageMember?.displayName || selectedMessageMember?.username || '?'}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
<span className="select-value">{selectedMessageMember?.displayName || selectedMessageMember?.username || '请选择成员'}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showMessageMemberSelect && (
|
||||||
|
<div className="select-dropdown member-select-dropdown">
|
||||||
|
<div className="member-select-search">
|
||||||
|
<Search size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={messageMemberSearchKeyword}
|
||||||
|
onChange={e => setMessageMemberSearchKeyword(e.target.value)}
|
||||||
|
placeholder="搜索 wxid / 昵称 / 备注 / 微信号"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="member-select-options">
|
||||||
|
{filteredMessageMemberOptions.length === 0 ? (
|
||||||
|
<div className="member-select-empty">无匹配成员</div>
|
||||||
|
) : (
|
||||||
|
filteredMessageMemberOptions.map(member => (
|
||||||
|
<button
|
||||||
|
key={member.username}
|
||||||
|
type="button"
|
||||||
|
className={`select-option member-select-option ${selectedMessageMemberUsername === member.username ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMessageMemberUsername(member.username)
|
||||||
|
setShowMessageMemberSelect(false)
|
||||||
|
if (selectedGroup) {
|
||||||
|
void loadFunctionData('memberAnalytics', selectedGroup, member.username)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar src={member.avatarUrl} name={member.displayName} size={28} />
|
||||||
|
<span className="member-option-main">{member.displayName || member.username}</span>
|
||||||
|
<span className="member-option-meta">
|
||||||
|
wxid: {member.username}
|
||||||
|
{member.alias ? ` · 微信号: ${member.alias}` : ''}
|
||||||
|
{member.remark ? ` · 备注: ${member.remark}` : ''}
|
||||||
|
{member.nickname ? ` · 昵称: ${member.nickname}` : ''}
|
||||||
|
{member.groupNickname ? ` · 群昵称: ${member.groupNickname}` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{analyticsError ? (
|
||||||
|
<div className="member-message-empty">{analyticsError}</div>
|
||||||
|
) : memberAnalyticsData ? (
|
||||||
|
<div className="analytics-content-scrollable" style={{ padding: '0', display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
|
<div className="stats-overview">
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon"><MessageSquare size={24} /></div>
|
||||||
|
<div className="stat-info">
|
||||||
|
<span className="stat-value">{formatNumber(memberAnalyticsData.statistics.sentMessages)}</span>
|
||||||
|
<span className="stat-label">发信数量</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon"><Clock size={24} /></div>
|
||||||
|
<div className="stat-info">
|
||||||
|
<span className="stat-value">{memberAnalyticsData.statistics.activeDays}</span>
|
||||||
|
<span className="stat-label">活跃天数</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card" style={{ gridColumn: 'span 2' }}>
|
||||||
|
<div className="stat-icon"><Calendar size={24} /></div>
|
||||||
|
<div className="stat-info">
|
||||||
|
<span className="stat-value">
|
||||||
|
{formatDate(memberAnalyticsData.statistics.firstMessageTime)} - {formatDate(memberAnalyticsData.statistics.lastMessageTime)}
|
||||||
|
</span>
|
||||||
|
<span className="stat-label">活跃周期</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="charts-grid">
|
||||||
|
<div className="chart-card wide">
|
||||||
|
<h3>活跃时段</h3>
|
||||||
|
<div className="chart-wrapper">
|
||||||
|
<ReactECharts
|
||||||
|
option={{
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: { type: 'category', data: Array.from({ length: 24 }, (_, i) => `${i}时`) },
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: [{ type: 'bar', data: Array.from({ length: 24 }, (_, i) => memberAnalyticsData.timeDistribution[i] || 0), itemStyle: { color: '#07c160', borderRadius: [4, 4, 0, 0] } }]
|
||||||
|
}}
|
||||||
|
style={{ height: '300px', width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-card wide">
|
||||||
|
<h3>消息类型分布</h3>
|
||||||
|
<div className="chart-wrapper">
|
||||||
|
<ReactECharts
|
||||||
|
option={{
|
||||||
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
|
series: [{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['40%', '70%'],
|
||||||
|
data: [
|
||||||
|
{ name: '文本', value: memberAnalyticsData.statistics.textMessages, itemStyle: { color: '#3b82f6' } },
|
||||||
|
{ name: '图片', value: memberAnalyticsData.statistics.imageMessages, itemStyle: { color: '#22c55e' } },
|
||||||
|
{ name: '语音', value: memberAnalyticsData.statistics.voiceMessages, itemStyle: { color: '#f97316' } },
|
||||||
|
{ name: '视频', value: memberAnalyticsData.statistics.videoMessages, itemStyle: { color: '#a855f7' } },
|
||||||
|
{ name: '表情', value: memberAnalyticsData.statistics.emojiMessages, itemStyle: { color: '#ec4899' } },
|
||||||
|
{ name: '其他', value: memberAnalyticsData.statistics.otherMessages, itemStyle: { color: '#6b7280' } }
|
||||||
|
].filter(item => item.value > 0),
|
||||||
|
label: { show: true, formatter: '{b} {d}%' }
|
||||||
|
}]
|
||||||
|
}}
|
||||||
|
style={{ height: '300px', width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chart-card wide" style={{ display: 'flex', gap: '32px' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h3 style={{ display: 'flex', alignItems: 'center', gap: '6px' }}><Hash size={18} /> 常用语</h3>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{memberAnalyticsData.commonPhrases && memberAnalyticsData.commonPhrases.length > 0 ? (
|
||||||
|
memberAnalyticsData.commonPhrases.map((item: any, idx: number) => (
|
||||||
|
<div key={idx} style={{ background: 'var(--bg-tertiary)', padding: '6px 12px', borderRadius: '8px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--border-color)' }}>
|
||||||
|
<span style={{ color: 'var(--text-primary)' }}>{item.phrase}</span>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: '11px' }}>{item.count}次</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: '13px' }}>暂无常用语数据</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h3 style={{ display: 'flex', alignItems: 'center', gap: '6px' }}><Smile size={18} /> 常用表情</h3>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{memberAnalyticsData.commonEmojis && memberAnalyticsData.commonEmojis.length > 0 ? (
|
||||||
|
memberAnalyticsData.commonEmojis.map((item: any, idx: number) => (
|
||||||
|
<div key={idx} style={{ background: 'var(--bg-tertiary)', padding: '6px 12px', borderRadius: '8px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--border-color)' }}>
|
||||||
|
<span style={{ color: 'var(--text-primary)' }}>{item.emoji}</span>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: '11px' }}>{item.count}次</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: '13px' }}>暂无表情包数据</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="content-loading"><Loader2 size={32} className="spin" /></div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{selectedFunction === 'ranking' && (
|
{selectedFunction === 'ranking' && (
|
||||||
<div className="rankings-list">
|
<div className="rankings-list">
|
||||||
{rankings.map((item, index) => (
|
{rankings.map((item, index) => (
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
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 [httpApiToken, setHttpApiToken] = useState('')
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -111,6 +113,25 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generateRandomToken = async () => {
|
||||||
|
// 生成 32 字符的十六进制随机字符串 (16 bytes)
|
||||||
|
const array = new Uint8Array(16)
|
||||||
|
crypto.getRandomValues(array)
|
||||||
|
const token = Array.from(array).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
|
||||||
|
setHttpApiToken(token)
|
||||||
|
await configService.setHttpApiToken(token)
|
||||||
|
showMessage('已生成并保存新的 Access Token', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearApiToken = async () => {
|
||||||
|
setHttpApiToken('')
|
||||||
|
await configService.setHttpApiToken('')
|
||||||
|
showMessage('已清除 Access Token,API 将允许无鉴权访问', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||||
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||||
|
|
||||||
@@ -169,6 +190,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
// HTTP API 设置 state
|
// HTTP API 设置 state
|
||||||
const [httpApiEnabled, setHttpApiEnabled] = useState(false)
|
const [httpApiEnabled, setHttpApiEnabled] = useState(false)
|
||||||
const [httpApiPort, setHttpApiPort] = useState(5031)
|
const [httpApiPort, setHttpApiPort] = useState(5031)
|
||||||
|
const [httpApiHost, setHttpApiHost] = useState('127.0.0.1')
|
||||||
const [httpApiRunning, setHttpApiRunning] = useState(false)
|
const [httpApiRunning, setHttpApiRunning] = useState(false)
|
||||||
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
|
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
|
||||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||||
@@ -192,6 +214,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
checkWaylandStatus()
|
checkWaylandStatus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 检查 Hello 可用性
|
// 检查 Hello 可用性
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHelloAvailable(isWindows)
|
setHelloAvailable(isWindows)
|
||||||
@@ -319,6 +343,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
||||||
const savedAuthUseHello = await configService.getAuthUseHello()
|
const savedAuthUseHello = await configService.getAuthUseHello()
|
||||||
const savedIsLockMode = await window.electronAPI.auth.isLockMode()
|
const savedIsLockMode = await window.electronAPI.auth.isLockMode()
|
||||||
|
|
||||||
|
const savedHttpApiToken = await configService.getHttpApiToken()
|
||||||
|
if (savedHttpApiToken) setHttpApiToken(savedHttpApiToken)
|
||||||
|
|
||||||
|
const savedApiPort = await configService.getHttpApiPort()
|
||||||
|
if (savedApiPort) setHttpApiPort(savedApiPort)
|
||||||
|
|
||||||
|
const savedApiHost = await configService.getHttpApiHost()
|
||||||
|
if (savedApiHost) setHttpApiHost(savedApiHost)
|
||||||
|
|
||||||
setAuthEnabled(savedAuthEnabled)
|
setAuthEnabled(savedAuthEnabled)
|
||||||
setAuthUseHello(savedAuthUseHello)
|
setAuthUseHello(savedAuthUseHello)
|
||||||
setIsLockMode(savedIsLockMode)
|
setIsLockMode(savedIsLockMode)
|
||||||
@@ -361,6 +395,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const savedAnalyticsConsent = await configService.getAnalyticsConsent()
|
const savedAnalyticsConsent = await configService.getAnalyticsConsent()
|
||||||
setAnalyticsConsent(savedAnalyticsConsent ?? false)
|
setAnalyticsConsent(savedAnalyticsConsent ?? false)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 如果语言列表为空,保存默认值
|
// 如果语言列表为空,保存默认值
|
||||||
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||||
const defaultLanguages = ['zh']
|
const defaultLanguages = ['zh']
|
||||||
@@ -1825,6 +1861,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
try {
|
try {
|
||||||
await window.electronAPI.http.stop()
|
await window.electronAPI.http.stop()
|
||||||
setHttpApiRunning(false)
|
setHttpApiRunning(false)
|
||||||
|
await configService.setHttpApiEnabled(false)
|
||||||
showMessage('API 服务已停止', true)
|
showMessage('API 服务已停止', true)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showMessage(`操作失败: ${e}`, false)
|
showMessage(`操作失败: ${e}`, false)
|
||||||
@@ -1838,10 +1875,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setShowApiWarning(false)
|
setShowApiWarning(false)
|
||||||
setIsTogglingApi(true)
|
setIsTogglingApi(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.http.start(httpApiPort)
|
const result = await window.electronAPI.http.start(httpApiPort, httpApiHost)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setHttpApiRunning(true)
|
setHttpApiRunning(true)
|
||||||
if (result.port) setHttpApiPort(result.port)
|
if (result.port) setHttpApiPort(result.port)
|
||||||
|
|
||||||
|
await configService.setHttpApiEnabled(true)
|
||||||
|
await configService.setHttpApiPort(result.port || httpApiPort)
|
||||||
|
|
||||||
showMessage(`API 服务已启动,端口 ${result.port}`, true)
|
showMessage(`API 服务已启动,端口 ${result.port}`, true)
|
||||||
} else {
|
} else {
|
||||||
showMessage(`启动失败: ${result.error}`, false)
|
showMessage(`启动失败: ${result.error}`, false)
|
||||||
@@ -1854,7 +1895,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCopyApiUrl = () => {
|
const handleCopyApiUrl = () => {
|
||||||
const url = `http://127.0.0.1:${httpApiPort}`
|
const url = `http://${httpApiHost}:${httpApiPort}`
|
||||||
navigator.clipboard.writeText(url)
|
navigator.clipboard.writeText(url)
|
||||||
showMessage('已复制 API 地址', true)
|
showMessage('已复制 API 地址', true)
|
||||||
}
|
}
|
||||||
@@ -1886,21 +1927,75 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>监听地址</label>
|
||||||
|
<span className="form-hint">
|
||||||
|
API 服务绑定的主机地址。默认 <code>127.0.0.1</code> 仅本机访问;Docker/N8N 等容器场景请改为 <code>0.0.0.0</code> 以允许外部访问(注意配合 Token 鉴权)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input"
|
||||||
|
value={httpApiHost}
|
||||||
|
placeholder="127.0.0.1"
|
||||||
|
onChange={(e) => {
|
||||||
|
const host = e.target.value.trim() || '127.0.0.1'
|
||||||
|
setHttpApiHost(host)
|
||||||
|
scheduleConfigSave('httpApiHost', () => configService.setHttpApiHost(host))
|
||||||
|
}}
|
||||||
|
disabled={httpApiRunning}
|
||||||
|
style={{ width: 180, fontFamily: 'monospace' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>服务端口</label>
|
<label>服务端口</label>
|
||||||
<span className="form-hint">API 服务监听的端口号(1024-65535)</span>
|
<span className="form-hint">API 服务监听的端口号(1024-65535)</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
value={httpApiPort}
|
value={httpApiPort}
|
||||||
onChange={(e) => setHttpApiPort(parseInt(e.target.value, 10) || 5031)}
|
onChange={(e) => {
|
||||||
disabled={httpApiRunning}
|
const port = parseInt(e.target.value, 10) || 5031
|
||||||
style={{ width: 120 }}
|
setHttpApiPort(port)
|
||||||
min={1024}
|
scheduleConfigSave('httpApiPort', () => configService.setHttpApiPort(port))
|
||||||
max={65535}
|
}}
|
||||||
|
disabled={httpApiRunning}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
min={1024}
|
||||||
|
max={65535}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Access Token (鉴权凭证)</label>
|
||||||
|
<span className="form-hint">
|
||||||
|
设置后,请求头需携带 <code>Authorization: Bearer <token></code>,
|
||||||
|
或者参数中携带 <code>?access_token=<token></code>
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input"
|
||||||
|
value={httpApiToken}
|
||||||
|
placeholder="留空表示不验证 Token"
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value
|
||||||
|
setHttpApiToken(val)
|
||||||
|
scheduleConfigSave('httpApiToken', () => configService.setHttpApiToken(val))
|
||||||
|
}}
|
||||||
|
style={{ flex: 1, fontFamily: 'monospace' }}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-secondary" onClick={generateRandomToken}>
|
||||||
|
<RefreshCw size={14} style={{ marginRight: 4 }} /> 随机生成
|
||||||
|
</button>
|
||||||
|
{httpApiToken && (
|
||||||
|
<button className="btn btn-danger" onClick={clearApiToken} title="清除 Token">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{httpApiRunning && (
|
{httpApiRunning && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>API 地址</label>
|
<label>API 地址</label>
|
||||||
@@ -1909,7 +2004,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
value={`http://127.0.0.1:${httpApiPort}`}
|
value={`http://${httpApiHost}:${httpApiPort}`}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复制">
|
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复制">
|
||||||
@@ -1956,18 +2051,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<span className="form-hint">外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务`</span>
|
<span className="form-hint">外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务`</span>
|
||||||
<div className="api-url-display">
|
<div className="api-url-display">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
value={`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}
|
value={`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`)
|
navigator.clipboard.writeText(`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`)
|
||||||
showMessage('已复制推送地址', true)
|
showMessage('已复制推送地址', true)
|
||||||
}}
|
}}
|
||||||
title="复制"
|
title="复制"
|
||||||
>
|
>
|
||||||
<Copy size={16} />
|
<Copy size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -1981,7 +2076,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<div className="api-item">
|
<div className="api-item">
|
||||||
<div className="api-endpoint">
|
<div className="api-endpoint">
|
||||||
<span className="method get">GET</span>
|
<span className="method get">GET</span>
|
||||||
<code>{`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}</code>
|
<code>{`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages`}</code>
|
||||||
</div>
|
</div>
|
||||||
<p className="api-desc">通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。</p>
|
<p className="api-desc">通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。</p>
|
||||||
<div className="api-params">
|
<div className="api-params">
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ export const CONFIG_KEYS = {
|
|||||||
NOTIFICATION_POSITION: 'notificationPosition',
|
NOTIFICATION_POSITION: 'notificationPosition',
|
||||||
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
||||||
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
||||||
|
HTTP_API_TOKEN: 'httpApiToken',
|
||||||
|
HTTP_API_ENABLED: 'httpApiEnabled',
|
||||||
|
HTTP_API_PORT: 'httpApiPort',
|
||||||
|
HTTP_API_HOST: 'httpApiHost',
|
||||||
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
|
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
|
||||||
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
||||||
QUOTE_LAYOUT: 'quoteLayout',
|
QUOTE_LAYOUT: 'quoteLayout',
|
||||||
@@ -117,6 +121,17 @@ export async function getDbPath(): Promise<string | null> {
|
|||||||
return value as string | null
|
return value as string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取api access_token
|
||||||
|
export async function getHttpApiToken(): Promise<string> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.HTTP_API_TOKEN)
|
||||||
|
return (value as string) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置access_token
|
||||||
|
export async function setHttpApiToken(token: string): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.HTTP_API_TOKEN, token)
|
||||||
|
}
|
||||||
|
|
||||||
// 设置数据库路径
|
// 设置数据库路径
|
||||||
export async function setDbPath(path: string): Promise<void> {
|
export async function setDbPath(path: string): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.DB_PATH, path)
|
await config.set(CONFIG_KEYS.DB_PATH, path)
|
||||||
@@ -1472,3 +1487,35 @@ export async function getAnalyticsDenyCount(): Promise<number> {
|
|||||||
export async function setAnalyticsDenyCount(count: number): Promise<void> {
|
export async function setAnalyticsDenyCount(count: number): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.ANALYTICS_DENY_COUNT, count)
|
await config.set(CONFIG_KEYS.ANALYTICS_DENY_COUNT, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 获取 HTTP API 自动启动状态
|
||||||
|
export async function getHttpApiEnabled(): Promise<boolean> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.HTTP_API_ENABLED)
|
||||||
|
return value === true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 HTTP API 自动启动状态
|
||||||
|
export async function setHttpApiEnabled(enabled: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.HTTP_API_ENABLED, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 HTTP API 端口
|
||||||
|
export async function getHttpApiPort(): Promise<number> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.HTTP_API_PORT)
|
||||||
|
return typeof value === 'number' ? value : 5031
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 HTTP API 端口
|
||||||
|
export async function setHttpApiPort(port: number): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.HTTP_API_PORT, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHttpApiHost(): Promise<string> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.HTTP_API_HOST)
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : '127.0.0.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setHttpApiHost(host: string): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.HTTP_API_HOST, host)
|
||||||
|
}
|
||||||
|
|||||||
24
src/types/electron.d.ts
vendored
24
src/types/electron.d.ts
vendored
@@ -496,6 +496,28 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: {
|
||||||
|
statistics: {
|
||||||
|
totalMessages: number
|
||||||
|
textMessages: number
|
||||||
|
imageMessages: number
|
||||||
|
voiceMessages: number
|
||||||
|
videoMessages: number
|
||||||
|
emojiMessages: number
|
||||||
|
otherMessages: number
|
||||||
|
sentMessages: number
|
||||||
|
receivedMessages: number
|
||||||
|
firstMessageTime: number | null
|
||||||
|
lastMessageTime: number | null
|
||||||
|
activeDays: number
|
||||||
|
messageTypeCounts: Record<number, number>
|
||||||
|
}
|
||||||
|
timeDistribution: Record<number, number>
|
||||||
|
}
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
getGroupMemberMessages: (
|
getGroupMemberMessages: (
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
memberUsername: string,
|
memberUsername: string,
|
||||||
@@ -838,7 +860,7 @@ export interface ElectronAPI {
|
|||||||
getLogs: () => Promise<string[]>
|
getLogs: () => Promise<string[]>
|
||||||
}
|
}
|
||||||
http: {
|
http: {
|
||||||
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
|
start: (port?: number, host?: string) => Promise<{ success: boolean; port?: number; error?: string }>
|
||||||
stop: () => Promise<{ success: boolean }>
|
stop: () => Promise<{ success: boolean }>
|
||||||
status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }>
|
status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user