mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
feat(export): add session detail sidebar entry
This commit is contained in:
@@ -569,6 +569,18 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-table-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -936,6 +948,35 @@
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
||||||
|
.row-action-main {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-detail-btn {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.row-export-btn {
|
.row-export-btn {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -974,6 +1015,179 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-session-detail-panel {
|
||||||
|
width: 300px;
|
||||||
|
min-width: 300px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-loading,
|
||||||
|
.detail-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: text;
|
||||||
|
|
||||||
|
&.highlight {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .copy-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table-placeholder {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.db-name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-count {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.table-state {
|
.table-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1401,6 +1615,16 @@
|
|||||||
.media-check-grid {
|
.media-check-grid {
|
||||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-table-layout.with-detail {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-session-detail-panel {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-height: 360px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -1421,4 +1645,8 @@
|
|||||||
.date-range-row {
|
.date-range-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-session-detail-panel {
|
||||||
|
max-height: 320px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,22 @@ import { useLocation } from 'react-router-dom'
|
|||||||
import { TableVirtuoso } from 'react-virtuoso'
|
import { TableVirtuoso } from 'react-virtuoso'
|
||||||
import {
|
import {
|
||||||
Aperture,
|
Aperture,
|
||||||
|
Calendar,
|
||||||
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
|
Copy,
|
||||||
|
Database,
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Hash,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
MessageSquare,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
Mic,
|
Mic,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -169,6 +175,15 @@ const formatAbsoluteDate = (timestamp: number): string => {
|
|||||||
return `${y}-${m}-${day}`
|
return `${y}-${m}-${day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatYmdDateFromSeconds = (timestamp?: number): string => {
|
||||||
|
if (!timestamp || !Number.isFinite(timestamp)) return '—'
|
||||||
|
const d = new Date(timestamp * 1000)
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = `${d.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const day = `${d.getDate()}`.padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
|
const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
|
||||||
if (!timestamp) return ''
|
if (!timestamp) return ''
|
||||||
const diff = Math.max(0, now - timestamp)
|
const diff = Math.max(0, now - timestamp)
|
||||||
@@ -270,6 +285,28 @@ interface ContactsLoadIssue {
|
|||||||
elapsedMs: number
|
elapsedMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SessionDetail {
|
||||||
|
wxid: string
|
||||||
|
displayName: string
|
||||||
|
remark?: string
|
||||||
|
nickName?: string
|
||||||
|
alias?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
messageCount: number
|
||||||
|
voiceMessages?: number
|
||||||
|
imageMessages?: number
|
||||||
|
videoMessages?: number
|
||||||
|
emojiMessages?: number
|
||||||
|
privateMutualGroups?: number
|
||||||
|
groupMemberCount?: number
|
||||||
|
groupMyMessages?: number
|
||||||
|
groupActiveSpeakers?: number
|
||||||
|
groupMutualFriends?: number
|
||||||
|
firstMessageTime?: number
|
||||||
|
latestMessageTime?: number
|
||||||
|
messageTables: { dbName: string; tableName: string; count: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
|
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
try {
|
try {
|
||||||
@@ -536,6 +573,11 @@ function ExportPage() {
|
|||||||
total: 0,
|
total: 0,
|
||||||
running: false
|
running: false
|
||||||
})
|
})
|
||||||
|
const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false)
|
||||||
|
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
||||||
|
const [isLoadingSessionDetail, setIsLoadingSessionDetail] = useState(false)
|
||||||
|
const [isLoadingSessionDetailExtra, setIsLoadingSessionDetailExtra] = useState(false)
|
||||||
|
const [copiedDetailField, setCopiedDetailField] = useState<string | null>(null)
|
||||||
|
|
||||||
const [exportFolder, setExportFolder] = useState('')
|
const [exportFolder, setExportFolder] = useState('')
|
||||||
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A')
|
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A')
|
||||||
@@ -598,6 +640,7 @@ function ExportPage() {
|
|||||||
const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
|
const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
|
||||||
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
|
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
|
||||||
const contactsListRef = useRef<HTMLDivElement>(null)
|
const contactsListRef = useRef<HTMLDivElement>(null)
|
||||||
|
const detailRequestSeqRef = useRef(0)
|
||||||
|
|
||||||
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
|
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
|
||||||
if (exportCacheScopeReadyRef.current) {
|
if (exportCacheScopeReadyRef.current) {
|
||||||
@@ -1913,6 +1956,163 @@ function ExportPage() {
|
|||||||
return map
|
return map
|
||||||
}, [sessions])
|
}, [sessions])
|
||||||
|
|
||||||
|
const contactByUsername = useMemo(() => {
|
||||||
|
const map = new Map<string, ContactInfo>()
|
||||||
|
for (const contact of contactsList) {
|
||||||
|
map.set(contact.username, contact)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [contactsList])
|
||||||
|
|
||||||
|
const loadSessionDetail = useCallback(async (sessionId: string) => {
|
||||||
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
|
if (!normalizedSessionId) return
|
||||||
|
|
||||||
|
const requestSeq = ++detailRequestSeqRef.current
|
||||||
|
const mappedSession = sessionRowByUsername.get(normalizedSessionId)
|
||||||
|
const mappedContact = contactByUsername.get(normalizedSessionId)
|
||||||
|
const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0
|
||||||
|
? Math.floor(mappedSession.messageCountHint)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
setCopiedDetailField(null)
|
||||||
|
setSessionDetail((prev) => {
|
||||||
|
const sameSession = prev?.wxid === normalizedSessionId
|
||||||
|
return {
|
||||||
|
wxid: normalizedSessionId,
|
||||||
|
displayName: mappedSession?.displayName || mappedContact?.displayName || prev?.displayName || normalizedSessionId,
|
||||||
|
remark: sameSession ? prev?.remark : mappedContact?.remark,
|
||||||
|
nickName: sameSession ? prev?.nickName : mappedContact?.nickname,
|
||||||
|
alias: sameSession ? prev?.alias : undefined,
|
||||||
|
avatarUrl: mappedSession?.avatarUrl || mappedContact?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined),
|
||||||
|
messageCount: hintedCount ?? (sameSession ? prev.messageCount : Number.NaN),
|
||||||
|
voiceMessages: sameSession ? prev?.voiceMessages : undefined,
|
||||||
|
imageMessages: sameSession ? prev?.imageMessages : undefined,
|
||||||
|
videoMessages: sameSession ? prev?.videoMessages : undefined,
|
||||||
|
emojiMessages: sameSession ? prev?.emojiMessages : undefined,
|
||||||
|
privateMutualGroups: sameSession ? prev?.privateMutualGroups : undefined,
|
||||||
|
groupMemberCount: sameSession ? prev?.groupMemberCount : undefined,
|
||||||
|
groupMyMessages: sameSession ? prev?.groupMyMessages : undefined,
|
||||||
|
groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined,
|
||||||
|
groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined,
|
||||||
|
firstMessageTime: sameSession ? prev?.firstMessageTime : undefined,
|
||||||
|
latestMessageTime: sameSession ? prev?.latestMessageTime : undefined,
|
||||||
|
messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setIsLoadingSessionDetail(true)
|
||||||
|
setIsLoadingSessionDetailExtra(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId)
|
||||||
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
|
if (result.success && result.detail) {
|
||||||
|
setSessionDetail((prev) => ({
|
||||||
|
wxid: normalizedSessionId,
|
||||||
|
displayName: result.detail!.displayName || prev?.displayName || normalizedSessionId,
|
||||||
|
remark: result.detail!.remark ?? prev?.remark,
|
||||||
|
nickName: result.detail!.nickName ?? prev?.nickName,
|
||||||
|
alias: result.detail!.alias ?? prev?.alias,
|
||||||
|
avatarUrl: result.detail!.avatarUrl || prev?.avatarUrl,
|
||||||
|
messageCount: Number.isFinite(result.detail!.messageCount) ? result.detail!.messageCount : prev?.messageCount ?? Number.NaN,
|
||||||
|
voiceMessages: prev?.voiceMessages,
|
||||||
|
imageMessages: prev?.imageMessages,
|
||||||
|
videoMessages: prev?.videoMessages,
|
||||||
|
emojiMessages: prev?.emojiMessages,
|
||||||
|
privateMutualGroups: prev?.privateMutualGroups,
|
||||||
|
groupMemberCount: prev?.groupMemberCount,
|
||||||
|
groupMyMessages: prev?.groupMyMessages,
|
||||||
|
groupActiveSpeakers: prev?.groupActiveSpeakers,
|
||||||
|
groupMutualFriends: prev?.groupMutualFriends,
|
||||||
|
firstMessageTime: prev?.firstMessageTime,
|
||||||
|
latestMessageTime: prev?.latestMessageTime,
|
||||||
|
messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : []
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出页加载会话详情失败:', error)
|
||||||
|
} finally {
|
||||||
|
if (requestSeq === detailRequestSeqRef.current) {
|
||||||
|
setIsLoadingSessionDetail(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
|
||||||
|
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
|
||||||
|
window.electronAPI.chat.getExportSessionStats([normalizedSessionId])
|
||||||
|
])
|
||||||
|
|
||||||
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
|
|
||||||
|
setSessionDetail((prev) => {
|
||||||
|
if (!prev || prev.wxid !== normalizedSessionId) return prev
|
||||||
|
|
||||||
|
let next = { ...prev }
|
||||||
|
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success && extraResultSettled.value.detail) {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
firstMessageTime: extraResultSettled.value.detail.firstMessageTime,
|
||||||
|
latestMessageTime: extraResultSettled.value.detail.latestMessageTime,
|
||||||
|
messageTables: Array.isArray(extraResultSettled.value.detail.messageTables) ? extraResultSettled.value.detail.messageTables : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success && statsResultSettled.value.data) {
|
||||||
|
const metric = statsResultSettled.value.data[normalizedSessionId]
|
||||||
|
if (metric) {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : next.messageCount,
|
||||||
|
voiceMessages: metric.voiceMessages,
|
||||||
|
imageMessages: metric.imageMessages,
|
||||||
|
videoMessages: metric.videoMessages,
|
||||||
|
emojiMessages: metric.emojiMessages,
|
||||||
|
privateMutualGroups: metric.privateMutualGroups,
|
||||||
|
groupMemberCount: metric.groupMemberCount,
|
||||||
|
groupMyMessages: metric.groupMyMessages,
|
||||||
|
groupActiveSpeakers: metric.groupActiveSpeakers,
|
||||||
|
groupMutualFriends: metric.groupMutualFriends,
|
||||||
|
firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : next.firstMessageTime,
|
||||||
|
latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : next.latestMessageTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出页加载会话详情补充统计失败:', error)
|
||||||
|
} finally {
|
||||||
|
if (requestSeq === detailRequestSeqRef.current) {
|
||||||
|
setIsLoadingSessionDetailExtra(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [contactByUsername, sessionRowByUsername])
|
||||||
|
|
||||||
|
const openSessionDetail = useCallback((sessionId: string) => {
|
||||||
|
if (!sessionId) return
|
||||||
|
setShowSessionDetailPanel(true)
|
||||||
|
void loadSessionDetail(sessionId)
|
||||||
|
}, [loadSessionDetail])
|
||||||
|
|
||||||
|
const handleCopyDetailField = useCallback(async (text: string, field: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
setCopiedDetailField(field)
|
||||||
|
setTimeout(() => setCopiedDetailField(null), 1500)
|
||||||
|
} catch {
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
setCopiedDetailField(field)
|
||||||
|
setTimeout(() => setCopiedDetailField(null), 1500)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const contactsUpdatedAtLabel = useMemo(() => {
|
const contactsUpdatedAtLabel = useMemo(() => {
|
||||||
if (!contactsUpdatedAt) return ''
|
if (!contactsUpdatedAt) return ''
|
||||||
return new Date(contactsUpdatedAt).toLocaleString()
|
return new Date(contactsUpdatedAt).toLocaleString()
|
||||||
@@ -2044,12 +2244,21 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderActionCell = (session: SessionRow) => {
|
const renderActionCell = (session: SessionRow) => {
|
||||||
|
const isDetailActive = showSessionDetailPanel && sessionDetail?.wxid === session.username
|
||||||
if (!session.hasSession) {
|
if (!session.hasSession) {
|
||||||
return (
|
return (
|
||||||
<div className="row-action-cell">
|
<div className="row-action-cell">
|
||||||
<button className="row-export-btn no-session" disabled>
|
<div className="row-action-main">
|
||||||
暂无会话
|
<button
|
||||||
</button>
|
className={`row-detail-btn ${isDetailActive ? 'active' : ''}`}
|
||||||
|
onClick={() => openSessionDetail(session.username)}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</button>
|
||||||
|
<button className="row-export-btn no-session" disabled>
|
||||||
|
暂无会话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2060,18 +2269,26 @@ function ExportPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row-action-cell">
|
<div className="row-action-cell">
|
||||||
<button
|
<div className="row-action-main">
|
||||||
className={`row-export-btn ${isRunning ? 'running' : ''}`}
|
<button
|
||||||
disabled={isRunning}
|
className={`row-detail-btn ${isDetailActive ? 'active' : ''}`}
|
||||||
onClick={() => openSingleExport(session)}
|
onClick={() => openSessionDetail(session.username)}
|
||||||
>
|
>
|
||||||
{isRunning ? (
|
详情
|
||||||
<>
|
</button>
|
||||||
<Loader2 size={14} className="spin" />
|
<button
|
||||||
导出中
|
className={`row-export-btn ${isRunning ? 'running' : ''}`}
|
||||||
</>
|
disabled={isRunning}
|
||||||
) : isQueued ? '排队中' : '导出'}
|
onClick={() => openSingleExport(session)}
|
||||||
</button>
|
>
|
||||||
|
{isRunning ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="spin" />
|
||||||
|
导出中
|
||||||
|
</>
|
||||||
|
) : isQueued ? '排队中' : '导出'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{recent && <span className="row-export-time">{recent}</span>}
|
{recent && <span className="row-export-time">{recent}</span>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -2364,110 +2581,310 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="table-wrap">
|
<div className={`session-table-layout ${showSessionDetailPanel ? 'with-detail' : ''}`}>
|
||||||
{contactsList.length === 0 && contactsLoadIssue ? (
|
<div className="table-wrap">
|
||||||
<div className="load-issue-state">
|
{contactsList.length === 0 && contactsLoadIssue ? (
|
||||||
<div className="issue-card">
|
<div className="load-issue-state">
|
||||||
<div className="issue-title">
|
<div className="issue-card">
|
||||||
<AlertTriangle size={18} />
|
<div className="issue-title">
|
||||||
<span>{contactsLoadIssue.title}</span>
|
<AlertTriangle size={18} />
|
||||||
|
<span>{contactsLoadIssue.title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="issue-message">{contactsLoadIssue.message}</p>
|
||||||
|
<p className="issue-reason">{contactsLoadIssue.reason}</p>
|
||||||
|
<ul className="issue-hints">
|
||||||
|
<li>可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。</li>
|
||||||
|
<li>可能原因2:contact.db 数据量较大,首次查询时间过长。</li>
|
||||||
|
<li>可能原因3:数据库连接状态异常或 IPC 调用卡住。</li>
|
||||||
|
</ul>
|
||||||
|
<div className="issue-actions">
|
||||||
|
<button className="issue-btn primary" onClick={() => void loadContactsList()}>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
<span>重试加载</span>
|
||||||
|
</button>
|
||||||
|
<button className="issue-btn" onClick={() => setShowContactsDiagnostics(prev => !prev)}>
|
||||||
|
<ClipboardList size={14} />
|
||||||
|
<span>{showContactsDiagnostics ? '收起诊断详情' : '查看诊断详情'}</span>
|
||||||
|
</button>
|
||||||
|
<button className="issue-btn" onClick={copyContactsDiagnostics}>
|
||||||
|
<span>复制诊断信息</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showContactsDiagnostics && (
|
||||||
|
<pre className="issue-diagnostics">{contactsDiagnosticsText}</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="issue-message">{contactsLoadIssue.message}</p>
|
|
||||||
<p className="issue-reason">{contactsLoadIssue.reason}</p>
|
|
||||||
<ul className="issue-hints">
|
|
||||||
<li>可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。</li>
|
|
||||||
<li>可能原因2:contact.db 数据量较大,首次查询时间过长。</li>
|
|
||||||
<li>可能原因3:数据库连接状态异常或 IPC 调用卡住。</li>
|
|
||||||
</ul>
|
|
||||||
<div className="issue-actions">
|
|
||||||
<button className="issue-btn primary" onClick={() => void loadContactsList()}>
|
|
||||||
<RefreshCw size={14} />
|
|
||||||
<span>重试加载</span>
|
|
||||||
</button>
|
|
||||||
<button className="issue-btn" onClick={() => setShowContactsDiagnostics(prev => !prev)}>
|
|
||||||
<ClipboardList size={14} />
|
|
||||||
<span>{showContactsDiagnostics ? '收起诊断详情' : '查看诊断详情'}</span>
|
|
||||||
</button>
|
|
||||||
<button className="issue-btn" onClick={copyContactsDiagnostics}>
|
|
||||||
<span>复制诊断信息</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showContactsDiagnostics && (
|
|
||||||
<pre className="issue-diagnostics">{contactsDiagnosticsText}</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : isContactsListLoading && contactsList.length === 0 ? (
|
||||||
) : isContactsListLoading && contactsList.length === 0 ? (
|
<div className="loading-state">
|
||||||
<div className="loading-state">
|
<Loader2 size={32} className="spin" />
|
||||||
<Loader2 size={32} className="spin" />
|
<span>联系人加载中...</span>
|
||||||
<span>联系人加载中...</span>
|
</div>
|
||||||
</div>
|
) : filteredContacts.length === 0 ? (
|
||||||
) : filteredContacts.length === 0 ? (
|
<div className="empty-state">
|
||||||
<div className="empty-state">
|
<span>暂无联系人</span>
|
||||||
<span>暂无联系人</span>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div className="contacts-list" ref={contactsListRef} onScroll={onContactsListScroll}>
|
||||||
<div className="contacts-list" ref={contactsListRef} onScroll={onContactsListScroll}>
|
<div
|
||||||
<div
|
className="contacts-list-virtual"
|
||||||
className="contacts-list-virtual"
|
style={{ height: filteredContacts.length * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT }}
|
||||||
style={{ height: filteredContacts.length * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT }}
|
>
|
||||||
>
|
{visibleContacts.map((contact, idx) => {
|
||||||
{visibleContacts.map((contact, idx) => {
|
const absoluteIndex = contactStartIndex + idx
|
||||||
const absoluteIndex = contactStartIndex + idx
|
const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT
|
||||||
const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT
|
const matchedSession = sessionRowByUsername.get(contact.username)
|
||||||
const matchedSession = sessionRowByUsername.get(contact.username)
|
const canExport = Boolean(matchedSession?.hasSession)
|
||||||
const canExport = Boolean(matchedSession?.hasSession)
|
const isRunning = canExport && runningSessionIds.has(contact.username)
|
||||||
const isRunning = canExport && runningSessionIds.has(contact.username)
|
const isQueued = canExport && queuedSessionIds.has(contact.username)
|
||||||
const isQueued = canExport && queuedSessionIds.has(contact.username)
|
const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : ''
|
||||||
const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : ''
|
return (
|
||||||
return (
|
<div
|
||||||
<div
|
key={contact.username}
|
||||||
key={contact.username}
|
className="contact-row"
|
||||||
className="contact-row"
|
style={{ transform: `translateY(${top}px)` }}
|
||||||
style={{ transform: `translateY(${top}px)` }}
|
>
|
||||||
>
|
<div className="contact-item">
|
||||||
<div className="contact-item">
|
<div className="contact-avatar">
|
||||||
<div className="contact-avatar">
|
{contact.avatarUrl ? (
|
||||||
{contact.avatarUrl ? (
|
<img src={contact.avatarUrl} alt="" loading="lazy" />
|
||||||
<img src={contact.avatarUrl} alt="" loading="lazy" />
|
) : (
|
||||||
) : (
|
<span>{getAvatarLetter(contact.displayName)}</span>
|
||||||
<span>{getAvatarLetter(contact.displayName)}</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div className="contact-info">
|
||||||
<div className="contact-info">
|
<div className="contact-name">{contact.displayName}</div>
|
||||||
<div className="contact-name">{contact.displayName}</div>
|
<div className="contact-remark">{contact.username}</div>
|
||||||
<div className="contact-remark">{contact.username}</div>
|
</div>
|
||||||
</div>
|
<div className={`contact-type ${contact.type}`}>
|
||||||
<div className={`contact-type ${contact.type}`}>
|
<span>{getContactTypeName(contact.type)}</span>
|
||||||
<span>{getContactTypeName(contact.type)}</span>
|
</div>
|
||||||
</div>
|
<div className="row-action-cell">
|
||||||
<div className="row-action-cell">
|
<div className="row-action-main">
|
||||||
<button
|
<button
|
||||||
className={`row-export-btn ${isRunning ? 'running' : ''} ${!canExport ? 'no-session' : ''}`}
|
className={`row-detail-btn ${showSessionDetailPanel && sessionDetail?.wxid === contact.username ? 'active' : ''}`}
|
||||||
disabled={!canExport || isRunning}
|
onClick={() => openSessionDetail(contact.username)}
|
||||||
onClick={() => {
|
>
|
||||||
if (!matchedSession || !matchedSession.hasSession) return
|
详情
|
||||||
openSingleExport({
|
</button>
|
||||||
...matchedSession,
|
<button
|
||||||
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
|
className={`row-export-btn ${isRunning ? 'running' : ''} ${!canExport ? 'no-session' : ''}`}
|
||||||
})
|
disabled={!canExport || isRunning}
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
if (!matchedSession || !matchedSession.hasSession) return
|
||||||
{isRunning ? (
|
openSingleExport({
|
||||||
<>
|
...matchedSession,
|
||||||
<Loader2 size={14} className="spin" />
|
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
|
||||||
导出中
|
})
|
||||||
</>
|
}}
|
||||||
) : !canExport ? '暂无会话' : isQueued ? '排队中' : '导出'}
|
>
|
||||||
</button>
|
{isRunning ? (
|
||||||
{recent && <span className="row-export-time">{recent}</span>}
|
<>
|
||||||
|
<Loader2 size={14} className="spin" />
|
||||||
|
导出中
|
||||||
|
</>
|
||||||
|
) : !canExport ? '暂无会话' : isQueued ? '排队中' : '导出'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{recent && <span className="row-export-time">{recent}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSessionDetailPanel && (
|
||||||
|
<aside className="export-session-detail-panel">
|
||||||
|
<div className="detail-header">
|
||||||
|
<h4>会话详情</h4>
|
||||||
|
<button className="close-btn" onClick={() => setShowSessionDetailPanel(false)}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isLoadingSessionDetail && !sessionDetail ? (
|
||||||
|
<div className="detail-loading">
|
||||||
|
<Loader2 size={20} className="spin" />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : sessionDetail ? (
|
||||||
|
<div className="detail-content">
|
||||||
|
<div className="detail-section">
|
||||||
|
<div className="detail-item">
|
||||||
|
<Hash size={14} />
|
||||||
|
<span className="label">微信ID</span>
|
||||||
|
<span className="value">{sessionDetail.wxid}</span>
|
||||||
|
<button className="copy-btn" title="复制" onClick={() => void handleCopyDetailField(sessionDetail.wxid, 'wxid')}>
|
||||||
|
{copiedDetailField === 'wxid' ? <Check size={12} /> : <Copy size={12} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{sessionDetail.remark && (
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">备注</span>
|
||||||
|
<span className="value">{sessionDetail.remark}</span>
|
||||||
|
<button className="copy-btn" title="复制" onClick={() => void handleCopyDetailField(sessionDetail.remark || '', 'remark')}>
|
||||||
|
{copiedDetailField === 'remark' ? <Check size={12} /> : <Copy size={12} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sessionDetail.nickName && (
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">昵称</span>
|
||||||
|
<span className="value">{sessionDetail.nickName}</span>
|
||||||
|
<button className="copy-btn" title="复制" onClick={() => void handleCopyDetailField(sessionDetail.nickName || '', 'nickName')}>
|
||||||
|
{copiedDetailField === 'nickName' ? <Check size={12} /> : <Copy size={12} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sessionDetail.alias && (
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">微信号</span>
|
||||||
|
<span className="value">{sessionDetail.alias}</span>
|
||||||
|
<button className="copy-btn" title="复制" onClick={() => void handleCopyDetailField(sessionDetail.alias || '', 'alias')}>
|
||||||
|
{copiedDetailField === 'alias' ? <Check size={12} /> : <Copy size={12} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-section">
|
||||||
|
<div className="section-title">
|
||||||
|
<MessageSquare size={14} />
|
||||||
|
<span>消息统计(导出口径)</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">消息总数</span>
|
||||||
|
<span className="value highlight">
|
||||||
|
{Number.isFinite(sessionDetail.messageCount)
|
||||||
|
? sessionDetail.messageCount.toLocaleString()
|
||||||
|
: ((isLoadingSessionDetail || isLoadingSessionDetailExtra) ? '统计中...' : '—')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">语音</span>
|
||||||
|
<span className="value">
|
||||||
|
{Number.isFinite(sessionDetail.voiceMessages)
|
||||||
|
? (sessionDetail.voiceMessages as number).toLocaleString()
|
||||||
|
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">图片</span>
|
||||||
|
<span className="value">
|
||||||
|
{Number.isFinite(sessionDetail.imageMessages)
|
||||||
|
? (sessionDetail.imageMessages as number).toLocaleString()
|
||||||
|
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">视频</span>
|
||||||
|
<span className="value">
|
||||||
|
{Number.isFinite(sessionDetail.videoMessages)
|
||||||
|
? (sessionDetail.videoMessages as number).toLocaleString()
|
||||||
|
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">表情包</span>
|
||||||
|
<span className="value">
|
||||||
|
{Number.isFinite(sessionDetail.emojiMessages)
|
||||||
|
? (sessionDetail.emojiMessages as number).toLocaleString()
|
||||||
|
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{sessionDetail.wxid.includes('@chatroom') ? (
|
||||||
|
<>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">我发的消息数</span>
|
||||||
|
<span className="value">
|
||||||
|
{Number.isFinite(sessionDetail.groupMyMessages)
|
||||||
|
? (sessionDetail.groupMyMessages as number).toLocaleString()
|
||||||
|
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">群人数</span>
|
||||||
|
<span className="value">
|
||||||
|
{Number.isFinite(sessionDetail.groupMemberCount)
|
||||||
|
? (sessionDetail.groupMemberCount as number).toLocaleString()
|
||||||
|
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">群发言人数</span>
|
||||||
|
<span className="value">
|
||||||
|
{Number.isFinite(sessionDetail.groupActiveSpeakers)
|
||||||
|
? (sessionDetail.groupActiveSpeakers as number).toLocaleString()
|
||||||
|
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">群共同好友数</span>
|
||||||
|
<span className="value">
|
||||||
|
{Number.isFinite(sessionDetail.groupMutualFriends)
|
||||||
|
? (sessionDetail.groupMutualFriends as number).toLocaleString()
|
||||||
|
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">共同群聊数</span>
|
||||||
|
<span className="value">
|
||||||
|
{Number.isFinite(sessionDetail.privateMutualGroups)
|
||||||
|
? (sessionDetail.privateMutualGroups as number).toLocaleString()
|
||||||
|
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="detail-item">
|
||||||
|
<Calendar size={14} />
|
||||||
|
<span className="label">首条消息</span>
|
||||||
|
<span className="value">
|
||||||
|
{sessionDetail.firstMessageTime
|
||||||
|
? formatYmdDateFromSeconds(sessionDetail.firstMessageTime)
|
||||||
|
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<Calendar size={14} />
|
||||||
|
<span className="label">最新消息</span>
|
||||||
|
<span className="value">
|
||||||
|
{sessionDetail.latestMessageTime
|
||||||
|
? formatYmdDateFromSeconds(sessionDetail.latestMessageTime)
|
||||||
|
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-section">
|
||||||
|
<div className="section-title">
|
||||||
|
<Database size={14} />
|
||||||
|
<span>数据库分布</span>
|
||||||
|
</div>
|
||||||
|
{Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 ? (
|
||||||
|
<div className="table-list">
|
||||||
|
{sessionDetail.messageTables.map((table, index) => (
|
||||||
|
<div key={`${table.dbName}-${table.tableName}-${index}`} className="table-item">
|
||||||
|
<span className="db-name">{table.dbName}</span>
|
||||||
|
<span className="table-count">{table.count.toLocaleString()} 条</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="detail-table-placeholder">
|
||||||
|
{isLoadingSessionDetailExtra ? '统计中...' : '暂无统计数据'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="detail-empty">暂无详情</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user