新增查看单个群成员消息

This commit is contained in:
xuncha
2026-03-16 17:51:13 +08:00
parent f2b1b07f58
commit 79e40f6a53
6 changed files with 701 additions and 25 deletions

View File

@@ -1949,6 +1949,18 @@ function registerIpcHandlers() {
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime) return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
}) })
ipcMain.handle(
'groupAnalytics:getGroupMemberMessages',
async (
_,
chatroomId: string,
memberUsername: string,
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
) => {
return groupAnalyticsService.getGroupMemberMessages(chatroomId, memberUsername, options)
}
)
ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => { ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => {
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath) return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
}) })

View File

@@ -292,6 +292,11 @@ 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),
getGroupMemberMessages: (
chatroomId: string,
memberUsername: string,
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
) => ipcRenderer.invoke('groupAnalytics:getGroupMemberMessages', chatroomId, memberUsername, options),
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath), exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) => exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime) ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)

View File

@@ -49,6 +49,12 @@ export interface GroupMediaStats {
total: number total: number
} }
export interface GroupMemberMessagesPage {
messages: Message[]
hasMore: boolean
nextCursor: number
}
interface GroupMemberContactInfo { interface GroupMemberContactInfo {
remark: string remark: string
nickName: string nickName: string
@@ -771,6 +777,100 @@ class GroupAnalyticsService {
return { success: true, data: matchedMessages } return { success: true, data: matchedMessages }
} }
async getGroupMemberMessages(
chatroomId: string,
memberUsername: string,
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
): Promise<{ success: boolean; data?: GroupMemberMessagesPage; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const normalizedChatroomId = String(chatroomId || '').trim()
const normalizedMemberUsername = String(memberUsername || '').trim()
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' }
const startTimeValue = Number.isFinite(options?.startTime) && typeof options?.startTime === 'number'
? Math.max(0, Math.floor(options.startTime))
: 0
const endTimeValue = Number.isFinite(options?.endTime) && typeof options?.endTime === 'number'
? Math.max(0, Math.floor(options.endTime))
: 0
const limit = Number.isFinite(options?.limit) && typeof options?.limit === 'number'
? Math.max(1, Math.min(100, Math.floor(options.limit)))
: 50
let cursor = Number.isFinite(options?.cursor) && typeof options?.cursor === 'number'
? Math.max(0, Math.floor(options.cursor))
: 0
const matchedMessages: Message[] = []
const batchSize = Math.max(limit * 2, 100)
let hasMore = false
while (matchedMessages.length < limit) {
const batch = await chatService.getMessages(
normalizedChatroomId,
cursor,
batchSize,
startTimeValue,
endTimeValue,
false
)
if (!batch.success || !batch.messages) {
return { success: false, error: batch.error || '获取群成员消息失败' }
}
const currentMessages = batch.messages
const nextCursor = typeof batch.nextOffset === 'number'
? Math.max(cursor, Math.floor(batch.nextOffset))
: cursor + currentMessages.length
let overflowMatchFound = false
for (const message of currentMessages) {
if (!this.isSameAccountIdentity(normalizedMemberUsername, message.senderUsername)) {
continue
}
if (matchedMessages.length < limit) {
matchedMessages.push(message)
} else {
overflowMatchFound = true
break
}
}
cursor = nextCursor
if (overflowMatchFound) {
hasMore = true
break
}
if (currentMessages.length === 0 || !batch.hasMore) {
hasMore = false
break
}
if (matchedMessages.length >= limit) {
hasMore = true
break
}
}
return {
success: true,
data: {
messages: matchedMessages,
hasMore,
nextCursor: cursor
}
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> { async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
try { try {
const conn = await this.ensureConnected() const conn = await this.ensureConnected()

View File

@@ -827,7 +827,8 @@
} }
} }
.member-export-panel { .member-export-panel,
.member-messages-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
@@ -1163,6 +1164,131 @@
cursor: not-allowed; cursor: not-allowed;
} }
} }
.member-message-empty {
padding: 20px;
border-radius: 12px;
background: var(--bg-tertiary);
color: var(--text-secondary);
text-align: center;
font-size: 14px;
}
.member-message-toolbar {
display: grid;
gap: 12px;
grid-template-columns: minmax(240px, 360px) minmax(0, 1fr);
align-items: start;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
.member-message-summary-card {
min-height: 48px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
padding: 12px 14px;
border-radius: 14px;
background: color-mix(in srgb, var(--card-bg) 88%, var(--bg-secondary));
border: 1px solid var(--border-color);
}
.summary-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.summary-desc {
font-size: 12px;
color: var(--text-secondary);
}
.member-message-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-message-item {
padding: 14px 16px;
border-radius: 14px;
background: color-mix(in srgb, var(--card-bg) 92%, var(--bg-secondary));
border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
}
.member-message-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.member-message-time {
font-size: 12px;
color: var(--text-secondary);
}
.member-message-type {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 9999px;
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
font-size: 11px;
font-weight: 600;
}
.member-message-content {
color: var(--text-primary);
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.member-message-actions {
display: flex;
justify-content: center;
padding-top: 4px;
}
.member-message-load-more {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 132px;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--primary);
color: var(--primary);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.member-message-end {
font-size: 12px;
color: var(--text-tertiary);
}
} }
.rankings-list { .rankings-list {
@@ -1538,6 +1664,34 @@
gap: 12px; gap: 12px;
} }
.member-modal-actions {
width: 100%;
margin-top: 18px;
display: flex;
justify-content: center;
}
.member-modal-primary-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
border: none;
border-radius: 12px;
background: var(--primary);
color: #fff;
padding: 12px 16px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.92;
}
}
.detail-row { .detail-row {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,11 +1,12 @@
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 } from 'lucide-react' import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker' import DateRangePicker from '../components/DateRangePicker'
import ChatAnalysisHeader from '../components/ChatAnalysisHeader' import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
import * as configService from '../services/config' import * as configService from '../services/config'
import type { Message } from '../types/models'
import { import {
finishBackgroundTask, finishBackgroundTask,
isBackgroundTaskCancelRequested, isBackgroundTaskCancelRequested,
@@ -36,7 +37,7 @@ interface GroupMessageRank {
messageCount: number messageCount: number
} }
type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats' type AnalysisFunction = 'members' | 'memberMessages' | 'memberExport' | '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 {
@@ -57,6 +58,93 @@ interface MemberExportFormatOption {
desc: string desc: string
} }
interface GroupMemberMessagesPage {
messages: Message[]
hasMore: boolean
nextCursor: number
}
const MEMBER_MESSAGE_PAGE_SIZE = 40
const filterMembersByKeyword = (members: GroupMember[], keyword: string) => {
const normalizedKeyword = keyword.trim().toLowerCase()
if (!normalizedKeyword) return members
return members.filter(member => {
const fields = [
member.username,
member.displayName,
member.nickname,
member.remark,
member.alias,
member.groupNickname
]
return fields.some(field => String(field || '').toLowerCase().includes(normalizedKeyword))
})
}
const formatMemberMessageTime = (createTime: number) => {
if (!createTime) return '-'
return new Date(createTime * 1000).toLocaleString('zh-CN', { hour12: false })
}
const getMemberMessageTypeLabel = (message: Message) => {
switch (message.localType) {
case 1:
return '文本'
case 3:
return '图片'
case 34:
return '语音'
case 42:
return '名片'
case 43:
return '视频'
case 47:
return '表情'
case 48:
return '位置'
case 49:
return message.fileName ? '文件' : '链接'
case 50:
return '通话'
case 10000:
case 10002:
return '系统'
default:
return `类型 ${message.localType}`
}
}
const getMemberMessagePreview = (message: Message) => {
const text = (message.parsedContent || message.content || message.rawContent || '').trim()
switch (message.localType) {
case 1:
case 10000:
case 10002:
return text || '[空文本]'
case 3:
return text || '[图片]'
case 34:
return message.voiceDurationSeconds ? `[语音] ${message.voiceDurationSeconds}` : '[语音]'
case 42:
return `[名片] ${message.cardNickname || message.cardUsername || text || '联系人名片'}`
case 43:
return text || '[视频]'
case 47:
return text || '[表情]'
case 48:
return `[位置] ${message.locationPoiname || message.locationLabel || text || '位置消息'}`
case 49:
if (message.fileName) return `[文件] ${message.fileName}`
if (message.linkTitle) return `[链接] ${message.linkTitle}`
return text || '[链接/文件]'
case 50:
return text || '[通话]'
default:
return text || `[消息类型 ${message.localType}]`
}
}
function GroupAnalyticsPage() { function GroupAnalyticsPage() {
const location = useLocation() const location = useLocation()
const [groups, setGroups] = useState<GroupChatInfo[]>([]) const [groups, setGroups] = useState<GroupChatInfo[]>([])
@@ -78,7 +166,12 @@ function GroupAnalyticsPage() {
const [functionLoading, setFunctionLoading] = useState(false) const [functionLoading, setFunctionLoading] = useState(false)
const [isExportingMembers, setIsExportingMembers] = useState(false) const [isExportingMembers, setIsExportingMembers] = useState(false)
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false) const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
const [memberMessages, setMemberMessages] = useState<Message[]>([])
const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false)
const [memberMessagesCursor, setMemberMessagesCursor] = useState(0)
const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false)
const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('') const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('')
const [selectedMessageMemberUsername, setSelectedMessageMemberUsername] = useState('')
const [exportFolder, setExportFolder] = useState('') const [exportFolder, setExportFolder] = useState('')
const [memberExportOptions, setMemberExportOptions] = useState<MemberMessageExportOptions>({ const [memberExportOptions, setMemberExportOptions] = useState<MemberMessageExportOptions>({
format: 'excel', format: 'excel',
@@ -95,10 +188,13 @@ function GroupAnalyticsPage() {
// 成员详情弹框 // 成员详情弹框
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null) const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
const [copiedField, setCopiedField] = useState<string | null>(null) const [copiedField, setCopiedField] = useState<string | null>(null)
const [showMessageMemberSelect, setShowMessageMemberSelect] = useState(false)
const [showMemberSelect, setShowMemberSelect] = useState(false) const [showMemberSelect, setShowMemberSelect] = useState(false)
const [showFormatSelect, setShowFormatSelect] = useState(false) const [showFormatSelect, setShowFormatSelect] = useState(false)
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
const [messageMemberSearchKeyword, setMessageMemberSearchKeyword] = useState('')
const [memberSearchKeyword, setMemberSearchKeyword] = useState('') const [memberSearchKeyword, setMemberSearchKeyword] = useState('')
const messageMemberSelectDropdownRef = useRef<HTMLDivElement>(null)
const memberSelectDropdownRef = useRef<HTMLDivElement>(null) const memberSelectDropdownRef = useRef<HTMLDivElement>(null)
const formatDropdownRef = useRef<HTMLDivElement>(null) const formatDropdownRef = useRef<HTMLDivElement>(null)
const displayNameDropdownRef = useRef<HTMLDivElement>(null) const displayNameDropdownRef = useRef<HTMLDivElement>(null)
@@ -149,6 +245,10 @@ function GroupAnalyticsPage() {
() => members.find(member => member.username === selectedExportMemberUsername) || null, () => members.find(member => member.username === selectedExportMemberUsername) || null,
[members, selectedExportMemberUsername] [members, selectedExportMemberUsername]
) )
const selectedMessageMember = useMemo(
() => members.find(member => member.username === selectedMessageMemberUsername) || null,
[members, selectedMessageMemberUsername]
)
const selectedFormatOption = useMemo( const selectedFormatOption = useMemo(
() => memberExportFormatOptions.find(option => option.value === memberExportOptions.format) || memberExportFormatOptions[0], () => memberExportFormatOptions.find(option => option.value === memberExportOptions.format) || memberExportFormatOptions[0],
[memberExportFormatOptions, memberExportOptions.format] [memberExportFormatOptions, memberExportOptions.format]
@@ -158,19 +258,28 @@ function GroupAnalyticsPage() {
[displayNameOptions, memberExportOptions.displayNamePreference] [displayNameOptions, memberExportOptions.displayNamePreference]
) )
const filteredMemberOptions = useMemo(() => { const filteredMemberOptions = useMemo(() => {
const keyword = memberSearchKeyword.trim().toLowerCase() return filterMembersByKeyword(members, memberSearchKeyword)
if (!keyword) return members
return members.filter(member => {
const fields = [
member.username,
member.displayName,
member.nickname,
member.remark,
member.alias
]
return fields.some(field => String(field || '').toLowerCase().includes(keyword))
})
}, [memberSearchKeyword, members]) }, [memberSearchKeyword, members])
const filteredMessageMemberOptions = useMemo(() => {
return filterMembersByKeyword(members, messageMemberSearchKeyword)
}, [members, messageMemberSearchKeyword])
const resetMemberMessageState = useCallback((clearSelection = true) => {
setMemberMessages([])
setMemberMessagesHasMore(false)
setMemberMessagesCursor(0)
setMemberMessagesLoadingMore(false)
setShowMessageMemberSelect(false)
if (clearSelection) {
setSelectedMessageMemberUsername('')
setMessageMemberSearchKeyword('')
}
}, [])
const getSelectedTimeRange = () => ({
startTime: startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined,
endTime: endDate ? Math.floor(new Date(`${endDate}T23:59:59`).getTime() / 1000) : undefined
})
const loadExportPath = useCallback(async () => { const loadExportPath = useCallback(async () => {
try { try {
@@ -245,17 +354,25 @@ function GroupAnalyticsPage() {
useEffect(() => { useEffect(() => {
if (members.length === 0) { if (members.length === 0) {
setSelectedExportMemberUsername('') setSelectedExportMemberUsername('')
setSelectedMessageMemberUsername('')
return return
} }
const exists = members.some(member => member.username === selectedExportMemberUsername) const exportExists = members.some(member => member.username === selectedExportMemberUsername)
if (!exists) { if (!exportExists) {
setSelectedExportMemberUsername(members[0].username) setSelectedExportMemberUsername(members[0].username)
} }
}, [members, selectedExportMemberUsername]) const messageExists = members.some(member => member.username === selectedMessageMemberUsername)
if (!messageExists) {
setSelectedMessageMemberUsername(members[0].username)
}
}, [members, selectedExportMemberUsername, selectedMessageMemberUsername])
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node const target = event.target as Node
if (showMessageMemberSelect && messageMemberSelectDropdownRef.current && !messageMemberSelectDropdownRef.current.contains(target)) {
setShowMessageMemberSelect(false)
}
if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) { if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) {
setShowMemberSelect(false) setShowMemberSelect(false)
} }
@@ -268,7 +385,7 @@ function GroupAnalyticsPage() {
} }
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showDisplayNameSelect, showFormatSelect, showMemberSelect]) }, [showDisplayNameSelect, showFormatSelect, showMemberSelect, showMessageMemberSelect])
useEffect(() => { useEffect(() => {
if (preselectAppliedRef.current) return if (preselectAppliedRef.current) return
@@ -318,6 +435,7 @@ function GroupAnalyticsPage() {
setSelectedGroupId(null) setSelectedGroupId(null)
setSelectedFunction(null) setSelectedFunction(null)
setMembers([]) setMembers([])
resetMemberMessageState()
setRankings([]) setRankings([])
setActiveHours({}) setActiveHours({})
setMediaStats(null) setMediaStats(null)
@@ -326,11 +444,13 @@ function GroupAnalyticsPage() {
} }
window.addEventListener('wxid-changed', handleChange as EventListener) window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadExportPath, loadGroups]) }, [loadExportPath, loadGroups, resetMemberMessageState])
const handleGroupSelect = (group: GroupChatInfo) => { const handleGroupSelect = (group: GroupChatInfo) => {
setSelectedGroupId(group.username) setSelectedGroupId(group.username)
setSelectedFunction(null) setSelectedFunction(null)
setSelectedMember(null)
resetMemberMessageState()
setSelectedExportMemberUsername('') setSelectedExportMemberUsername('')
setMemberSearchKeyword('') setMemberSearchKeyword('')
setShowMemberSelect(false) setShowMemberSelect(false)
@@ -339,13 +459,53 @@ function GroupAnalyticsPage() {
} }
const loadMemberMessagesPage = async (
targetGroup: GroupChatInfo,
memberUsername: string,
options?: {
cursor?: number
append?: boolean
startTime?: number
endTime?: number
}
): Promise<GroupMemberMessagesPage> => {
const result = await window.electronAPI.groupAnalytics.getGroupMemberMessages(targetGroup.username, memberUsername, {
startTime: options?.startTime,
endTime: options?.endTime,
limit: MEMBER_MESSAGE_PAGE_SIZE,
cursor: options?.cursor && options.cursor > 0 ? options.cursor : undefined
})
if (!result.success || !result.data) {
throw new Error(result.error || '读取成员消息失败')
}
setMemberMessages(prev => {
if (!options?.append) return result.data!.messages
const next = [...prev]
const seen = new Set(prev.map(message => message.messageKey))
for (const message of result.data!.messages) {
if (seen.has(message.messageKey)) continue
seen.add(message.messageKey)
next.push(message)
}
return next
})
setMemberMessagesHasMore(result.data.hasMore)
setMemberMessagesCursor(result.data.nextCursor || 0)
return result.data
}
const handleFunctionSelect = async (func: AnalysisFunction) => { const handleFunctionSelect = async (func: AnalysisFunction) => {
if (!selectedGroup) return if (!selectedGroup) return
setSelectedFunction(func) setSelectedFunction(func)
await loadFunctionData(func) await loadFunctionData(func)
} }
const loadFunctionData = async (func: AnalysisFunction, targetGroup: GroupChatInfo | null = selectedGroup) => { const loadFunctionData = async (
func: AnalysisFunction,
targetGroup: GroupChatInfo | null = selectedGroup,
preferredMemberUsername?: string
) => {
if (!targetGroup) return if (!targetGroup) return
const taskId = registerBackgroundTask({ const taskId = registerBackgroundTask({
sourcePage: 'groupAnalytics', sourcePage: 'groupAnalytics',
@@ -356,9 +516,7 @@ function GroupAnalyticsPage() {
}) })
setFunctionLoading(true) setFunctionLoading(true)
// 计算时间戳 const { startTime, endTime } = getSelectedTimeRange()
const startTime = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined
const endTime = endDate ? Math.floor(new Date(endDate + 'T23:59:59').getTime() / 1000) : undefined
try { try {
switch (func) { switch (func) {
@@ -379,6 +537,49 @@ function GroupAnalyticsPage() {
}) })
break break
} }
case 'memberMessages': {
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) {
resetMemberMessageState()
finishBackgroundTask(taskId, 'failed', {
detail: result.error || '读取群成员失败',
progressText: '失败'
})
break
}
setMembers(result.data)
const targetMember = result.data.find(member => member.username === (preferredMemberUsername || selectedMessageMemberUsername)) || result.data[0]
if (!targetMember) {
resetMemberMessageState()
finishBackgroundTask(taskId, 'completed', {
detail: '当前群暂无可用成员数据',
progressText: '0 条'
})
break
}
setSelectedMessageMemberUsername(targetMember.username)
updateBackgroundTask(taskId, {
detail: `正在读取 ${targetMember.displayName || targetMember.username} 的发言记录`,
progressText: '消息分页'
})
const page = await loadMemberMessagesPage(targetGroup, targetMember.username, { startTime, endTime })
finishBackgroundTask(taskId, 'completed', {
detail: `成员消息加载完成,已读取 ${page.messages.length}`,
progressText: `${page.messages.length}`
})
break
}
case 'memberExport': { case 'memberExport': {
updateBackgroundTask(taskId, { updateBackgroundTask(taskId, {
detail: '正在读取导出成员列表', detail: '正在读取导出成员列表',
@@ -525,7 +726,7 @@ function GroupAnalyticsPage() {
const handleRefresh = () => { const handleRefresh = () => {
if (selectedFunction) { if (selectedFunction) {
loadFunctionData(selectedFunction) void loadFunctionData(selectedFunction)
} }
} }
@@ -539,6 +740,62 @@ function GroupAnalyticsPage() {
setCopiedField(null) setCopiedField(null)
} }
const openSelectedGroupChat = () => {
if (!selectedGroup) return
void window.electronAPI.window.openSessionChatWindow(selectedGroup.username, {
source: 'chat',
initialDisplayName: selectedGroup.displayName || selectedGroup.username,
initialAvatarUrl: selectedGroup.avatarUrl,
initialContactType: 'group'
})
}
const handleMessageMemberSelect = async (memberUsername: string) => {
if (!selectedGroup) return
setSelectedMessageMemberUsername(memberUsername)
setMessageMemberSearchKeyword('')
setShowMessageMemberSelect(false)
setFunctionLoading(true)
try {
const { startTime, endTime } = getSelectedTimeRange()
await loadMemberMessagesPage(selectedGroup, memberUsername, { startTime, endTime })
} catch (e) {
console.error('读取成员消息失败:', e)
alert(`读取成员消息失败:${String(e)}`)
} finally {
setFunctionLoading(false)
}
}
const handleLoadMoreMemberMessages = async () => {
if (!selectedGroup || !selectedMessageMemberUsername || !memberMessagesHasMore || memberMessagesLoadingMore) return
setMemberMessagesLoadingMore(true)
try {
const { startTime, endTime } = getSelectedTimeRange()
await loadMemberMessagesPage(selectedGroup, selectedMessageMemberUsername, {
cursor: memberMessagesCursor,
append: true,
startTime,
endTime
})
} catch (e) {
console.error('加载更多成员消息失败:', e)
alert(`加载更多成员消息失败:${String(e)}`)
} finally {
setMemberMessagesLoadingMore(false)
}
}
const handleViewMemberMessagesFromModal = async (member: GroupMember) => {
if (!selectedGroup) return
setSelectedMember(null)
setSelectedFunction('memberMessages')
setSelectedMessageMemberUsername(member.username)
setMessageMemberSearchKeyword('')
setShowMessageMemberSelect(false)
await loadFunctionData('memberMessages', selectedGroup, member.username)
}
const handleExportMembers = async () => { const handleExportMembers = async () => {
if (!selectedGroup || isExportingMembers) return if (!selectedGroup || isExportingMembers) return
setIsExportingMembers(true) setIsExportingMembers(true)
@@ -721,6 +978,16 @@ function GroupAnalyticsPage() {
</div> </div>
)} )}
</div> </div>
<div className="member-modal-actions">
<button
type="button"
className="member-modal-primary-btn"
onClick={() => void handleViewMemberMessagesFromModal(selectedMember)}
>
<MessageSquare size={16} />
<span></span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -808,6 +1075,11 @@ function GroupAnalyticsPage() {
<span></span> <span></span>
<small></small> <small></small>
</div> </div>
<div className="function-card" onClick={() => handleFunctionSelect('memberMessages')}>
<MessageSquare size={32} />
<span></span>
<small></small>
</div>
<div className="function-card" onClick={() => handleFunctionSelect('memberExport')}> <div className="function-card" onClick={() => handleFunctionSelect('memberExport')}>
<Download size={32} /> <Download size={32} />
<span></span> <span></span>
@@ -836,6 +1108,7 @@ function GroupAnalyticsPage() {
const getFunctionTitle = () => { const getFunctionTitle = () => {
switch (selectedFunction) { switch (selectedFunction) {
case 'members': return '群成员查看' case 'members': return '群成员查看'
case 'memberMessages': return '成员消息查看'
case 'memberExport': return '成员消息导出' case 'memberExport': return '成员消息导出'
case 'ranking': return '群聊发言排行' case 'ranking': return '群聊发言排行'
case 'activeHours': return '群聊活跃时段' case 'activeHours': return '群聊活跃时段'
@@ -871,6 +1144,12 @@ function GroupAnalyticsPage() {
<span></span> <span></span>
</button> </button>
)} )}
{selectedFunction === 'memberMessages' && (
<button className="export-btn" onClick={openSelectedGroupChat}>
<MessageSquare size={16} />
<span></span>
</button>
)}
<button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}> <button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}>
<RefreshCw size={16} className={functionLoading ? 'spin' : ''} /> <RefreshCw size={16} className={functionLoading ? 'spin' : ''} />
</button> </button>
@@ -892,6 +1171,118 @@ function GroupAnalyticsPage() {
))} ))}
</div> </div>
)} )}
{selectedFunction === 'memberMessages' && (
<div className="member-messages-panel">
{members.length === 0 ? (
<div className="member-message-empty"></div>
) : (
<>
<div className="member-message-toolbar">
<div className="member-export-field" ref={messageMemberSelectDropdownRef}>
<span></span>
<button
type="button"
className={`select-trigger ${showMessageMemberSelect ? 'open' : ''}`}
onClick={() => {
setShowMessageMemberSelect(prev => !prev)
setShowMemberSelect(false)
setShowFormatSelect(false)
setShowDisplayNameSelect(false)
}}
>
<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 / 昵称 / 备注 / 微信号"
/>
</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={() => void handleMessageMemberSelect(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 className="member-message-summary-card">
<span className="summary-title"> {memberMessages.length} </span>
<span className="summary-desc">
{selectedMessageMember?.displayName || selectedMessageMember?.username || '未选择成员'}
</span>
</div>
</div>
{memberMessages.length === 0 ? (
<div className="member-message-empty"></div>
) : (
<div className="member-message-list">
{memberMessages.map(message => (
<div key={message.messageKey || `${message.localId}-${message.createTime}`} className="member-message-item">
<div className="member-message-meta">
<span className="member-message-time">{formatMemberMessageTime(message.createTime)}</span>
<span className="member-message-type">{getMemberMessageTypeLabel(message)}</span>
</div>
<div className="member-message-content">{getMemberMessagePreview(message)}</div>
</div>
))}
</div>
)}
{(memberMessagesHasMore || memberMessages.length > 0) && (
<div className="member-message-actions">
{memberMessagesHasMore ? (
<button
type="button"
className="member-message-load-more"
disabled={memberMessagesLoadingMore}
onClick={() => void handleLoadMoreMemberMessages()}
>
{memberMessagesLoadingMore ? <Loader2 size={16} className="spin" /> : null}
<span>{memberMessagesLoadingMore ? '加载中...' : '加载更多'}</span>
</button>
) : (
<span className="member-message-end"></span>
)}
</div>
)}
</>
)}
</div>
)}
{selectedFunction === 'memberExport' && ( {selectedFunction === 'memberExport' && (
<div className="member-export-panel"> <div className="member-export-panel">
{members.length === 0 ? ( {members.length === 0 ? (
@@ -1211,3 +1602,4 @@ function GroupAnalyticsPage() {
} }
export default GroupAnalyticsPage export default GroupAnalyticsPage

View File

@@ -494,6 +494,19 @@ export interface ElectronAPI {
} }
error?: string error?: string
}> }>
getGroupMemberMessages: (
chatroomId: string,
memberUsername: string,
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
) => Promise<{
success: boolean
data?: {
messages: Message[]
hasMore: boolean
nextCursor: number
}
error?: string
}>
exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{ exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{
success: boolean success: boolean
count?: number count?: number