mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat(chat): add group members sidebar with owner/friend badges
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
2
src/types/electron.d.ts
vendored
2
src/types/electron.d.ts
vendored
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user