Merge pull request #549 from hicccc77/dev

Dev
This commit is contained in:
cc
2026-03-25 20:02:58 +08:00
committed by GitHub
33 changed files with 11592 additions and 462 deletions

View File

@@ -119,7 +119,7 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
npx electron-builder --publish always npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
release-windows-arm64: release-windows-arm64:
runs-on: windows-latest runs-on: windows-latest
@@ -155,7 +155,7 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
npx electron-builder --win nsis --arm64 --publish always '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}' npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
update-release-notes: update-release-notes:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -184,10 +184,12 @@ jobs:
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""' echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
} }
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')" WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("x64.*\\.exe$"))][0] // ""')"
if [ -z "$WINDOWS_ASSET" ]; then
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')"
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$")"
@@ -201,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")"
@@ -216,10 +217,15 @@ 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}
## macOS 安装提示(未知来源)
- 若打开时提示“来自未知开发者”或“无法验证开发者”,请到「系统设置 -> 隐私与安全性」中允许打开该应用。
- 如果仍被系统拦截,请在终端执行以下命令去除隔离标记:
- `xattr -dr com.apple.quarantine "/Applications/WeFlow.app"`
- 执行后重新打开 WeFlow。
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE > 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
EOF EOF

1
.gitignore vendored
View File

@@ -70,3 +70,4 @@ resources/wx_send
概述.md 概述.md
pnpm-lock.yaml pnpm-lock.yaml
/pnpm-workspace.yaml /pnpm-workspace.yaml
wechat-research-site

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 方式获取消息
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)
``` ```
--- ---

View File

@@ -36,6 +36,10 @@ import { messagePushService } from './services/messagePushService'
autoUpdater.autoDownload = false autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true autoUpdater.autoInstallOnAppQuit = true
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载 autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
// Windows x64 与 arm64 使用不同更新通道,避免 latest.yml 互相覆盖导致下错架构安装包。
if (process.platform === 'win32' && process.arch === 'arm64') {
autoUpdater.channel = 'latest-arm64'
}
const AUTO_UPDATE_ENABLED = const AUTO_UPDATE_ENABLED =
process.env.AUTO_UPDATE_ENABLED === 'true' || process.env.AUTO_UPDATE_ENABLED === 'true' ||
process.env.AUTO_UPDATE_ENABLED === '1' || process.env.AUTO_UPDATE_ENABLED === '1' ||
@@ -142,33 +146,47 @@ const normalizeReleaseNotes = (rawReleaseNotes: unknown): string => {
if (!merged.trim()) return '' if (!merged.trim()) return ''
const shouldStripReleaseSection = (headingRaw: string): boolean => {
const heading = headingRaw.trim().toLowerCase()
if (heading === '下载' || heading === 'download') return true
const compactHeading = heading.replace(/\s+/g, '')
if (compactHeading.startsWith('macos安装提示')) return true
if (compactHeading.startsWith('mac安装提示')) return true
return false
}
// 兼容 electron-updater 直接返回 HTML 的场景 // 兼容 electron-updater 直接返回 HTML 的场景
const removeDownloadSectionFromHtml = (input: string): string => { const removeDownloadSectionFromHtml = (input: string): string => {
return input.replace( return input
/<h[1-6][^>]*>\s*(?:下载|download)\s*<\/h[1-6]>\s*[\s\S]*?(?=<h[1-6]\b|$)/gi, .replace(
'' /<h[1-6][^>]*>\s*(?:下载|download)\s*<\/h[1-6]>\s*[\s\S]*?(?=<h[1-6]\b|$)/gi,
) ''
)
.replace(
/<h[1-6][^>]*>\s*(?:mac\s*os|mac)\s*安装提示(?:\s*[(]\s*未知来源\s*[)])?\s*<\/h[1-6]>\s*[\s\S]*?(?=<h[1-6]\b|$)/gi,
''
)
} }
// 兼容 Markdown 场景Action 最终 release note 模板) // 兼容 Markdown 场景Action 最终 release note 模板)
const removeDownloadSectionFromMarkdown = (input: string): string => { const removeDownloadSectionFromMarkdown = (input: string): string => {
const lines = input.split(/\r?\n/) const lines = input.split(/\r?\n/)
const output: string[] = [] const output: string[] = []
let skipDownloadSection = false let skipSection = false
for (const line of lines) { for (const line of lines) {
const headingMatch = line.match(/^\s*#{1,6}\s*(.+?)\s*$/) const headingMatch = line.match(/^\s*#{1,6}\s*(.+?)\s*$/)
if (headingMatch) { if (headingMatch) {
const heading = headingMatch[1].trim().toLowerCase() if (shouldStripReleaseSection(headingMatch[1])) {
if (heading === '下载' || heading === 'download') { skipSection = true
skipDownloadSection = true
continue continue
} }
if (skipDownloadSection) { if (skipSection) {
skipDownloadSection = false skipSection = false
} }
} }
if (!skipDownloadSection) { if (!skipSection) {
output.push(line) output.push(line)
} }
} }
@@ -1527,8 +1545,8 @@ function registerIpcHandlers() {
return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername) return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername)
}) })
ipcMain.handle('chat:getContacts', async () => { ipcMain.handle('chat:getContacts', async (_, options?: { lite?: boolean }) => {
return await chatService.getContacts() return await chatService.getContacts(options)
}) })
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => { ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
@@ -2139,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 (
@@ -2587,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 () => {
@@ -2807,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

@@ -225,7 +225,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('chat:voiceTranscriptPartial', listener) ipcRenderer.on('chat:voiceTranscriptPartial', listener)
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener) return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
}, },
getContacts: () => ipcRenderer.invoke('chat:getContacts'), getContacts: (options?: { lite?: boolean }) => ipcRenderer.invoke('chat:getContacts', options),
getMessage: (sessionId: string, localId: number) => getMessage: (sessionId: string, localId: number) =>
ipcRenderer.invoke('chat:getMessage', sessionId, localId), ipcRenderer.invoke('chat:getMessage', sessionId, localId),
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
@@ -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

