新增查看单个群成员消息

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

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

View File

@@ -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<GroupChatInfo[]>([])
@@ -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<Message[]>([])
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<MemberMessageExportOptions>({
format: 'excel',
@@ -95,10 +188,13 @@ function GroupAnalyticsPage() {
// 成员详情弹框
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
const [copiedField, setCopiedField] = useState<string | null>(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<HTMLDivElement>(null)
const memberSelectDropdownRef = useRef<HTMLDivElement>(null)
const formatDropdownRef = useRef<HTMLDivElement>(null)
const displayNameDropdownRef = useRef<HTMLDivElement>(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<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) => {
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() {
</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>
@@ -808,6 +1075,11 @@ function GroupAnalyticsPage() {
<span></span>
<small></small>
</div>
<div className="function-card" onClick={() => handleFunctionSelect('memberMessages')}>
<MessageSquare size={32} />
<span></span>
<small></small>
</div>
<div className="function-card" onClick={() => handleFunctionSelect('memberExport')}>
<Download size={32} />
<span></span>
@@ -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() {
<span></span>
</button>
)}
{selectedFunction === 'memberMessages' && (
<button className="export-btn" onClick={openSelectedGroupChat}>
<MessageSquare size={16} />
<span></span>
</button>
)}
<button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}>
<RefreshCw size={16} className={functionLoading ? 'spin' : ''} />
</button>
@@ -892,6 +1171,118 @@ function GroupAnalyticsPage() {
))}
</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' && (
<div className="member-export-panel">
{members.length === 0 ? (
@@ -1211,3 +1602,4 @@ function GroupAnalyticsPage() {
}
export default GroupAnalyticsPage