feat(chat): add group members sidebar with owner/friend badges

This commit is contained in:
tisonhuang
2026-03-02 17:13:10 +08:00
parent 5cb364f754
commit 7bd801cd01
4 changed files with 537 additions and 11 deletions

View File

@@ -21,6 +21,7 @@ export interface GroupMember {
alias?: string alias?: string
remark?: string remark?: string
groupNickname?: string groupNickname?: string
isOwner?: boolean
} }
export interface GroupMessageRank { export interface GroupMessageRank {
@@ -89,6 +90,128 @@ class GroupAnalyticsService {
return cleaned return cleaned
} }
private resolveMemberUsername(
candidate: unknown,
memberLookup: Map<string, string>
): 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<string, string>,
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<string, unknown>
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<string | undefined> {
const memberLookup = new Map<string, string>()
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 }> { private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
const wxid = this.configService.get('myWxid') const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath') const dbPath = this.configService.get('dbPath')
@@ -497,6 +620,7 @@ class GroupAnalyticsService {
username: string username: string
avatarUrl?: string avatarUrl?: string
originalName?: string originalName?: string
[key: string]: unknown
}> }>
const usernames = members.map((m) => m.username).filter(Boolean) const usernames = members.map((m) => m.username).filter(Boolean)
@@ -543,6 +667,7 @@ class GroupAnalyticsService {
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates) const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
const ownerUsername = await this.detectGroupOwnerUsername(chatroomId, members)
const data: GroupMember[] = members.map((m) => { const data: GroupMember[] = members.map((m) => {
const wxid = m.username || '' const wxid = m.username || ''
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
@@ -572,7 +697,8 @@ class GroupAnalyticsService {
alias, alias,
remark, remark,
groupNickname, groupNickname,
avatarUrl: m.avatarUrl avatarUrl: m.avatarUrl,
isOwner: Boolean(ownerUsername && ownerUsername === wxid)
} }
}) })

View File

