diff --git a/electron/main.ts b/electron/main.ts index 34084c9..6ba867a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1949,6 +1949,18 @@ function registerIpcHandlers() { 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) => { return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath) }) diff --git a/electron/preload.ts b/electron/preload.ts index 7d56dba..4cce51c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -292,6 +292,11 @@ contextBridge.exposeInMainWorld('electronAPI', { 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), 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), exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime) diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index fbdb32e..01c012d 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -49,6 +49,12 @@ export interface GroupMediaStats { total: number } +export interface GroupMemberMessagesPage { + messages: Message[] + hasMore: boolean + nextCursor: number +} + interface GroupMemberContactInfo { remark: string nickName: string @@ -771,6 +777,100 @@ class GroupAnalyticsService { 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 }> { try { const conn = await this.ensureConnected() diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index 6066448..796a65c 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -827,7 +827,8 @@ } } -.member-export-panel { +.member-export-panel, +.member-messages-panel { display: flex; flex-direction: column; gap: 16px; @@ -1163,6 +1164,131 @@ 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 { @@ -1538,6 +1664,34 @@ 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 { display: flex; align-items: center; diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 9103f2f..93891b8 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1,11 +1,12 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' 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 ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' import ChatAnalysisHeader from '../components/ChatAnalysisHeader' import * as configService from '../services/config' +import type { Message } from '../types/models' import { finishBackgroundTask, isBackgroundTaskCancelRequested, @@ -36,7 +37,7 @@ interface GroupMessageRank { 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' interface MemberMessageExportOptions { @@ -57,6 +58,93 @@ interface MemberExportFormatOption { 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() { const location = useLocation() const [groups, setGroups] = useState([]) @@ -78,7 +166,12 @@ function GroupAnalyticsPage() { const [functionLoading, setFunctionLoading] = useState(false) const [isExportingMembers, setIsExportingMembers] = useState(false) const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false) + const [memberMessages, setMemberMessages] = useState([]) + const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false) + const [memberMessagesCursor, setMemberMessagesCursor] = useState(0) + const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false) const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('') + const [selectedMessageMemberUsername, setSelectedMessageMemberUsername] = useState('') const [exportFolder, setExportFolder] = useState('') const [memberExportOptions, setMemberExportOptions] = useState({ format: 'excel', @@ -95,10 +188,13 @@ function GroupAnalyticsPage() { // 成员详情弹框 const [selectedMember, setSelectedMember] = useState(null) const [copiedField, setCopiedField] = useState(null) + const [showMessageMemberSelect, setShowMessageMemberSelect] = useState(false) const [showMemberSelect, setShowMemberSelect] = useState(false) const [showFormatSelect, setShowFormatSelect] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) + const [messageMemberSearchKeyword, setMessageMemberSearchKeyword] = useState('') const [memberSearchKeyword, setMemberSearchKeyword] = useState('') + const messageMemberSelectDropdownRef = useRef(null) const memberSelectDropdownRef = useRef(null) const formatDropdownRef = useRef(null) const displayNameDropdownRef = useRef(null) @@ -149,6 +245,10 @@ function GroupAnalyticsPage() { () => members.find(member => member.username === selectedExportMemberUsername) || null, [members, selectedExportMemberUsername] ) + const selectedMessageMember = useMemo( + () => members.find(member => member.username === selectedMessageMemberUsername) || null, + [members, selectedMessageMemberUsername] + ) const selectedFormatOption = useMemo( () => memberExportFormatOptions.find(option => option.value === memberExportOptions.format) || memberExportFormatOptions[0], [memberExportFormatOptions, memberExportOptions.format] @@ -158,19 +258,28 @@ function GroupAnalyticsPage() { [displayNameOptions, memberExportOptions.displayNamePreference] ) const filteredMemberOptions = useMemo(() => { - const keyword = memberSearchKeyword.trim().toLowerCase() - 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)) - }) + return filterMembersByKeyword(members, memberSearchKeyword) }, [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 () => { try { @@ -245,17 +354,25 @@ function GroupAnalyticsPage() { useEffect(() => { if (members.length === 0) { setSelectedExportMemberUsername('') + setSelectedMessageMemberUsername('') return } - const exists = members.some(member => member.username === selectedExportMemberUsername) - if (!exists) { + const exportExists = members.some(member => member.username === selectedExportMemberUsername) + if (!exportExists) { setSelectedExportMemberUsername(members[0].username) } - }, [members, selectedExportMemberUsername]) + const messageExists = members.some(member => member.username === selectedMessageMemberUsername) + if (!messageExists) { + setSelectedMessageMemberUsername(members[0].username) + } + }, [members, selectedExportMemberUsername, selectedMessageMemberUsername]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node + if (showMessageMemberSelect && messageMemberSelectDropdownRef.current && !messageMemberSelectDropdownRef.current.contains(target)) { + setShowMessageMemberSelect(false) + } if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) { setShowMemberSelect(false) } @@ -268,7 +385,7 @@ function GroupAnalyticsPage() { } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showDisplayNameSelect, showFormatSelect, showMemberSelect]) + }, [showDisplayNameSelect, showFormatSelect, showMemberSelect, showMessageMemberSelect]) useEffect(() => { if (preselectAppliedRef.current) return @@ -318,6 +435,7 @@ function GroupAnalyticsPage() { setSelectedGroupId(null) setSelectedFunction(null) setMembers([]) + resetMemberMessageState() setRankings([]) setActiveHours({}) setMediaStats(null) @@ -326,11 +444,13 @@ function GroupAnalyticsPage() { } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, [loadExportPath, loadGroups]) + }, [loadExportPath, loadGroups, resetMemberMessageState]) const handleGroupSelect = (group: GroupChatInfo) => { setSelectedGroupId(group.username) setSelectedFunction(null) + setSelectedMember(null) + resetMemberMessageState() setSelectedExportMemberUsername('') setMemberSearchKeyword('') 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 => { + 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) => { if (!selectedGroup) return setSelectedFunction(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 const taskId = registerBackgroundTask({ sourcePage: 'groupAnalytics', @@ -356,9 +516,7 @@ function GroupAnalyticsPage() { }) setFunctionLoading(true) - // 计算时间戳 - 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 + const { startTime, endTime } = getSelectedTimeRange() try { switch (func) { @@ -379,6 +537,49 @@ function GroupAnalyticsPage() { }) 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': { updateBackgroundTask(taskId, { detail: '正在读取导出成员列表', @@ -525,7 +726,7 @@ function GroupAnalyticsPage() { const handleRefresh = () => { if (selectedFunction) { - loadFunctionData(selectedFunction) + void loadFunctionData(selectedFunction) } } @@ -539,6 +740,62 @@ function GroupAnalyticsPage() { 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 () => { if (!selectedGroup || isExportingMembers) return setIsExportingMembers(true) @@ -721,6 +978,16 @@ function GroupAnalyticsPage() { )} +
+ +
@@ -808,6 +1075,11 @@ function GroupAnalyticsPage() { 群成员查看 查看群成员列表和基础资料 +
handleFunctionSelect('memberMessages')}> + + 成员消息查看 + 按成员筛选并分页查看群聊消息 +
handleFunctionSelect('memberExport')}> 成员消息导出 @@ -836,6 +1108,7 @@ function GroupAnalyticsPage() { const getFunctionTitle = () => { switch (selectedFunction) { case 'members': return '群成员查看' + case 'memberMessages': return '成员消息查看' case 'memberExport': return '成员消息导出' case 'ranking': return '群聊发言排行' case 'activeHours': return '群聊活跃时段' @@ -871,6 +1144,12 @@ function GroupAnalyticsPage() { 导出成员 )} + {selectedFunction === 'memberMessages' && ( + + )} @@ -892,6 +1171,118 @@ function GroupAnalyticsPage() { ))}
)} + {selectedFunction === 'memberMessages' && ( +
+ {members.length === 0 ? ( +
暂无群成员数据,请先刷新。
+ ) : ( + <> +
+
+ 查看成员 + + {showMessageMemberSelect && ( +
+
+ + setMessageMemberSearchKeyword(e.target.value)} + placeholder="搜索 wxid / 昵称 / 备注 / 微信号" + /> +
+
+ {filteredMessageMemberOptions.length === 0 ? ( +
无匹配成员
+ ) : ( + filteredMessageMemberOptions.map(member => ( + + )) + )} +
+
+ )} +
+
+ 已加载 {memberMessages.length} 条消息 + + 当前成员:{selectedMessageMember?.displayName || selectedMessageMember?.username || '未选择成员'} + +
+
+ + {memberMessages.length === 0 ? ( +
当前时间范围内暂无该成员消息。
+ ) : ( +
+ {memberMessages.map(message => ( +
+
+ {formatMemberMessageTime(message.createTime)} + {getMemberMessageTypeLabel(message)} +
+
{getMemberMessagePreview(message)}
+
+ ))} +
+ )} + + {(memberMessagesHasMore || memberMessages.length > 0) && ( +
+ {memberMessagesHasMore ? ( + + ) : ( + 已显示当前可读取的全部消息 + )} +
+ )} + + )} +
+ )} {selectedFunction === 'memberExport' && (
{members.length === 0 ? ( @@ -1211,3 +1602,4 @@ function GroupAnalyticsPage() { } export default GroupAnalyticsPage + diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index e9023da..a035f50 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -494,6 +494,19 @@ export interface ElectronAPI { } 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<{ success: boolean count?: number