From 7bd801cd01ac7d318e73f1d489b10889bcf61d74 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 17:13:10 +0800 Subject: [PATCH] feat(chat): add group members sidebar with owner/friend badges --- electron/services/groupAnalyticsService.ts | 128 +++++++++- src/pages/ChatPage.scss | 152 ++++++++++++ src/pages/ChatPage.tsx | 266 ++++++++++++++++++++- src/types/electron.d.ts | 2 + 4 files changed, 537 insertions(+), 11 deletions(-) diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 22abcb9..f763337 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -21,6 +21,7 @@ export interface GroupMember { alias?: string remark?: string groupNickname?: string + isOwner?: boolean } export interface GroupMessageRank { @@ -89,6 +90,128 @@ class GroupAnalyticsService { return cleaned } + private resolveMemberUsername( + candidate: unknown, + memberLookup: Map + ): string | null { + if (typeof candidate !== 'string') return null + const raw = candidate.trim() + if (!raw) return null + if (memberLookup.has(raw)) return memberLookup.get(raw) || null + const cleaned = this.cleanAccountDirName(raw) + if (memberLookup.has(cleaned)) return memberLookup.get(cleaned) || null + + const parts = raw.split(/[,\s;|]+/).filter(Boolean) + for (const part of parts) { + if (memberLookup.has(part)) return memberLookup.get(part) || null + const normalizedPart = this.cleanAccountDirName(part) + if (memberLookup.has(normalizedPart)) return memberLookup.get(normalizedPart) || null + } + + if ((raw.startsWith('{') || raw.startsWith('[')) && raw.length < 4096) { + try { + const parsed = JSON.parse(raw) + return this.extractOwnerUsername(parsed, memberLookup, 0) + } catch { + return null + } + } + + return null + } + + private extractOwnerUsername( + value: unknown, + memberLookup: Map, + depth: number + ): string | null { + if (depth > 4 || value == null) return null + if (Buffer.isBuffer(value) || value instanceof Uint8Array) return null + + if (typeof value === 'string') { + return this.resolveMemberUsername(value, memberLookup) + } + + if (Array.isArray(value)) { + for (const item of value) { + const owner = this.extractOwnerUsername(item, memberLookup, depth + 1) + if (owner) return owner + } + return null + } + + if (typeof value !== 'object') return null + const row = value as Record + + for (const [key, entry] of Object.entries(row)) { + const keyLower = key.toLowerCase() + if (!keyLower.includes('owner') && !keyLower.includes('host') && !keyLower.includes('creator')) { + continue + } + + if (typeof entry === 'boolean') { + if (entry && typeof row.username === 'string') { + const owner = this.resolveMemberUsername(row.username, memberLookup) + if (owner) return owner + } + continue + } + + const owner = this.extractOwnerUsername(entry, memberLookup, depth + 1) + if (owner) return owner + } + + return null + } + + private async detectGroupOwnerUsername( + chatroomId: string, + members: Array<{ username: string; [key: string]: unknown }> + ): Promise { + const memberLookup = new Map() + for (const member of members) { + const username = String(member.username || '').trim() + if (!username) continue + const cleaned = this.cleanAccountDirName(username) + memberLookup.set(username, username) + memberLookup.set(cleaned, username) + } + if (memberLookup.size === 0) return undefined + + const tryResolve = (candidate: unknown): string | undefined => { + const owner = this.extractOwnerUsername(candidate, memberLookup, 0) + return owner || undefined + } + + for (const member of members) { + const owner = tryResolve(member) + if (owner) return owner + } + + try { + const groupContact = await wcdbService.getContact(chatroomId) + if (groupContact.success && groupContact.contact) { + const owner = tryResolve(groupContact.contact) + if (owner) return owner + } + } catch { + // ignore + } + + try { + const escapedChatroomId = chatroomId.replace(/'/g, "''") + const roomResult = await wcdbService.execQuery('contact', null, `SELECT * FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`) + if (roomResult.success && roomResult.rows && roomResult.rows.length > 0) { + const owner = tryResolve(roomResult.rows[0]) + if (owner) return owner + } + } catch { + // ignore + } + + return undefined + } + private async ensureConnected(): Promise<{ success: boolean; error?: string }> { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') @@ -497,6 +620,7 @@ class GroupAnalyticsService { username: string avatarUrl?: string originalName?: string + [key: string]: unknown }> const usernames = members.map((m) => m.username).filter(Boolean) @@ -543,6 +667,7 @@ class GroupAnalyticsService { const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates) const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') + const ownerUsername = await this.detectGroupOwnerUsername(chatroomId, members) const data: GroupMember[] = members.map((m) => { const wxid = m.username || '' const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid @@ -572,7 +697,8 @@ class GroupAnalyticsService { alias, remark, groupNickname, - avatarUrl: m.avatarUrl + avatarUrl: m.avatarUrl, + isOwner: Boolean(ownerUsername && ownerUsername === wxid) } }) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 911912a..db73c49 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2830,6 +2830,158 @@ } } +.group-members-panel { + .group-members-toolbar { + padding: 12px 16px 10px; + border-bottom: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 8px; + } + + .group-members-count { + font-size: 12px; + color: var(--text-secondary); + } + + .group-members-search { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); + + svg { + color: var(--text-tertiary); + flex-shrink: 0; + } + + input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--text-primary); + font-size: 13px; + + &::placeholder { + color: var(--text-tertiary); + } + } + } + + .group-members-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 4px 0; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 2px; + } + } + + .group-member-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--border-color); + + &:last-child { + border-bottom: none; + } + } + + .group-member-main { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + flex: 1; + } + + .group-member-avatar { + flex-shrink: 0; + } + + .group-member-meta { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + .group-member-name-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + } + + .group-member-name { + font-size: 13px; + color: var(--text-primary); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .group-member-id { + font-size: 11px; + color: var(--text-tertiary); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .group-member-badges { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + + .member-flag { + width: 18px; + height: 18px; + border-radius: 9999px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-color); + + &.owner { + color: #f59e0b; + background: color-mix(in srgb, #f59e0b 16%, transparent); + border-color: color-mix(in srgb, #f59e0b 35%, var(--border-color)); + } + + &.friend { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 14%, transparent); + border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color)); + } + } + + .group-member-count { + flex-shrink: 0; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + } +} + @keyframes slideInRight { from { opacity: 0; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 195d67e..5a83b63 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' @@ -213,6 +213,19 @@ interface SessionDetail { messageTables: { dbName: string; tableName: string; count: number }[] } +interface GroupPanelMember { + username: string + displayName: string + avatarUrl?: string + nickname?: string + alias?: string + remark?: string + groupNickname?: string + isOwner?: boolean + isFriend: boolean + messageCount: number +} + interface SessionListCachePayload { updatedAt: number sessions: ChatSession[] @@ -378,9 +391,14 @@ function ChatPage(_props: ChatPageProps) { const [sidebarWidth, setSidebarWidth] = useState(260) const [isResizing, setIsResizing] = useState(false) const [showDetailPanel, setShowDetailPanel] = useState(false) + const [showGroupMembersPanel, setShowGroupMembersPanel] = useState(false) const [sessionDetail, setSessionDetail] = useState(null) const [isLoadingDetail, setIsLoadingDetail] = useState(false) const [isLoadingDetailExtra, setIsLoadingDetailExtra] = useState(false) + const [groupPanelMembers, setGroupPanelMembers] = useState([]) + const [isLoadingGroupMembers, setIsLoadingGroupMembers] = useState(false) + const [groupMembersError, setGroupMembersError] = useState(null) + const [groupMemberSearchKeyword, setGroupMemberSearchKeyword] = useState('') const [copiedField, setCopiedField] = useState(null) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) @@ -459,6 +477,7 @@ function ChatPage(_props: ChatPageProps) { const preloadImageKeysRef = useRef>(new Set()) const lastPreloadSessionRef = useRef(null) const detailRequestSeqRef = useRef(0) + const groupMembersRequestSeqRef = useRef(0) const chatCacheScopeRef = useRef('default') const previewCacheRef = useRef>({}) const previewPersistTimerRef = useRef(null) @@ -466,6 +485,10 @@ function ChatPage(_props: ChatPageProps) { const pendingExportRequestIdRef = useRef(null) const exportPrepareLongWaitTimerRef = useRef(null) + const isGroupChatSession = useCallback((username: string) => { + return username.includes('@chatroom') + }, []) + const clearExportPrepareState = useCallback(() => { pendingExportRequestIdRef.current = null setIsPreparingExportDialog(false) @@ -824,18 +847,132 @@ function ChatPage(_props: ChatPageProps) { } }, []) + const loadGroupMembersPanel = useCallback(async (chatroomId: string) => { + if (!chatroomId || !isGroupChatSession(chatroomId)) return + + const requestSeq = ++groupMembersRequestSeqRef.current + setIsLoadingGroupMembers(true) + setGroupMembersError(null) + + try { + const [membersResult, rankingResult, contactsResult] = await Promise.all([ + window.electronAPI.groupAnalytics.getGroupMembers(chatroomId), + window.electronAPI.groupAnalytics.getGroupMessageRanking(chatroomId, 20000), + window.electronAPI.chat.getContacts() + ]) + if (requestSeq !== groupMembersRequestSeqRef.current) return + + if (!membersResult.success || !Array.isArray(membersResult.data)) { + setGroupPanelMembers([]) + setGroupMembersError(membersResult.error || '加载群成员失败') + return + } + + const messageCountMap = new Map() + if (rankingResult.success && Array.isArray(rankingResult.data)) { + for (const rank of rankingResult.data) { + const username = String(rank.member?.username || '').trim() + if (!username) continue + const count = Number.isFinite(rank.messageCount) ? Math.max(0, Math.floor(rank.messageCount)) : 0 + messageCountMap.set(username, count) + } + } + + const friendSet = new Set() + if (contactsResult.success && Array.isArray(contactsResult.contacts)) { + for (const contact of contactsResult.contacts) { + if (contact.type !== 'friend') continue + const username = String(contact.username || '').trim() + if (!username) continue + friendSet.add(username) + } + } + + const members: GroupPanelMember[] = membersResult.data + .map((member) => { + const username = String(member.username || '').trim() + if (!username) return null + const preferredName = String( + member.groupNickname || + member.remark || + member.displayName || + member.nickname || + username + ) + + return { + username, + displayName: preferredName, + avatarUrl: member.avatarUrl, + nickname: member.nickname, + alias: member.alias, + remark: member.remark, + groupNickname: member.groupNickname, + isOwner: Boolean(member.isOwner), + isFriend: friendSet.has(username), + messageCount: messageCountMap.get(username) || 0 + } + }) + .filter((member): member is GroupPanelMember => Boolean(member)) + .sort((a, b) => { + const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner)) + if (ownerDiff !== 0) return ownerDiff + + const friendDiff = Number(b.isFriend) - Number(a.isFriend) + if (friendDiff !== 0) return friendDiff + + if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount + return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN') + }) + + setGroupPanelMembers(members) + if (!rankingResult.success) { + setGroupMembersError(rankingResult.error || '群成员发言统计加载失败') + } + } catch (e) { + if (requestSeq !== groupMembersRequestSeqRef.current) return + setGroupPanelMembers([]) + setGroupMembersError(String(e)) + } finally { + if (requestSeq === groupMembersRequestSeqRef.current) { + setIsLoadingGroupMembers(false) + } + } + }, []) + + const toggleGroupMembersPanel = useCallback(() => { + if (!currentSessionId || !isGroupChatSession(currentSessionId)) return + if (showGroupMembersPanel) { + setShowGroupMembersPanel(false) + return + } + setShowDetailPanel(false) + setShowGroupMembersPanel(true) + }, [currentSessionId, showGroupMembersPanel, isGroupChatSession]) + // 切换详情面板 const toggleDetailPanel = useCallback(() => { if (showDetailPanel) { setShowDetailPanel(false) return } + setShowGroupMembersPanel(false) setShowDetailPanel(true) if (currentSessionId) { void loadSessionDetail(currentSessionId) } }, [showDetailPanel, currentSessionId, loadSessionDetail]) + useEffect(() => { + if (!showGroupMembersPanel) return + if (!currentSessionId || !isGroupChatSession(currentSessionId)) { + setShowGroupMembersPanel(false) + return + } + setGroupMemberSearchKeyword('') + void loadGroupMembersPanel(currentSessionId) + }, [showGroupMembersPanel, currentSessionId, loadGroupMembersPanel, isGroupChatSession]) + // 复制字段值到剪贴板 const handleCopyField = useCallback(async (text: string, field: string) => { try { @@ -887,6 +1024,13 @@ function ChatPage(_props: ChatPageProps) { pendingSessionLoadRef.current = null setIsSessionSwitching(false) setSessionDetail(null) + setShowDetailPanel(false) + setShowGroupMembersPanel(false) + setGroupPanelMembers([]) + setGroupMembersError(null) + setGroupMemberSearchKeyword('') + groupMembersRequestSeqRef.current += 1 + setIsLoadingGroupMembers(false) setCurrentSession(null) setSessions([]) setFilteredSessions([]) @@ -914,6 +1058,8 @@ function ChatPage(_props: ChatPageProps) { setMessages, setSearchKeyword, setSessionDetail, + setShowDetailPanel, + setShowGroupMembersPanel, setSessions ]) @@ -1545,6 +1691,11 @@ function ChatPage(_props: ChatPageProps) { void loadMessages(session.username, 0, 0, 0) // 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开 setShowDetailPanel(false) + setShowGroupMembersPanel(false) + setGroupMemberSearchKeyword('') + setGroupMembersError(null) + groupMembersRequestSeqRef.current += 1 + setIsLoadingGroupMembers(false) setSessionDetail(null) } @@ -1932,6 +2083,21 @@ function ChatPage(_props: ChatPageProps) { displayName: fallbackDisplayName || currentSessionId, } as ChatSession })() + const filteredGroupPanelMembers = useMemo(() => { + const keyword = groupMemberSearchKeyword.trim().toLowerCase() + if (!keyword) return groupPanelMembers + return groupPanelMembers.filter((member) => { + const fields = [ + member.username, + member.displayName, + member.groupNickname, + member.remark, + member.nickname, + member.alias + ] + return fields.some(field => String(field || '').toLowerCase().includes(keyword)) + }) + }, [groupMemberSearchKeyword, groupPanelMembers]) const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId)) const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog @@ -1949,9 +2115,6 @@ function ChatPage(_props: ChatPageProps) { }) }, [currentSessionId, sessions]) - // 判断是否为群聊 - const isGroupChat = (username: string) => username.includes('@chatroom') - // 渲染日期分隔 const shouldShowDateDivider = (msg: Message, prevMsg?: Message): boolean => { if (!prevMsg) return true @@ -2069,13 +2232,13 @@ function ChatPage(_props: ChatPageProps) { }, [currentSession, currentSessionId, inProgressExportSessionIds, isPreparingExportDialog]) const handleGroupAnalytics = useCallback(() => { - if (!currentSessionId || !isGroupChat(currentSessionId)) return + if (!currentSessionId || !isGroupChatSession(currentSessionId)) return navigate('/group-analytics', { state: { preselectGroupIds: [currentSessionId] } }) - }, [currentSessionId, navigate]) + }, [currentSessionId, navigate, isGroupChatSession]) // 确认批量转写 const confirmBatchTranscribe = useCallback(async () => { @@ -2723,16 +2886,16 @@ function ChatPage(_props: ChatPageProps) { src={currentSession.avatarUrl} name={currentSession.displayName || currentSession.username} size={40} - className={isGroupChat(currentSession.username) ? 'group session-avatar' : 'session-avatar'} + className={isGroupChatSession(currentSession.username) ? 'group session-avatar' : 'session-avatar'} />

{currentSession.displayName || currentSession.username}

- {isGroupChat(currentSession.username) && ( + {isGroupChatSession(currentSession.username) && (
群聊
)}
- {isGroupChat(currentSession.username) && ( + {isGroupChatSession(currentSession.username) && ( )} + {isGroupChatSession(currentSession.username) && ( + + )}
+ {/* 群成员面板 */} + {showGroupMembersPanel && isGroupChatSession(currentSession.username) && ( +
+
+

群成员

+ +
+ +
+ 共 {groupPanelMembers.length} 人 +
+ + setGroupMemberSearchKeyword(event.target.value)} + placeholder="搜索成员" + /> +
+
+ + {isLoadingGroupMembers ? ( +
+ + 加载群成员中... +
+ ) : groupMembersError && groupPanelMembers.length === 0 ? ( +
{groupMembersError}
+ ) : filteredGroupPanelMembers.length === 0 ? ( +
{groupMemberSearchKeyword.trim() ? '暂无匹配成员' : '暂无群成员数据'}
+ ) : ( +
+ {filteredGroupPanelMembers.map((member) => ( +
+
+ +
+
+ + {member.displayName || member.username} + +
+ {member.isOwner && ( + + + + )} + {member.isFriend && ( + + + + )} +
+
+ + {member.alias || member.username} + +
+
+ {member.messageCount.toLocaleString()} 条 +
+ ))} +
+ )} +
+ )} + {/* 会话详情面板 */} {showDetailPanel && (
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index cb45554..3101799 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -323,6 +323,7 @@ export interface ElectronAPI { alias?: string remark?: string groupNickname?: string + isOwner?: boolean }> error?: string }> @@ -638,6 +639,7 @@ export interface ExportProgress { current: number total: number currentSession: string + currentSessionId?: string phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' phaseProgress?: number phaseTotal?: number