@@ -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 { @keyframes slideInRight {
from { from {
opacity: 0; opacity: 0;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' 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 { useNavigate } from 'react-router-dom'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
@@ -213,6 +213,19 @@ interface SessionDetail {
messageTables: { dbName: string; tableName: string; count: number }[] 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 { interface SessionListCachePayload {
updatedAt: number updatedAt: number
sessions: ChatSession[] sessions: ChatSession[]
@@ -378,9 +391,14 @@ function ChatPage(_props: ChatPageProps) {
const [sidebarWidth, setSidebarWidth] = useState(260) const [sidebarWidth, setSidebarWidth] = useState(260)
const [isResizing, setIsResizing] = useState(false) const [isResizing, setIsResizing] = useState(false)
const [showDetailPanel, setShowDetailPanel] = useState(false) const [showDetailPanel, setShowDetailPanel] = useState(false)
const [showGroupMembersPanel, setShowGroupMembersPanel] = useState(false)
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null) const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
const [isLoadingDetail, setIsLoadingDetail] = useState(false) const [isLoadingDetail, setIsLoadingDetail] = useState(false)
const [isLoadingDetailExtra, setIsLoadingDetailExtra] = useState(false) const [isLoadingDetailExtra, setIsLoadingDetailExtra] = useState(false)
const [groupPanelMembers, setGroupPanelMembers] = useState<GroupPanelMember[]>([])
const [isLoadingGroupMembers, setIsLoadingGroupMembers] = useState(false)
const [groupMembersError, setGroupMembersError] = useState<string | null>(null)
const [groupMemberSearchKeyword, setGroupMemberSearchKeyword] = useState('')
const [copiedField, setCopiedField] = useState<string | null>(null) const [copiedField, setCopiedField] = useState<string | null>(null)
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([]) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
@@ -459,6 +477,7 @@ function ChatPage(_props: ChatPageProps) {
const preloadImageKeysRef = useRef<Set<string>>(new Set()) const preloadImageKeysRef = useRef<Set<string>>(new Set())
const lastPreloadSessionRef = useRef<string | null>(null) const lastPreloadSessionRef = useRef<string | null>(null)
const detailRequestSeqRef = useRef(0) const detailRequestSeqRef = useRef(0)
const groupMembersRequestSeqRef = useRef(0)
const chatCacheScopeRef = useRef('default') const chatCacheScopeRef = useRef('default')
const previewCacheRef = useRef<Record<string, SessionPreviewCacheEntry>>({}) const previewCacheRef = useRef<Record<string, SessionPreviewCacheEntry>>({})
const previewPersistTimerRef = useRef<number | null>(null) const previewPersistTimerRef = useRef<number | null>(null)
@@ -466,6 +485,10 @@ function ChatPage(_props: ChatPageProps) {
const pendingExportRequestIdRef = useRef<string | null>(null) const pendingExportRequestIdRef = useRef<string | null>(null)
const exportPrepareLongWaitTimerRef = useRef<number | null>(null) const exportPrepareLongWaitTimerRef = useRef<number | null>(null)
const isGroupChatSession = useCallback((username: string) => {
return username.includes('@chatroom')
}, [])
const clearExportPrepareState = useCallback(() => { const clearExportPrepareState = useCallback(() => {
pendingExportRequestIdRef.current = null pendingExportRequestIdRef.current = null
setIsPreparingExportDialog(false) 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<string, number>()
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<string>()
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(() => { const toggleDetailPanel = useCallback(() => {
if (showDetailPanel) { if (showDetailPanel) {
setShowDetailPanel(false) setShowDetailPanel(false)
return return
} }
setShowGroupMembersPanel(false)
setShowDetailPanel(true) setShowDetailPanel(true)
if (currentSessionId) { if (currentSessionId) {
void loadSessionDetail(currentSessionId) void loadSessionDetail(currentSessionId)
} }
}, [showDetailPanel, currentSessionId, loadSessionDetail]) }, [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) => { const handleCopyField = useCallback(async (text: string, field: string) => {
try { try {
@@ -887,6 +1024,13 @@ function ChatPage(_props: ChatPageProps) {
pendingSessionLoadRef.current = null pendingSessionLoadRef.current = null
setIsSessionSwitching(false) setIsSessionSwitching(false)
setSessionDetail(null) setSessionDetail(null)
setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setGroupPanelMembers([])
setGroupMembersError(null)
setGroupMemberSearchKeyword('')
groupMembersRequestSeqRef.current += 1
setIsLoadingGroupMembers(false)
setCurrentSession(null) setCurrentSession(null)
setSessions([]) setSessions([])
setFilteredSessions([]) setFilteredSessions([])
@@ -914,6 +1058,8 @@ function ChatPage(_props: ChatPageProps) {
setMessages, setMessages,
setSearchKeyword, setSearchKeyword,
setSessionDetail, setSessionDetail,
setShowDetailPanel,
setShowGroupMembersPanel,
setSessions setSessions
]) ])
@@ -1545,6 +1691,11 @@ function ChatPage(_props: ChatPageProps) {
void loadMessages(session.username, 0, 0, 0) void loadMessages(session.username, 0, 0, 0)
// 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开 // 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开
setShowDetailPanel(false) setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setGroupMemberSearchKeyword('')
setGroupMembersError(null)
groupMembersRequestSeqRef.current += 1
setIsLoadingGroupMembers(false)
setSessionDetail(null) setSessionDetail(null)
} }
@@ -1932,6 +2083,21 @@ function ChatPage(_props: ChatPageProps) {
displayName: fallbackDisplayName || currentSessionId, displayName: fallbackDisplayName || currentSessionId,
} as ChatSession } 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 isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId))
const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog
@@ -1949,9 +2115,6 @@ function ChatPage(_props: ChatPageProps) {
}) })
}, [currentSessionId, sessions]) }, [currentSessionId, sessions])
// 判断是否为群聊
const isGroupChat = (username: string) => username.includes('@chatroom')
// 渲染日期分隔 // 渲染日期分隔
const shouldShowDateDivider = (msg: Message, prevMsg?: Message): boolean => { const shouldShowDateDivider = (msg: Message, prevMsg?: Message): boolean => {
if (!prevMsg) return true if (!prevMsg) return true
@@ -2069,13 +2232,13 @@ function ChatPage(_props: ChatPageProps) {
}, [currentSession, currentSessionId, inProgressExportSessionIds, isPreparingExportDialog]) }, [currentSession, currentSessionId, inProgressExportSessionIds, isPreparingExportDialog])
const handleGroupAnalytics = useCallback(() => { const handleGroupAnalytics = useCallback(() => {
if (!currentSessionId || !isGroupChat(currentSessionId)) return if (!currentSessionId || !isGroupChatSession(currentSessionId)) return
navigate('/group-analytics', { navigate('/group-analytics', {
state: { state: {
preselectGroupIds: [currentSessionId] preselectGroupIds: [currentSessionId]
} }
}) })
}, [currentSessionId, navigate]) }, [currentSessionId, navigate, isGroupChatSession])
// 确认批量转写 // 确认批量转写
const confirmBatchTranscribe = useCallback(async () => { const confirmBatchTranscribe = useCallback(async () => {
@@ -2723,16 +2886,16 @@ function ChatPage(_props: ChatPageProps) {
src={currentSession.avatarUrl} src={currentSession.avatarUrl}
name={currentSession.displayName || currentSession.username} name={currentSession.displayName || currentSession.username}
size={40} size={40}
className={isGroupChat(currentSession.username) ? 'group session-avatar' : 'session-avatar'} className={isGroupChatSession(currentSession.username) ? 'group session-avatar' : 'session-avatar'}
/> />
<div className="header-info"> <div className="header-info">
<h3>{currentSession.displayName || currentSession.username}</h3> <h3>{currentSession.displayName || currentSession.username}</h3>
{isGroupChat(currentSession.username) && ( {isGroupChatSession(currentSession.username) && (
<div className="header-subtitle"></div> <div className="header-subtitle"></div>
)} )}
</div> </div>
<div className="header-actions"> <div className="header-actions">
{isGroupChat(currentSession.username) && ( {isGroupChatSession(currentSession.username) && (
<button <button
className="icon-btn group-analytics-btn" className="icon-btn group-analytics-btn"
onClick={handleGroupAnalytics} onClick={handleGroupAnalytics}
@@ -2741,6 +2904,15 @@ function ChatPage(_props: ChatPageProps) {
<BarChart3 size={18} /> <BarChart3 size={18} />
</button> </button>
)} )}
{isGroupChatSession(currentSession.username) && (
<button
className={`icon-btn group-members-btn ${showGroupMembersPanel ? 'active' : ''}`}
onClick={toggleGroupMembersPanel}
title="群成员"
>
<Users size={18} />
</button>
)}
<button <button
className={`icon-btn export-session-btn${isExportActionBusy ? ' exporting' : ''}`} className={`icon-btn export-session-btn${isExportActionBusy ? ' exporting' : ''}`}
onClick={handleExportCurrentSession} onClick={handleExportCurrentSession}
@@ -2920,7 +3092,7 @@ function ChatPage(_props: ChatPageProps) {
session={currentSession} session={currentSession}
showTime={!showDateDivider && showTime} showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl} myAvatarUrl={myAvatarUrl}
isGroupChat={isGroupChat(currentSession.username)} isGroupChat={isGroupChatSession(currentSession.username)}
onRequireModelDownload={handleRequireModelDownload} onRequireModelDownload={handleRequireModelDownload}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
@@ -2951,6 +3123,80 @@ function ChatPage(_props: ChatPageProps) {
</div> </div>
</div> </div>
{/* 群成员面板 */}
{showGroupMembersPanel && isGroupChatSession(currentSession.username) && (
<div className="detail-panel group-members-panel">
<div className="detail-header">
<h4></h4>
<button className="close-btn" onClick={() => setShowGroupMembersPanel(false)}>
<X size={16} />
</button>
</div>
<div className="group-members-toolbar">
<span className="group-members-count"> {groupPanelMembers.length} </span>
<div className="group-members-search">
<Search size={14} />
<input
type="text"
value={groupMemberSearchKeyword}
onChange={(event) => setGroupMemberSearchKeyword(event.target.value)}
placeholder="搜索成员"
/>
</div>
</div>
{isLoadingGroupMembers ? (
<div className="detail-loading">
<Loader2 size={20} className="spin" />
<span>...</span>
</div>
) : groupMembersError && groupPanelMembers.length === 0 ? (
<div className="detail-empty">{groupMembersError}</div>
) : filteredGroupPanelMembers.length === 0 ? (
<div className="detail-empty">{groupMemberSearchKeyword.trim() ? '暂无匹配成员' : '暂无群成员数据'}</div>
) : (
<div className="group-members-list">
{filteredGroupPanelMembers.map((member) => (
<div key={member.username} className="group-member-item">
<div className="group-member-main">
<Avatar
src={member.avatarUrl}
name={member.displayName || member.username}
size={34}
className="group-member-avatar"
/>
<div className="group-member-meta">
<div className="group-member-name-row">
<span className="group-member-name" title={member.displayName || member.username}>
{member.displayName || member.username}
</span>
<div className="group-member-badges">
{member.isOwner && (
<span className="member-flag owner" title="群主">
<Crown size={12} />
</span>
)}
{member.isFriend && (
<span className="member-flag friend" title="好友">
<UserCheck size={12} />
</span>
)}
</div>
</div>
<span className="group-member-id" title={member.alias || member.username}>
{member.alias || member.username}
</span>
</div>
</div>
<span className="group-member-count">{member.messageCount.toLocaleString()} </span>
</div>
))}
</div>
)}
</div>
)}
{/* 会话详情面板 */} {/* 会话详情面板 */}
{showDetailPanel && ( {showDetailPanel && (
<div className="detail-panel"> <div className="detail-panel">

View File

@@ -323,6 +323,7 @@ export interface ElectronAPI {
alias?: string alias?: string
remark?: string remark?: string
groupNickname?: string groupNickname?: string
isOwner?: boolean
}> }>
error?: string error?: string
}> }>
@@ -638,6 +639,7 @@ export interface ExportProgress {
current: number current: number
total: number total: number
currentSession: string currentSession: string
currentSessionId?: string
phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
phaseProgress?: number phaseProgress?: number
phaseTotal?: number phaseTotal?: number