@@ -16,6 +16,7 @@ import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from '
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService' import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
import { voiceTranscribeService } from './voiceTranscribeService' import { voiceTranscribeService } from './voiceTranscribeService'
import { ImageDecryptService } from './imageDecryptService' import { ImageDecryptService } from './imageDecryptService'
import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData'
import { LRUCache } from '../utils/LRUCache.js' import { LRUCache } from '../utils/LRUCache.js'
export interface ChatSession { export interface ChatSession {
@@ -153,10 +154,17 @@ export interface ContactInfo {
remark?: string remark?: string
nickname?: string nickname?: string
alias?: string alias?: string
labels?: string[]
detailDescription?: string
region?: string
avatarUrl?: string avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
} }
interface GetContactsOptions {
lite?: boolean
}
interface ExportSessionStats { interface ExportSessionStats {
totalMessages: number totalMessages: number
voiceMessages: number voiceMessages: number
@@ -293,6 +301,21 @@ class ChatService {
private groupMyMessageCountCacheScope = '' private groupMyMessageCountCacheScope = ''
private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>() private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>()
private initFailureDialogShown = false private initFailureDialogShown = false
private readonly contactExtendedFieldCandidates = [
'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList',
'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription', 'signature', 'sign',
'country', 'province', 'city', 'region',
'profile', 'introduction', 'phone', 'mobile', 'telephone', 'tel', 'vcard', 'card_info', 'cardInfo',
'extra_buffer', 'extraBuffer'
]
private readonly contactExtendedFieldCandidateSet = new Set(this.contactExtendedFieldCandidates.map((name) => name.toLowerCase()))
private contactExtendedSelectableColumns: string[] | null = null
private contactLabelNameMapCache: Map<number, string> | null = null
private contactLabelNameMapCacheAt = 0
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN')
private readonly slowGetContactsLogThresholdMs = 1200
constructor() { constructor() {
this.configService = new ConfigService() this.configService = new ConfigService()
@@ -1267,25 +1290,61 @@ class ChatService {
/** /**
* 获取通讯录列表 * 获取通讯录列表
*/ */
async getContacts(): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> { async getContacts(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
const mode: 'lite' | 'full' = options?.lite ? 'lite' : 'full'
const inFlight = this.contactsLoadInFlight
if (inFlight && (inFlight.mode === mode || (mode === 'lite' && inFlight.mode === 'full'))) {
return await inFlight.promise
}
const promise = this.getContactsInternal(options)
this.contactsLoadInFlight = { mode, promise }
try { try {
return await promise
} finally {
if (this.contactsLoadInFlight?.promise === promise) {
this.contactsLoadInFlight = null
}
}
}
private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
const isLiteMode = options?.lite === true
const startedAt = Date.now()
const stageDurations: Array<{ stage: string; ms: number }> = []
const captureStage = (stage: string, stageStartedAt: number) => {
stageDurations.push({ stage, ms: Date.now() - stageStartedAt })
}
try {
const connectStartedAt = Date.now()
const connectResult = await this.ensureConnected() const connectResult = await this.ensureConnected()
captureStage('ensureConnected', connectStartedAt)
if (!connectResult.success) { if (!connectResult.success) {
return { success: false, error: connectResult.error } return { success: false, error: connectResult.error }
} }
const contactsCompactStartedAt = Date.now()
const contactResult = await wcdbService.getContactsCompact() const contactResult = await wcdbService.getContactsCompact()
captureStage('getContactsCompact', contactsCompactStartedAt)
if (!contactResult.success || !contactResult.contacts) { if (!contactResult.success || !contactResult.contacts) {
console.error('查询联系人失败:', contactResult.error) console.error('查询联系人失败:', contactResult.error)
return { success: false, error: contactResult.error || '查询联系人失败' } return { success: false, error: contactResult.error || '查询联系人失败' }
} }
let rows = contactResult.contacts as Record<string, any>[]
if (!isLiteMode) {
const hydrateStartedAt = Date.now()
rows = await this.hydrateContactsWithExtendedFields(rows)
captureStage('hydrateContactsWithExtendedFields', hydrateStartedAt)
}
const rows = contactResult.contacts as Record<string, any>[]
// 获取会话表的最后联系时间用于排序 // 获取会话表的最后联系时间用于排序
const sessionsStartedAt = Date.now()
const lastContactTimeMap = new Map<string, number>() const lastContactTimeMap = new Map<string, number>()
const sessionResult = await wcdbService.getSessions() const sessionResult = await wcdbService.getSessions()
captureStage('getSessions', sessionsStartedAt)
if (sessionResult.success && sessionResult.sessions) { if (sessionResult.success && sessionResult.sessions) {
for (const session of sessionResult.sessions as any[]) { for (const session of sessionResult.sessions as any[]) {
const username = session.username || session.user_name || session.userName || '' const username = session.username || session.user_name || session.userName || ''
@@ -1297,9 +1356,14 @@ class ChatService {
} }
// 转换为ContactInfo // 转换为ContactInfo
const transformStartedAt = Date.now()
const contacts: (ContactInfo & { lastContactTime: number })[] = [] const contacts: (ContactInfo & { lastContactTime: number })[] = []
const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) let contactLabelNameMap = new Map<number, string>()
if (!isLiteMode) {
const labelMapStartedAt = Date.now()
contactLabelNameMap = await this.getContactLabelNameMap()
captureStage('getContactLabelNameMap', labelMapStartedAt)
}
for (const row of rows) { for (const row of rows) {
const username = String(row.username || '').trim() const username = String(row.username || '').trim()
@@ -1313,7 +1377,7 @@ class ChatService {
type = 'group' type = 'group'
} else if (username.startsWith('gh_')) { } else if (username.startsWith('gh_')) {
type = 'official' type = 'official'
} else if (localType === 1 && !excludeNames.has(username)) { } else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) {
type = 'friend' type = 'friend'
} else if (localType === 0 && quanPin) { } else if (localType === 0 && quanPin) {
type = 'former_friend' type = 'former_friend'
@@ -1322,6 +1386,9 @@ class ChatService {
} }
const displayName = row.remark || row.nick_name || row.alias || username const displayName = row.remark || row.nick_name || row.alias || username
const labels = isLiteMode ? [] : this.parseContactLabels(row, contactLabelNameMap)
const detailDescription = isLiteMode ? '' : this.getContactSignature(row)
const region = isLiteMode ? '' : this.getContactRegion(row)
contacts.push({ contacts.push({
username, username,
@@ -1329,16 +1396,19 @@ class ChatService {
remark: row.remark || undefined, remark: row.remark || undefined,
nickname: row.nick_name || undefined, nickname: row.nick_name || undefined,
alias: row.alias || undefined, alias: row.alias || undefined,
labels: labels.length > 0 ? labels : undefined,
detailDescription: detailDescription || undefined,
region: region || undefined,
avatarUrl: undefined, avatarUrl: undefined,
type, type,
lastContactTime: lastContactTimeMap.get(username) || 0 lastContactTime: lastContactTimeMap.get(username) || 0
}) })
} }
captureStage('transformContacts', transformStartedAt)
// 按最近联系时间排序 // 按最近联系时间排序
const sortStartedAt = Date.now()
contacts.sort((a, b) => { contacts.sort((a, b) => {
const timeA = a.lastContactTime || 0 const timeA = a.lastContactTime || 0
const timeB = b.lastContactTime || 0 const timeB = b.lastContactTime || 0
@@ -1347,13 +1417,22 @@ class ChatService {
} }
if (timeA && !timeB) return -1 if (timeA && !timeB) return -1
if (!timeA && timeB) return 1 if (!timeA && timeB) return 1
return a.displayName.localeCompare(b.displayName, 'zh-CN') return this.contactDisplayNameCollator.compare(a.displayName, b.displayName)
}) })
captureStage('sortContacts', sortStartedAt)
// 移除临时的lastContactTime字段 // 移除临时的lastContactTime字段
const finalizeStartedAt = Date.now()
const result = contacts.map(({ lastContactTime, ...rest }) => rest) const result = contacts.map(({ lastContactTime, ...rest }) => rest)
captureStage('finalizeResult', finalizeStartedAt)
const totalMs = Date.now() - startedAt
if (totalMs >= this.slowGetContactsLogThresholdMs) {
const stageSummary = stageDurations
.map((item) => `${item.stage}=${item.ms}ms`)
.join(', ')
console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`)
}
return { success: true, contacts: result } return { success: true, contacts: result }
} catch (e) { } catch (e) {
console.error('ChatService: 获取通讯录失败:', e) console.error('ChatService: 获取通讯录失败:', e)
@@ -1880,6 +1959,568 @@ class ChatService {
return Number.isFinite(parsed) ? parsed : fallback return Number.isFinite(parsed) ? parsed : fallback
} }
private hasAnyContactExtendedFieldKey(row: Record<string, any>): boolean {
for (const key of Object.keys(row || {})) {
if (this.contactExtendedFieldCandidateSet.has(String(key || '').toLowerCase())) {
return true
}
}
return false
}
private async hydrateContactsWithExtendedFields(rows: Record<string, any>[]): Promise<Record<string, any>[]> {
if (!Array.isArray(rows) || rows.length === 0) return rows
const hasAnyExtendedFieldKey = rows.some((row) => this.hasAnyContactExtendedFieldKey(row || {}))
if (hasAnyExtendedFieldKey) {
// wcdb_get_contacts_compact 可能只给“部分联系人”返回 extra_buffer。
// 只有在每一行都能拿到可解析的 extra_buffer 时才跳过补偿查询。
const allRowsHaveUsableExtraBuffer = rows.every((row) => this.toExtraBufferBytes(row || {}) !== null)
if (allRowsHaveUsableExtraBuffer) return rows
}
try {
let selectableColumns = this.contactExtendedSelectableColumns
if (!selectableColumns) {
const tableInfoResult = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact)')
if (!tableInfoResult.success || !Array.isArray(tableInfoResult.rows)) {
return rows
}
const availableColumns = new Map<string, string>()
for (const tableInfoRow of tableInfoResult.rows as Record<string, any>[]) {
const rawName = tableInfoRow.name ?? tableInfoRow.column_name ?? tableInfoRow.columnName
const name = String(rawName || '').trim()
if (!name) continue
availableColumns.set(name.toLowerCase(), name)
}
const resolvedColumns: string[] = []
const seenColumns = new Set<string>()
for (const candidate of this.contactExtendedFieldCandidates) {
const actual = availableColumns.get(candidate.toLowerCase())
if (!actual) continue
const normalized = actual.toLowerCase()
if (seenColumns.has(normalized)) continue
seenColumns.add(normalized)
resolvedColumns.push(actual)
}
this.contactExtendedSelectableColumns = resolvedColumns
selectableColumns = resolvedColumns
}
if (!selectableColumns || selectableColumns.length === 0) return rows
const selectColumns = ['username', ...selectableColumns]
const sql = `SELECT ${selectColumns.map((column) => this.quoteSqlIdentifier(column)).join(', ')} FROM contact WHERE username IS NOT NULL AND username != ''`
const extendedResult = await wcdbService.execQuery('contact', null, sql)
if (!extendedResult.success || !Array.isArray(extendedResult.rows) || extendedResult.rows.length === 0) {
return rows
}
const extendedByUsername = new Map<string, Record<string, any>>()
for (const extendedRow of extendedResult.rows as Record<string, any>[]) {
const username = String(extendedRow.username || '').trim()
if (!username) continue
extendedByUsername.set(username, extendedRow)
}
if (extendedByUsername.size === 0) return rows
return rows.map((row) => {
const username = String(row.username || row.user_name || row.userName || '').trim()
if (!username) return row
const extended = extendedByUsername.get(username)
if (!extended) return row
return {
...extended,
...row
}
})
} catch (error) {
console.warn('联系人扩展字段补偿查询失败:', error)
return rows
}
}
private async getContactLabelNameMap(): Promise<Map<number, string>> {
const now = Date.now()
if (this.contactLabelNameMapCache && now - this.contactLabelNameMapCacheAt <= this.contactLabelNameMapCacheTtlMs) {
return new Map(this.contactLabelNameMapCache)
}
const labelMap = new Map<number, string>()
try {
const tableInfoResult = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact_label)')
if (!tableInfoResult.success || !Array.isArray(tableInfoResult.rows) || tableInfoResult.rows.length === 0) {
this.contactLabelNameMapCache = labelMap
this.contactLabelNameMapCacheAt = now
return labelMap
}
const availableColumns = new Map<string, string>()
for (const tableInfoRow of tableInfoResult.rows as Record<string, any>[]) {
const rawName = tableInfoRow.name ?? tableInfoRow.column_name ?? tableInfoRow.columnName
const name = String(rawName || '').trim()
if (!name) continue
availableColumns.set(name.toLowerCase(), name)
}
const pickColumn = (candidates: string[]): string | null => {
for (const candidate of candidates) {
const actual = availableColumns.get(candidate.toLowerCase())
if (actual) return actual
}
return null
}
const idColumn = pickColumn(['label_id_', 'label_id', 'labelId', 'labelid', 'id'])
const nameColumn = pickColumn(['label_name_', 'label_name', 'labelName', 'labelname', 'name'])
if (!idColumn || !nameColumn) {
this.contactLabelNameMapCache = labelMap
this.contactLabelNameMapCacheAt = now
return labelMap
}
const sql = `SELECT ${this.quoteSqlIdentifier(idColumn)} AS label_id, ${this.quoteSqlIdentifier(nameColumn)} AS label_name FROM contact_label`
const result = await wcdbService.execQuery('contact', null, sql)
if (result.success && Array.isArray(result.rows)) {
for (const row of result.rows as Record<string, any>[]) {
const id = Number(String(row.label_id ?? row.labelId ?? '').trim())
const name = String(row.label_name ?? row.labelName ?? '').trim()
if (Number.isFinite(id) && id > 0 && name) {
labelMap.set(Math.floor(id), name)
}
}
}
} catch (error) {
console.warn('读取 contact_label 失败:', error)
}
this.contactLabelNameMapCache = labelMap
this.contactLabelNameMapCacheAt = now
return new Map(labelMap)
}
private toExtraBufferBytes(row: Record<string, any>): Buffer | null {
const raw = this.getRowField(row, ['extra_buffer', 'extraBuffer'])
if (raw === undefined || raw === null) return null
if (Buffer.isBuffer(raw)) return raw.length > 0 ? raw : null
if (raw instanceof Uint8Array) return raw.length > 0 ? Buffer.from(raw) : null
if (Array.isArray(raw)) {
const bytes = Buffer.from(raw)
return bytes.length > 0 ? bytes : null
}
const text = String(raw || '').trim()
if (!text) return null
const compact = text.replace(/\s+/g, '')
if (compact.length >= 2 && compact.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(compact)) {
try {
const bytes = Buffer.from(compact, 'hex')
return bytes.length > 0 ? bytes : null
} catch {
return null
}
}
return null
}
private readProtoVarint(buffer: Buffer, offset: number): { value: number; nextOffset: number } | null {
if (!buffer || offset < 0 || offset >= buffer.length) return null
let value = 0
let shift = 0
let index = offset
while (index < buffer.length) {
const byte = buffer[index]
index += 1
value += (byte & 0x7f) * Math.pow(2, shift)
if ((byte & 0x80) === 0) {
return { value, nextOffset: index }
}
shift += 7
if (shift > 56) return null
}
return null
}
private extractExtraBufferTopLevelFieldStrings(row: Record<string, any>, targetField: number): string[] {
const bytes = this.toExtraBufferBytes(row)
if (!bytes || !Number.isFinite(targetField) || targetField <= 0) return []
const values: string[] = []
let offset = 0
while (offset < bytes.length) {
const tagResult = this.readProtoVarint(bytes, offset)
if (!tagResult) break
offset = tagResult.nextOffset
const fieldNumber = Math.floor(tagResult.value / 8)
const wireType = tagResult.value & 0x07
if (wireType === 0) {
const varint = this.readProtoVarint(bytes, offset)
if (!varint) break
offset = varint.nextOffset
continue
}
if (wireType === 1) {
if (offset + 8 > bytes.length) break
offset += 8
continue
}
if (wireType === 2) {
const lengthResult = this.readProtoVarint(bytes, offset)
if (!lengthResult) break
const payloadLength = Math.floor(lengthResult.value)
offset = lengthResult.nextOffset
if (payloadLength < 0 || offset + payloadLength > bytes.length) break
const payload = bytes.subarray(offset, offset + payloadLength)
offset += payloadLength
if (fieldNumber === targetField) {
const text = payload.toString('utf-8').replace(/\u0000/g, '').trim()
if (text) values.push(text)
}
continue
}
if (wireType === 5) {
if (offset + 4 > bytes.length) break
offset += 4
continue
}
break
}
return values
}
private parseContactLabelsFromExtraBuffer(row: Record<string, any>, labelNameMap?: Map<number, string>): string[] {
const labelNames: string[] = []
const seen = new Set<string>()
const texts = this.extractExtraBufferTopLevelFieldStrings(row, 30)
for (const text of texts) {
const matches = text.match(/\d+/g) || []
for (const match of matches) {
const id = Number(match)
if (!Number.isFinite(id) || id <= 0) continue
const labelName = labelNameMap?.get(Math.floor(id))
if (!labelName) continue
if (seen.has(labelName)) continue
seen.add(labelName)
labelNames.push(labelName)
}
}
return labelNames
}
private parseContactLabels(row: Record<string, any>, labelNameMap?: Map<number, string>): string[] {
const raw = this.getRowField(row, [
'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList'
])
const normalizedFromValue = (value: unknown): string[] => {
if (Array.isArray(value)) {
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
}
const text = String(value || '').trim()
if (!text) return []
return Array.from(new Set(
text
.replace(/[;、|]+/g, ',')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
))
}
const direct = normalizedFromValue(raw)
if (direct.length > 0) return direct
for (const [key, value] of Object.entries(row)) {
const normalizedKey = key.toLowerCase()
if (!normalizedKey.includes('label') && !normalizedKey.includes('tag')) continue
if (normalizedKey.includes('img') || normalizedKey.includes('head')) continue
const fallback = normalizedFromValue(value)
if (fallback.length > 0) return fallback
}
const extraBufferLabels = this.parseContactLabelsFromExtraBuffer(row, labelNameMap)
if (extraBufferLabels.length > 0) return extraBufferLabels
return []
}
private getContactSignature(row: Record<string, any>): string {
const normalize = (raw: unknown): string => {
const text = String(raw || '').replace(/\u0000/g, '').trim()
if (!text) return ''
const lower = text.toLowerCase()
if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') {
return ''
}
return text
}
const value = this.getRowField(row, [
'signature', 'sign', 'personal_signature', 'personalSignature', 'profile', 'introduction',
'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription'
])
const direct = normalize(value)
if (direct) return direct
for (const [key, rawValue] of Object.entries(row)) {
const normalizedKey = key.toLowerCase()
const isCandidate =
normalizedKey.includes('sign') ||
normalizedKey.includes('signature') ||
normalizedKey.includes('profile') ||
normalizedKey.includes('intro') ||
normalizedKey.includes('description') ||
normalizedKey.includes('detail') ||
normalizedKey.includes('desc')
if (!isCandidate) continue
if (
normalizedKey.includes('avatar') ||
normalizedKey.includes('img') ||
normalizedKey.includes('head') ||
normalizedKey.includes('label') ||
normalizedKey.includes('tag')
) continue
const text = normalize(rawValue)
if (text) return text
}
// contact.extra_buffer field 4: 个性签名兜底
const signatures = this.extractExtraBufferTopLevelFieldStrings(row, 4)
for (const signature of signatures) {
const text = normalize(signature)
if (!text) continue
return text
}
return ''
}
private normalizeContactRegionPart(raw: unknown): string {
const text = String(raw || '').replace(/\u0000/g, '').trim()
if (!text) return ''
const lower = text.toLowerCase()
if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') {
return ''
}
return text
}
private normalizeRegionLookupKey(raw: string): string {
return String(raw || '')
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '')
}
private buildRegionLookupCandidates(raw: string): string[] {
const normalized = this.normalizeRegionLookupKey(raw)
if (!normalized) return []
const candidates = new Set<string>([normalized])
const withoutTrailingDigits = normalized.replace(/\d+$/g, '')
if (withoutTrailingDigits) candidates.add(withoutTrailingDigits)
return Array.from(candidates)
}
private normalizeChineseProvinceName(raw: string): string {
const text = String(raw || '').trim()
if (!text) return ''
return text
.replace(/特别行政区$/g, '')
.replace(/维吾尔自治区$/g, '')
.replace(/壮族自治区$/g, '')
.replace(/回族自治区$/g, '')
.replace(/自治区$/g, '')
.replace(/省$/g, '')
.replace(/市$/g, '')
.trim()
}
private normalizeChineseCityName(raw: string): string {
const text = String(raw || '').trim()
if (!text) return ''
return text
.replace(/特别行政区$/g, '')
.replace(/自治州$/g, '')
.replace(/地区$/g, '')
.replace(/盟$/g, '')
.replace(/林区$/g, '')
.replace(/市$/g, '')
.trim()
}
private resolveProvinceLookupKey(raw: string): string {
const candidates = this.buildRegionLookupCandidates(raw)
if (candidates.length === 0) return ''
for (const candidate of candidates) {
const byName = CONTACT_REGION_LOOKUP_DATA.provinceKeyByName[candidate]
if (byName) return byName
if (CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[candidate]) return candidate
}
return candidates[0]
}
private toChineseCountryName(raw: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const candidates = this.buildRegionLookupCandidates(text)
for (const candidate of candidates) {
const mapped = CONTACT_REGION_LOOKUP_DATA.countryNameByKey[candidate]
if (mapped) return mapped
}
return text
}
private toChineseProvinceName(raw: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const candidates = this.buildRegionLookupCandidates(text)
if (candidates.length === 0) return text
const provinceKey = this.resolveProvinceLookupKey(text)
const mappedFromCandidates = candidates
.map((candidate) => CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[candidate])
.find(Boolean)
const mapped = CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[provinceKey] || mappedFromCandidates
if (mapped) return mapped
if (/[\u4e00-\u9fa5]/.test(text)) {
return this.normalizeChineseProvinceName(text) || text
}
return text
}
private toChineseCityName(raw: string, provinceRaw?: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const candidates = this.buildRegionLookupCandidates(text)
if (candidates.length === 0) return text
const provinceKey = this.resolveProvinceLookupKey(String(provinceRaw || ''))
if (provinceKey) {
const byProvince = CONTACT_REGION_LOOKUP_DATA.cityNameByProvinceKey[provinceKey]
if (byProvince) {
for (const candidate of candidates) {
const mappedInProvince = byProvince[candidate]
if (mappedInProvince) return mappedInProvince
}
}
}
for (const candidate of candidates) {
const mapped = CONTACT_REGION_LOOKUP_DATA.cityNameByKey[candidate]
if (mapped) return mapped
}
if (/[\u4e00-\u9fa5]/.test(text)) {
return this.normalizeChineseCityName(text) || text
}
return text
}
private toChineseRegionText(raw: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const tokens = text
.split(/[\s,,、/|·]+/)
.map((item) => this.normalizeContactRegionPart(item))
.filter(Boolean)
if (tokens.length === 0) return text
let provinceContext = ''
const mapped = tokens.map((token) => {
const country = this.toChineseCountryName(token)
if (country !== token) return country
const province = this.toChineseProvinceName(token)
if (province !== token) {
provinceContext = province
return province
}
const city = this.toChineseCityName(token, provinceContext)
if (city !== token) return city
return token
})
return mapped.join(' ').trim()
}
private shouldHideCountryInRegion(country: string, hasProvinceOrCity: boolean): boolean {
if (!country) return true
const normalized = country.toLowerCase()
if (normalized === 'cn' || normalized === 'chn' || normalized === 'china' || normalized === '中国') {
return hasProvinceOrCity
}
return false
}
private getContactRegion(row: Record<string, any>): string {
const pickByTokens = (tokens: string[]): string => {
for (const [key, value] of Object.entries(row || {})) {
const normalizedKey = String(key || '').toLowerCase()
if (!normalizedKey) continue
if (normalizedKey.includes('avatar') || normalizedKey.includes('img') || normalizedKey.includes('head')) continue
if (!tokens.some((token) => normalizedKey.includes(token))) continue
const text = this.normalizeContactRegionPart(value)
if (text) return text
}
return ''
}
const directCountry = this.normalizeContactRegionPart(this.getRowField(row, ['country', 'Country'])) || pickByTokens(['country'])
const directProvince = this.normalizeContactRegionPart(this.getRowField(row, ['province', 'Province'])) || pickByTokens(['province'])
const directCity = this.normalizeContactRegionPart(this.getRowField(row, ['city', 'City'])) || pickByTokens(['city'])
const directRegion =
this.normalizeContactRegionPart(this.getRowField(row, ['region', 'Region', 'location', 'area'])) ||
pickByTokens(['region', 'location', 'area', 'addr', 'address'])
if (directRegion) {
const normalizedRegion = this.toChineseRegionText(directRegion)
const parts = normalizedRegion
.split(/\s+/)
.map((item) => this.normalizeContactRegionPart(item))
.filter(Boolean)
if (parts.length > 1 && this.shouldHideCountryInRegion(parts[0], true)) {
return parts.slice(1).join(' ').trim()
}
return normalizedRegion
}
const fallbackCountry = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 5)[0] || '')
const fallbackProvince = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 6)[0] || '')
const fallbackCity = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 7)[0] || '')
const country = this.toChineseCountryName(directCountry || fallbackCountry)
const province = this.toChineseProvinceName(directProvince || fallbackProvince)
const city = this.toChineseCityName(directCity || fallbackCity, directProvince || fallbackProvince)
const hasProvinceOrCity = Boolean(province || city)
const parts: string[] = []
if (!this.shouldHideCountryInRegion(country, hasProvinceOrCity)) {
parts.push(country)
}
if (province) {
parts.push(province)
}
if (city && city !== province) {
parts.push(city)
}
return parts.join(' ').trim()
}
private normalizeUnsignedIntegerToken(raw: any): string | undefined { private normalizeUnsignedIntegerToken(raw: any): string | undefined {
if (raw === undefined || raw === null || raw === '') return undefined if (raw === undefined || raw === null || raw === '') return undefined
@@ -5096,14 +5737,35 @@ class ChatService {
} }
// 如果是群聊,尝试获取群昵称 // 如果是群聊,尝试获取群昵称
let groupNicknames: Record<string, string> = {} const groupNicknames = new Map<string, string>()
if (chatroomId.endsWith('@chatroom')) { if (chatroomId.endsWith('@chatroom')) {
const nickResult = await wcdbService.getGroupNicknames(chatroomId) const nickResult = await wcdbService.getGroupNicknames(chatroomId)
if (nickResult.success && nickResult.nicknames) { if (nickResult.success && nickResult.nicknames) {
groupNicknames = nickResult.nicknames const nicknameBuckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nickResult.nicknames)) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
const slot = nicknameBuckets.get(memberId)
if (slot) {
slot.add(nickname)
} else {
nicknameBuckets.set(memberId, new Set([nickname]))
}
}
for (const [memberId, nicknameSet] of nicknameBuckets.entries()) {
if (nicknameSet.size !== 1) continue
groupNicknames.set(memberId, Array.from(nicknameSet)[0])
}
} }
} }
const lookupGroupNickname = (username?: string | null): string => {
const key = String(username || '').trim().toLowerCase()
if (!key) return ''
return groupNicknames.get(key) || ''
}
// 获取当前用户 wxid用于识别"自己" // 获取当前用户 wxid用于识别"自己"
const myWxid = this.configService.get('myWxid') const myWxid = this.configService.get('myWxid')
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : '' const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
@@ -5113,7 +5775,7 @@ class ChatService {
// 特判如果是当前用户自己contact 表通常不包含自己) // 特判如果是当前用户自己contact 表通常不包含自己)
if (myWxid && (username === myWxid || username === cleanedMyWxid)) { if (myWxid && (username === myWxid || username === cleanedMyWxid)) {
// 先查群昵称中是否有自己 // 先查群昵称中是否有自己
const myGroupNick = groupNicknames[username] const myGroupNick = lookupGroupNickname(username) || lookupGroupNickname(myWxid)
if (myGroupNick) return myGroupNick if (myGroupNick) return myGroupNick
// 尝试从缓存获取自己的昵称 // 尝试从缓存获取自己的昵称
const cached = this.avatarCache.get(username) || this.avatarCache.get(myWxid) const cached = this.avatarCache.get(username) || this.avatarCache.get(myWxid)
@@ -5122,7 +5784,7 @@ class ChatService {
} }
// 先查群昵称 // 先查群昵称
const groupNick = groupNicknames[username] const groupNick = lookupGroupNickname(username)
if (groupNick) return groupNick if (groupNick) return groupNick
// 再查联系人信息 // 再查联系人信息

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

@@ -93,6 +93,9 @@ class ContactExportService {
displayName: c.displayName, displayName: c.displayName,
remark: c.remark, remark: c.remark,
nickname: c.nickname, nickname: c.nickname,
alias: c.alias,
labels: Array.isArray(c.labels) ? c.labels : [],
detailDescription: c.detailDescription,
type: c.type type: c.type
})) }))
} }
@@ -103,12 +106,15 @@ class ContactExportService {
* 导出为CSV格式 * 导出为CSV格式
*/ */
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> { private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
const headers = ['用户名', '显示名称', '备注', '昵称', '类型'] const headers = ['用户名', '显示名称', '备注', '昵称', '微信号', '标签', '详细描述', '类型']
const rows = contacts.map(c => [ const rows = contacts.map(c => [
c.username || '', c.username || '',
c.displayName || '', c.displayName || '',
c.remark || '', c.remark || '',
c.nickname || '', c.nickname || '',
c.alias || '',
Array.isArray(c.labels) ? c.labels.join(' | ') : '',
c.detailDescription || '',
this.getTypeLabel(c.type) this.getTypeLabel(c.type)
]) ])
@@ -137,9 +143,13 @@ class ContactExportService {
lines.push(`NICKNAME:${c.nickname}`) lines.push(`NICKNAME:${c.nickname}`)
} }
// 备注 const noteParts = [
if (c.remark) { c.remark ? String(c.remark) : '',
lines.push(`NOTE:${c.remark}`) Array.isArray(c.labels) && c.labels.length > 0 ? `标签: ${c.labels.join(', ')}` : '',
c.detailDescription ? `详细描述: ${c.detailDescription}` : ''
].filter(Boolean)
if (noteParts.length > 0) {
lines.push(`NOTE:${noteParts.join('\\n')}`)
} }
// 微信ID // 微信ID

File diff suppressed because it is too large Load Diff

View File

@@ -1404,33 +1404,60 @@ class ExportService {
} }
/** /**
* 获取群成员群昵称。优先使用 DLL必要时回退到 `contact.chat_room.ext_buffer` 解析 * 获取群成员群昵称。后端结果为唯一业务真值,前端仅做冲突净化防串号
*/ */
async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> { async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
const nicknameMap = new Map<string, string>()
try { try {
const dllResult = await wcdbService.getGroupNicknames(chatroomId) const dllResult = await wcdbService.getGroupNicknames(chatroomId)
if (dllResult.success && dllResult.nicknames) { if (!dllResult.success || !dllResult.nicknames) {
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames)) return new Map<string, string>()
} }
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
} catch (e) { } catch (e) {
console.error('getGroupNicknamesForRoom dll error:', e) console.error('getGroupNicknamesForRoom dll error:', e)
return new Map<string, string>()
}
}
private normalizeGroupNicknameIdentity(value: string): string {
const raw = String(value || '').trim()
if (!raw) return ''
return raw.toLowerCase()
}
private buildTrustedGroupNicknameMap(
entries: Iterable<[string, string]>,
candidates: string[] = []
): Map<string, string> {
const candidateSet = new Set(
this.buildGroupNicknameIdCandidates(candidates)
.map((id) => this.normalizeGroupNicknameIdentity(id))
.filter(Boolean)
)
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of entries) {
const identity = this.normalizeGroupNicknameIdentity(memberIdRaw || '')
if (!identity) continue
if (candidateSet.size > 0 && !candidateSet.has(identity)) continue
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
if (!nickname) continue
const slot = buckets.get(identity)
if (slot) {
slot.add(nickname)
} else {
buckets.set(identity, new Set([nickname]))
}
} }
try { const trusted = new Map<string, string>()
const result = await wcdbService.getChatRoomExtBuffer(chatroomId) for (const [identity, nicknameSet] of buckets.entries()) {
if (!result.success || !result.extBuffer) { if (nicknameSet.size !== 1) continue
return nicknameMap trusted.set(identity, Array.from(nicknameSet)[0])
}
const extBuffer = this.decodeExtBuffer(result.extBuffer)
if (!extBuffer) return nicknameMap
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
return nicknameMap
} catch (e) {
console.error('getGroupNicknamesForRoom error:', e)
return nicknameMap
} }
return trusted
} }
private mergeGroupNicknameEntries( private mergeGroupNicknameEntries(
@@ -1680,8 +1707,6 @@ class ExportService {
const raw = String(rawValue || '').trim() const raw = String(rawValue || '').trim()
if (!raw) continue if (!raw) continue
set.add(raw) set.add(raw)
const cleaned = this.cleanAccountDirName(raw)
if (cleaned && cleaned !== raw) set.add(cleaned)
} }
return Array.from(set) return Array.from(set)
} }
@@ -1690,29 +1715,20 @@ class ExportService {
const idCandidates = this.buildGroupNicknameIdCandidates(candidates) const idCandidates = this.buildGroupNicknameIdCandidates(candidates)
if (idCandidates.length === 0) return '' if (idCandidates.length === 0) return ''
let resolved = ''
for (const id of idCandidates) { for (const id of idCandidates) {
const exact = this.normalizeGroupNickname(groupNicknamesMap.get(id) || '') const normalizedId = this.normalizeGroupNicknameIdentity(id)
if (exact) return exact if (!normalizedId) continue
const lower = this.normalizeGroupNickname(groupNicknamesMap.get(id.toLowerCase()) || '') const candidateNickname = this.normalizeGroupNickname(groupNicknamesMap.get(normalizedId) || '')
if (lower) return lower if (!candidateNickname) continue
} if (!resolved) {
resolved = candidateNickname
for (const id of idCandidates) { continue
const lower = id.toLowerCase()
let found = ''
let matched = 0
for (const [key, value] of groupNicknamesMap.entries()) {
if (String(key || '').toLowerCase() !== lower) continue
const normalized = this.normalizeGroupNickname(value || '')
if (!normalized) continue
found = normalized
matched += 1
if (matched > 1) return ''
} }
if (matched === 1 && found) return found if (resolved !== candidateNickname) return ''
} }
return '' return resolved
} }
/** /**

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
@@ -257,34 +265,60 @@ class GroupAnalyticsService {
} }
/** /**
* 从 DLL 获取群成员群昵称 * 从后端获取群成员群昵称,并在前端进行唯一性净化防串号。
*/ */
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> { private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
const nicknameMap = new Map<string, string>()
try { try {
const dllResult = await wcdbService.getGroupNicknames(chatroomId) const dllResult = await wcdbService.getGroupNicknames(chatroomId)
if (dllResult.success && dllResult.nicknames) { if (!dllResult.success || !dllResult.nicknames) {
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames)) return new Map<string, string>()
} }
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
} catch (e) { } catch (e) {
console.error('getGroupNicknamesForRoom dll error:', e) console.error('getGroupNicknamesForRoom dll error:', e)
return new Map<string, string>()
} }
}
try { private normalizeGroupNicknameIdentity(value: string): string {
const result = await wcdbService.getChatRoomExtBuffer(chatroomId) const raw = String(value || '').trim()
if (!result.success || !result.extBuffer) { if (!raw) return ''
return nicknameMap return raw.toLowerCase()
}
private buildTrustedGroupNicknameMap(
entries: Iterable<[string, string]>,
candidates: string[] = []
): Map<string, string> {
const candidateSet = new Set(
this.buildGroupNicknameIdCandidates(candidates)
.map((id) => this.normalizeGroupNicknameIdentity(id))
.filter(Boolean)
)
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of entries) {
const identity = this.normalizeGroupNicknameIdentity(memberIdRaw || '')
if (!identity) continue
if (candidateSet.size > 0 && !candidateSet.has(identity)) continue
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
if (!nickname) continue
const slot = buckets.get(identity)
if (slot) {
slot.add(nickname)
} else {
buckets.set(identity, new Set([nickname]))
} }
const extBuffer = this.decodeExtBuffer(result.extBuffer)
if (!extBuffer) return nicknameMap
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
return nicknameMap
} catch (e) {
console.error('getGroupNicknamesForRoom error:', e)
return nicknameMap
} }
const trusted = new Map<string, string>()
for (const [identity, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted.set(identity, Array.from(nicknameSet)[0])
}
return trusted
} }
private mergeGroupNicknameEntries( private mergeGroupNicknameEntries(
@@ -475,6 +509,16 @@ class GroupAnalyticsService {
return Array.from(set) return Array.from(set)
} }
private buildGroupNicknameIdCandidates(values: Array<string | undefined | null>): string[] {
const set = new Set<string>()
for (const rawValue of values) {
const raw = String(rawValue || '').trim()
if (!raw) continue
set.add(raw)
}
return Array.from(set)
}
private toNonNegativeInteger(value: unknown): number { private toNonNegativeInteger(value: unknown): number {
const parsed = Number(value) const parsed = Number(value)
if (!Number.isFinite(parsed)) return 0 if (!Number.isFinite(parsed)) return 0
@@ -663,30 +707,23 @@ class GroupAnalyticsService {
} }
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string { private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
const idCandidates = this.buildIdCandidates(candidates) const idCandidates = this.buildGroupNicknameIdCandidates(candidates)
if (idCandidates.length === 0) return '' if (idCandidates.length === 0) return ''
let resolved = ''
for (const id of idCandidates) { for (const id of idCandidates) {
const exact = this.normalizeGroupNickname(groupNicknames.get(id) || '') const normalizedId = this.normalizeGroupNicknameIdentity(id)
if (exact) return exact if (!normalizedId) continue
} const candidateNickname = this.normalizeGroupNickname(groupNicknames.get(normalizedId) || '')
if (!candidateNickname) continue
for (const id of idCandidates) { if (!resolved) {
const lower = id.toLowerCase() resolved = candidateNickname
let found = '' continue
let matched = 0
for (const [key, value] of groupNicknames.entries()) {
if (String(key || '').toLowerCase() !== lower) continue
const normalized = this.normalizeGroupNickname(value || '')
if (!normalized) continue
found = normalized
matched += 1
if (matched > 1) return ''
} }
if (matched === 1 && found) return found if (resolved !== candidateNickname) return ''
} }
return '' return resolved
} }
private sanitizeWorksheetName(name: string): string { private sanitizeWorksheetName(name: string): string {
@@ -768,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,
@@ -791,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
} }
@@ -852,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
} }
@@ -958,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
} }
@@ -1438,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,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
} }
} }
@@ -1017,13 +1086,31 @@ class HttpService {
} }
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string { private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
if (!sender) return '' const key = String(sender || '').trim().toLowerCase()
const cleaned = this.normalizeAccountId(sender) if (!key) return ''
return groupNicknamesMap.get(sender) return groupNicknamesMap.get(key) || ''
|| groupNicknamesMap.get(sender.toLowerCase()) }
|| groupNicknamesMap.get(cleaned)
|| groupNicknamesMap.get(cleaned.toLowerCase()) private buildTrustedGroupNicknameMap(nicknames: Record<string, string>): Map<string, string> {
|| '' const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
const slot = buckets.get(memberId)
if (slot) {
slot.add(nickname)
} else {
buckets.set(memberId, new Set([nickname]))
}
}
const trusted = new Map<string, string>()
for (const [memberId, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted.set(memberId, Array.from(nicknameSet)[0])
}
return trusted
} }
private resolveChatLabSenderInfo( private resolveChatLabSenderInfo(
@@ -1094,21 +1181,7 @@ class HttpService {
try { try {
const result = await wcdbService.getGroupNicknames(talkerId) const result = await wcdbService.getGroupNicknames(talkerId)
if (result.success && result.nicknames) { if (result.success && result.nicknames) {
groupNicknamesMap = new Map() groupNicknamesMap = this.buildTrustedGroupNicknameMap(result.nicknames)
for (const [memberIdRaw, nicknameRaw] of Object.entries(result.nicknames)) {
const memberId = String(memberIdRaw || '').trim()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
groupNicknamesMap.set(memberId, nickname)
groupNicknamesMap.set(memberId.toLowerCase(), nickname)
const cleaned = this.normalizeAccountId(memberId)
if (cleaned) {
groupNicknamesMap.set(cleaned, nickname)
groupNicknamesMap.set(cleaned.toLowerCase(), nickname)
}
}
} }
} catch (e) { } catch (e) {
console.error('[HttpService] Failed to get group nicknames:', e) console.error('[HttpService] Failed to get group nicknames:', e)
@@ -1161,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
} }
}) })
@@ -1389,4 +1462,3 @@ class HttpService {
} }
export const httpService = new HttpService() export const httpService = new HttpService()

View File

@@ -304,11 +304,8 @@ class MessagePushService {
} }
const groupNicknames = await this.getGroupNicknames(chatroomId) const groupNicknames = await this.getGroupNicknames(chatroomId)
const normalizedSender = this.normalizeAccountId(senderUsername) const senderKey = senderUsername.toLowerCase()
const nickname = groupNicknames[senderUsername] const nickname = groupNicknames[senderKey]
|| groupNicknames[senderUsername.toLowerCase()]
|| groupNicknames[normalizedSender]
|| groupNicknames[normalizedSender.toLowerCase()]
if (nickname) { if (nickname) {
return nickname return nickname
@@ -328,22 +325,33 @@ class MessagePushService {
} }
const result = await wcdbService.getGroupNicknames(cacheKey) const result = await wcdbService.getGroupNicknames(cacheKey)
const nicknames = result.success && result.nicknames ? result.nicknames : {} const nicknames = result.success && result.nicknames
? this.sanitizeGroupNicknames(result.nicknames)
: {}
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() }) this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
return nicknames return nicknames
} }
private normalizeAccountId(value: string): string { private sanitizeGroupNicknames(nicknames: Record<string, string>): Record<string, string> {
const trimmed = String(value || '').trim() const buckets = new Map<string, Set<string>>()
if (!trimmed) return trimmed for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
if (trimmed.toLowerCase().startsWith('wxid_')) { const nickname = String(nicknameRaw || '').trim()
const match = trimmed.match(/^(wxid_[^_]+)/i) if (!memberId || !nickname) continue
return match ? match[1] : trimmed const slot = buckets.get(memberId)
if (slot) {
slot.add(nickname)
} else {
buckets.set(memberId, new Set([nickname]))
}
} }
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const trusted: Record<string, string> = {}
return suffixMatch ? suffixMatch[1] : trimmed for (const [memberId, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted[memberId] = Array.from(nicknameSet)[0]
}
return trusted
} }
private isRecentMessage(messageKey: string): boolean { private isRecentMessage(messageKey: string): boolean {

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

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

Binary file not shown.

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -625,7 +625,7 @@
.bubble-content { .bubble-content {
background: var(--primary-gradient); background: var(--primary-gradient);
color: #fff; color: var(--on-primary);
border-radius: 18px 18px 4px 18px; border-radius: 18px 18px 4px 18px;
padding: 10px 14px; padding: 10px 14px;
font-size: 14px; font-size: 14px;
@@ -1962,7 +1962,7 @@
.bubble-content { .bubble-content {
background: var(--primary); background: var(--primary);
color: white; color: var(--on-primary);
border-radius: 18px 18px 4px 18px; border-radius: 18px 18px 4px 18px;
} }
} }
@@ -2453,15 +2453,15 @@
// 自己发送的消息中的引用样式 // 自己发送的消息中的引用样式
.message-bubble.sent .quoted-message { .message-bubble.sent .quoted-message {
background: rgba(255, 255, 255, 0.15); background: color-mix(in srgb, var(--on-primary) 12%, var(--primary));
border-left-color: rgba(255, 255, 255, 0.5); border-left-color: color-mix(in srgb, var(--on-primary) 36%, var(--primary));
.quoted-sender { .quoted-sender {
color: rgba(255, 255, 255, 0.9); color: color-mix(in srgb, var(--on-primary) 92%, var(--primary));
} }
.quoted-text { .quoted-text {
color: rgba(255, 255, 255, 0.8); color: color-mix(in srgb, var(--on-primary) 80%, var(--primary));
} }
} }

View File

@@ -18,7 +18,7 @@ const AVATAR_ENRICH_BATCH_SIZE = 80
const SEARCH_DEBOUNCE_MS = 120 const SEARCH_DEBOUNCE_MS = 120
const VIRTUAL_ROW_HEIGHT = 76 const VIRTUAL_ROW_HEIGHT = 76
const VIRTUAL_OVERSCAN = 10 const VIRTUAL_OVERSCAN = 10
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
interface ContactsLoadSession { interface ContactsLoadSession {
@@ -397,6 +397,10 @@ function ContactsPage() {
displayName: contact.displayName, displayName: contact.displayName,
remark: contact.remark, remark: contact.remark,
nickname: contact.nickname, nickname: contact.nickname,
alias: contact.alias,
labels: contact.labels,
detailDescription: contact.detailDescription,
region: contact.region,
type: contact.type type: contact.type
})) }))
).catch((error) => { ).catch((error) => {
@@ -1110,6 +1114,16 @@ function ContactsPage() {
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.username}</span></div> <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.username}</span></div>
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div> <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
{selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>} {selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>}
{selectedContact.alias && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.alias}</span></div>}
{selectedContact.labels && selectedContact.labels.length > 0 && (
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.labels.join('、')}</span></div>
)}
{selectedContact.detailDescription && (
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.detailDescription}</span></div>
)}
{selectedContact.region && (
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.region}</span></div>
)}
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div> <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
{selectedContactSupportsSns && ( {selectedContactSupportsSns && (
<div className="detail-row"> <div className="detail-row">

View File

@@ -568,7 +568,7 @@ const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(
const CONTACT_ENRICH_TIMEOUT_MS = 7000 const CONTACT_ENRICH_TIMEOUT_MS = 7000
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80 const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000 const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000
const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000 const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000
const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000 const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000
@@ -1928,7 +1928,7 @@ function ExportPage() {
setIsContactsListLoading(true) setIsContactsListLoading(true)
try { try {
const contactsResult = await window.electronAPI.chat.getContacts() const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
if (contactsLoadVersionRef.current !== loadVersion) return if (contactsLoadVersionRef.current !== loadVersion) return
if (contactsResult.success && contactsResult.contacts) { if (contactsResult.success && contactsResult.contacts) {
@@ -3782,7 +3782,7 @@ function ExportPage() {
if (isStale()) return if (isStale()) return
if (detailStatsPriorityRef.current) return if (detailStatsPriorityRef.current) return
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) const contactsResult = await withTimeout(window.electronAPI.chat.getContacts({ lite: true }), CONTACT_ENRICH_TIMEOUT_MS)
if (isStale()) return if (isStale()) return
const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : []

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 { .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;
} }
} }
} }

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

@@ -1145,9 +1145,13 @@
} }
} }
.quote-layout-group {
margin-top: 14px;
}
.quote-layout-picker { .quote-layout-picker {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 12px; gap: 12px;
margin-top: 10px; margin-top: 10px;
} }
@@ -1155,32 +1159,146 @@
.quote-layout-card { .quote-layout-card {
position: relative; position: relative;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 16px; border-radius: 14px;
padding: 14px; padding: 14px 14px 12px;
background: var(--bg-primary); background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease, background 0.2s ease; transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
&:hover { &:hover {
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color)); border-color: color-mix(in srgb, var(--primary) 32%, var(--border-color));
transform: translateY(-1px);
} }
&.active { &.active {
border-color: var(--primary); border-color: color-mix(in srgb, var(--primary) 68%, var(--border-color));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 14%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 12%, transparent);
background: color-mix(in srgb, var(--bg-primary) 92%, var(--primary) 8%); background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
} }
} }
.quote-layout-card-header { .quote-layout-card-check {
position: absolute;
top: 12px;
left: 12px;
width: 18px;
height: 18px;
border-radius: 50%;
border: 1.5px solid color-mix(in srgb, var(--primary) 46%, var(--border-color));
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s ease;
&::after {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary) 78%, #6f8653);
transform: scale(0);
transition: transform 0.2s ease;
}
&.active {
border-color: color-mix(in srgb, var(--primary) 78%, var(--border-color));
}
&.active::after {
transform: scale(1);
}
}
.quote-layout-preview-shell {
padding-left: 22px;
min-height: 72px;
}
.quote-layout-preview-chat {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; }
gap: 12px;
margin-bottom: 12px; .quote-layout-preview-chat .message-bubble {
display: flex;
gap: 10px;
max-width: 70%;
align-items: flex-start;
}
.quote-layout-preview-chat .message-bubble.sent {
flex-direction: row-reverse;
}
.quote-layout-preview-chat .message-bubble.sent .bubble-content {
background: var(--primary);
color: var(--on-primary);
border-radius: 18px 18px 4px 18px;
}
.quote-layout-preview-chat .bubble-content {
padding: 10px 14px;
font-size: 14px;
line-height: 1.6;
word-break: break-word;
white-space: pre-wrap;
}
.quote-layout-preview-chat .message-text {
font-size: 14px;
line-height: 1.6;
}
.quote-layout-preview-chat .quoted-message {
background: rgba(0, 0, 0, 0.04);
border-left: 2px solid var(--primary);
padding: 6px 10px;
border-radius: 4px;
font-size: 13px;
}
.quote-layout-preview-chat .quoted-sender {
color: var(--primary);
font-weight: 500;
margin-right: 4px;
}
.quote-layout-preview-chat .quoted-sender::after {
content: ':';
}
.quote-layout-preview-chat .quoted-text {
color: var(--text-secondary);
white-space: pre-wrap;
}
.quote-layout-preview-chat .message-bubble.sent .quoted-message {
background: color-mix(in srgb, var(--on-primary) 12%, var(--primary));
border-left-color: color-mix(in srgb, var(--on-primary) 36%, var(--primary));
}
.quote-layout-preview-chat .message-bubble.sent .quoted-sender {
color: color-mix(in srgb, var(--on-primary) 92%, var(--primary));
}
.quote-layout-preview-chat .message-bubble.sent .quoted-text {
color: color-mix(in srgb, var(--on-primary) 80%, var(--primary));
}
.quote-layout-preview-chat .bubble-content.quote-layout-top .quoted-message {
margin-bottom: 8px;
}
.quote-layout-preview-chat .bubble-content.quote-layout-bottom .quoted-message {
margin-top: 8px;
}
.quote-layout-card-footer {
margin-top: 8px;
padding-left: 22px;
} }
.quote-layout-card-title-group { .quote-layout-card-title-group {
@@ -1190,89 +1308,22 @@
} }
.quote-layout-card-title { .quote-layout-card-title {
font-size: 14px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
} }
.quote-layout-card-desc { .quote-layout-card-desc {
font-size: 12px; font-size: 11px;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.quote-layout-card-check { @media (max-width: 760px) {
width: 22px; .quote-layout-picker {
height: 22px; grid-template-columns: 1fr;
border-radius: 50%;
border: 1px solid var(--border-color);
color: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s ease;
&.active {
border-color: var(--primary);
background: var(--primary);
color: #fff;
} }
} }
.quote-layout-preview {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-radius: 12px;
background: color-mix(in srgb, var(--bg-secondary) 92%, var(--bg-primary));
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
min-height: 112px;
&.quote-bottom {
.quote-layout-preview-message {
order: 1;
}
.quote-layout-preview-quote {
order: 2;
}
}
}
.quote-layout-preview-quote {
padding: 8px 10px;
border-left: 2px solid var(--primary);
border-radius: 8px;
background: rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
gap: 3px;
}
.quote-layout-preview-sender {
font-size: 12px;
font-weight: 600;
color: var(--primary);
}
.quote-layout-preview-text {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.quote-layout-preview-message {
align-self: flex-start;
max-width: 88%;
padding: 9px 12px;
border-radius: 12px;
background: color-mix(in srgb, var(--primary) 14%, var(--bg-primary));
color: var(--text-primary);
font-size: 13px;
line-height: 1.45;
}
.theme-grid { .theme-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));

View File

@@ -32,6 +32,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
const isMac = navigator.userAgent.toLowerCase().includes('mac') const isMac = navigator.userAgent.toLowerCase().includes('mac')
const isLinux = navigator.userAgent.toLowerCase().includes('linux') const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const isWindows = !isMac && !isLinux
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录' const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
const dbPathPlaceholder = isMac const dbPathPlaceholder = isMac
@@ -102,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;
@@ -110,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'])
@@ -168,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)
@@ -191,11 +214,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
checkWaylandStatus() checkWaylandStatus()
}, []) }, [])
// 检查 Hello 可用性 // 检查 Hello 可用性
useEffect(() => { useEffect(() => {
if (window.PublicKeyCredential) { setHelloAvailable(isWindows)
void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable)
}
}, []) }, [])
// 检查 HTTP API 服务状态 // 检查 HTTP API 服务状态
@@ -320,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)
@@ -362,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']
@@ -1061,9 +1096,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
))} ))}
</div> </div>
<div className="form-group"> <div className="form-group quote-layout-group">
<label></label> <label></label>
<span className="form-hint"></span> <span className="form-hint"></span>
<div className="quote-layout-picker" role="radiogroup" aria-label="引用样式选择"> <div className="quote-layout-picker" role="radiogroup" aria-label="引用样式选择">
{[ {[
{ {
@@ -1080,15 +1115,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
} }
].map(option => { ].map(option => {
const selected = quoteLayout === option.value const selected = quoteLayout === option.value
const quotePreview = ( const isQuoteBottom = option.value === 'quote-bottom'
<div className="quote-layout-preview-quote">
<span className="quote-layout-preview-sender"></span>
<span className="quote-layout-preview-text"></span>
</div>
)
const messagePreview = (
<div className="quote-layout-preview-message"></div>
)
return ( return (
<button <button
@@ -1104,27 +1131,37 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
role="radio" role="radio"
aria-checked={selected} aria-checked={selected}
> >
<div className="quote-layout-card-header"> <span className={`quote-layout-card-check ${selected ? 'active' : ''}`} aria-hidden="true" />
<div className="quote-layout-preview-shell">
<div className="quote-layout-preview-chat">
<div className="message-bubble sent">
<div className={`bubble-content ${isQuoteBottom ? 'quote-layout-bottom' : 'quote-layout-top'}`}>
{isQuoteBottom ? (
<>
<div className="message-text">!</div>
<div className="quoted-message">
<span className="quoted-sender"></span>
<span className="quoted-text">...</span>
</div>
</>
) : (
<>
<div className="quoted-message">
<span className="quoted-sender"></span>
<span className="quoted-text">...</span>
</div>
<div className="message-text">!</div>
</>
)}
</div>
</div>
</div>
</div>
<div className="quote-layout-card-footer">
<div className="quote-layout-card-title-group"> <div className="quote-layout-card-title-group">
<span className="quote-layout-card-title">{option.label}</span> <span className="quote-layout-card-title">{option.label}</span>
<span className="quote-layout-card-desc">{option.description}</span> <span className="quote-layout-card-desc">{option.description}</span>
</div> </div>
<span className={`quote-layout-card-check ${selected ? 'active' : ''}`}>
<Check size={14} />
</span>
</div>
<div className={`quote-layout-preview ${option.value}`}>
{option.value === 'quote-bottom' ? (
<>
{messagePreview}
{quotePreview}
</>
) : (
<>
{quotePreview}
{messagePreview}
</>
)}
</div> </div>
</button> </button>
) )
@@ -1824,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)
@@ -1837,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)
@@ -1853,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)
} }
@@ -1885,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 &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>
@@ -1908,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="复制">
@@ -1955,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>
@@ -1980,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">
@@ -2037,33 +2133,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage('请输入当前密码以开启 Hello', false) showMessage('请输入当前密码以开启 Hello', false)
return return
} }
if (!isWindows) {
showMessage('当前系统不支持 Windows Hello', false)
return
}
setIsSettingHello(true) setIsSettingHello(true)
try { try {
const challenge = new Uint8Array(32) const verifyResult = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
window.crypto.getRandomValues(challenge) if (!verifyResult.success) {
showMessage(verifyResult.error || 'Windows Hello 验证失败', false)
const credential = await navigator.credentials.create({ return
publicKey: {
challenge,
rp: { name: 'WeFlow', id: 'localhost' },
user: { id: new Uint8Array([1]), name: 'user', displayName: 'User' },
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
authenticatorSelection: { userVerification: 'required' },
timeout: 60000
}
})
if (credential) {
// 存储密码作为 Hello Secret以便 Hello 解锁时能派生密钥
await window.electronAPI.auth.setHelloSecret(helloPassword)
setAuthUseHello(true)
setHelloPassword('')
showMessage('Windows Hello 设置成功', true)
} }
const saveResult = await window.electronAPI.auth.setHelloSecret(helloPassword)
if (!saveResult.success) {
showMessage('Windows Hello 配置保存失败', false)
return
}
setAuthUseHello(true)
setHelloPassword('')
showMessage('Windows Hello 设置成功', true)
} catch (e: any) { } catch (e: any) {
if (e.name !== 'NotAllowedError') { showMessage(`Windows Hello 设置失败: ${e?.message || String(e)}`, false)
showMessage(`Windows Hello 设置失败: ${e.message}`, false)
}
} finally { } finally {
setIsSettingHello(false) setIsSettingHello(false)
} }

View File

@@ -13,6 +13,7 @@ import './WelcomePage.scss'
const isMac = navigator.userAgent.toLowerCase().includes('mac') const isMac = navigator.userAgent.toLowerCase().includes('mac')
const isLinux = navigator.userAgent.toLowerCase().includes('linux') const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const isWindows = !isMac && !isLinux
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录' const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
const dbPathPlaceholder = isMac const dbPathPlaceholder = isMac
@@ -46,6 +47,19 @@ const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
return `${base};最近状态:${tailLogs.join(' | ')}` return `${base};最近状态:${tailLogs.join(' | ')}`
} }
const normalizeDbKeyStatusMessage = (message: string): string => {
if (isWindows && message.includes('Hook安装成功')) {
return '已准备就绪,现在登录微信或退出登录后重新登录微信'
}
return message
}
const isDbKeyReadyMessage = (message: string): boolean => (
message.includes('现在可以登录')
|| message.includes('Hook安装成功')
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
)
function WelcomePage({ standalone = false }: WelcomePageProps) { function WelcomePage({ standalone = false }: WelcomePageProps) {
const navigate = useNavigate() const navigate = useNavigate()
const { isDbConnected, setDbConnected, setLoading } = useAppStore() const { isDbConnected, setDbConnected, setLoading } = useAppStore()
@@ -89,9 +103,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
// 检查 Hello 可用性 // 检查 Hello 可用性
useEffect(() => { useEffect(() => {
if (window.PublicKeyCredential) { setHelloAvailable(isWindows)
void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable)
}
}, []) }, [])
async function sha256(message: string) { async function sha256(message: string) {
@@ -103,35 +115,27 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
} }
const handleSetupHello = async () => { const handleSetupHello = async () => {
if (!isWindows) {
setError('当前系统不支持 Windows Hello')
return
}
if (!authPassword || authPassword !== authConfirmPassword) {
setError('请先设置并确认应用密码,再开启 Windows Hello')
return
}
setIsSettingHello(true) setIsSettingHello(true)
try { try {
// 注册凭证 (WebAuthn) const result = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
const challenge = new Uint8Array(32) if (!result.success) {
window.crypto.getRandomValues(challenge) setError(`Windows Hello 设置失败: ${result.error || '验证失败'}`)
return
const credential = await navigator.credentials.create({
publicKey: {
challenge,
rp: { name: 'WeFlow', id: 'localhost' },
user: {
id: new Uint8Array([1]),
name: 'user',
displayName: 'User'
},
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
authenticatorSelection: { userVerification: 'required' },
timeout: 60000
}
})
if (credential) {
setEnableHello(true)
// 成功提示?
} }
setEnableHello(true)
setError('')
} catch (e: any) { } catch (e: any) {
if (e.name !== 'NotAllowedError') { setError(`Windows Hello 设置失败: ${e?.message || String(e)}`)
setError('Windows Hello 设置失败: ' + e.message)
}
} finally { } finally {
setIsSettingHello(false) setIsSettingHello(false)
} }
@@ -139,8 +143,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message) const normalizedMessage = normalizeDbKeyStatusMessage(payload.message)
if (payload.message.includes('现在可以登录') || payload.message.includes('Hook安装成功')) { setDbKeyStatus(normalizedMessage)
if (isDbKeyReadyMessage(normalizedMessage)) {
window.electronAPI.notification?.show({ window.electronAPI.notification?.show({
title: 'WeFlow 准备就绪', title: 'WeFlow 准备就绪',
content: '现在可以登录微信了', content: '现在可以登录微信了',
@@ -487,7 +492,17 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const hash = await sha256(authPassword) const hash = await sha256(authPassword)
await configService.setAuthEnabled(true) await configService.setAuthEnabled(true)
await configService.setAuthPassword(hash) await configService.setAuthPassword(hash)
await configService.setAuthUseHello(enableHello) if (enableHello) {
const helloResult = await window.electronAPI.auth.setHelloSecret(authPassword)
if (!helloResult.success) {
setError('Windows Hello 配置保存失败')
setLoading(false)
return
}
} else {
await window.electronAPI.auth.clearHelloSecret()
await configService.setAuthUseHello(false)
}
} }
await configService.setOnboardingDone(true) await configService.setOnboardingDone(true)
@@ -761,8 +776,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
)} )}
</div> </div>
{dbKeyStatus && <div className={`status-message ${dbKeyStatus.includes('现在可以登录') || dbKeyStatus.includes('Hook安装成功') ? 'is-success' : ''}`}>{dbKeyStatus}</div>} {dbKeyStatus && <div className={`status-message ${isDbKeyReadyMessage(dbKeyStatus) ? 'is-success' : ''}`}>{dbKeyStatus}</div>}
<div className="field-hint"></div>
</div> </div>
)} )}

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)
@@ -663,6 +678,9 @@ export interface ContactsListCacheContact {
remark?: string remark?: string
nickname?: string nickname?: string
alias?: string alias?: string
labels?: string[]
detailDescription?: string
region?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
} }
@@ -1137,16 +1155,18 @@ export async function setSnsPageCache(
export async function getContactsLoadTimeoutMs(): Promise<number> { export async function getContactsLoadTimeoutMs(): Promise<number> {
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS) const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)
if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) { if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) {
return Math.floor(value) const normalized = Math.floor(value)
// 兼容历史默认值 3000ms自动提升到新的更稳妥阈值。
return normalized === 3000 ? 10000 : normalized
} }
return 3000 return 10000
} }
// 设置通讯录加载超时阈值(毫秒) // 设置通讯录加载超时阈值(毫秒)
export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> { export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> {
const normalized = Number.isFinite(timeoutMs) const normalized = Number.isFinite(timeoutMs)
? Math.min(60000, Math.max(1000, Math.floor(timeoutMs))) ? Math.min(60000, Math.max(1000, Math.floor(timeoutMs)))
: 3000 : 10000
await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized) await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized)
} }
@@ -1176,6 +1196,11 @@ export async function getContactsListCache(scopeKey: string): Promise<ContactsLi
remark: typeof item.remark === 'string' ? item.remark : undefined, remark: typeof item.remark === 'string' ? item.remark : undefined,
nickname: typeof item.nickname === 'string' ? item.nickname : undefined, nickname: typeof item.nickname === 'string' ? item.nickname : undefined,
alias: typeof item.alias === 'string' ? item.alias : undefined, alias: typeof item.alias === 'string' ? item.alias : undefined,
labels: Array.isArray(item.labels)
? Array.from(new Set(item.labels.map((label) => String(label || '').trim()).filter(Boolean)))
: undefined,
detailDescription: typeof item.detailDescription === 'string' ? (item.detailDescription.trim() || undefined) : undefined,
region: typeof item.region === 'string' ? (item.region.trim() || undefined) : undefined,
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other') type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
? type ? type
: 'other' : 'other'
@@ -1210,6 +1235,11 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL
remark: contact?.remark ? String(contact.remark) : undefined, remark: contact?.remark ? String(contact.remark) : undefined,
nickname: contact?.nickname ? String(contact.nickname) : undefined, nickname: contact?.nickname ? String(contact.nickname) : undefined,
alias: contact?.alias ? String(contact.alias) : undefined, alias: contact?.alias ? String(contact.alias) : undefined,
labels: Array.isArray(contact?.labels)
? Array.from(new Set(contact.labels.map((label) => String(label || '').trim()).filter(Boolean)))
: undefined,
detailDescription: contact?.detailDescription ? (String(contact.detailDescription).trim() || undefined) : undefined,
region: contact?.region ? (String(contact.region).trim() || undefined) : undefined,
type type
}) })
} }
@@ -1457,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

@@ -219,7 +219,7 @@ export interface ElectronAPI {
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }> updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }> deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }> resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
getContacts: () => Promise<{ getContacts: (options?: { lite?: boolean }) => Promise<{
success: boolean success: boolean
contacts?: ContactInfo[] contacts?: ContactInfo[]
error?: string error?: string
@@ -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 }>
} }

View File

@@ -37,6 +37,9 @@ export interface ContactInfo {
remark?: string remark?: string
nickname?: string nickname?: string
alias?: string alias?: string
labels?: string[]
detailDescription?: string
region?: string
avatarUrl?: string avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
} }