Compare commits

...

15 Commits
v4.1.6 ... main

Author SHA1 Message Date
cc
cfa335564a Merge pull request #549 from hicccc77/dev
Dev
2026-03-25 20:02:58 +08:00
cc
3d1493b0a6 Merge pull request #548 from Leoluis0705/perf/optimize-group-analytics
Perf/optimize group analytics
2026-03-25 19:51:08 +08:00
Leoluis0705
a46b52e603 fix(analytics): 完善群成员分析失败时的错误边界处理与UI展示 2026-03-25 19:15:12 +08:00
Leoluis0705
3c0683b9f8 perf(core): 为底层提取器引入 isSend 标识智能判断,解决大量本地消息及富文本消息引发的性能退化问题 2026-03-25 18:30:24 +08:00
Leoluis0705
3214c2804e feat(group-analytics): 新增并极致优化群成员详细分析与图表呈现功能 2026-03-25 18:24:05 +08:00
hicccc77
83f50cbaee fix: support configurable bind host for HTTP API and fix Windows sherpa-onnx PATH
- fix(#547): HTTP API server now supports configurable bind host (default 127.0.0.1)
  Docker/N8N users can set host to 0.0.0.0 in settings to allow container access.
  Adds httpApiHost config key, UI input in settings, and passes host through
  IPC chain (preload -> main -> httpService).

- fix(#546): Add Windows PATH injection for sherpa-onnx native module
  buildTranscribeWorkerEnv() now adds the sherpa-onnx-win-x64 directory to
  PATH on Windows, fixing 'Could not find sherpa-onnx-node' errors caused
  by missing DLL search path in forked worker processes.
2026-03-25 15:10:16 +08:00
Forrest
61ef10de9b Merge pull request #545 from JiQingzhe2004/main
更新图标
2026-03-25 02:09:50 +08:00
Forrest
73f36d6b29 更新图标 2026-03-25 01:36:04 +08:00
Forrest
666a1a3296 Merge branch 'hicccc77:main' into main 2026-03-25 00:18:12 +08:00
H3CoF6
acec2e95a2 Merge pull request #540 from H3CoF6/main
Dev
2026-03-24 04:45:13 +08:00
H3CoF6
d26e7e78a1 支持appimage,添加安装脚本,更新文档 2026-03-24 04:33:17 +08:00
H3CoF6
77e5c44673 feat: 保存api服务的配置,实现随weflow静默启动 2026-03-24 04:11:34 +08:00
H3CoF6
619cc84d15 feat: api接口新增access_token校验 2026-03-24 03:55:37 +08:00
H3CoF6
22b85439d3 chore: 向下兼容低版本linux 2026-03-24 03:14:44 +08:00
xuncha
b5a371da87 Merge pull request #349 from hicccc77/dev
Dev
2026-03-13 08:55:32 +03:00
21 changed files with 992 additions and 131 deletions

View File

@@ -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 x64Win10+: ${WINDOWS_URL:-$RELEASE_PAGE} - Windows x64Win10+: ${WINDOWS_URL:-$RELEASE_PAGE}
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE} - Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
- macOSM系列芯片: ${MAC_URL:-$RELEASE_PAGE} - macOSM系列芯片: ${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}

View File

@@ -50,13 +50,15 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|------|----------|--------| |------|----------|--------|
| Windows | Windows10+、x64amd64 | `.exe` | | Windows | Windows10+、x64amd64 | `.exe` |
| macOS | Apple SiliconM 系列arm64 | `.dmg` | | macOS | Apple SiliconM 系列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` 快速安装
## 详细功能清单 ## 详细功能清单
当前版本已支持以下能力: 当前版本已支持以下能力:

View File

@@ -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 方式获取消息
messages = requests.post(
f"{BASE_URL}/api/v1/messages", f"{BASE_URL}/api/v1/messages",
params={"talker": "xxx@chatroom", "limit": 50} 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)
``` ```
--- ---

View File

@@ -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()

View File

@@ -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')
} }

View File

@@ -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
} }
// === 工具方法 === // === 工具方法 ===

View File

@@ -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,

View File

@@ -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,14 +248,68 @@ class HttpService {
} }
} }
async autoStart(): Promise<void> {
const enabled = this.configService.get('httpApiEnabled')
if (enabled) {
const port = Number(this.configService.get('httpApiPort')) || 5031
const host = String(this.configService.get('httpApiHost') || '127.0.0.1').trim() || '127.0.0.1'
try {
await this.start(port, host)
console.log(`[HttpService] Auto-started on port ${port}`)
} catch (err) {
console.error('[HttpService] Auto-start failed:', err)
}
}
}
/** /**
* 处理 HTTP 请求 * 解析 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> { private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
// 设置 CORS 头
res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
res.writeHead(204) res.writeHead(204)
@@ -261,11 +317,25 @@ class HttpService {
return return
} }
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`) const url = new URL(req.url || '/', `http://${this.host}:${this.port}`)
const pathname = url.pathname const pathname = url.pathname
try { 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') { if (pathname === '/health' || pathname === '/api/v1/health') {
this.sendJson(res, { status: 'ok' }) this.sendJson(res, { status: 'ok' })
} else if (pathname === '/api/v1/push/messages') { } else if (pathname === '/api/v1/push/messages') {
@@ -288,7 +358,6 @@ class HttpService {
this.sendError(res, 500, String(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
} }
}) })

View File

@@ -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

View File

@@ -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,

20
preinstall.js Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 570 KiB

View 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.

View File

@@ -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 {
background: transparent;
border-radius: 12px;
padding: 16px;
text-align: center;
.value {
display: block;
font-size: 24px;
font-weight: 600;
color: var(--primary);
margin-bottom: 4px;
} }
.label { .stat-card {
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;
color: var(--primary);
}
.stat-info {
display: flex;
flex-direction: column;
gap: 4px;
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
}
.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;
} }
} }
} }

View File

@@ -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) => (

View File

@@ -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 TokenAPI 将允许无鉴权访问', 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,6 +1927,26 @@ 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>
@@ -1893,7 +1954,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
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) => {
const port = parseInt(e.target.value, 10) || 5031
setHttpApiPort(port)
scheduleConfigSave('httpApiPort', () => configService.setHttpApiPort(port))
}}
disabled={httpApiRunning} disabled={httpApiRunning}
style={{ width: 120 }} style={{ width: 120 }}
min={1024} min={1024}
@@ -1901,6 +1966,36 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
/> />
</div> </div>
<div className="form-group">
<label>Access Token ()</label>
<span className="form-hint">
<code>Authorization: Bearer &lt;token&gt;</code>
<code>?access_token=&lt;token&gt;</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="复制">
@@ -1958,13 +2053,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<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="复制"
@@ -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">

View File

@@ -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)
}

View File

@@ -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 }>
} }