Merge branch 'hicccc77:dev' into dev

This commit is contained in:
xuncha authored and GitHub committed 2026-03-05 14:03:06 +08:00
commit 80420302c1
51 files changed
+21690 -3118

No files matched your search

+87 -1
View File
@@ -26,6 +26,48 @@
margin: 0 0 48px;
}
.page-desc.load-summary {
margin: 0 0 28px;
}
.page-desc.load-summary.complete {
color: var(--text-secondary);
}
.load-telemetry {
width: min(760px, 100%);
padding: 12px 14px;
margin: 0 0 28px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
text-align: left;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
p {
margin: 4px 0;
}
.label {
color: var(--text-tertiary);
}
}
.load-telemetry.loading {
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
}
.load-telemetry.complete {
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
}
.load-telemetry.compact {
margin: 12px 0 0;
width: min(560px, 100%);
}
.report-sections {
display: flex;
flex-direction: column;
@@ -83,6 +125,14 @@
color: var(--text-tertiary);
}
.year-grid-with-status {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.year-grid {
display: flex;
flex-wrap: wrap;
@@ -95,7 +145,39 @@
.report-section .year-grid {
justify-content: flex-start;
max-width: none;
margin-bottom: 24px;
margin-bottom: 0;
}
.year-grid-with-status .year-grid {
flex: 1;
}
.year-load-status {
display: inline-flex;
align-items: center;
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
margin-top: 6px;
flex-shrink: 0;
}
.year-load-status.complete {
color: color-mix(in srgb, var(--primary) 80%, var(--text-secondary));
}
.dot-ellipsis {
display: inline-block;
width: 0;
overflow: hidden;
vertical-align: bottom;
animation: dot-ellipsis 1.2s steps(4, end) infinite;
}
.year-load-status.complete .dot-ellipsis,
.page-desc.load-summary.complete .dot-ellipsis {
animation: none;
width: 0;
}
.year-card {
@@ -185,3 +267,7 @@
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes dot-ellipsis {
to { width: 1.4em; }
}
+224 -44
View File
@@ -4,6 +4,28 @@ import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
import './AnnualReportPage.scss'
type YearOption = number | 'all'
type YearsLoadPayload = {
years?: number[]
done: boolean
error?: string
canceled?: boolean
strategy?: 'cache' | 'native' | 'hybrid'
phase?: 'cache' | 'native' | 'scan' | 'done'
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}
const formatLoadElapsed = (ms: number) => {
const totalSeconds = Math.max(0, ms) / 1000
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`
const minutes = Math.floor(totalSeconds / 60)
const seconds = Math.floor(totalSeconds % 60)
return `${minutes}m ${String(seconds).padStart(2, '0')}s`
}
function AnnualReportPage() {
const navigate = useNavigate()
@@ -11,32 +33,117 @@ function AnnualReportPage() {
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isLoadingMoreYears, setIsLoadingMoreYears] = useState(false)
const [hasYearsLoadFinished, setHasYearsLoadFinished] = useState(false)
const [loadStrategy, setLoadStrategy] = useState<'cache' | 'native' | 'hybrid'>('native')
const [loadPhase, setLoadPhase] = useState<'cache' | 'native' | 'scan' | 'done'>('native')
const [loadStatusText, setLoadStatusText] = useState('准备加载年份数据...')
const [nativeElapsedMs, setNativeElapsedMs] = useState(0)
const [scanElapsedMs, setScanElapsedMs] = useState(0)
const [totalElapsedMs, setTotalElapsedMs] = useState(0)
const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false)
const [nativeTimedOut, setNativeTimedOut] = useState(false)
const [isGenerating, setIsGenerating] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
useEffect(() => {
loadAvailableYears()
}, [])
let disposed = false
let taskId = ''
const loadAvailableYears = async () => {
setIsLoading(true)
setLoadError(null)
try {
const result = await window.electronAPI.annualReport.getAvailableYears()
if (result.success && result.data && result.data.length > 0) {
setAvailableYears(result.data)
setSelectedYear((prev) => prev ?? result.data[0])
setSelectedPairYear((prev) => prev ?? result.data[0])
} else if (!result.success) {
setLoadError(result.error || '加载年度数据失败')
const applyLoadPayload = (payload: YearsLoadPayload) => {
if (payload.strategy) setLoadStrategy(payload.strategy)
if (payload.phase) setLoadPhase(payload.phase)
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
if (typeof payload.nativeElapsedMs === 'number' && Number.isFinite(payload.nativeElapsedMs)) {
setNativeElapsedMs(Math.max(0, payload.nativeElapsedMs))
}
if (typeof payload.scanElapsedMs === 'number' && Number.isFinite(payload.scanElapsedMs)) {
setScanElapsedMs(Math.max(0, payload.scanElapsedMs))
}
if (typeof payload.totalElapsedMs === 'number' && Number.isFinite(payload.totalElapsedMs)) {
setTotalElapsedMs(Math.max(0, payload.totalElapsedMs))
}
if (typeof payload.switched === 'boolean') setHasSwitchedStrategy(payload.switched)
if (typeof payload.nativeTimedOut === 'boolean') setNativeTimedOut(payload.nativeTimedOut)
const years = Array.isArray(payload.years) ? payload.years : []
if (years.length > 0) {
setAvailableYears(years)
setSelectedYear((prev) => {
if (prev === 'all') return prev
if (typeof prev === 'number' && years.includes(prev)) return prev
return years[0]
})
setSelectedPairYear((prev) => {
if (prev === 'all') return prev
if (typeof prev === 'number' && years.includes(prev)) return prev
return years[0]
})
setIsLoading(false)
}
if (payload.error && !payload.canceled) {
setLoadError(payload.error || '加载年度数据失败')
}
if (payload.done) {
setIsLoading(false)
setIsLoadingMoreYears(false)
setHasYearsLoadFinished(true)
setLoadPhase('done')
} else {
setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false)
}
} catch (e) {
console.error(e)
setLoadError(String(e))
} finally {
setIsLoading(false)
}
}
const stopListen = window.electronAPI.annualReport.onAvailableYearsProgress((payload) => {
if (disposed) return
if (taskId && payload.taskId !== taskId) return
if (!taskId) taskId = payload.taskId
applyLoadPayload(payload)
})
const startLoad = async () => {
setIsLoading(true)
setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false)
setLoadStrategy('native')
setLoadPhase('native')
setLoadStatusText('准备使用原生快速模式加载年份...')
setNativeElapsedMs(0)
setScanElapsedMs(0)
setTotalElapsedMs(0)
setHasSwitchedStrategy(false)
setNativeTimedOut(false)
setLoadError(null)
try {
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
if (!startResult.success || !startResult.taskId) {
setLoadError(startResult.error || '加载年度数据失败')
setIsLoading(false)
setIsLoadingMoreYears(false)
return
}
taskId = startResult.taskId
if (startResult.snapshot) {
applyLoadPayload(startResult.snapshot)
}
} catch (e) {
console.error(e)
setLoadError(String(e))
setIsLoading(false)
setIsLoadingMoreYears(false)
}
}
void startLoad()
return () => {
disposed = true
stopListen()
}
}, [])
const handleGenerateReport = async () => {
if (selectedYear === null) return
@@ -57,16 +164,25 @@ function AnnualReportPage() {
navigate(`/dual-report?year=${yearParam}`)
}
if (isLoading) {
if (isLoading && availableYears.length === 0) {
return (
<div className="annual-report-page">
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在加载年份数据...</p>
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在加载年份数据(首批)...</p>
<div className="load-telemetry compact">
<p><span className="label">加载方式:</span>{getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })}</p>
<p><span className="label">状态:</span>{loadStatusText || '正在加载年份数据...'}</p>
<p>
<span className="label">原生耗时:</span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} |{' '}
<span className="label">扫表耗时:</span>{formatLoadElapsed(scanElapsedMs)} |{' '}
<span className="label">总耗时:</span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
</div>
)
}
if (availableYears.length === 0) {
if (availableYears.length === 0 && !isLoadingMoreYears) {
return (
<div className="annual-report-page">
<Calendar size={64} style={{ color: 'var(--text-tertiary)', opacity: 0.5 }} />
@@ -87,11 +203,50 @@ function AnnualReportPage() {
return value === 'all' ? '全部时间' : `${value} 年`
}
const loadedYearCount = availableYears.length
const isYearStatusComplete = hasYearsLoadFinished
const strategyLabel = getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })
const renderYearLoadStatus = () => (
<div className={`year-load-status ${isYearStatusComplete ? 'complete' : 'loading'}`}>
{isYearStatusComplete ? (
<>全部年份已加载完毕</>
) : (
<>
更多年份加载中<span className="dot-ellipsis" aria-hidden="true">...</span>
</>
)}
</div>
)
return (
<div className="annual-report-page">
<Sparkles size={32} className="header-icon" />
<h1 className="page-title">年度报告</h1>
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
{loadedYearCount > 0 && (
<p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}>
{isYearStatusComplete ? (
<>已显示 {loadedYearCount} 个年份,年份数据已全部加载完毕。总耗时 {formatLoadElapsed(totalElapsedMs)}</>
) : (
<>
已显示 {loadedYearCount} 个年份,正在补充更多年份<span className="dot-ellipsis" aria-hidden="true">...</span>
(已耗时 {formatLoadElapsed(totalElapsedMs)})
</>
)}
</p>
)}
<div className={`load-telemetry ${isYearStatusComplete ? 'complete' : 'loading'}`}>
<p><span className="label">加载方式:</span>{strategyLabel}</p>
<p>
<span className="label">状态:</span>
{loadStatusText || (isYearStatusComplete ? '全部年份已加载完毕' : '正在加载年份数据...')}
</p>
<p>
<span className="label">原生耗时:</span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} |{' '}
<span className="label">扫表耗时:</span>{formatLoadElapsed(scanElapsedMs)} |{' '}
<span className="label">总耗时:</span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
<div className="report-sections">
<section className="report-section">
@@ -102,17 +257,20 @@ function AnnualReportPage() {
</div>
</div>
<div className="year-grid">
{yearOptions.map(option => (
<div
key={option}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
onClick={() => setSelectedYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
<div className="year-grid-with-status">
<div className="year-grid">
{yearOptions.map(option => (
<div
key={option}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
onClick={() => setSelectedYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
</div>
{renderYearLoadStatus()}
</div>
<button
@@ -146,17 +304,20 @@ function AnnualReportPage() {
</div>
</div>
<div className="year-grid">
{yearOptions.map(option => (
<div
key={`pair-${option}`}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
onClick={() => setSelectedPairYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
<div className="year-grid-with-status">
<div className="year-grid">
{yearOptions.map(option => (
<div
key={`pair-${option}`}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
onClick={() => setSelectedPairYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
</div>
{renderYearLoadStatus()}
</div>
<button
@@ -174,4 +335,23 @@ function AnnualReportPage() {
)
}
function getStrategyLabel(params: {
loadStrategy: 'cache' | 'native' | 'hybrid'
loadPhase: 'cache' | 'native' | 'scan' | 'done'
hasYearsLoadFinished: boolean
hasSwitchedStrategy: boolean
nativeTimedOut: boolean
}): string {
const { loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut } = params
if (loadStrategy === 'cache') return '缓存模式(快速)'
if (hasYearsLoadFinished) {
if (loadStrategy === 'native') return '原生快速模式'
if (hasSwitchedStrategy || nativeTimedOut) return '混合策略(原生→扫表)'
return '扫表兼容模式'
}
if (loadPhase === 'native') return '原生快速模式(优先)'
if (loadPhase === 'scan') return '扫表兼容模式(回退)'
return '混合策略'
}
export default AnnualReportPage
+303 -3
View File
@@ -490,6 +490,18 @@
gap: 8px;
-webkit-app-region: no-drag;
.jump-calendar-anchor {
position: relative;
display: flex;
align-items: center;
isolation: isolate;
z-index: 20;
.jump-date-popover {
z-index: 2600;
}
}
.icon-btn {
width: 34px;
height: 34px;
@@ -534,6 +546,22 @@
overflow: hidden;
}
.export-prepare-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 24px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
-webkit-app-region: no-drag;
.spin {
animation: spin 1s linear infinite;
}
}
.message-list {
flex: 1;
background: var(--chat-pattern);
@@ -815,6 +843,24 @@
min-width: 0;
}
.session-sync-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: var(--bg-primary);
color: var(--text-tertiary);
font-size: 11px;
white-space: nowrap;
border: 1px solid var(--border-color);
flex-shrink: 0;
.spin {
animation: spin 0.9s linear infinite;
}
}
.search-box {
flex: 1;
display: flex;
@@ -1592,6 +1638,13 @@
display: flex;
align-items: center;
gap: 8px;
.jump-calendar-anchor {
position: relative;
display: flex;
align-items: center;
isolation: isolate;
}
}
.icon-btn {
@@ -1624,6 +1677,10 @@
opacity: 0.5;
cursor: not-allowed;
}
.spin {
animation: spin 1s linear infinite;
}
}
}
@@ -1651,6 +1708,33 @@
opacity: 0;
pointer-events: none;
}
&.switching .message-list {
opacity: 0.42;
transform: scale(0.995);
filter: saturate(0.72) blur(1px);
pointer-events: none;
}
&.switching .loading-overlay {
background: rgba(127, 127, 127, 0.18);
backdrop-filter: blur(4px);
}
}
.export-prepare-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
.spin {
animation: spin 1s linear infinite;
}
}
.message-list {
@@ -1666,7 +1750,7 @@
background-color: var(--bg-tertiary);
position: relative;
-webkit-app-region: no-drag !important;
transition: opacity 240ms ease, transform 240ms ease;
transition: opacity 240ms ease, transform 240ms ease, filter 220ms ease;
// 滚动条样式
&::-webkit-scrollbar {
@@ -2662,6 +2746,13 @@
opacity: 0.7;
}
}
.detail-stats-meta {
margin-top: -6px;
margin-bottom: 10px;
font-size: 12px;
color: var(--text-tertiary);
}
}
.detail-item {
@@ -2699,6 +2790,26 @@
}
}
.detail-inline-btn {
border: none;
background: var(--bg-secondary);
color: var(--primary);
border-radius: 6px;
padding: 4px 8px;
font-size: 12px;
line-height: 1;
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
&:hover:not(:disabled) {
background: var(--bg-hover);
}
}
.copy-btn {
display: flex;
align-items: center;
@@ -2736,6 +2847,14 @@
gap: 8px;
}
.detail-table-placeholder {
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.table-item {
display: flex;
align-items: center;
@@ -2757,6 +2876,188 @@
}
}
.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-status {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
.spin {
animation: spin 1s linear infinite;
}
&.warning {
color: #b45309;
background: color-mix(in srgb, #f59e0b 10%, transparent);
}
}
.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);
&.loading {
color: var(--text-tertiary);
font-weight: 500;
}
&.failed {
color: #b45309;
font-weight: 600;
}
}
}
@keyframes slideInRight {
from {
opacity: 0;
@@ -4133,7 +4434,6 @@
font-weight: 500;
}
}
// 消息信息弹窗
.message-info-overlay {
position: fixed;
@@ -4298,4 +4598,4 @@
user-select: text;
}
}
}
}
+2011 -221
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed } from 'lucide-react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { createPortal } from 'react-dom'
import { useChatStore } from '../stores/chatStore'
@@ -10,8 +10,14 @@ import { getEmojiPath } from 'wechat-emojis'
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
import JumpToDateDialog from '../components/JumpToDateDialog'
import JumpToDatePopover from '../components/JumpToDatePopover'
import * as configService from '../services/config'
import {
emitOpenSingleExport,
onExportSessionStatus,
onSingleExportDialogStatus,
requestExportSessionStatus
} from '../services/exportBridge'
import './ChatPage.scss'
// 系统消息类型常量
@@ -142,8 +148,62 @@ function cleanMessageContent(content: string): string {
return content.trim()
}
const CHAT_SESSION_LIST_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const CHAT_SESSION_PREVIEW_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION = 30
const CHAT_SESSION_PREVIEW_MAX_SESSIONS = 18
const CHAT_SESSION_WINDOW_CACHE_TTL_MS = 12 * 60 * 60 * 1000
const CHAT_SESSION_WINDOW_CACHE_MAX_SESSIONS = 30
const CHAT_SESSION_WINDOW_CACHE_MAX_MESSAGES = 300
const GROUP_MEMBERS_PANEL_CACHE_TTL_MS = 10 * 60 * 1000
function buildChatSessionListCacheKey(scope: string): string {
return `weflow.chat.sessions.v1::${scope || 'default'}`
}
function buildChatSessionPreviewCacheKey(scope: string): string {
return `weflow.chat.preview.v1::${scope || 'default'}`
}
function normalizeChatCacheScope(dbPath: unknown, wxid: unknown): string {
const db = String(dbPath || '').trim()
const id = String(wxid || '').trim()
if (!db && !id) return 'default'
return `${db}::${id}`
}
function safeParseJson<T>(raw: string | null): T | null {
if (!raw) return null
try {
return JSON.parse(raw) as T
} catch {
return null
}
}
function 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}`
}
function formatYmdHmDateTime(timestamp?: number): string {
if (!timestamp || !Number.isFinite(timestamp)) return '—'
const d = new Date(timestamp)
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
const h = `${d.getHours()}`.padStart(2, '0')
const min = `${d.getMinutes()}`.padStart(2, '0')
return `${y}-${m}-${day} ${h}:${min}`
}
interface ChatPageProps {
// 保留接口以备将来扩展
standaloneSessionWindow?: boolean
initialSessionId?: string | null
}
@@ -155,11 +215,105 @@ interface SessionDetail {
alias?: string
avatarUrl?: string
messageCount: number
voiceMessages?: number
imageMessages?: number
videoMessages?: number
emojiMessages?: number
transferMessages?: number
redPacketMessages?: number
callMessages?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
relationStatsLoaded?: boolean
statsUpdatedAt?: number
statsStale?: boolean
firstMessageTime?: number
latestMessageTime?: number
messageTables: { dbName: string; tableName: string; count: number }[]
}
interface SessionExportMetric {
totalMessages: number
voiceMessages: number
imageMessages: number
videoMessages: number
emojiMessages: number
transferMessages: number
redPacketMessages: number
callMessages: number
firstTimestamp?: number
lastTimestamp?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
}
interface SessionExportCacheMeta {
updatedAt: number
stale: boolean
includeRelations: boolean
source: 'memory' | 'disk' | 'fresh'
}
type GroupMessageCountStatus = 'loading' | 'ready' | 'failed'
interface GroupPanelMember {
username: string
displayName: string
avatarUrl?: string
nickname?: string
alias?: string
remark?: string
groupNickname?: string
isOwner?: boolean
isFriend: boolean
messageCount: number
messageCountStatus: GroupMessageCountStatus
}
interface SessionListCachePayload {
updatedAt: number
sessions: ChatSession[]
}
interface SessionPreviewCacheEntry {
updatedAt: number
messages: Message[]
}
interface SessionPreviewCachePayload {
updatedAt: number
entries: Record<string, SessionPreviewCacheEntry>
}
interface GroupMembersPanelCacheEntry {
updatedAt: number
members: GroupPanelMember[]
includeMessageCounts: boolean
}
interface SessionWindowCacheEntry {
updatedAt: number
messages: Message[]
currentOffset: number
hasMoreMessages: boolean
hasMoreLater: boolean
jumpStartTime: number
jumpEndTime: number
}
interface LoadMessagesOptions {
preferLatestPath?: boolean
deferGroupSenderWarmup?: boolean
forceInitialLimit?: number
switchRequestSeq?: number
}
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
@@ -253,7 +407,9 @@ const SessionItem = React.memo(function SessionItem({
function ChatPage(_props: ChatPageProps) {
function ChatPage(props: ChatPageProps) {
const { standaloneSessionWindow = false, initialSessionId = null } = props
const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId])
const navigate = useNavigate()
const {
@@ -296,31 +452,53 @@ function ChatPage(_props: ChatPageProps) {
}, [])
const initialRevealTimerRef = useRef<number | null>(null)
const sessionListRef = useRef<HTMLDivElement>(null)
const jumpCalendarWrapRef = useRef<HTMLDivElement>(null)
const jumpPopoverPortalRef = useRef<HTMLDivElement>(null)
const [currentOffset, setCurrentOffset] = useState(0)
const [jumpStartTime, setJumpStartTime] = useState(0)
const [jumpEndTime, setJumpEndTime] = useState(0)
const [showJumpDialog, setShowJumpDialog] = useState(false)
const [showJumpPopover, setShowJumpPopover] = useState(false)
const [jumpPopoverDate, setJumpPopoverDate] = useState<Date>(new Date())
const [jumpPopoverPosition, setJumpPopoverPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
const isDateJumpRef = useRef(false)
const [messageDates, setMessageDates] = useState<Set<string>>(new Set())
const [hasLoadedMessageDates, setHasLoadedMessageDates] = useState(false)
const [loadingDates, setLoadingDates] = useState(false)
const messageDatesCache = useRef<Map<string, Set<string>>>(new Map())
const [messageDateCounts, setMessageDateCounts] = useState<Record<string, number>>({})
const [loadingDateCounts, setLoadingDateCounts] = useState(false)
const messageDateCountsCache = useRef<Map<string, Record<string, number>>>(new Map())
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
const [myWxid, setMyWxid] = useState<string | undefined>(undefined)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [sidebarWidth, setSidebarWidth] = useState(260)
const [isResizing, setIsResizing] = useState(false)
const [showDetailPanel, setShowDetailPanel] = useState(false)
const [showGroupMembersPanel, setShowGroupMembersPanel] = useState(false)
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
const [isLoadingDetailExtra, setIsLoadingDetailExtra] = useState(false)
const [isRefreshingDetailStats, setIsRefreshingDetailStats] = useState(false)
const [isLoadingRelationStats, setIsLoadingRelationStats] = useState(false)
const [groupPanelMembers, setGroupPanelMembers] = useState<GroupPanelMember[]>([])
const [isLoadingGroupMembers, setIsLoadingGroupMembers] = useState(false)
const [groupMembersError, setGroupMembersError] = useState<string | null>(null)
const [groupMembersLoadingHint, setGroupMembersLoadingHint] = useState('')
const [isRefreshingGroupMembers, setIsRefreshingGroupMembers] = useState(false)
const [groupMemberSearchKeyword, setGroupMemberSearchKeyword] = useState('')
const [copiedField, setCopiedField] = useState<string | null>(null)
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图
const [hasInitialMessages, setHasInitialMessages] = useState(false)
const [isSessionSwitching, setIsSessionSwitching] = useState(false)
const [noMessageTable, setNoMessageTable] = useState(false)
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null)
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(new Set())
const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false)
const [exportPrepareHint, setExportPrepareHint] = useState('')
// 消息右键菜单
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null)
@@ -376,6 +554,9 @@ function ChatPage(_props: ChatPageProps) {
const sessionMapRef = useRef<Map<string, ChatSession>>(new Map())
const sessionsRef = useRef<ChatSession[]>([])
const currentSessionRef = useRef<string | null>(null)
const pendingSessionLoadRef = useRef<string | null>(null)
const sessionSwitchRequestSeqRef = useRef(0)
const initialLoadRequestedSessionRef = useRef<string | null>(null)
const prevSessionRef = useRef<string | null>(null)
const isLoadingMessagesRef = useRef(false)
const isLoadingMoreRef = useRef(false)
@@ -384,6 +565,215 @@ function ChatPage(_props: ChatPageProps) {
const searchKeywordRef = useRef('')
const preloadImageKeysRef = useRef<Set<string>>(new Set())
const lastPreloadSessionRef = useRef<string | null>(null)
const detailRequestSeqRef = useRef(0)
const groupMembersRequestSeqRef = useRef(0)
const groupMembersPanelCacheRef = useRef<Map<string, GroupMembersPanelCacheEntry>>(new Map())
const hasInitializedGroupMembersRef = useRef(false)
const chatCacheScopeRef = useRef('default')
const previewCacheRef = useRef<Record<string, SessionPreviewCacheEntry>>({})
const sessionWindowCacheRef = useRef<Map<string, SessionWindowCacheEntry>>(new Map())
const previewPersistTimerRef = useRef<number | null>(null)
const sessionListPersistTimerRef = useRef<number | null>(null)
const pendingExportRequestIdRef = useRef<string | null>(null)
const exportPrepareLongWaitTimerRef = useRef<number | null>(null)
const jumpDatesRequestSeqRef = useRef(0)
const jumpDateCountsRequestSeqRef = useRef(0)
const isGroupChatSession = useCallback((username: string) => {
return username.includes('@chatroom')
}, [])
const clearExportPrepareState = useCallback(() => {
pendingExportRequestIdRef.current = null
setIsPreparingExportDialog(false)
setExportPrepareHint('')
if (exportPrepareLongWaitTimerRef.current) {
window.clearTimeout(exportPrepareLongWaitTimerRef.current)
exportPrepareLongWaitTimerRef.current = null
}
}, [])
const resolveCurrentViewDate = useCallback(() => {
if (jumpStartTime > 0) {
return new Date(jumpStartTime * 1000)
}
const fallbackMessage = messages[messages.length - 1] || messages[0]
const rawTimestamp = Number(fallbackMessage?.createTime || 0)
if (Number.isFinite(rawTimestamp) && rawTimestamp > 0) {
return new Date(rawTimestamp > 10000000000 ? rawTimestamp : rawTimestamp * 1000)
}
return new Date()
}, [jumpStartTime, messages])
const loadJumpCalendarData = useCallback(async (sessionId: string) => {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return
const cachedDates = messageDatesCache.current.get(normalizedSessionId)
if (cachedDates) {
setMessageDates(new Set(cachedDates))
setHasLoadedMessageDates(true)
setLoadingDates(false)
} else {
setLoadingDates(true)
setHasLoadedMessageDates(false)
setMessageDates(new Set())
const requestSeq = jumpDatesRequestSeqRef.current + 1
jumpDatesRequestSeqRef.current = requestSeq
try {
const result = await window.electronAPI.chat.getMessageDates(normalizedSessionId)
if (requestSeq !== jumpDatesRequestSeqRef.current || currentSessionRef.current !== normalizedSessionId) return
if (result?.success && Array.isArray(result.dates)) {
const dateSet = new Set<string>(result.dates)
messageDatesCache.current.set(normalizedSessionId, dateSet)
setMessageDates(new Set(dateSet))
setHasLoadedMessageDates(true)
}
} catch (error) {
console.error('获取消息日期失败:', error)
} finally {
if (requestSeq === jumpDatesRequestSeqRef.current && currentSessionRef.current === normalizedSessionId) {
setLoadingDates(false)
}
}
}
const cachedCounts = messageDateCountsCache.current.get(normalizedSessionId)
if (cachedCounts) {
setMessageDateCounts({ ...cachedCounts })
setLoadingDateCounts(false)
return
}
setLoadingDateCounts(true)
setMessageDateCounts({})
const requestSeq = jumpDateCountsRequestSeqRef.current + 1
jumpDateCountsRequestSeqRef.current = requestSeq
try {
const result = await window.electronAPI.chat.getMessageDateCounts(normalizedSessionId)
if (requestSeq !== jumpDateCountsRequestSeqRef.current || currentSessionRef.current !== normalizedSessionId) return
if (result?.success && result.counts) {
const normalizedCounts: Record<string, number> = {}
Object.entries(result.counts).forEach(([date, value]) => {
const count = Number(value)
if (!date || !Number.isFinite(count) || count <= 0) return
normalizedCounts[date] = count
})
messageDateCountsCache.current.set(normalizedSessionId, normalizedCounts)
setMessageDateCounts(normalizedCounts)
}
} catch (error) {
console.error('获取每日消息数失败:', error)
} finally {
if (requestSeq === jumpDateCountsRequestSeqRef.current && currentSessionRef.current === normalizedSessionId) {
setLoadingDateCounts(false)
}
}
}, [])
const updateJumpPopoverPosition = useCallback(() => {
const anchor = jumpCalendarWrapRef.current
if (!anchor) return
const popoverWidth = 312
const viewportGap = 8
const anchorRect = anchor.getBoundingClientRect()
let left = anchorRect.right - popoverWidth
left = Math.max(viewportGap, Math.min(left, window.innerWidth - popoverWidth - viewportGap))
const portalHeight = jumpPopoverPortalRef.current?.offsetHeight || 0
const belowTop = anchorRect.bottom + 10
let top = belowTop
if (portalHeight > 0 && belowTop + portalHeight > window.innerHeight - viewportGap) {
top = Math.max(viewportGap, anchorRect.top - portalHeight - 10)
}
setJumpPopoverPosition(prev => {
if (prev.top === top && prev.left === left) return prev
return { top, left }
})
}, [])
const handleToggleJumpPopover = useCallback(() => {
if (!currentSessionId) return
if (showJumpPopover) {
setShowJumpPopover(false)
return
}
setJumpPopoverDate(resolveCurrentViewDate())
updateJumpPopoverPosition()
setShowJumpPopover(true)
requestAnimationFrame(() => updateJumpPopoverPosition())
void loadJumpCalendarData(currentSessionId)
}, [currentSessionId, loadJumpCalendarData, resolveCurrentViewDate, showJumpPopover, updateJumpPopoverPosition])
useEffect(() => {
const unsubscribe = onExportSessionStatus((payload) => {
const ids = Array.isArray(payload?.inProgressSessionIds)
? payload.inProgressSessionIds
.filter((id): id is string => typeof id === 'string')
.map(id => id.trim())
.filter(Boolean)
: []
setInProgressExportSessionIds(new Set(ids))
})
requestExportSessionStatus()
const timer = window.setTimeout(() => {
requestExportSessionStatus()
}, 0)
return () => {
window.clearTimeout(timer)
unsubscribe()
}
}, [])
useEffect(() => {
const unsubscribe = onSingleExportDialogStatus((payload) => {
const requestId = typeof payload?.requestId === 'string' ? payload.requestId.trim() : ''
if (!requestId || requestId !== pendingExportRequestIdRef.current) return
if (payload.status === 'initializing') {
setExportPrepareHint('正在准备导出模块(首次会稍慢,通常 1-3 秒)')
if (exportPrepareLongWaitTimerRef.current) {
window.clearTimeout(exportPrepareLongWaitTimerRef.current)
}
exportPrepareLongWaitTimerRef.current = window.setTimeout(() => {
if (pendingExportRequestIdRef.current !== requestId) return
setExportPrepareHint('仍在准备导出模块,请稍候...')
}, 8000)
return
}
if (payload.status === 'opened') {
clearExportPrepareState()
return
}
if (payload.status === 'failed') {
const message = (typeof payload.message === 'string' && payload.message.trim())
? payload.message.trim()
: '导出模块初始化失败,请重试'
clearExportPrepareState()
window.alert(message)
}
})
return () => {
unsubscribe()
if (exportPrepareLongWaitTimerRef.current) {
window.clearTimeout(exportPrepareLongWaitTimerRef.current)
exportPrepareLongWaitTimerRef.current = null
}
}
}, [clearExportPrepareState])
useEffect(() => {
if (!isPreparingExportDialog || !currentSessionId) return
if (!inProgressExportSessionIds.has(currentSessionId)) return
clearExportPrepareState()
}, [clearExportPrepareState, currentSessionId, inProgressExportSessionIds, isPreparingExportDialog])
// 加载当前用户头像
const loadMyAvatar = useCallback(async () => {
@@ -397,29 +787,858 @@ function ChatPage(_props: ChatPageProps) {
}
}, [])
const resolveChatCacheScope = useCallback(async (): Promise<string> => {
try {
const [dbPath, myWxid] = await Promise.all([
window.electronAPI.config.get('dbPath'),
window.electronAPI.config.get('myWxid')
])
const scope = normalizeChatCacheScope(dbPath, myWxid)
chatCacheScopeRef.current = scope
return scope
} catch {
chatCacheScopeRef.current = 'default'
return 'default'
}
}, [])
const loadPreviewCacheFromStorage = useCallback((scope: string): Record<string, SessionPreviewCacheEntry> => {
try {
const cacheKey = buildChatSessionPreviewCacheKey(scope)
const payload = safeParseJson<SessionPreviewCachePayload>(window.localStorage.getItem(cacheKey))
if (!payload || typeof payload.updatedAt !== 'number' || !payload.entries) {
return {}
}
if (Date.now() - payload.updatedAt > CHAT_SESSION_PREVIEW_CACHE_TTL_MS) {
return {}
}
return payload.entries
} catch {
return {}
}
}, [])
const persistPreviewCacheToStorage = useCallback((scope: string, entries: Record<string, SessionPreviewCacheEntry>) => {
try {
const cacheKey = buildChatSessionPreviewCacheKey(scope)
const payload: SessionPreviewCachePayload = {
updatedAt: Date.now(),
entries
}
window.localStorage.setItem(cacheKey, JSON.stringify(payload))
} catch {
// ignore cache write failures
}
}, [])
const persistSessionPreviewCache = useCallback((sessionId: string, previewMessages: Message[]) => {
const id = String(sessionId || '').trim()
if (!id || !Array.isArray(previewMessages) || previewMessages.length === 0) return
const trimmed = previewMessages.slice(-CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION)
const currentEntries = { ...previewCacheRef.current }
currentEntries[id] = {
updatedAt: Date.now(),
messages: trimmed
}
const sortedIds = Object.entries(currentEntries)
.sort((a, b) => (b[1]?.updatedAt || 0) - (a[1]?.updatedAt || 0))
.map(([entryId]) => entryId)
const keptIds = new Set(sortedIds.slice(0, CHAT_SESSION_PREVIEW_MAX_SESSIONS))
const compactEntries: Record<string, SessionPreviewCacheEntry> = {}
for (const [entryId, entry] of Object.entries(currentEntries)) {
if (keptIds.has(entryId)) {
compactEntries[entryId] = entry
}
}
previewCacheRef.current = compactEntries
if (previewPersistTimerRef.current !== null) {
window.clearTimeout(previewPersistTimerRef.current)
}
previewPersistTimerRef.current = window.setTimeout(() => {
persistPreviewCacheToStorage(chatCacheScopeRef.current, previewCacheRef.current)
previewPersistTimerRef.current = null
}, 220)
}, [persistPreviewCacheToStorage])
const hydrateSessionPreview = useCallback(async (sessionId: string) => {
const id = String(sessionId || '').trim()
if (!id) return
const localEntry = previewCacheRef.current[id]
if (
localEntry &&
Array.isArray(localEntry.messages) &&
localEntry.messages.length > 0 &&
Date.now() - localEntry.updatedAt <= CHAT_SESSION_PREVIEW_CACHE_TTL_MS
) {
setMessages(localEntry.messages.slice())
setHasInitialMessages(true)
return
}
try {
const result = await window.electronAPI.chat.getCachedMessages(id)
if (!result.success || !Array.isArray(result.messages) || result.messages.length === 0) {
return
}
if (currentSessionRef.current !== id && pendingSessionLoadRef.current !== id) return
setMessages(result.messages)
setHasInitialMessages(true)
persistSessionPreviewCache(id, result.messages)
} catch {
// ignore preview cache errors
}
}, [persistSessionPreviewCache, setMessages])
const saveSessionWindowCache = useCallback((sessionId: string, entry: Omit<SessionWindowCacheEntry, 'updatedAt'>) => {
const id = String(sessionId || '').trim()
if (!id || !Array.isArray(entry.messages) || entry.messages.length === 0) return
const trimmedMessages = entry.messages.length > CHAT_SESSION_WINDOW_CACHE_MAX_MESSAGES
? entry.messages.slice(-CHAT_SESSION_WINDOW_CACHE_MAX_MESSAGES)
: entry.messages.slice()
const cache = sessionWindowCacheRef.current
cache.set(id, {
updatedAt: Date.now(),
...entry,
messages: trimmedMessages,
currentOffset: trimmedMessages.length
})
if (cache.size <= CHAT_SESSION_WINDOW_CACHE_MAX_SESSIONS) return
const sortedByTime = [...cache.entries()]
.sort((a, b) => (a[1].updatedAt || 0) - (b[1].updatedAt || 0))
for (const [key] of sortedByTime) {
if (cache.size <= CHAT_SESSION_WINDOW_CACHE_MAX_SESSIONS) break
cache.delete(key)
}
}, [])
const restoreSessionWindowCache = useCallback((sessionId: string): boolean => {
const id = String(sessionId || '').trim()
if (!id) return false
const cache = sessionWindowCacheRef.current
const entry = cache.get(id)
if (!entry) return false
if (Date.now() - entry.updatedAt > CHAT_SESSION_WINDOW_CACHE_TTL_MS) {
cache.delete(id)
return false
}
if (!Array.isArray(entry.messages) || entry.messages.length === 0) {
cache.delete(id)
return false
}
// LRU: 命中后更新时间
cache.set(id, {
...entry,
updatedAt: Date.now(),
messages: entry.messages.slice()
})
setMessages(entry.messages.slice())
setCurrentOffset(entry.messages.length)
setHasMoreMessages(entry.hasMoreMessages !== false)
setHasMoreLater(entry.hasMoreLater === true)
setJumpStartTime(entry.jumpStartTime || 0)
setJumpEndTime(entry.jumpEndTime || 0)
setNoMessageTable(false)
setHasInitialMessages(true)
return true
}, [
setMessages,
setHasMoreMessages,
setHasMoreLater,
setCurrentOffset,
setJumpStartTime,
setJumpEndTime,
setNoMessageTable,
setHasInitialMessages
])
const hydrateSessionListCache = useCallback((scope: string): boolean => {
try {
const cacheKey = buildChatSessionListCacheKey(scope)
const payload = safeParseJson<SessionListCachePayload>(window.localStorage.getItem(cacheKey))
if (!payload || typeof payload.updatedAt !== 'number' || !Array.isArray(payload.sessions)) {
previewCacheRef.current = loadPreviewCacheFromStorage(scope)
return false
}
previewCacheRef.current = loadPreviewCacheFromStorage(scope)
if (Date.now() - payload.updatedAt > CHAT_SESSION_LIST_CACHE_TTL_MS) {
return false
}
if (!Array.isArray(sessionsRef.current) || sessionsRef.current.length === 0) {
setSessions(payload.sessions)
sessionsRef.current = payload.sessions
return payload.sessions.length > 0
}
return false
} catch {
previewCacheRef.current = loadPreviewCacheFromStorage(scope)
return false
}
}, [loadPreviewCacheFromStorage, setSessions])
const persistSessionListCache = useCallback((scope: string, nextSessions: ChatSession[]) => {
try {
const cacheKey = buildChatSessionListCacheKey(scope)
const payload: SessionListCachePayload = {
updatedAt: Date.now(),
sessions: nextSessions
}
window.localStorage.setItem(cacheKey, JSON.stringify(payload))
} catch {
// ignore cache write failures
}
}, [])
const applySessionDetailStats = useCallback((
sessionId: string,
metric: SessionExportMetric,
cacheMeta?: SessionExportCacheMeta,
relationLoadedOverride?: boolean
) => {
setSessionDetail((prev) => {
if (!prev || prev.wxid !== sessionId) return prev
const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded)
return {
...prev,
messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : prev.messageCount,
voiceMessages: Number.isFinite(metric.voiceMessages) ? metric.voiceMessages : prev.voiceMessages,
imageMessages: Number.isFinite(metric.imageMessages) ? metric.imageMessages : prev.imageMessages,
videoMessages: Number.isFinite(metric.videoMessages) ? metric.videoMessages : prev.videoMessages,
emojiMessages: Number.isFinite(metric.emojiMessages) ? metric.emojiMessages : prev.emojiMessages,
transferMessages: Number.isFinite(metric.transferMessages) ? metric.transferMessages : prev.transferMessages,
redPacketMessages: Number.isFinite(metric.redPacketMessages) ? metric.redPacketMessages : prev.redPacketMessages,
callMessages: Number.isFinite(metric.callMessages) ? metric.callMessages : prev.callMessages,
groupMemberCount: Number.isFinite(metric.groupMemberCount) ? metric.groupMemberCount : prev.groupMemberCount,
groupMyMessages: Number.isFinite(metric.groupMyMessages) ? metric.groupMyMessages : prev.groupMyMessages,
groupActiveSpeakers: Number.isFinite(metric.groupActiveSpeakers) ? metric.groupActiveSpeakers : prev.groupActiveSpeakers,
privateMutualGroups: relationLoaded && Number.isFinite(metric.privateMutualGroups)
? metric.privateMutualGroups
: prev.privateMutualGroups,
groupMutualFriends: relationLoaded && Number.isFinite(metric.groupMutualFriends)
? metric.groupMutualFriends
: prev.groupMutualFriends,
relationStatsLoaded: relationLoaded,
statsUpdatedAt: cacheMeta?.updatedAt ?? prev.statsUpdatedAt,
statsStale: typeof cacheMeta?.stale === 'boolean' ? cacheMeta.stale : prev.statsStale,
firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : prev.firstMessageTime,
latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : prev.latestMessageTime
}
})
}, [])
// 加载会话详情
const loadSessionDetail = useCallback(async (sessionId: string) => {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return
const requestSeq = ++detailRequestSeqRef.current
const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId)
const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0
? Math.floor(mappedSession.messageCountHint)
: undefined
setIsRefreshingDetailStats(false)
setIsLoadingRelationStats(false)
setSessionDetail((prev) => {
const sameSession = prev?.wxid === normalizedSessionId
return {
wxid: normalizedSessionId,
displayName: mappedSession?.displayName || prev?.displayName || normalizedSessionId,
remark: sameSession ? prev?.remark : undefined,
nickName: sameSession ? prev?.nickName : undefined,
alias: sameSession ? prev?.alias : undefined,
avatarUrl: mappedSession?.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,
transferMessages: sameSession ? prev?.transferMessages : undefined,
redPacketMessages: sameSession ? prev?.redPacketMessages : undefined,
callMessages: sameSession ? prev?.callMessages : 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,
relationStatsLoaded: sameSession ? prev?.relationStatsLoaded : false,
statsUpdatedAt: sameSession ? prev?.statsUpdatedAt : undefined,
statsStale: sameSession ? prev?.statsStale : undefined,
firstMessageTime: sameSession ? prev?.firstMessageTime : undefined,
latestMessageTime: sameSession ? prev?.latestMessageTime : undefined,
messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : []
}
})
setIsLoadingDetail(true)
setIsLoadingDetailExtra(true)
if (normalizedSessionId.includes('@chatroom')) {
void (async () => {
try {
const hintResult = await window.electronAPI.chat.getGroupMyMessageCountHint(normalizedSessionId)
if (requestSeq !== detailRequestSeqRef.current) return
if (!hintResult.success || !Number.isFinite(hintResult.count)) return
const hintedMyCount = Math.max(0, Math.floor(hintResult.count as number))
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
return {
...prev,
groupMyMessages: hintedMyCount
}
})
} catch {
// ignore hint errors
}
})()
}
try {
const result = await window.electronAPI.chat.getSessionDetail(sessionId)
const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId)
if (requestSeq !== detailRequestSeqRef.current) return
if (result.success && result.detail) {
setSessionDetail(result.detail)
setSessionDetail((prev) => ({
wxid: normalizedSessionId,
displayName: result.detail!.displayName || prev?.displayName || normalizedSessionId,
remark: result.detail!.remark,
nickName: result.detail!.nickName,
alias: result.detail!.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,
transferMessages: prev?.transferMessages,
redPacketMessages: prev?.redPacketMessages,
callMessages: prev?.callMessages,
privateMutualGroups: prev?.privateMutualGroups,
groupMemberCount: prev?.groupMemberCount,
groupMyMessages: prev?.groupMyMessages,
groupActiveSpeakers: prev?.groupActiveSpeakers,
groupMutualFriends: prev?.groupMutualFriends,
relationStatsLoaded: prev?.relationStatsLoaded,
statsUpdatedAt: prev?.statsUpdatedAt,
statsStale: prev?.statsStale,
firstMessageTime: prev?.firstMessageTime,
latestMessageTime: prev?.latestMessageTime,
messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : []
}))
}
} catch (e) {
console.error('加载会话详情失败:', e)
} finally {
setIsLoadingDetail(false)
if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingDetail(false)
}
}
try {
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true }
)
])
if (requestSeq !== detailRequestSeqRef.current) return
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) {
const detail = extraResultSettled.value.detail
if (detail) {
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
return {
...prev,
firstMessageTime: detail.firstMessageTime,
latestMessageTime: detail.latestMessageTime,
messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : []
}
})
}
}
let refreshIncludeRelations = false
if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) {
const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined
const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
refreshIncludeRelations = Boolean(cacheMeta?.includeRelations)
if (metric) {
applySessionDetailStats(normalizedSessionId, metric, cacheMeta, refreshIncludeRelations)
} else if (cacheMeta) {
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
return {
...prev,
relationStatsLoaded: refreshIncludeRelations || prev.relationStatsLoaded,
statsUpdatedAt: cacheMeta.updatedAt,
statsStale: cacheMeta.stale
}
})
}
}
} catch (e) {
console.error('加载会话详情补充统计失败:', e)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingDetailExtra(false)
}
}
}, [applySessionDetailStats])
const loadRelationStats = useCallback(async () => {
const normalizedSessionId = String(currentSessionId || '').trim()
if (!normalizedSessionId || isLoadingRelationStats) return
const requestSeq = detailRequestSeqRef.current
setIsLoadingRelationStats(true)
try {
const relationResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
const metric = relationResult.success && relationResult.data
? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined
: undefined
const cacheMeta = relationResult.success
? relationResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
: undefined
if (metric) {
applySessionDetailStats(normalizedSessionId, metric, cacheMeta, true)
}
const needRefresh = relationResult.success &&
Array.isArray(relationResult.needsRefresh) &&
relationResult.needsRefresh.includes(normalizedSessionId)
if (needRefresh) {
setIsRefreshingDetailStats(true)
void (async () => {
try {
const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
if (freshResult.success && freshResult.data) {
const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
if (freshMetric) {
applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true)
}
}
} catch (error) {
console.error('刷新会话关系统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsRefreshingDetailStats(false)
}
}
})()
}
} catch (error) {
console.error('加载会话关系统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingRelationStats(false)
}
}
}, [applySessionDetailStats, currentSessionId, isLoadingRelationStats])
const normalizeGroupPanelMembers = useCallback((
payload: GroupPanelMember[],
options?: { messageCountStatus?: GroupMessageCountStatus }
): GroupPanelMember[] => {
const membersPayload = Array.isArray(payload) ? payload : []
return membersPayload
.map((member: GroupPanelMember): GroupPanelMember | null => {
const username = String(member.username || '').trim()
if (!username) return null
const preferredName = String(
member.groupNickname ||
member.remark ||
member.displayName ||
member.nickname ||
username
)
const rawStatus = member.messageCountStatus
const normalizedStatus: GroupMessageCountStatus = options?.messageCountStatus
?? (rawStatus === 'loading' || rawStatus === 'failed' ? rawStatus : 'ready')
return {
username,
displayName: preferredName,
avatarUrl: member.avatarUrl,
nickname: member.nickname,
alias: member.alias,
remark: member.remark,
groupNickname: member.groupNickname,
isOwner: Boolean(member.isOwner),
isFriend: Boolean(member.isFriend),
messageCount: Number.isFinite(member.messageCount) ? Math.max(0, Math.floor(member.messageCount)) : 0,
messageCountStatus: normalizedStatus
}
})
.filter((member: GroupPanelMember | null): member is GroupPanelMember => Boolean(member))
.sort((a: GroupPanelMember, b: GroupPanelMember) => {
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
const canSortByCount = a.messageCountStatus === 'ready' && b.messageCountStatus === 'ready'
if (canSortByCount && a.messageCount !== b.messageCount) return b.messageCount - a.messageCount
return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN')
})
}, [])
const normalizeWxidLikeIdentity = useCallback((value?: string): string => {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
const lowered = trimmed.toLowerCase()
if (lowered.startsWith('wxid_')) {
const matched = lowered.match(/^(wxid_[^_]+)/i)
return matched ? matched[1].toLowerCase() : lowered
}
const suffixMatch = lowered.match(/^(.+)_([a-z0-9]{4})$/i)
return suffixMatch ? suffixMatch[1].toLowerCase() : lowered
}, [])
const isSelfGroupMember = useCallback((memberUsername?: string): boolean => {
const selfRaw = String(myWxid || '').trim().toLowerCase()
const selfNormalized = normalizeWxidLikeIdentity(myWxid)
if (!selfRaw && !selfNormalized) return false
const memberRaw = String(memberUsername || '').trim().toLowerCase()
const memberNormalized = normalizeWxidLikeIdentity(memberUsername)
return Boolean(
(selfRaw && memberRaw && selfRaw === memberRaw) ||
(selfNormalized && memberNormalized && selfNormalized === memberNormalized)
)
}, [myWxid, normalizeWxidLikeIdentity])
const resolveMyGroupMessageCountFromMembers = useCallback((members: GroupPanelMember[]): number | undefined => {
if (!myWxid) return undefined
for (const member of members) {
if (!isSelfGroupMember(member.username)) continue
if (Number.isFinite(member.messageCount)) {
return Math.max(0, Math.floor(member.messageCount))
}
return 0
}
return undefined
}, [isSelfGroupMember, myWxid])
const syncGroupMyMessagesFromMembers = useCallback((chatroomId: string, members: GroupPanelMember[]) => {
const myMessageCount = resolveMyGroupMessageCountFromMembers(members)
if (!Number.isFinite(myMessageCount)) return
setSessionDetail((prev) => {
if (!prev || prev.wxid !== chatroomId || !prev.wxid.includes('@chatroom')) return prev
return {
...prev,
groupMyMessages: myMessageCount as number
}
})
}, [resolveMyGroupMessageCountFromMembers])
const updateGroupMembersPanelCache = useCallback((
chatroomId: string,
members: GroupPanelMember[],
includeMessageCounts: boolean
) => {
groupMembersPanelCacheRef.current.set(chatroomId, {
updatedAt: Date.now(),
members,
includeMessageCounts
})
if (groupMembersPanelCacheRef.current.size > 80) {
const oldestEntry = Array.from(groupMembersPanelCacheRef.current.entries())
.sort((a, b) => a[1].updatedAt - b[1].updatedAt)[0]
if (oldestEntry) {
groupMembersPanelCacheRef.current.delete(oldestEntry[0])
}
}
}, [])
const setGroupMembersCountStatus = useCallback((
status: GroupMessageCountStatus,
options?: { onlyWhenNotReady?: boolean }
) => {
setGroupPanelMembers((prev) => {
if (!Array.isArray(prev) || prev.length === 0) return prev
if (options?.onlyWhenNotReady && prev.some((member) => member.messageCountStatus === 'ready')) {
return prev
}
const next = normalizeGroupPanelMembers(prev, { messageCountStatus: status })
const changed = next.some((member, index) => member.messageCountStatus !== prev[index]?.messageCountStatus)
return changed ? next : prev
})
}, [normalizeGroupPanelMembers])
const syncGroupMembersMyCountFromDetail = useCallback((chatroomId: string, myMessageCount: number) => {
if (!chatroomId || !chatroomId.includes('@chatroom')) return
const normalizedCount = Number.isFinite(myMessageCount) ? Math.max(0, Math.floor(myMessageCount)) : 0
const patchMembers = (members: GroupPanelMember[]): { changed: boolean; members: GroupPanelMember[] } => {
if (!Array.isArray(members) || members.length === 0) {
return { changed: false, members }
}
let changed = false
const patched = members.map((member) => {
if (!isSelfGroupMember(member.username)) return member
if (member.messageCount === normalizedCount) return member
changed = true
return {
...member,
messageCount: normalizedCount
}
})
if (!changed) return { changed: false, members }
return { changed: true, members: normalizeGroupPanelMembers(patched) }
}
const cached = groupMembersPanelCacheRef.current.get(chatroomId)
if (cached && cached.members.length > 0) {
const patchedCache = patchMembers(cached.members)
if (patchedCache.changed) {
updateGroupMembersPanelCache(chatroomId, patchedCache.members, true)
}
}
setGroupPanelMembers((prev) => {
const patched = patchMembers(prev)
if (!patched.changed) return prev
return patched.members
})
}, [
isSelfGroupMember,
normalizeGroupPanelMembers,
updateGroupMembersPanelCache
])
const getGroupMembersPanelDataWithTimeout = useCallback(async (
chatroomId: string,
options: { forceRefresh?: boolean; includeMessageCounts?: boolean },
timeoutMs: number
) => {
let timeoutTimer: number | null = null
try {
const timeoutPromise = new Promise<{ success: false; error: string }>((resolve) => {
timeoutTimer = window.setTimeout(() => {
resolve({ success: false, error: '加载群成员超时,请稍后重试' })
}, timeoutMs)
})
return await Promise.race([
window.electronAPI.groupAnalytics.getGroupMembersPanelData(chatroomId, options),
timeoutPromise
])
} finally {
if (timeoutTimer) {
window.clearTimeout(timeoutTimer)
}
}
}, [])
const loadGroupMembersPanel = useCallback(async (chatroomId: string) => {
if (!chatroomId || !isGroupChatSession(chatroomId)) return
const requestSeq = ++groupMembersRequestSeqRef.current
const now = Date.now()
const cached = groupMembersPanelCacheRef.current.get(chatroomId)
const cacheFresh = Boolean(cached && now - cached.updatedAt < GROUP_MEMBERS_PANEL_CACHE_TTL_MS)
const hasCachedMembers = Boolean(cached && cached.members.length > 0)
const hasFreshMessageCounts = Boolean(cacheFresh && cached?.includeMessageCounts)
let startedBackgroundRefresh = false
const refreshMessageCountsInBackground = (forceRefresh: boolean) => {
startedBackgroundRefresh = true
setIsRefreshingGroupMembers(true)
setGroupMembersCountStatus('loading', { onlyWhenNotReady: true })
void (async () => {
try {
const countsResult = await getGroupMembersPanelDataWithTimeout(
chatroomId,
{ forceRefresh, includeMessageCounts: true },
25000
)
if (requestSeq !== groupMembersRequestSeqRef.current) return
if (!countsResult.success || !Array.isArray(countsResult.data)) {
setGroupMembersError('成员列表已加载,发言统计稍后再试')
setGroupMembersCountStatus('failed', { onlyWhenNotReady: true })
return
}
const membersWithCounts = normalizeGroupPanelMembers(
countsResult.data as GroupPanelMember[],
{ messageCountStatus: 'ready' }
)
setGroupPanelMembers(membersWithCounts)
syncGroupMyMessagesFromMembers(chatroomId, membersWithCounts)
setGroupMembersError(null)
updateGroupMembersPanelCache(chatroomId, membersWithCounts, true)
hasInitializedGroupMembersRef.current = true
} catch {
if (requestSeq !== groupMembersRequestSeqRef.current) return
setGroupMembersError('成员列表已加载,发言统计稍后再试')
setGroupMembersCountStatus('failed', { onlyWhenNotReady: true })
} finally {
if (requestSeq === groupMembersRequestSeqRef.current) {
setIsRefreshingGroupMembers(false)
}
}
})()
}
if (cacheFresh && cached) {
const cachedMembers = normalizeGroupPanelMembers(
cached.members,
{ messageCountStatus: cached.includeMessageCounts ? 'ready' : 'loading' }
)
setGroupPanelMembers(cachedMembers)
if (cached.includeMessageCounts) {
syncGroupMyMessagesFromMembers(chatroomId, cachedMembers)
}
setGroupMembersError(null)
setGroupMembersLoadingHint('')
setIsLoadingGroupMembers(false)
hasInitializedGroupMembersRef.current = true
if (!hasFreshMessageCounts) {
refreshMessageCountsInBackground(false)
} else {
setIsRefreshingGroupMembers(false)
}
return
}
setGroupMembersError(null)
if (hasCachedMembers && cached) {
const cachedMembers = normalizeGroupPanelMembers(
cached.members,
{ messageCountStatus: cached.includeMessageCounts ? 'ready' : 'loading' }
)
setGroupPanelMembers(cachedMembers)
if (cached.includeMessageCounts) {
syncGroupMyMessagesFromMembers(chatroomId, cachedMembers)
}
setIsRefreshingGroupMembers(true)
setGroupMembersLoadingHint('')
setIsLoadingGroupMembers(false)
} else {
setGroupPanelMembers([])
setIsRefreshingGroupMembers(false)
setIsLoadingGroupMembers(true)
setGroupMembersLoadingHint(
hasInitializedGroupMembersRef.current
? '加载群成员中...'
: '首次加载群成员,正在初始化索引(可能需要几秒)'
)
}
try {
const membersResult = await getGroupMembersPanelDataWithTimeout(
chatroomId,
{ includeMessageCounts: false, forceRefresh: false },
12000
)
if (requestSeq !== groupMembersRequestSeqRef.current) return
if (!membersResult.success || !Array.isArray(membersResult.data)) {
if (!hasCachedMembers) {
setGroupPanelMembers([])
}
setGroupMembersError(membersResult.error || (hasCachedMembers ? '刷新群成员失败,已显示缓存数据' : '加载群成员失败'))
return
}
const members = normalizeGroupPanelMembers(
membersResult.data as GroupPanelMember[],
{ messageCountStatus: 'loading' }
)
setGroupPanelMembers(members)
setGroupMembersError(null)
updateGroupMembersPanelCache(chatroomId, members, false)
hasInitializedGroupMembersRef.current = true
refreshMessageCountsInBackground(false)
} catch (e) {
if (requestSeq !== groupMembersRequestSeqRef.current) return
if (!hasCachedMembers) {
setGroupPanelMembers([])
}
setGroupMembersError(hasCachedMembers ? '刷新群成员失败,已显示缓存数据' : String(e))
} finally {
if (requestSeq === groupMembersRequestSeqRef.current) {
setIsLoadingGroupMembers(false)
setGroupMembersLoadingHint('')
if (!startedBackgroundRefresh) {
setIsRefreshingGroupMembers(false)
}
}
}
}, [
getGroupMembersPanelDataWithTimeout,
isGroupChatSession,
syncGroupMyMessagesFromMembers,
normalizeGroupPanelMembers,
updateGroupMembersPanelCache
])
const toggleGroupMembersPanel = useCallback(() => {
if (!currentSessionId || !isGroupChatSession(currentSessionId)) return
if (showGroupMembersPanel) {
setShowGroupMembersPanel(false)
return
}
setShowDetailPanel(false)
setShowGroupMembersPanel(true)
}, [currentSessionId, showGroupMembersPanel, isGroupChatSession])
// 切换详情面板
const toggleDetailPanel = useCallback(() => {
if (!showDetailPanel && currentSessionId) {
loadSessionDetail(currentSessionId)
if (showDetailPanel) {
setShowDetailPanel(false)
return
}
setShowGroupMembersPanel(false)
setShowDetailPanel(true)
if (currentSessionId) {
void loadSessionDetail(currentSessionId)
}
setShowDetailPanel(!showDetailPanel)
}, [showDetailPanel, currentSessionId, loadSessionDetail])
useEffect(() => {
if (!showGroupMembersPanel) return
if (!currentSessionId || !isGroupChatSession(currentSessionId)) {
setShowGroupMembersPanel(false)
return
}
setGroupMemberSearchKeyword('')
void loadGroupMembersPanel(currentSessionId)
}, [showGroupMembersPanel, currentSessionId, loadGroupMembersPanel, isGroupChatSession])
useEffect(() => {
const chatroomId = String(sessionDetail?.wxid || '').trim()
if (!chatroomId || !chatroomId.includes('@chatroom')) return
if (!Number.isFinite(sessionDetail?.groupMyMessages)) return
syncGroupMembersMyCountFromDetail(chatroomId, sessionDetail!.groupMyMessages as number)
}, [sessionDetail?.groupMyMessages, sessionDetail?.wxid, syncGroupMembersMyCountFromDetail])
// 复制字段值到剪贴板
const handleCopyField = useCallback(async (text: string, field: string) => {
try {
@@ -444,13 +1663,14 @@ function ChatPage(_props: ChatPageProps) {
setConnecting(true)
setConnectionError(null)
try {
const scopePromise = resolveChatCacheScope()
const result = await window.electronAPI.chat.connect()
if (result.success) {
setConnected(true)
await loadSessions()
await loadMyAvatar()
const wxidPromise = window.electronAPI.config.get('myWxid')
await Promise.all([scopePromise, loadSessions(), loadMyAvatar()])
// 获取 myWxid 用于匹配个人头像
const wxid = await window.electronAPI.config.get('myWxid')
const wxid = await wxidPromise
if (wxid) setMyWxid(wxid as string)
} else {
setConnectionError(result.error || '连接失败')
@@ -460,14 +1680,32 @@ function ChatPage(_props: ChatPageProps) {
} finally {
setConnecting(false)
}
}, [loadMyAvatar])
}, [loadMyAvatar, resolveChatCacheScope])
const handleAccountChanged = useCallback(async () => {
senderAvatarCache.clear()
senderAvatarLoading.clear()
preloadImageKeysRef.current.clear()
lastPreloadSessionRef.current = null
pendingSessionLoadRef.current = null
initialLoadRequestedSessionRef.current = null
sessionSwitchRequestSeqRef.current += 1
sessionWindowCacheRef.current.clear()
setIsSessionSwitching(false)
setSessionDetail(null)
setIsRefreshingDetailStats(false)
setIsLoadingRelationStats(false)
setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setGroupPanelMembers([])
setGroupMembersError(null)
setGroupMembersLoadingHint('')
setIsRefreshingGroupMembers(false)
setGroupMemberSearchKeyword('')
groupMembersRequestSeqRef.current += 1
groupMembersPanelCacheRef.current.clear()
hasInitializedGroupMembersRef.current = false
setIsLoadingGroupMembers(false)
setCurrentSession(null)
setSessions([])
setFilteredSessions([])
@@ -478,9 +1716,13 @@ function ChatPage(_props: ChatPageProps) {
setConnecting(false)
setHasMoreMessages(true)
setHasMoreLater(false)
const scope = await resolveChatCacheScope()
hydrateSessionListCache(scope)
await connect()
}, [
connect,
resolveChatCacheScope,
hydrateSessionListCache,
setConnected,
setConnecting,
setConnectionError,
@@ -491,14 +1733,68 @@ function ChatPage(_props: ChatPageProps) {
setMessages,
setSearchKeyword,
setSessionDetail,
setShowDetailPanel,
setShowGroupMembersPanel,
setSessions
])
useEffect(() => {
let cancelled = false
void (async () => {
const scope = await resolveChatCacheScope()
if (cancelled) return
hydrateSessionListCache(scope)
})()
return () => {
cancelled = true
}
}, [resolveChatCacheScope, hydrateSessionListCache])
// 同步 currentSessionId 到 ref
useEffect(() => {
currentSessionRef.current = currentSessionId
}, [currentSessionId])
const hydrateSessionStatuses = useCallback(async (sessionList: ChatSession[]) => {
const usernames = sessionList.map((s) => s.username).filter(Boolean)
if (usernames.length === 0) return
try {
const result = await window.electronAPI.chat.getSessionStatuses(usernames)
if (!result.success || !result.map) return
const statusMap = result.map
const { sessions: latestSessions } = useChatStore.getState()
if (!Array.isArray(latestSessions) || latestSessions.length === 0) return
let hasChanges = false
const updatedSessions = latestSessions.map((session) => {
const status = statusMap[session.username]
if (!status) return session
const nextIsFolded = status.isFolded ?? session.isFolded
const nextIsMuted = status.isMuted ?? session.isMuted
if (nextIsFolded === session.isFolded && nextIsMuted === session.isMuted) {
return session
}
hasChanges = true
return {
...session,
isFolded: nextIsFolded,
isMuted: nextIsMuted
}
})
if (hasChanges) {
setSessions(updatedSessions)
}
} catch (e) {
console.warn('会话状态补齐失败:', e)
}
}, [setSessions])
// 加载会话列表(优化:先返回基础数据,异步加载联系人信息)
const loadSessions = async (options?: { silent?: boolean }) => {
if (options?.silent) {
@@ -507,6 +1803,7 @@ function ChatPage(_props: ChatPageProps) {
setLoadingSessions(true)
}
try {
const scope = await resolveChatCacheScope()
const result = await window.electronAPI.chat.getSessions()
if (result.success && result.sessions) {
// 确保 sessions 是数组
@@ -518,11 +1815,16 @@ function ChatPage(_props: ChatPageProps) {
setSessions(nextSessions)
sessionsRef.current = nextSessions
persistSessionListCache(scope, nextSessions)
void hydrateSessionStatuses(nextSessions)
// 立即启动联系人信息加载,不再延迟 500ms
void enrichSessionsContactInfo(nextSessions)
} else {
console.error('mergeSessions returned non-array:', nextSessions)
setSessions(sessionsArray)
sessionsRef.current = sessionsArray
persistSessionListCache(scope, sessionsArray)
void hydrateSessionStatuses(sessionsArray)
void enrichSessionsContactInfo(sessionsArray)
}
} else if (!result.success) {
@@ -575,8 +1877,8 @@ function ChatPage(_props: ChatPageProps) {
// 进一步减少批次大小,每批3个,避免DLL调用阻塞
const batchSize = 3
// 批量补齐联系人,平衡吞吐和 UI 流畅性
const batchSize = 8
let loadedCount = 0
for (let i = 0; i < needEnrich.length; i += batchSize) {
@@ -585,7 +1887,7 @@ function ChatPage(_props: ChatPageProps) {
// 等待滚动结束
while (isScrollingRef.current && !enrichCancelledRef.current) {
await new Promise(resolve => setTimeout(resolve, 200))
await new Promise(resolve => setTimeout(resolve, 120))
}
if (enrichCancelledRef.current) break
}
@@ -602,11 +1904,11 @@ function ChatPage(_props: ChatPageProps) {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(() => {
void loadContactInfoBatch(usernames).then(() => resolve())
}, { timeout: 2000 })
}, { timeout: 700 })
} else {
setTimeout(() => {
void loadContactInfoBatch(usernames).then(() => resolve())
}, 300)
}, 80)
}
})
@@ -618,8 +1920,7 @@ function ChatPage(_props: ChatPageProps) {
// 批次间延迟,给UI更多时间(DLL调用可能阻塞,需要更长的延迟)
if (i + batchSize < needEnrich.length && !enrichCancelledRef.current) {
// 如果不在滚动,可以延迟短一点
const delay = isScrollingRef.current ? 1000 : 800
const delay = isScrollingRef.current ? 260 : 120
await new Promise(resolve => setTimeout(resolve, delay))
}
}
@@ -649,17 +1950,17 @@ function ChatPage(_props: ChatPageProps) {
contactUpdateTimerRef.current = null
}
// 增加防抖延迟到500ms,避免在滚动时频繁更新
// 使用短防抖,让头像和昵称更快补齐但依然避免频繁重渲染
contactUpdateTimerRef.current = window.setTimeout(() => {
const updates = contactUpdateQueueRef.current
if (updates.size === 0) return
const now = Date.now()
// 如果距离上次更新太近(小于1秒),继续延迟
if (now - lastUpdateTimeRef.current < 1000) {
// 如果距离上次更新太近(小于250ms),继续延迟
if (now - lastUpdateTimeRef.current < 250) {
contactUpdateTimerRef.current = window.setTimeout(() => {
flushContactUpdates()
}, 1000 - (now - lastUpdateTimeRef.current))
}, 250 - (now - lastUpdateTimeRef.current))
return
}
@@ -696,7 +1997,7 @@ function ChatPage(_props: ChatPageProps) {
updates.clear()
contactUpdateTimerRef.current = null
}, 500) // 500ms 防抖,减少更新频率
}, 120)
}, [setSessions])
// 加载一批联系人信息并更新会话列表(优化:使用队列批量更新)
@@ -853,39 +2154,69 @@ function ChatPage(_props: ChatPageProps) {
setIsRefreshingMessages(false)
}
}
// 动态游标批量大小控制
// 消息批量大小控制(保持稳定,避免游标反复重建)
const currentBatchSizeRef = useRef(50)
const warmupGroupSenderProfiles = useCallback((usernames: string[], defer = false) => {
if (!Array.isArray(usernames) || usernames.length === 0) return
const runWarmup = () => {
const batchPromise = loadContactInfoBatch(usernames)
usernames.forEach(username => {
if (!senderAvatarLoading.has(username)) {
senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null))
}
})
batchPromise.finally(() => {
usernames.forEach(username => senderAvatarLoading.delete(username))
})
}
if (defer) {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(() => {
runWarmup()
}, { timeout: 1200 })
} else {
globalThis.setTimeout(runWarmup, 120)
}
return
}
runWarmup()
}, [loadContactInfoBatch])
// 加载消息
const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0, ascending = false) => {
const loadMessages = async (
sessionId: string,
offset = 0,
startTime = 0,
endTime = 0,
ascending = false,
options: LoadMessagesOptions = {}
) => {
const listEl = messageListRef.current
const session = sessionMapRef.current.get(sessionId)
const unreadCount = session?.unreadCount ?? 0
let messageLimit = 50
let messageLimit = currentBatchSizeRef.current
if (offset === 0) {
// 初始加载:重置批量大小
currentBatchSizeRef.current = 50
// 首屏优化:消息过多时限制数量
messageLimit = unreadCount > 99 ? 30 : 50
const preferredLimit = Number.isFinite(options.forceInitialLimit)
? Math.max(10, Math.floor(options.forceInitialLimit as number))
: (unreadCount > 99 ? 30 : 40)
currentBatchSizeRef.current = preferredLimit
messageLimit = preferredLimit
} else {
// 滚动加载:动态递增 (50 -> 100 -> 200)
if (currentBatchSizeRef.current < 100) {
currentBatchSizeRef.current = 100
} else {
currentBatchSizeRef.current = 200
}
// 同一会话内保持固定批量,避免后端游标因 batch 改变而重建
messageLimit = currentBatchSizeRef.current
}
if (offset === 0) {
setLoadingMessages(true)
setMessages([])
// 切会话时保留旧内容作为过渡,避免大面积闪烁
setHasInitialMessages(true)
} else {
setLoadingMore(true)
}
@@ -894,22 +2225,32 @@ function ChatPage(_props: ChatPageProps) {
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
try {
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime, ascending) as {
const useLatestPath = offset === 0 && startTime === 0 && endTime === 0 && !ascending && options.preferLatestPath
const result = (useLatestPath
? await window.electronAPI.chat.getLatestMessages(sessionId, messageLimit)
: await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime, ascending)
) as {
success: boolean;
messages?: Message[];
hasMore?: boolean;
error?: string
}
if (options.switchRequestSeq && options.switchRequestSeq !== sessionSwitchRequestSeqRef.current) {
return
}
if (currentSessionRef.current !== sessionId) {
return
}
if (result.success && result.messages) {
if (offset === 0) {
setMessages(result.messages)
persistSessionPreviewCache(sessionId, result.messages)
if (result.messages.length === 0) {
setNoMessageTable(true)
setHasMoreMessages(false)
}
// 预取发送者信息:在关闭加载遮罩前处理
const unreadCount = session?.unreadCount ?? 0
// 群聊发送者信息补齐改为非阻塞执行,避免影响首屏切换
const isGroup = sessionId.includes('@chatroom')
if (isGroup && result.messages.length > 0) {
const unknownSenders = [...new Set(result.messages
@@ -917,18 +2258,7 @@ function ChatPage(_props: ChatPageProps) {
.map(m => m.senderUsername as string)
)]
if (unknownSenders.length > 0) {
// 在批量请求前,先将这些发送者标记为加载中,防止 MessageBubble 触发重复请求
const batchPromise = loadContactInfoBatch(unknownSenders)
unknownSenders.forEach(username => {
if (!senderAvatarLoading.has(username)) {
senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null))
}
})
// 确保在请求完成后清理 loading 状态
batchPromise.finally(() => {
unknownSenders.forEach(username => senderAvatarLoading.delete(username))
})
warmupGroupSenderProfiles(unknownSenders, options.deferGroupSenderWarmup === true)
}
}
@@ -954,15 +2284,7 @@ function ChatPage(_props: ChatPageProps) {
.map(m => m.senderUsername as string)
)]
if (unknownSenders.length > 0) {
const batchPromise = loadContactInfoBatch(unknownSenders)
unknownSenders.forEach(username => {
if (!senderAvatarLoading.has(username)) {
senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null))
}
})
batchPromise.finally(() => {
unknownSenders.forEach(username => senderAvatarLoading.delete(username))
})
warmupGroupSenderProfiles(unknownSenders, false)
}
}
@@ -996,12 +2318,36 @@ function ChatPage(_props: ChatPageProps) {
console.error('加载消息失败:', e)
setConnectionError('加载消息失败')
setHasMoreMessages(false)
if (offset === 0 && currentSessionRef.current === sessionId) {
setMessages([])
}
} finally {
setLoadingMessages(false)
setLoadingMore(false)
if (offset === 0 && pendingSessionLoadRef.current === sessionId) {
if (!options.switchRequestSeq || options.switchRequestSeq === sessionSwitchRequestSeqRef.current) {
pendingSessionLoadRef.current = null
initialLoadRequestedSessionRef.current = null
setIsSessionSwitching(false)
}
}
}
}
const handleJumpDateSelect = useCallback((date: Date) => {
if (!currentSessionId) return
const targetDate = new Date(date)
const end = Math.floor(targetDate.setHours(23, 59, 59, 999) / 1000)
// 日期跳转采用“锚点定位”而非“当天过滤”:
// 先定位到当日附近,再允许上下滚动跨天浏览。
isDateJumpRef.current = false
setCurrentOffset(0)
setJumpStartTime(0)
setJumpEndTime(end)
setShowJumpPopover(false)
void loadMessages(currentSessionId, 0, 0, end, false)
}, [currentSessionId, loadMessages])
// 加载更晚的消息
const loadLaterMessages = useCallback(async () => {
if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return
@@ -1034,6 +2380,86 @@ function ChatPage(_props: ChatPageProps) {
}
}, [currentSessionId, isLoadingMore, isLoadingMessages, messages, getMessageKey, appendMessages, setHasMoreLater, setLoadingMore])
const refreshSessionIncrementally = useCallback(async (sessionId: string, switchRequestSeq?: number) => {
const currentMessages = useChatStore.getState().messages || []
const lastMsg = currentMessages[currentMessages.length - 1]
const minTime = lastMsg?.createTime || 0
if (!sessionId || minTime <= 0) return
try {
const result = await window.electronAPI.chat.getNewMessages(sessionId, minTime, 120) as {
success: boolean
messages?: Message[]
error?: string
}
if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return
if (currentSessionRef.current !== sessionId) return
if (!result.success || !Array.isArray(result.messages) || result.messages.length === 0) return
const latestMessages = useChatStore.getState().messages || []
const existing = new Set(latestMessages.map(getMessageKey))
const newMessages = result.messages.filter((msg) => !existing.has(getMessageKey(msg)))
if (newMessages.length > 0) {
appendMessages(newMessages, false)
}
} catch (error) {
console.warn('[SessionCache] 增量刷新失败:', error)
}
}, [appendMessages, getMessageKey])
// 选择会话
const selectSessionById = useCallback((sessionId: string) => {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId || normalizedSessionId === currentSessionId) return
const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1
sessionSwitchRequestSeqRef.current = switchRequestSeq
setCurrentSession(normalizedSessionId, { preserveMessages: false })
setNoMessageTable(false)
const restoredFromWindowCache = restoreSessionWindowCache(normalizedSessionId)
if (restoredFromWindowCache) {
pendingSessionLoadRef.current = null
initialLoadRequestedSessionRef.current = null
setIsSessionSwitching(false)
void refreshSessionIncrementally(normalizedSessionId, switchRequestSeq)
} else {
pendingSessionLoadRef.current = normalizedSessionId
initialLoadRequestedSessionRef.current = normalizedSessionId
setIsSessionSwitching(true)
void hydrateSessionPreview(normalizedSessionId)
setCurrentOffset(0)
setJumpStartTime(0)
setJumpEndTime(0)
void loadMessages(normalizedSessionId, 0, 0, 0, false, {
preferLatestPath: true,
deferGroupSenderWarmup: true,
forceInitialLimit: 30,
switchRequestSeq
})
}
// 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开
setShowJumpPopover(false)
setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setGroupMemberSearchKeyword('')
setGroupMembersError(null)
setGroupMembersLoadingHint('')
setIsRefreshingGroupMembers(false)
groupMembersRequestSeqRef.current += 1
setIsLoadingGroupMembers(false)
setSessionDetail(null)
setIsRefreshingDetailStats(false)
setIsLoadingRelationStats(false)
}, [
currentSessionId,
setCurrentSession,
restoreSessionWindowCache,
refreshSessionIncrementally,
hydrateSessionPreview,
loadMessages
])
// 选择会话
const handleSelectSession = (session: ChatSession) => {
// 点击折叠群入口,切换到折叠群视图
@@ -1041,17 +2467,7 @@ function ChatPage(_props: ChatPageProps) {
setFoldedView(true)
return
}
if (session.username === currentSessionId) return
setCurrentSession(session.username)
setCurrentOffset(0)
setJumpStartTime(0)
setJumpEndTime(0)
loadMessages(session.username, 0, 0, 0)
// 重置详情面板
setSessionDetail(null)
if (showDetailPanel) {
loadSessionDetail(session.username)
}
selectSessionById(session.username)
}
// 搜索过滤
@@ -1185,6 +2601,14 @@ function ChatPage(_props: ChatPageProps) {
// 组件卸载时清理
return () => {
avatarLoadQueue.clear()
if (previewPersistTimerRef.current !== null) {
window.clearTimeout(previewPersistTimerRef.current)
previewPersistTimerRef.current = null
}
if (sessionListPersistTimerRef.current !== null) {
window.clearTimeout(sessionListPersistTimerRef.current)
sessionListPersistTimerRef.current = null
}
if (contactUpdateTimerRef.current) {
clearTimeout(contactUpdateTimerRef.current)
}
@@ -1311,8 +2735,15 @@ function ChatPage(_props: ChatPageProps) {
useEffect(() => {
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
if (pendingSessionLoadRef.current === currentSessionId) return
if (initialLoadRequestedSessionRef.current === currentSessionId) return
initialLoadRequestedSessionRef.current = currentSessionId
setHasInitialMessages(false)
loadMessages(currentSessionId, 0)
void loadMessages(currentSessionId, 0, 0, 0, false, {
preferLatestPath: true,
deferGroupSenderWarmup: true,
forceInitialLimit: 30
})
}
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
@@ -1333,6 +2764,79 @@ function ChatPage(_props: ChatPageProps) {
searchKeywordRef.current = searchKeyword
}, [searchKeyword])
useEffect(() => {
if (!showJumpPopover) return
const handleGlobalPointerDown = (event: MouseEvent) => {
const target = event.target as Node | null
if (!target) return
if (jumpCalendarWrapRef.current?.contains(target)) return
if (jumpPopoverPortalRef.current?.contains(target)) return
setShowJumpPopover(false)
}
document.addEventListener('mousedown', handleGlobalPointerDown)
return () => {
document.removeEventListener('mousedown', handleGlobalPointerDown)
}
}, [showJumpPopover])
useEffect(() => {
if (!showJumpPopover) return
const syncPosition = () => {
requestAnimationFrame(() => updateJumpPopoverPosition())
}
syncPosition()
window.addEventListener('resize', syncPosition)
window.addEventListener('scroll', syncPosition, true)
return () => {
window.removeEventListener('resize', syncPosition)
window.removeEventListener('scroll', syncPosition, true)
}
}, [showJumpPopover, updateJumpPopoverPosition])
useEffect(() => {
setShowJumpPopover(false)
setLoadingDates(false)
setLoadingDateCounts(false)
setHasLoadedMessageDates(false)
setMessageDates(new Set())
setMessageDateCounts({})
}, [currentSessionId])
useEffect(() => {
if (!currentSessionId || !Array.isArray(messages) || messages.length === 0) return
persistSessionPreviewCache(currentSessionId, messages)
saveSessionWindowCache(currentSessionId, {
messages,
currentOffset,
hasMoreMessages,
hasMoreLater,
jumpStartTime,
jumpEndTime
})
}, [
currentSessionId,
messages,
currentOffset,
hasMoreMessages,
hasMoreLater,
jumpStartTime,
jumpEndTime,
persistSessionPreviewCache,
saveSessionWindowCache
])
useEffect(() => {
if (!Array.isArray(sessions) || sessions.length === 0) return
if (sessionListPersistTimerRef.current !== null) {
window.clearTimeout(sessionListPersistTimerRef.current)
}
sessionListPersistTimerRef.current = window.setTimeout(() => {
persistSessionListCache(chatCacheScopeRef.current, sessions)
sessionListPersistTimerRef.current = null
}, 260)
}, [sessions, persistSessionListCache])
// 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口
useEffect(() => {
if (!Array.isArray(sessions)) {
@@ -1368,6 +2872,10 @@ function ChatPage(_props: ChatPageProps) {
)
}, [sessions, searchKeyword, foldedView])
const hasSessionRecords = Array.isArray(sessions) && sessions.length > 0
const shouldShowSessionsSkeleton = isLoadingSessions && !hasSessionRecords
const isSessionListSyncing = (isLoadingSessions || isRefreshingSessions) && hasSessionRecords
// 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算
const formatSessionTime = useCallback((timestamp: number): string => {
@@ -1410,6 +2918,38 @@ function ChatPage(_props: ChatPageProps) {
displayName: fallbackDisplayName || currentSessionId,
} as ChatSession
})()
const filteredGroupPanelMembers = useMemo(() => {
const keyword = groupMemberSearchKeyword.trim().toLowerCase()
if (!keyword) return groupPanelMembers
return groupPanelMembers.filter((member) => {
const fields = [
member.username,
member.displayName,
member.groupNickname,
member.remark,
member.nickname,
member.alias
]
return fields.some(field => String(field || '').toLowerCase().includes(keyword))
})
}, [groupMemberSearchKeyword, groupPanelMembers])
const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId))
const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog
useEffect(() => {
if (!standaloneSessionWindow) return
if (!normalizedInitialSessionId) return
if (!isConnected || isConnecting) return
if (currentSessionId === normalizedInitialSessionId) return
selectSessionById(normalizedInitialSessionId)
}, [
standaloneSessionWindow,
normalizedInitialSessionId,
isConnected,
isConnecting,
currentSessionId,
selectSessionById
])
// 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称
useEffect(() => {
@@ -1425,9 +2965,6 @@ function ChatPage(_props: ChatPageProps) {
})
}, [currentSessionId, sessions])
// 判断是否为群聊
const isGroupChat = (username: string) => username.includes('@chatroom')
// 渲染日期分隔
const shouldShowDateDivider = (msg: Message, prevMsg?: Message): boolean => {
if (!prevMsg) return true
@@ -1526,21 +3063,32 @@ function ChatPage(_props: ChatPageProps) {
const handleExportCurrentSession = useCallback(() => {
if (!currentSessionId) return
navigate('/export', {
state: {
preselectSessionIds: [currentSessionId]
}
if (inProgressExportSessionIds.has(currentSessionId) || isPreparingExportDialog) return
const requestId = `chat-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const sessionName = currentSession?.displayName || currentSession?.username || currentSessionId
pendingExportRequestIdRef.current = requestId
setIsPreparingExportDialog(true)
setExportPrepareHint('')
if (exportPrepareLongWaitTimerRef.current) {
window.clearTimeout(exportPrepareLongWaitTimerRef.current)
exportPrepareLongWaitTimerRef.current = null
}
emitOpenSingleExport({
sessionId: currentSessionId,
sessionName,
requestId
})
}, [currentSessionId, navigate])
}, [currentSession, currentSessionId, inProgressExportSessionIds, isPreparingExportDialog])
const handleGroupAnalytics = useCallback(() => {
if (!currentSessionId || !isGroupChat(currentSessionId)) return
if (!currentSessionId || !isGroupChatSession(currentSessionId)) return
navigate('/group-analytics', {
state: {
preselectGroupIds: [currentSessionId]
}
})
}, [currentSessionId, navigate])
}, [currentSessionId, navigate, isGroupChatSession])
// 确认批量转写
const confirmBatchTranscribe = useCallback(async () => {
@@ -1969,7 +3517,7 @@ function ChatPage(_props: ChatPageProps) {
}
return (
<div className={`chat-page ${isResizing ? 'resizing' : ''}`}>
<div className={`chat-page ${isResizing ? 'resizing' : ''} ${standaloneSessionWindow ? 'standalone session-only' : ''}`}>
{/* 自定义删除确认对话框 */}
{deleteConfirm.show && (
<div className="delete-confirm-overlay">
@@ -2041,6 +3589,7 @@ function ChatPage(_props: ChatPageProps) {
</div>
)}
{/* 左侧会话列表 */}
{!standaloneSessionWindow && (
<div
className="session-sidebar"
ref={sidebarRef}
@@ -2068,6 +3617,12 @@ function ChatPage(_props: ChatPageProps) {
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
</button>
{isSessionListSyncing && (
<div className="session-sync-indicator">
<Loader2 size={12} className="spin" />
<span>同步中</span>
</div>
)}
</div>
</div>
{/* 折叠群 header */}
@@ -2093,7 +3648,7 @@ function ChatPage(_props: ChatPageProps) {
)}
{/* ... (previous content) ... */}
{isLoadingSessions ? (
{shouldShowSessionsSkeleton ? (
<div className="loading-sessions">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="skeleton-item">
@@ -2169,9 +3724,10 @@ function ChatPage(_props: ChatPageProps) {
</div>
)}
{/* 拖动调节条 */}
<div className="resize-handle" onMouseDown={handleResizeStart} />
{!standaloneSessionWindow && <div className="resize-handle" onMouseDown={handleResizeStart} />}
{/* 右侧消息区域 */}
<div className="message-area">
@@ -2182,16 +3738,16 @@ function ChatPage(_props: ChatPageProps) {
src={currentSession.avatarUrl}
name={currentSession.displayName || currentSession.username}
size={40}
className={isGroupChat(currentSession.username) ? 'group session-avatar' : 'session-avatar'}
className={isGroupChatSession(currentSession.username) ? 'group session-avatar' : 'session-avatar'}
/>
<div className="header-info">
<h3>{currentSession.displayName || currentSession.username}</h3>
{isGroupChat(currentSession.username) && (
{isGroupChatSession(currentSession.username) && (
<div className="header-subtitle">群聊</div>
)}
</div>
<div className="header-actions">
{isGroupChat(currentSession.username) && (
{!standaloneSessionWindow && isGroupChatSession(currentSession.username) && (
<button
className="icon-btn group-analytics-btn"
onClick={handleGroupAnalytics}
@@ -2200,99 +3756,105 @@ function ChatPage(_props: ChatPageProps) {
<BarChart3 size={18} />
</button>
)}
<button
className="icon-btn export-session-btn"
onClick={handleExportCurrentSession}
disabled={!currentSessionId}
title="导出当前会话"
>
<Download size={18} />
</button>
<button
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
onClick={() => {
if (isBatchTranscribing) {
setShowBatchProgress(true)
} else {
handleBatchTranscribe()
}
}}
disabled={!currentSessionId}
title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度` : '批量语音转文字'}
>
{isBatchTranscribing ? (
<Loader2 size={18} className="spin" />
) : (
<Mic size={18} />
)}
</button>
<button
className={`icon-btn batch-decrypt-btn${isBatchDecrypting ? ' transcribing' : ''}`}
onClick={() => {
if (isBatchDecrypting) {
setShowBatchDecryptToast(true)
} else {
handleBatchDecrypt()
}
}}
disabled={!currentSessionId}
title={isBatchDecrypting
? `批量解密中 (${batchDecryptProgress.current}/${batchDecryptProgress.total}),点击查看进度`
: '批量解密图片'}
>
{isBatchDecrypting ? (
<Loader2 size={18} className="spin" />
) : (
<ImageIcon size={18} />
)}
</button>
<button
className="icon-btn jump-to-time-btn"
onClick={async () => {
setShowJumpDialog(true)
if (!currentSessionId) return
// 检查缓存
const cached = messageDatesCache.current.get(currentSessionId)
if (cached) {
setMessageDates(cached)
return
}
// 获取消息日期
setMessageDates(new Set()) // 清除旧数据
setLoadingDates(true)
try {
const result = await (window as any).electronAPI.chat.getMessageDates(currentSessionId)
if (result?.success && result.dates) {
const dateSet = new Set<string>(result.dates)
setMessageDates(dateSet)
messageDatesCache.current.set(currentSessionId, dateSet)
{isGroupChatSession(currentSession.username) && (
<button
className={`icon-btn group-members-btn ${showGroupMembersPanel ? 'active' : ''}`}
onClick={toggleGroupMembersPanel}
title="群成员"
>
<Users size={18} />
</button>
)}
{!standaloneSessionWindow && (
<button
className={`icon-btn export-session-btn${isExportActionBusy ? ' exporting' : ''}`}
onClick={handleExportCurrentSession}
disabled={!currentSessionId || isExportActionBusy}
title={isCurrentSessionExporting ? '导出中' : isPreparingExportDialog ? '正在准备导出模块' : '导出当前会话'}
>
{isExportActionBusy ? (
<Loader2 size={18} className="spin" />
) : (
<Download size={18} />
)}
</button>
)}
{!standaloneSessionWindow && (
<button
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
onClick={() => {
if (isBatchTranscribing) {
setShowBatchProgress(true)
} else {
handleBatchTranscribe()
}
} catch (e) {
console.error('获取消息日期失败:', e)
} finally {
setLoadingDates(false)
}
}}
title="跳转到指定时间"
>
<Calendar size={18} />
</button>
<JumpToDateDialog
isOpen={showJumpDialog}
onClose={() => setShowJumpDialog(false)}
onSelect={(date) => {
if (!currentSessionId) return
const start = Math.floor(date.setHours(0, 0, 0, 0) / 1000)
const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000)
isDateJumpRef.current = true
setCurrentOffset(0)
setJumpStartTime(start)
setJumpEndTime(end)
loadMessages(currentSessionId, 0, start, end, true)
}}
messageDates={messageDates}
loadingDates={loadingDates}
/>
}}
disabled={!currentSessionId}
title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度` : '批量语音转文字'}
>
{isBatchTranscribing ? (
<Loader2 size={18} className="spin" />
) : (
<Mic size={18} />
)}
</button>
)}
{!standaloneSessionWindow && (
<button
className={`icon-btn batch-decrypt-btn${isBatchDecrypting ? ' transcribing' : ''}`}
onClick={() => {
if (isBatchDecrypting) {
setShowBatchDecryptToast(true)
} else {
handleBatchDecrypt()
}
}}
disabled={!currentSessionId}
title={isBatchDecrypting
? `批量解密中 (${batchDecryptProgress.current}/${batchDecryptProgress.total}),点击查看进度`
: '批量解密图片'}
>
{isBatchDecrypting ? (
<Loader2 size={18} className="spin" />
) : (
<ImageIcon size={18} />
)}
</button>
)}
<div className="jump-calendar-anchor" ref={jumpCalendarWrapRef}>
<button
className={`icon-btn jump-to-time-btn ${showJumpPopover ? 'active' : ''}`}
onClick={handleToggleJumpPopover}
title="跳转到指定时间"
>
<Calendar size={18} />
</button>
</div>
{showJumpPopover && createPortal(
<div
ref={jumpPopoverPortalRef}
style={{
position: 'fixed',
top: jumpPopoverPosition.top,
left: jumpPopoverPosition.left,
zIndex: 3600
}}
>
<JumpToDatePopover
isOpen={showJumpPopover}
currentDate={jumpPopoverDate}
onClose={() => setShowJumpPopover(false)}
onSelect={handleJumpDateSelect}
messageDates={messageDates}
hasLoadedMessageDates={hasLoadedMessageDates}
messageDateCounts={messageDateCounts}
loadingDates={loadingDates}
loadingDateCounts={loadingDateCounts}
style={{ position: 'static', top: 'auto', right: 'auto' }}
/>
</div>,
document.body
)}
<button
className="icon-btn refresh-messages-btn"
onClick={handleRefreshMessages}
@@ -2311,11 +3873,18 @@ function ChatPage(_props: ChatPageProps) {
</div>
</div>
<div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'}`}>
{isLoadingMessages && !hasInitialMessages && (
{isPreparingExportDialog && exportPrepareHint && (
<div className="export-prepare-hint" role="status" aria-live="polite">
<Loader2 size={14} className="spin" />
<span>{exportPrepareHint}</span>
</div>
)}
<div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'} ${isSessionSwitching ? 'switching' : ''}`}>
{isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
<div className="loading-messages loading-overlay">
<Loader2 size={24} />
<span>加载消息中...</span>
<span>{isSessionSwitching ? '切换会话中...' : '加载消息中...'}</span>
</div>
)}
<div
@@ -2368,7 +3937,7 @@ function ChatPage(_props: ChatPageProps) {
session={currentSession}
showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl}
isGroupChat={isGroupChat(currentSession.username)}
isGroupChat={isGroupChatSession(currentSession.username)}
onRequireModelDownload={handleRequireModelDownload}
onContextMenu={handleContextMenu}
isSelectionMode={isSelectionMode}
@@ -2399,6 +3968,98 @@ function ChatPage(_props: ChatPageProps) {
</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>
{isRefreshingGroupMembers && (
<div className="group-members-status" role="status" aria-live="polite">
<Loader2 size={14} className="spin" />
<span>正在统计成员发言数...</span>
</div>
)}
{groupMembersError && groupPanelMembers.length > 0 && (
<div className="group-members-status warning" role="status" aria-live="polite">
<span>{groupMembersError}</span>
</div>
)}
{isLoadingGroupMembers ? (
<div className="detail-loading">
<Loader2 size={20} className="spin" />
<span>{groupMembersLoadingHint || '加载群成员中...'}</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.messageCountStatus}`}>
{member.messageCountStatus === 'loading'
? '统计中'
: member.messageCountStatus === 'failed'
? '统计失败'
: `${member.messageCount.toLocaleString()} 条`}
</span>
</div>
))}
</div>
)}
</div>
)}
{/* 会话详情面板 */}
{showDetailPanel && (
<div className="detail-panel">
@@ -2408,7 +4069,7 @@ function ChatPage(_props: ChatPageProps) {
<X size={16} />
</button>
</div>
{isLoadingDetail ? (
{isLoadingDetail && !sessionDetail ? (
<div className="detail-loading">
<Loader2 size={20} className="spin" />
<span>加载中...</span>
@@ -2456,46 +4117,170 @@ function ChatPage(_props: ChatPageProps) {
<div className="detail-section">
<div className="section-title">
<MessageSquare size={14} />
<span>消息统计</span>
<span>消息统计(导出口径)</span>
</div>
<div className="detail-stats-meta">
{isRefreshingDetailStats
? '统计刷新中...'
: sessionDetail.statsUpdatedAt
? `${sessionDetail.statsStale ? '缓存于' : '更新于'} ${formatYmdHmDateTime(sessionDetail.statsUpdatedAt)}${sessionDetail.statsStale ? '(将后台刷新)' : ''}`
: (isLoadingDetailExtra ? '统计加载中...' : '暂无统计缓存')}
</div>
<div className="detail-item">
<span className="label">消息总数</span>
<span className="value highlight">
{Number.isFinite(sessionDetail.messageCount)
? sessionDetail.messageCount.toLocaleString()
: '—'}
: ((isLoadingDetail || isLoadingDetailExtra) ? '统计中...' : '—')}
</span>
</div>
{sessionDetail.firstMessageTime && (
<div className="detail-item">
<span className="label">语音</span>
<span className="value">
{Number.isFinite(sessionDetail.voiceMessages)
? (sessionDetail.voiceMessages as number).toLocaleString()
: (isLoadingDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">图片</span>
<span className="value">
{Number.isFinite(sessionDetail.imageMessages)
? (sessionDetail.imageMessages as number).toLocaleString()
: (isLoadingDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">视频</span>
<span className="value">
{Number.isFinite(sessionDetail.videoMessages)
? (sessionDetail.videoMessages as number).toLocaleString()
: (isLoadingDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">表情包</span>
<span className="value">
{Number.isFinite(sessionDetail.emojiMessages)
? (sessionDetail.emojiMessages as number).toLocaleString()
: (isLoadingDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">转账消息数</span>
<span className="value">
{Number.isFinite(sessionDetail.transferMessages)
? (sessionDetail.transferMessages as number).toLocaleString()
: (isLoadingDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">红包消息数</span>
<span className="value">
{Number.isFinite(sessionDetail.redPacketMessages)
? (sessionDetail.redPacketMessages as number).toLocaleString()
: (isLoadingDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">通话消息数</span>
<span className="value">
{Number.isFinite(sessionDetail.callMessages)
? (sessionDetail.callMessages as number).toLocaleString()
: (isLoadingDetailExtra ? '统计中...' : '—')}
</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()
: (isLoadingDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">群人数</span>
<span className="value">
{Number.isFinite(sessionDetail.groupMemberCount)
? (sessionDetail.groupMemberCount as number).toLocaleString()
: (isLoadingDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">群发言人数</span>
<span className="value">
{Number.isFinite(sessionDetail.groupActiveSpeakers)
? (sessionDetail.groupActiveSpeakers as number).toLocaleString()
: (isLoadingDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">群共同好友数</span>
<span className="value">
{sessionDetail.relationStatsLoaded
? (Number.isFinite(sessionDetail.groupMutualFriends)
? (sessionDetail.groupMutualFriends as number).toLocaleString()
: '—')
: (
<button
className="detail-inline-btn"
onClick={() => { void loadRelationStats() }}
disabled={isLoadingRelationStats || isLoadingDetailExtra}
>
{isLoadingRelationStats ? '加载中...' : '点击加载'}
</button>
)}
</span>
</div>
</>
) : (
<div className="detail-item">
<Calendar size={14} />
<span className="label">首条消息</span>
<span className="label">共同群聊数</span>
<span className="value">
{Number.isFinite(sessionDetail.firstMessageTime)
? new Date(sessionDetail.firstMessageTime * 1000).toLocaleDateString('zh-CN')
: '—'}
</span>
</div>
)}
{sessionDetail.latestMessageTime && (
<div className="detail-item">
<Calendar size={14} />
<span className="label">最新消息</span>
<span className="value">
{Number.isFinite(sessionDetail.latestMessageTime)
? new Date(sessionDetail.latestMessageTime * 1000).toLocaleDateString('zh-CN')
: '—'}
{sessionDetail.relationStatsLoaded
? (Number.isFinite(sessionDetail.privateMutualGroups)
? (sessionDetail.privateMutualGroups as number).toLocaleString()
: '—')
: (
<button
className="detail-inline-btn"
onClick={() => { void loadRelationStats() }}
disabled={isLoadingRelationStats || isLoadingDetailExtra}
>
{isLoadingRelationStats ? '加载中...' : '点击加载'}
</button>
)}
</span>
</div>
)}
<div className="detail-item">
<Calendar size={14} />
<span className="label">首条消息</span>
<span className="value">
{sessionDetail.firstMessageTime
? formatYmdDateFromSeconds(sessionDetail.firstMessageTime)
: (isLoadingDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<Calendar size={14} />
<span className="label">最新消息</span>
<span className="value">
{sessionDetail.latestMessageTime
? formatYmdDateFromSeconds(sessionDetail.latestMessageTime)
: (isLoadingDetailExtra ? '统计中...' : '—')}
</span>
</div>
</div>
{Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 && (
<div className="detail-section">
<div className="section-title">
<Database size={14} />
<span>数据库分布</span>
</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((t, i) => (
<div key={i} className="table-item">
@@ -2504,8 +4289,12 @@ function ChatPage(_props: ChatPageProps) {
</div>
))}
</div>
</div>
)}
) : (
<div className="detail-table-placeholder">
{isLoadingDetailExtra ? '统计中...' : '暂无统计数据'}
</div>
)}
</div>
</div>
) : (
<div className="detail-empty">暂无详情</div>
@@ -2517,7 +4306,8 @@ function ChatPage(_props: ChatPageProps) {
) : (
<div className="empty-chat">
<MessageSquare />
<p>选择一个会话开始查看聊天记录</p>
<p>{standaloneSessionWindow ? '会话加载中或暂无会话记录' : '选择一个会话开始查看聊天记录'}</p>
{standaloneSessionWindow && connectionError && <p className="hint">{connectionError}</p>}
</div>
)}
</div>
+137 -1
View File
@@ -148,6 +148,17 @@
svg {
opacity: 0.7;
transition: transform 0.2s;
flex-shrink: 0;
}
.chip-label {
min-width: 0;
}
.chip-count {
margin-left: auto;
text-align: right;
font-variant-numeric: tabular-nums;
}
&:hover {
@@ -177,6 +188,22 @@
padding: 0 20px 12px;
font-size: 13px;
color: var(--text-secondary);
.contacts-cache-meta {
margin-left: 10px;
color: var(--text-tertiary);
font-size: 12px;
&.syncing {
color: var(--primary);
}
}
.avatar-enrich-progress {
margin-left: 10px;
color: var(--text-tertiary);
font-size: 12px;
}
}
.selection-toolbar {
@@ -213,10 +240,103 @@
}
}
.load-issue-state {
flex: 1;
padding: 14px 14px 18px;
overflow-y: auto;
}
.issue-card {
border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color));
background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg));
border-radius: 12px;
padding: 14px;
color: var(--text-primary);
.issue-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary));
margin-bottom: 8px;
}
.issue-message {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.issue-reason {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.issue-hints {
margin: 10px 0 0;
padding-left: 18px;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.6;
}
.issue-actions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.issue-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 8px;
padding: 7px 10px;
font-size: 12px;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--text-primary);
border-color: var(--text-tertiary);
background: var(--bg-hover);
}
&.primary {
background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
color: var(--primary);
}
}
.issue-diagnostics {
margin-top: 12px;
border-radius: 8px;
background: var(--bg-primary);
border: 1px dashed var(--border-color);
padding: 10px;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
}
}
.contacts-list {
flex: 1;
overflow-y: auto;
padding: 0 12px 12px;
position: relative;
&::-webkit-scrollbar {
width: 6px;
@@ -229,15 +349,31 @@
}
}
.contacts-list-virtual {
position: relative;
min-height: 100%;
}
.contact-row {
position: absolute;
left: 0;
right: 0;
height: 76px;
padding-bottom: 4px;
will-change: transform;
}
.contact-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
height: 72px;
box-sizing: border-box;
border-radius: 10px;
transition: all 0.2s;
cursor: pointer;
margin-bottom: 4px;
margin-bottom: 0;
&:hover {
background: var(--bg-hover);
+698 -104
View File
@@ -1,7 +1,9 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList } from 'lucide-react'
import { useChatStore } from '../stores/chatStore'
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import * as configService from '../services/config'
import './ContactsPage.scss'
interface ContactInfo {
@@ -13,12 +15,43 @@ interface ContactInfo {
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
interface ContactEnrichInfo {
displayName?: string
avatarUrl?: string
}
const AVATAR_ENRICH_BATCH_SIZE = 80
const SEARCH_DEBOUNCE_MS = 120
const VIRTUAL_ROW_HEIGHT = 76
const VIRTUAL_OVERSCAN = 10
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
interface ContactsLoadSession {
requestId: string
startedAt: number
attempt: number
timeoutMs: number
}
interface ContactsLoadIssue {
kind: 'timeout' | 'error'
title: string
message: string
reason: string
errorDetail?: string
occurredAt: number
elapsedMs: number
}
type ContactsDataSource = 'cache' | 'network' | null
function ContactsPage() {
const [contacts, setContacts] = useState<ContactInfo[]>([])
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
const [selectedUsernames, setSelectedUsernames] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(true)
const [searchKeyword, setSearchKeyword] = useState('')
const [debouncedSearchKeyword, setDebouncedSearchKeyword] = useState('')
const [contactTypes, setContactTypes] = useState({
friends: true,
groups: false,
@@ -39,79 +72,495 @@ function ContactsPage() {
const [isExporting, setIsExporting] = useState(false)
const [showFormatSelect, setShowFormatSelect] = useState(false)
const formatDropdownRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const loadVersionRef = useRef(0)
const [avatarEnrichProgress, setAvatarEnrichProgress] = useState({
loaded: 0,
total: 0,
running: false
})
const [scrollTop, setScrollTop] = useState(0)
const [listViewportHeight, setListViewportHeight] = useState(480)
const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts)
const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts)
const loadAttemptRef = useRef(0)
const loadTimeoutTimerRef = useRef<number | null>(null)
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const [loadSession, setLoadSession] = useState<ContactsLoadSession | null>(null)
const [loadIssue, setLoadIssue] = useState<ContactsLoadIssue | null>(null)
const [showDiagnostics, setShowDiagnostics] = useState(false)
const [diagnosticTick, setDiagnosticTick] = useState(Date.now())
const [contactsDataSource, setContactsDataSource] = useState<ContactsDataSource>(null)
const [contactsUpdatedAt, setContactsUpdatedAt] = useState<number | null>(null)
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null)
const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const contactsCacheScopeRef = useRef('default')
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
// 加载通讯录
const loadContacts = useCallback(async () => {
setIsLoading(true)
try {
const result = await window.electronAPI.chat.connect()
if (!result.success) {
console.error('连接失败:', result.error)
setIsLoading(false)
return
}
const contactsResult = await window.electronAPI.chat.getContacts()
if (contactsResult.success && contactsResult.contacts) {
const ensureContactsCacheScope = useCallback(async () => {
if (contactsCacheScopeRef.current !== 'default') {
return contactsCacheScopeRef.current
}
const [dbPath, myWxid] = await Promise.all([
configService.getDbPath(),
configService.getMyWxid()
])
const scopeKey = dbPath || myWxid
? `${dbPath || ''}::${myWxid || ''}`
: 'default'
contactsCacheScopeRef.current = scopeKey
return scopeKey
}, [])
// 获取头像URL
const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username)
if (usernames.length > 0) {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (avatarResult.success && avatarResult.contacts) {
contactsResult.contacts.forEach((contact: ContactInfo) => {
const enriched = avatarResult.contacts?.[contact.username]
if (enriched?.avatarUrl) {
contact.avatarUrl = enriched.avatarUrl
}
})
}
useEffect(() => {
let cancelled = false
void (async () => {
try {
const value = await configService.getContactsLoadTimeoutMs()
if (!cancelled) {
setContactsLoadTimeoutMs(value)
}
setContacts(contactsResult.contacts)
setFilteredContacts(contactsResult.contacts)
setSelectedUsernames(new Set())
} catch (error) {
console.error('读取通讯录超时配置失败:', error)
}
} catch (e) {
console.error('加载通讯录失败:', e)
} finally {
setIsLoading(false)
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
loadContacts()
}, [loadContacts])
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
}, [contactsLoadTimeoutMs])
const mergeAvatarCacheIntoContacts = useCallback((sourceContacts: ContactInfo[]): ContactInfo[] => {
const avatarCache = contactsAvatarCacheRef.current
if (!sourceContacts.length || Object.keys(avatarCache).length === 0) {
return sourceContacts
}
let changed = false
const merged = sourceContacts.map((contact) => {
const cachedAvatar = avatarCache[contact.username]?.avatarUrl
if (!cachedAvatar || contact.avatarUrl) {
return contact
}
changed = true
return {
...contact,
avatarUrl: cachedAvatar
}
})
return changed ? merged : sourceContacts
}, [])
const upsertAvatarCacheFromContacts = useCallback((
scopeKey: string,
sourceContacts: ContactInfo[],
options?: { prune?: boolean; markCheckedUsernames?: string[] }
) => {
if (!scopeKey) return
const nextCache = { ...contactsAvatarCacheRef.current }
const now = Date.now()
const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean))
const usernamesInSource = new Set<string>()
let changed = false
for (const contact of sourceContacts) {
const username = String(contact.username || '').trim()
if (!username) continue
usernamesInSource.add(username)
const prev = nextCache[username]
const avatarUrl = String(contact.avatarUrl || '').trim()
if (!avatarUrl) continue
const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt
const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now)
if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) {
nextCache[username] = {
avatarUrl,
updatedAt,
checkedAt
}
changed = true
}
}
for (const username of markCheckedSet) {
const prev = nextCache[username]
if (!prev) continue
if (prev.checkedAt !== now) {
nextCache[username] = {
...prev,
checkedAt: now
}
changed = true
}
}
if (options?.prune) {
for (const username of Object.keys(nextCache)) {
if (usernamesInSource.has(username)) continue
delete nextCache[username]
changed = true
}
}
if (!changed) return
contactsAvatarCacheRef.current = nextCache
setAvatarCacheUpdatedAt(now)
void configService.setContactsAvatarCache(scopeKey, nextCache).catch((error) => {
console.error('写入通讯录头像缓存失败:', error)
})
}, [])
const applyEnrichedContacts = useCallback((enrichedMap: Record<string, ContactEnrichInfo>) => {
if (!enrichedMap || Object.keys(enrichedMap).length === 0) return
setContacts(prev => {
let changed = false
const next = prev.map(contact => {
const enriched = enrichedMap[contact.username]
if (!enriched) return contact
const displayName = enriched.displayName || contact.displayName
const avatarUrl = enriched.avatarUrl || contact.avatarUrl
if (displayName === contact.displayName && avatarUrl === contact.avatarUrl) {
return contact
}
changed = true
return {
...contact,
displayName,
avatarUrl
}
})
return changed ? next : prev
})
setSelectedContact(prev => {
if (!prev) return prev
const enriched = enrichedMap[prev.username]
if (!enriched) return prev
const displayName = enriched.displayName || prev.displayName
const avatarUrl = enriched.avatarUrl || prev.avatarUrl
if (displayName === prev.displayName && avatarUrl === prev.avatarUrl) {
return prev
}
return {
...prev,
displayName,
avatarUrl
}
})
}, [])
const enrichContactsInBackground = useCallback(async (
sourceContacts: ContactInfo[],
loadVersion: number,
scopeKey: string
) => {
const sourceByUsername = new Map<string, ContactInfo>()
for (const contact of sourceContacts) {
if (!contact.username) continue
sourceByUsername.set(contact.username, contact)
}
const now = Date.now()
const usernames = sourceContacts
.map(contact => contact.username)
.filter(Boolean)
.filter((username) => {
const currentContact = sourceByUsername.get(username)
if (!currentContact) return false
const cacheEntry = contactsAvatarCacheRef.current[username]
if (!cacheEntry || !cacheEntry.avatarUrl) {
return !currentContact.avatarUrl
}
if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) {
return true
}
const checkedAt = cacheEntry.checkedAt || 0
return now - checkedAt >= AVATAR_RECHECK_INTERVAL_MS
})
const total = usernames.length
setAvatarEnrichProgress({
loaded: 0,
total,
running: total > 0
})
if (total === 0) return
for (let i = 0; i < total; i += AVATAR_ENRICH_BATCH_SIZE) {
if (loadVersionRef.current !== loadVersion) return
const batch = usernames.slice(i, i + AVATAR_ENRICH_BATCH_SIZE)
if (batch.length === 0) continue
try {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch)
if (loadVersionRef.current !== loadVersion) return
if (avatarResult.success && avatarResult.contacts) {
applyEnrichedContacts(avatarResult.contacts)
for (const [username, enriched] of Object.entries(avatarResult.contacts)) {
const prev = sourceByUsername.get(username)
if (!prev) continue
sourceByUsername.set(username, {
...prev,
displayName: enriched.displayName || prev.displayName,
avatarUrl: enriched.avatarUrl || prev.avatarUrl
})
}
}
const batchContacts = batch
.map(username => sourceByUsername.get(username))
.filter((contact): contact is ContactInfo => Boolean(contact))
upsertAvatarCacheFromContacts(scopeKey, batchContacts, {
markCheckedUsernames: batch
})
} catch (e) {
console.error('分批补全头像失败:', e)
}
const loaded = Math.min(i + batch.length, total)
setAvatarEnrichProgress({
loaded,
total,
running: loaded < total
})
await new Promise(resolve => setTimeout(resolve, 0))
}
}, [applyEnrichedContacts, upsertAvatarCacheFromContacts])
// 加载通讯录
const loadContacts = useCallback(async (options?: { scopeKey?: string }) => {
const scopeKey = options?.scopeKey || await ensureContactsCacheScope()
const loadVersion = loadVersionRef.current + 1
loadVersionRef.current = loadVersion
loadAttemptRef.current += 1
const startedAt = Date.now()
const timeoutMs = contactsLoadTimeoutMsRef.current
const requestId = `contacts-${startedAt}-${loadAttemptRef.current}`
setLoadSession({
requestId,
startedAt,
attempt: loadAttemptRef.current,
timeoutMs
})
setLoadIssue(null)
setShowDiagnostics(false)
if (loadTimeoutTimerRef.current) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
const timeoutTimerId = window.setTimeout(() => {
if (loadVersionRef.current !== loadVersion) return
const elapsedMs = Date.now() - startedAt
setLoadIssue({
kind: 'timeout',
title: '通讯录加载超时',
message: `等待超过 ${timeoutMs}ms,联系人列表仍未返回。`,
reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。',
occurredAt: Date.now(),
elapsedMs
})
}, timeoutMs)
loadTimeoutTimerRef.current = timeoutTimerId
setIsLoading(true)
setAvatarEnrichProgress({
loaded: 0,
total: 0,
running: false
})
try {
const contactsResult = await window.electronAPI.chat.getContacts()
if (loadVersionRef.current !== loadVersion) return
if (contactsResult.success && contactsResult.contacts) {
if (loadTimeoutTimerRef.current === timeoutTimerId) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
const contactsWithAvatarCache = mergeAvatarCacheIntoContacts(contactsResult.contacts)
setContacts(contactsWithAvatarCache)
syncContactTypeCounts(contactsWithAvatarCache)
setSelectedUsernames(new Set())
setSelectedContact(prev => {
if (!prev) return prev
return contactsWithAvatarCache.find(contact => contact.username === prev.username) || null
})
const now = Date.now()
setContactsDataSource('network')
setContactsUpdatedAt(now)
setLoadIssue(null)
setIsLoading(false)
upsertAvatarCacheFromContacts(scopeKey, contactsWithAvatarCache, { prune: true })
void configService.setContactsListCache(
scopeKey,
contactsWithAvatarCache.map(contact => ({
username: contact.username,
displayName: contact.displayName,
remark: contact.remark,
nickname: contact.nickname,
type: contact.type
}))
).catch((error) => {
console.error('写入通讯录缓存失败:', error)
})
void enrichContactsInBackground(contactsWithAvatarCache, loadVersion, scopeKey)
return
}
const elapsedMs = Date.now() - startedAt
setLoadIssue({
kind: 'error',
title: '通讯录加载失败',
message: '联系人接口返回失败,未拿到联系人列表。',
reason: 'chat.getContacts 返回 success=false。',
errorDetail: contactsResult.error || '未知错误',
occurredAt: Date.now(),
elapsedMs
})
} catch (e) {
console.error('加载通讯录失败:', e)
const elapsedMs = Date.now() - startedAt
setLoadIssue({
kind: 'error',
title: '通讯录加载失败',
message: '联系人请求执行异常。',
reason: '调用 chat.getContacts 发生异常。',
errorDetail: String(e),
occurredAt: Date.now(),
elapsedMs
})
} finally {
if (loadTimeoutTimerRef.current === timeoutTimerId) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
if (loadVersionRef.current === loadVersion) {
setIsLoading(false)
}
}
}, [
ensureContactsCacheScope,
enrichContactsInBackground,
mergeAvatarCacheIntoContacts,
syncContactTypeCounts,
upsertAvatarCacheFromContacts
])
// 搜索和类型过滤
useEffect(() => {
let filtered = contacts
let cancelled = false
void (async () => {
const scopeKey = await ensureContactsCacheScope()
if (cancelled) return
try {
const [cacheItem, avatarCacheItem] = await Promise.all([
configService.getContactsListCache(scopeKey),
configService.getContactsAvatarCache(scopeKey)
])
const avatarCacheMap = avatarCacheItem?.avatars || {}
contactsAvatarCacheRef.current = avatarCacheMap
setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null)
if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) {
const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({
...contact,
avatarUrl: avatarCacheMap[contact.username]?.avatarUrl
}))
setContacts(cachedContacts)
syncContactTypeCounts(cachedContacts)
setContactsDataSource('cache')
setContactsUpdatedAt(cacheItem.updatedAt || null)
setIsLoading(false)
}
} catch (error) {
console.error('读取通讯录缓存失败:', error)
}
if (!cancelled) {
void loadContacts({ scopeKey })
}
})()
return () => {
cancelled = true
}
}, [ensureContactsCacheScope, loadContacts, syncContactTypeCounts])
// 类型过滤
filtered = filtered.filter(c => {
if (c.type === 'friend' && !contactTypes.friends) return false
if (c.type === 'group' && !contactTypes.groups) return false
if (c.type === 'official' && !contactTypes.officials) return false
if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false
useEffect(() => {
return () => {
if (loadTimeoutTimerRef.current) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
loadVersionRef.current += 1
}
}, [])
useEffect(() => {
if (!loadIssue || contacts.length > 0) return
if (!(isLoading && loadIssue.kind === 'timeout')) return
const timer = window.setInterval(() => {
setDiagnosticTick(Date.now())
}, 500)
return () => window.clearInterval(timer)
}, [contacts.length, isLoading, loadIssue])
useEffect(() => {
const timer = window.setTimeout(() => {
setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase())
}, SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(timer)
}, [searchKeyword])
const filteredContacts = useMemo(() => {
let filtered = contacts.filter(contact => {
if (contact.type === 'friend' && !contactTypes.friends) return false
if (contact.type === 'group' && !contactTypes.groups) return false
if (contact.type === 'official' && !contactTypes.officials) return false
if (contact.type === 'former_friend' && !contactTypes.deletedFriends) return false
return true
})
// 关键词过滤
if (searchKeyword.trim()) {
const lower = searchKeyword.toLowerCase()
filtered = filtered.filter(c =>
c.displayName?.toLowerCase().includes(lower) ||
c.remark?.toLowerCase().includes(lower) ||
c.username.toLowerCase().includes(lower)
if (debouncedSearchKeyword) {
filtered = filtered.filter(contact =>
contact.displayName?.toLowerCase().includes(debouncedSearchKeyword) ||
contact.remark?.toLowerCase().includes(debouncedSearchKeyword) ||
contact.username.toLowerCase().includes(debouncedSearchKeyword)
)
}
setFilteredContacts(filtered)
}, [searchKeyword, contacts, contactTypes])
return filtered
}, [contacts, contactTypes, debouncedSearchKeyword])
// 点击外部关闭下拉菜单
const contactTypeCounts = useMemo(() => toContactTypeCardCounts(sharedTabCounts), [sharedTabCounts])
useEffect(() => {
if (!listRef.current) return
listRef.current.scrollTop = 0
setScrollTop(0)
}, [debouncedSearchKeyword, contactTypes])
useEffect(() => {
const node = listRef.current
if (!node) return
const updateViewportHeight = () => {
setListViewportHeight(Math.max(node.clientHeight, VIRTUAL_ROW_HEIGHT))
}
updateViewportHeight()
const observer = new ResizeObserver(() => updateViewportHeight())
observer.observe(node)
return () => observer.disconnect()
}, [filteredContacts.length, isLoading])
useEffect(() => {
const maxScroll = Math.max(0, filteredContacts.length * VIRTUAL_ROW_HEIGHT - listViewportHeight)
if (scrollTop <= maxScroll) return
setScrollTop(maxScroll)
if (listRef.current) {
listRef.current.scrollTop = maxScroll
}
}, [filteredContacts.length, listViewportHeight, scrollTop])
// 搜索和类型过滤
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
@@ -123,11 +572,85 @@ function ContactsPage() {
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showFormatSelect])
const selectedInFilteredCount = filteredContacts.reduce((count, contact) => {
return selectedUsernames.has(contact.username) ? count + 1 : count
}, 0)
const selectedInFilteredCount = useMemo(() => {
return filteredContacts.reduce((count, contact) => {
return selectedUsernames.has(contact.username) ? count + 1 : count
}, 0)
}, [filteredContacts, selectedUsernames])
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length
const { startIndex, endIndex } = useMemo(() => {
if (filteredContacts.length === 0) {
return { startIndex: 0, endIndex: 0 }
}
const baseStart = Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT)
const visibleCount = Math.ceil(listViewportHeight / VIRTUAL_ROW_HEIGHT)
const nextStart = Math.max(0, baseStart - VIRTUAL_OVERSCAN)
const nextEnd = Math.min(filteredContacts.length, nextStart + visibleCount + VIRTUAL_OVERSCAN * 2)
return {
startIndex: nextStart,
endIndex: nextEnd
}
}, [filteredContacts.length, listViewportHeight, scrollTop])
const visibleContacts = useMemo(() => {
return filteredContacts.slice(startIndex, endIndex)
}, [filteredContacts, startIndex, endIndex])
const onContactsListScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
setScrollTop(event.currentTarget.scrollTop)
}, [])
const issueElapsedMs = useMemo(() => {
if (!loadIssue) return 0
if (isLoading && loadSession) {
return Math.max(loadIssue.elapsedMs, diagnosticTick - loadSession.startedAt)
}
return loadIssue.elapsedMs
}, [diagnosticTick, isLoading, loadIssue, loadSession])
const diagnosticsText = useMemo(() => {
if (!loadIssue || !loadSession) return ''
return [
`请求ID: ${loadSession.requestId}`,
`请求序号: 第 ${loadSession.attempt} 次`,
`阈值配置: ${loadSession.timeoutMs}ms`,
`当前状态: ${loadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`,
`累计耗时: ${(issueElapsedMs / 1000).toFixed(1)}s`,
`发生时间: ${new Date(loadIssue.occurredAt).toLocaleString()}`,
`阶段: chat.getContacts`,
`原因: ${loadIssue.reason}`,
`错误详情: ${loadIssue.errorDetail || '无'}`
].join('\n')
}, [issueElapsedMs, loadIssue, loadSession])
const copyDiagnostics = useCallback(async () => {
if (!diagnosticsText) return
try {
await navigator.clipboard.writeText(diagnosticsText)
alert('诊断信息已复制')
} catch (error) {
console.error('复制诊断信息失败:', error)
alert('复制失败,请手动复制诊断信息')
}
}, [diagnosticsText])
const contactsUpdatedAtLabel = useMemo(() => {
if (!contactsUpdatedAt) return ''
return new Date(contactsUpdatedAt).toLocaleString()
}, [contactsUpdatedAt])
const avatarCachedCount = useMemo(() => {
return contacts.reduce((count, contact) => (
contact.avatarUrl ? count + 1 : count
), 0)
}, [contacts])
const avatarCacheUpdatedAtLabel = useMemo(() => {
if (!avatarCacheUpdatedAt) return ''
return new Date(avatarCacheUpdatedAt).toLocaleString()
}, [avatarCacheUpdatedAt])
const toggleContactSelected = (username: string, checked: boolean) => {
setSelectedUsernames(prev => {
const next = new Set(prev)
@@ -256,7 +779,7 @@ function ContactsPage() {
>
<Download size={18} />
</button>
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
<button className="icon-btn" onClick={() => void loadContacts()} disabled={isLoading}>
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
</button>
</div>
@@ -280,24 +803,51 @@ function ContactsPage() {
<div className="type-filters">
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
<User size={16} /><span>好友</span>
<User size={16} />
<span className="chip-label">好友</span>
<span className="chip-count">{contactTypeCounts.friends}</span>
</label>
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
<Users size={16} /><span>群聊</span>
<Users size={16} />
<span className="chip-label">群聊</span>
<span className="chip-count">{contactTypeCounts.groups}</span>
</label>
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
<MessageSquare size={16} /><span>公众号</span>
<MessageSquare size={16} />
<span className="chip-label">公众号</span>
<span className="chip-count">{contactTypeCounts.officials}</span>
</label>
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
<UserX size={16} /><span>曾经的好友</span>
<UserX size={16} />
<span className="chip-label">曾经的好友</span>
<span className="chip-count">{contactTypeCounts.deletedFriends}</span>
</label>
</div>
<div className="contacts-count">
共 {filteredContacts.length} 个联系人
共 {filteredContacts.length} / {contacts.length} 个联系人
{contactsUpdatedAt && (
<span className="contacts-cache-meta">
{contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel}
</span>
)}
{contacts.length > 0 && (
<span className="contacts-cache-meta">
头像缓存 {avatarCachedCount}/{contacts.length}
{avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''}
</span>
)}
{isLoading && contacts.length > 0 && (
<span className="contacts-cache-meta syncing">后台同步中...</span>
)}
{avatarEnrichProgress.running && (
<span className="avatar-enrich-progress">
头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total}
</span>
)}
</div>
{exportMode && (
@@ -315,61 +865,105 @@ function ContactsPage() {
</div>
)}
{isLoading ? (
{contacts.length === 0 && loadIssue ? (
<div className="load-issue-state">
<div className="issue-card">
<div className="issue-title">
<AlertTriangle size={18} />
<span>{loadIssue.title}</span>
</div>
<p className="issue-message">{loadIssue.message}</p>
<p className="issue-reason">{loadIssue.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 loadContacts()}>
<RefreshCw size={14} />
<span>重试加载</span>
</button>
<button className="issue-btn" onClick={() => setShowDiagnostics(prev => !prev)}>
<ClipboardList size={14} />
<span>{showDiagnostics ? '收起诊断详情' : '查看诊断详情'}</span>
</button>
<button className="issue-btn" onClick={copyDiagnostics}>
<span>复制诊断信息</span>
</button>
</div>
{showDiagnostics && (
<pre className="issue-diagnostics">{diagnosticsText}</pre>
)}
</div>
</div>
) : isLoading && contacts.length === 0 ? (
<div className="loading-state">
<Loader2 size={32} className="spin" />
<span>加载中...</span>
<span>联系人加载中...</span>
</div>
) : filteredContacts.length === 0 ? (
<div className="empty-state">
<span>暂无联系人</span>
</div>
) : (
<div className="contacts-list">
{filteredContacts.map(contact => {
<div className="contacts-list" ref={listRef} onScroll={onContactsListScroll}>
<div
className="contacts-list-virtual"
style={{ height: filteredContacts.length * VIRTUAL_ROW_HEIGHT }}
>
{visibleContacts.map((contact, idx) => {
const absoluteIndex = startIndex + idx
const top = absoluteIndex * VIRTUAL_ROW_HEIGHT
const isChecked = selectedUsernames.has(contact.username)
const isActive = !exportMode && selectedContact?.username === contact.username
return (
<div
key={contact.username}
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
onClick={() => {
if (exportMode) {
toggleContactSelected(contact.username, !isChecked)
} else {
setSelectedContact(isActive ? null : contact)
}
}}
className="contact-row"
style={{ transform: `translateY(${top}px)` }}
>
{exportMode && (
<label className="contact-select" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={isChecked}
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
/>
</label>
)}
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
<div
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
onClick={() => {
if (exportMode) {
toggleContactSelected(contact.username, !isChecked)
} else {
setSelectedContact(isActive ? null : contact)
}
}}
>
{exportMode && (
<label className="contact-select" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={isChecked}
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
/>
</label>
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
{contact.remark && contact.remark !== contact.displayName && (
<div className="contact-remark">备注: {contact.remark}</div>
)}
</div>
<div className={`contact-type ${contact.type}`}>
{getContactTypeIcon(contact.type)}
<span>{getContactTypeName(contact.type)}</span>
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" loading="lazy" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
{contact.remark && contact.remark !== contact.displayName && (
<div className="contact-remark">备注: {contact.remark}</div>
)}
</div>
<div className={`contact-type ${contact.type}`}>
{getContactTypeIcon(contact.type)}
<span>{getContactTypeName(contact.type)}</span>
</div>
</div>
</div>
)
})}
})}
</div>
</div>
)}
</div>
+10 -1
View File
@@ -107,7 +107,16 @@ function DualReportWindow() {
setLoadingStage('完成')
if (result.success && result.data) {
setReportData(result.data)
const normalizedResponse = result.data.response
? {
...result.data.response,
slowest: result.data.response.slowest ?? result.data.response.avg
}
: undefined
setReportData({
...result.data,
response: normalizedResponse
})
setIsLoading(false)
} else {
setError(result.error || '生成报告失败')
+2571 -1055
View File
@@ -1,141 +1,1018 @@
.export-page {
display: flex;
height: calc(100% + 48px);
.export-board-page {
min-height: calc(100% + 48px);
height: auto;
margin: -24px;
padding: 20px;
background: var(--bg-primary);
overflow: hidden;
display: flex;
flex-direction: column;
gap: 16px;
overflow-x: hidden;
overflow-y: visible;
// 左侧会话选择面板
.session-panel {
width: 380px;
min-width: 380px;
.spin {
animation: exportSpin 1s linear infinite;
}
}
.export-top-panel {
display: block;
flex-shrink: 0;
}
.export-section-title-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
}
.export-section-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.section-info-tooltip {
position: relative;
flex-shrink: 0;
}
.section-info-trigger {
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-tertiary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
&:hover,
&.active {
border-color: rgba(var(--primary-rgb), 0.45);
color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
}
}
.section-info-popover {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: min(340px, calc(100vw - 40px));
border-radius: 10px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.2);
padding: 10px 12px;
z-index: 70;
h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
p {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.55;
color: var(--text-secondary);
}
}
.global-export-controls {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px;
display: grid;
grid-template-columns: minmax(0, 1fr) max-content minmax(0, 1fr);
gap: 10px;
align-items: stretch;
.control-label {
font-size: 11px;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.2px;
}
.path-control {
min-width: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
background: var(--card-bg);
gap: 4px;
}
.panel-header {
.path-inline-row {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.icon-btn {
width: 32px;
height: 32px;
border: none;
background: var(--bg-tertiary);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spin {
animation: exportSpin 1s linear infinite;
}
}
gap: 6px;
flex-wrap: nowrap;
}
.search-bar {
display: flex;
align-items: center;
gap: 10px;
margin: 16px 20px;
padding: 10px 14px;
.path-value {
border: 1px dashed var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
border-radius: 10px;
border: 1px solid var(--border-color);
transition: border-color 0.2s;
display: flex;
align-items: stretch;
min-width: 0;
flex: 1;
overflow: hidden;
}
&:focus-within {
.path-link {
border: none;
background: transparent;
font-size: 12px;
color: var(--text-primary);
text-align: left;
padding: 8px 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
cursor: pointer;
&:hover {
color: var(--primary);
}
&:focus-visible {
outline: none;
}
}
.path-change-btn {
border: none;
border-left: 1px dashed var(--border-color);
background: transparent;
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
padding: 0 9px;
cursor: pointer;
flex-shrink: 0;
&:hover {
border-color: var(--primary);
color: var(--primary);
}
}
.write-layout-control {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
width: fit-content;
max-width: 100%;
justify-self: start;
z-index: 40;
}
.layout-trigger {
width: auto;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
cursor: pointer;
transition: border-color 0.12s ease;
&:hover {
border-color: var(--primary);
}
svg {
color: var(--text-tertiary);
flex-shrink: 0;
&.active {
border-color: var(--primary);
}
}
.layout-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: auto;
width: clamp(300px, 36vw, 420px);
max-width: calc(100vw - 40px);
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.28);
padding: 6px;
z-index: 3000;
max-height: 260px;
overflow-y: auto;
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
visibility: hidden;
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s step-end;
backdrop-filter: none;
will-change: opacity, transform;
&.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
visibility: visible;
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s step-start;
}
}
.layout-option {
width: 100%;
border: none;
background: transparent;
text-align: left;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
&:hover {
background: var(--bg-hover);
}
input {
flex: 1;
border: none;
background: none;
outline: none;
font-size: 14px;
color: var(--text-primary);
&.active {
background: rgba(var(--primary-rgb), 0.12);
color: var(--primary);
}
}
&::placeholder {
color: var(--text-tertiary);
}
.layout-option-label {
font-size: 13px;
font-weight: 600;
}
.layout-option-desc {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.45;
}
.layout-prefix-toggle {
margin-top: 4px;
padding: 10px;
border-top: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.layout-prefix-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.layout-prefix-label {
font-size: 12px;
color: var(--text-primary);
font-weight: 600;
line-height: 1.35;
}
.layout-prefix-desc {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.4;
}
.layout-prefix-switch {
width: 38px;
height: 22px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
cursor: pointer;
padding: 2px;
display: inline-flex;
align-items: center;
transition: background 0.15s ease, border-color 0.15s ease;
flex-shrink: 0;
.layout-prefix-switch-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.22);
transition: transform 0.15s ease;
}
.clear-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
&.on {
background: rgba(var(--primary-rgb), 0.9);
border-color: rgba(var(--primary-rgb), 0.95);
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
.layout-prefix-switch-thumb {
transform: translateX(16px);
}
}
}
.select-actions {
.task-center-control {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.task-center-inline {
min-height: 34px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
padding: 0 8px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px 12px;
gap: 6px;
flex-wrap: nowrap;
}
.select-all-btn {
background: none;
border: none;
padding: 6px 12px;
font-size: 13px;
.task-summary {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.task-open-btn {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: 7px;
padding: 3px 7px;
font-size: 11px;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
flex-shrink: 0;
transition: border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
&:hover {
border-color: var(--primary);
color: var(--primary);
cursor: pointer;
border-radius: 6px;
}
&:hover {
background: rgba(var(--primary-rgb), 0.1);
&.active-running {
border-color: rgba(255, 77, 79, 0.45);
color: #ff4d4f;
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.16);
}
}
.task-running-badge {
min-width: 16px;
height: 16px;
border-radius: 999px;
background: #ff4d4f;
color: #fff;
font-size: 10px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
line-height: 1;
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2);
animation: exportTaskBadgePulse 1.2s ease-in-out infinite;
}
.secondary-btn {
border-radius: 7px;
padding: 6px 9px;
font-size: 11px;
gap: 4px;
flex-shrink: 0;
}
}
.content-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 8px;
flex-shrink: 0;
}
.content-card {
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--card-bg);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.card-title {
display: flex;
align-items: center;
gap: 5px;
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
}
.card-title-meta {
color: var(--text-secondary);
font-size: 12px;
white-space: nowrap;
font-weight: 500;
}
.card-refresh-hint {
color: var(--text-tertiary);
font-size: 11px;
white-space: nowrap;
}
.card-stats {
display: grid;
grid-template-columns: 1fr;
gap: 3px;
.stat-item {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
color: var(--text-secondary);
strong {
color: var(--text-primary);
font-size: 13px;
}
}
}
.selected-count {
font-size: 13px;
color: var(--text-secondary);
padding: 4px 12px;
background: var(--bg-secondary);
border-radius: 12px;
.card-export-btn {
margin-top: auto;
border: none;
border-radius: 7px;
padding: 7px 9px;
background: var(--primary);
color: #fff;
cursor: pointer;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
&:hover {
background: var(--primary-hover);
}
&:disabled {
cursor: not-allowed;
opacity: 0.86;
}
&.running {
background: var(--primary-hover);
opacity: 0.65;
}
}
&.skeleton-card {
pointer-events: none;
.card-stats {
gap: 10px;
}
}
}
.count-loading {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 500;
display: inline-flex;
align-items: baseline;
gap: 1px;
}
.task-center-modal-overlay {
position: fixed;
top: 40px;
right: 0;
bottom: 0;
left: 0;
z-index: 1180;
background: rgba(15, 23, 42, 0.28);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px 20px;
}
.task-center-modal {
width: min(980px, calc(100vw - 40px));
max-height: calc(100vh - 72px);
border-radius: 14px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, #ffffff);
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.24);
display: flex;
flex-direction: column;
overflow: hidden;
}
.task-center-modal-header {
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.task-center-modal-title {
min-width: 0;
h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary);
}
span {
display: block;
margin-top: 3px;
font-size: 12px;
color: var(--text-secondary);
}
}
.task-center-modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px;
background: var(--bg-secondary-solid, #ffffff);
}
.task-empty {
padding: 12px;
background: var(--bg-secondary);
border-radius: 8px;
font-size: 13px;
color: var(--text-secondary);
}
.task-list {
display: grid;
gap: 8px;
}
.task-card {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 10px;
display: flex;
gap: 10px;
align-items: flex-start;
background: var(--bg-secondary-solid, #ffffff);
&.running {
border-color: var(--primary);
}
&.paused {
border-color: rgba(250, 173, 20, 0.55);
}
&.stopped {
border-color: rgba(148, 163, 184, 0.46);
}
&.error {
border-color: rgba(255, 77, 79, 0.45);
}
&.success {
border-color: rgba(82, 196, 26, 0.4);
}
}
.task-main {
flex: 1;
min-width: 0;
}
.task-title {
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-meta {
margin-top: 2px;
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 11px;
color: var(--text-secondary);
}
.task-status {
border-radius: 999px;
padding: 2px 8px;
font-weight: 600;
&.queued {
background: rgba(var(--primary-rgb), 0.14);
color: var(--primary);
}
&.running {
background: rgba(var(--primary-rgb), 0.2);
color: var(--primary);
}
&.paused {
background: rgba(250, 173, 20, 0.2);
color: #d48806;
}
&.stopped {
background: rgba(148, 163, 184, 0.2);
color: #64748b;
}
&.success {
background: rgba(82, 196, 26, 0.18);
color: #52c41a;
}
&.error {
background: rgba(255, 77, 79, 0.15);
color: #ff4d4f;
}
}
.task-progress-bar {
margin-top: 8px;
height: 6px;
background: rgba(0, 0, 0, 0.08);
border-radius: 3px;
overflow: hidden;
}
.task-progress-fill {
height: 100%;
background: var(--primary);
transition: width 0.2s ease;
}
.task-progress-text {
margin-top: 4px;
font-size: 11px;
color: var(--text-secondary);
}
.task-perf-summary {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 11px;
color: var(--text-secondary);
}
.task-perf-panel {
margin-top: 8px;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px;
background: var(--bg-secondary);
display: grid;
gap: 8px;
}
.task-perf-title {
font-size: 12px;
color: var(--text-primary);
font-weight: 600;
}
.task-perf-row {
display: grid;
gap: 4px;
}
.task-perf-row-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-secondary);
}
.task-perf-row-track {
height: 6px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.task-perf-row-fill {
height: 100%;
background: var(--primary);
}
.task-perf-empty {
font-size: 11px;
color: var(--text-secondary);
}
.task-perf-session-list {
display: grid;
gap: 4px;
}
.task-perf-session-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
font-size: 11px;
color: var(--text-secondary);
}
.task-perf-session-rank {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-perf-session-time {
font-variant-numeric: tabular-nums;
}
.task-error {
margin-top: 6px;
font-size: 12px;
color: #ff4d4f;
}
.task-actions {
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.task-action-btn {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
min-height: 30px;
padding: 0 10px;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
&:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
&:disabled {
opacity: 0.65;
cursor: not-allowed;
}
&.primary {
border-color: rgba(var(--primary-rgb), 0.35);
color: var(--primary);
}
&.danger {
border-color: rgba(255, 77, 79, 0.36);
color: #ff4d4f;
}
}
.session-table-section {
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--card-bg);
padding: 12px;
flex: 0 0 auto;
min-height: 420px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: visible;
}
.table-stage-hint {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 8px 12px 0;
padding: 6px 10px;
border-radius: 999px;
background: rgba(var(--primary-rgb), 0.1);
border: 1px solid rgba(var(--primary-rgb), 0.2);
color: var(--primary);
font-size: 12px;
width: fit-content;
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
padding: 10px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent);
background: color-mix(in srgb, var(--bg-primary) 82%, var(--bg-secondary));
}
.table-cache-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 12px;
.meta-item {
color: var(--text-tertiary);
}
.meta-item.syncing {
color: var(--primary);
display: inline-flex;
align-items: center;
gap: 4px;
}
}
.table-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
.tab-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
padding: 7px 12px;
border-radius: 999px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
&.active {
border-color: var(--primary);
color: var(--primary);
background: rgba(var(--primary-rgb), 0.12);
}
}
}
.animated-ellipsis {
display: inline-block;
width: 0;
overflow: hidden;
vertical-align: bottom;
animation: exportDots 1s steps(4, end) infinite;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.search-input-wrap {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
min-width: 220px;
input {
border: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
outline: none;
width: 180px;
}
.clear-search {
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
display: flex;
}
}
.selected-batch-actions {
display: flex;
align-items: center;
gap: 8px;
border: 1px dashed rgba(var(--primary-rgb), 0.45);
background: rgba(var(--primary-rgb), 0.06);
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
color: var(--text-secondary);
}
.session-table-layout {
display: flex;
min-height: 0;
.table-wrap {
flex: 1;
min-width: 0;
}
}
.table-wrap {
--contacts-row-height: 76px;
--contacts-default-visible-rows: 10;
--contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows));
--contacts-select-col-width: 34px;
--contacts-message-col-width: 120px;
--contacts-action-col-width: 280px;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: 10px;
min-height: 320px;
height: auto;
flex: 1;
display: flex;
flex-direction: column;
}
.table-wrap {
.session-table-sticky {
position: sticky;
top: 0;
z-index: 20;
background: var(--card-bg);
}
.loading-state,
@@ -154,10 +1031,162 @@
}
}
.export-session-list {
.load-issue-state {
flex: 1;
padding: 14px;
overflow-y: auto;
}
.issue-card {
border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color));
background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg));
border-radius: 12px;
padding: 14px;
color: var(--text-primary);
}
.issue-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary));
margin-bottom: 8px;
}
.issue-message {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.issue-reason {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.issue-hints {
margin: 10px 0 0;
padding-left: 18px;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.6;
}
.issue-actions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.issue-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 8px;
padding: 7px 10px;
font-size: 12px;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--text-primary);
border-color: var(--text-tertiary);
background: var(--bg-hover);
}
&.primary {
background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
color: var(--primary);
}
}
.issue-diagnostics {
margin-top: 12px;
border-radius: 8px;
background: var(--bg-primary);
border: 1px dashed var(--border-color);
padding: 10px;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
}
.contacts-list-header {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px 8px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent);
background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary));
font-size: 12px;
color: var(--text-tertiary);
font-weight: 600;
letter-spacing: 0.01em;
flex-shrink: 0;
}
.contacts-list-header-select {
width: var(--contacts-select-col-width);
min-width: var(--contacts-select-col-width);
text-align: center;
flex-shrink: 0;
}
.contacts-list-header-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.contacts-list-header-main-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contacts-list-header-count {
width: var(--contacts-message-col-width);
text-align: center;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contacts-list-header-actions {
width: var(--contacts-action-col-width);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
.contacts-list {
flex: 1;
min-height: var(--contacts-default-list-height);
height: var(--contacts-default-list-height);
overflow: hidden;
padding: 0 12px 12px;
}
.contacts-virtuoso {
height: 100%;
&::-webkit-scrollbar {
width: 6px;
@@ -170,45 +1199,489 @@
}
}
.export-session-item {
.selection-clear-btn {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
padding: 6px 10px;
cursor: pointer;
&:hover:not(:disabled) {
border-color: var(--text-tertiary);
color: var(--text-primary);
}
&:disabled {
opacity: 0.65;
cursor: not-allowed;
}
}
.selection-export-btn {
border: none;
border-radius: 8px;
padding: 6px 10px;
background: var(--primary);
color: #fff;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
&:hover:not(:disabled) {
background: var(--primary-hover);
}
.selection-export-count {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-variant-numeric: tabular-nums;
}
}
.contact-row {
position: static;
height: auto;
padding-bottom: 4px;
&.selected .contact-item {
background: rgba(var(--primary-rgb), 0.08);
}
}
.contact-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
height: 72px;
box-sizing: border-box;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
cursor: default;
&:hover {
background: var(--bg-hover);
}
}
&.selected {
background: rgba(var(--primary-rgb), 0.08);
.row-select-cell {
width: var(--contacts-select-col-width);
min-width: var(--contacts-select-col-width);
display: flex;
justify-content: center;
flex-shrink: 0;
}
.check-box {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.contact-avatar {
width: 44px;
height: 44px;
border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.check-box {
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-radius: 6px;
span {
color: #fff;
font-size: 16px;
font-weight: 600;
}
}
.contact-info {
flex: 1;
min-width: 0;
}
.contact-name {
font-size: 14px;
color: var(--text-primary);
margin-bottom: 2px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-remark {
font-size: 12px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-type {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
padding: 4px 8px;
border-radius: 999px;
background: var(--bg-secondary);
color: var(--text-secondary);
flex-shrink: 0;
}
.row-message-count {
width: var(--contacts-message-col-width);
min-width: var(--contacts-message-col-width);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
text-align: center;
}
.row-message-stats {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
white-space: nowrap;
}
.row-message-stat {
display: inline-flex;
align-items: baseline;
gap: 3px;
font-size: 11px;
color: var(--text-secondary);
min-width: 0;
.label {
color: var(--text-tertiary);
flex-shrink: 0;
}
&.total .label {
color: var(--text-secondary);
}
}
.row-message-count-value {
margin: 0;
font-size: 12px;
line-height: 1.1;
color: var(--text-primary);
font-weight: 600;
font-variant-numeric: tabular-nums;
&.muted {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
}
}
.row-message-stat.total .row-message-count-value {
font-size: 13px;
}
}
.table-virtuoso {
height: 100%;
}
.session-table,
.table-wrap table {
width: 100%;
min-width: 1300px;
border-collapse: separate;
border-spacing: 0;
thead th {
position: sticky;
top: 0;
background: color-mix(in srgb, var(--bg-primary) 75%, var(--bg-secondary));
z-index: 4;
font-size: 12px;
text-align: left;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
padding: 10px 10px;
white-space: nowrap;
}
tbody td {
padding: 10px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
font-size: 13px;
color: var(--text-primary);
vertical-align: middle;
white-space: nowrap;
}
tbody tr:hover {
background: rgba(var(--primary-rgb), 0.03);
}
.selected-row,
tbody tr:has(.select-icon-btn.checked) {
background: rgba(var(--primary-rgb), 0.08);
}
.sticky-col {
position: sticky;
left: 0;
z-index: 5;
background: inherit;
}
.sticky-right {
position: sticky;
right: 0;
z-index: 5;
background: inherit;
}
}
.select-icon-btn {
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
&:hover {
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
}
&.checked {
color: var(--primary);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.session-cell {
display: flex;
align-items: center;
gap: 10px;
min-width: 230px;
.session-avatar {
width: 36px;
height: 36px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: #fff;
font-size: 14px;
font-weight: 600;
}
}
.session-name {
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.session-id {
margin-top: 2px;
font-size: 11px;
color: var(--text-tertiary);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
}
.row-action-cell {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
width: var(--contacts-action-col-width);
flex-shrink: 0;
.row-action-main {
display: inline-flex;
align-items: center;
gap: 6px;
}
.row-open-chat-btn {
border: 1px solid color-mix(in srgb, var(--primary) 38%, var(--border-color));
border-radius: 8px;
padding: 7px 10px;
background: color-mix(in srgb, var(--primary) 12%, var(--bg-secondary));
color: var(--primary);
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--primary) 18%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary) 55%, var(--border-color));
}
&:disabled {
opacity: 0.65;
cursor: not-allowed;
color: var(--text-tertiary);
border-color: var(--border-color);
background: var(--bg-secondary);
}
}
.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 {
border: none;
border-radius: 8px;
padding: 7px 10px;
background: var(--primary);
color: #fff;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
&:hover:not(:disabled) {
background: var(--primary-hover);
}
&:disabled {
opacity: 0.75;
cursor: not-allowed;
}
&.running {
background: color-mix(in srgb, var(--primary) 80%, #000);
}
&.paused {
background: rgba(250, 173, 20, 0.16);
color: #d48806;
border: 1px solid rgba(250, 173, 20, 0.38);
}
&.no-session {
background: var(--bg-secondary);
color: var(--text-tertiary);
border: 1px dashed var(--border-color);
}
}
.row-export-time {
font-size: 11px;
color: var(--text-tertiary);
}
}
.export-session-detail-overlay {
position: fixed;
top: 40px;
right: 0;
bottom: 0;
left: 0;
z-index: 1100;
display: flex;
justify-content: flex-end;
background: rgba(15, 23, 42, 0.24);
}
.export-session-detail-panel {
width: min(360px, calc(100vw - 16px));
height: calc(100vh - 40px);
border-left: 1px solid var(--border-color);
border-radius: 0;
background: var(--bg-secondary-solid, #ffffff);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: -12px 0 30px rgba(0, 0, 0, 0.18);
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px;
border-bottom: 1px solid var(--border-color);
.detail-header-main {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s;
gap: 10px;
min-width: 0;
}
.export-avatar {
width: 44px;
height: 44px;
border-radius: 10px;
.detail-header-avatar {
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
@@ -224,912 +1697,891 @@
span {
color: #fff;
font-size: 16px;
font-size: 13px;
font-weight: 600;
}
}
.export-session-info {
flex: 1;
.detail-header-meta {
min-width: 0;
}
.export-session-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.export-session-summary {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
}
// 右侧设置面板
.settings-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.settings-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 3px;
}
}
.setting-section {
margin-bottom: 28px;
h3 {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 14px;
}
}
.format-options {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
}
.format-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px 16px;
background: var(--bg-secondary);
border: 2px solid transparent;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
&:hover {
background: var(--bg-hover);
}
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.05);
svg {
color: var(--primary);
}
}
svg {
color: var(--text-secondary);
}
.format-label {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.format-desc {
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.4;
}
}
.time-range-picker-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
cursor: pointer;
transition: background 0.2s;
background: transparent;
&:hover {
background: var(--bg-hover);
}
.time-picker-info {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: var(--text-primary);
svg {
color: var(--primary);
}
}
svg {
color: var(--text-tertiary);
}
}
.select-field {
position: relative;
}
.select-trigger {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--text-tertiary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.select-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
box-shadow: var(--shadow-md);
z-index: 20;
max-height: 260px;
overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.select-option {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
transition: all 0.15s;
color: var(--text-primary);
font-size: 14px;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
}
.option-label {
font-weight: 500;
}
.option-desc {
font-size: 12px;
color: var(--text-tertiary);
}
.select-option.active .option-desc {
color: var(--primary);
}
.media-options {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 12px;
padding-left: 28px;
}
.folder-select {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px dashed var(--border-color);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.02);
}
svg {
color: var(--primary);
}
.folder-path {
flex: 1;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.export-path-display {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: var(--bg-secondary);
border-radius: 10px;
font-size: 13px;
color: var(--text-primary);
svg {
color: var(--primary);
flex-shrink: 0;
}
span {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.path-hint {
font-size: 12px;
color: var(--text-tertiary);
margin: 8px 0 0;
}
.select-folder-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
margin-top: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
svg {
color: var(--primary);
}
}
&:active {
transform: scale(0.98);
}
svg {
color: var(--text-secondary);
transition: color 0.2s;
}
}
.export-action {
padding: 20px 24px;
border-top: 1px solid var(--border-color);
}
.export-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 24px;
background: var(--primary);
color: #fff;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: var(--primary-hover);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spin {
animation: exportSpin 1s linear infinite;
}
}
// 导出进度弹窗
.export-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.export-progress-modal {
background: var(--card-bg);
padding: 32px 40px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
text-align: center;
min-width: 320px;
.progress-spinner {
margin-bottom: 20px;
color: var(--primary);
.spin {
animation: exportSpin 1s linear infinite;
}
}
h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px;
}
.progress-text {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-bar {
height: 6px;
background: var(--bg-secondary);
border-radius: 3px;
overflow: hidden;
margin-bottom: 12px;
.progress-fill {
height: 100%;
background: var(--primary);
border-radius: 3px;
transition: width 0.3s ease;
}
}
.progress-count {
font-size: 13px;
color: var(--text-tertiary);
margin: 0;
}
}
.export-layout-modal {
background: var(--card-bg);
padding: 28px 32px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
text-align: center;
width: min(520px, 90vw);
h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px;
}
.layout-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 20px;
}
.layout-options {
display: grid;
gap: 12px;
}
.layout-option-btn {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 18px;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
text-align: left;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
}
&.primary {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.12);
}
.layout-title {
font-size: 15px;
h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.layout-desc {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.layout-actions {
margin-top: 18px;
.detail-header-id {
margin-top: 3px;
font-size: 11px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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;
}
.layout-cancel-btn {
padding: 8px 20px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.export-result-modal {
background: var(--card-bg);
padding: 32px 40px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
text-align: center;
min-width: 320px;
.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;
}
.result-icon {
margin-bottom: 16px;
.detail-content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px;
}
&.success {
color: #52c41a;
}
.detail-section {
margin-bottom: 18px;
&.error {
color: #ff4d4f;
}
&:last-child {
margin-bottom: 0;
}
h3 {
font-size: 18px;
.section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px;
}
.result-text {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 24px;
&.error {
color: #ff4d4f;
}
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.result-actions {
display: flex;
gap: 12px;
justify-content: center;
button {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.open-folder-btn {
background: var(--primary);
color: #fff;
border: none;
&:hover {
background: var(--primary-hover);
}
}
.close-btn {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
&:hover {
background: var(--bg-hover);
}
}
.detail-stats-meta {
margin-top: -4px;
margin-bottom: 10px;
font-size: 12px;
color: var(--text-tertiary);
}
}
.date-picker-modal {
background: var(--card-bg);
padding: 28px 32px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
width: 420px;
.detail-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
font-size: 13px;
h3 {
font-size: 18px;
font-weight: 600;
&:last-child {
border-bottom: none;
}
.label {
color: var(--text-secondary);
flex-shrink: 0;
}
.value {
flex: 1;
text-align: right;
color: var(--text-primary);
margin: 0 0 20px;
}
word-break: break-all;
user-select: text;
.quick-select {
display: flex;
gap: 8px;
margin-bottom: 20px;
.quick-btn {
flex: 1;
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
&:active {
transform: scale(0.98);
}
}
}
.date-display {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--bg-secondary);
border-radius: 12px;
margin-bottom: 24px;
.date-display-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(var(--primary-rgb), 0.05);
}
&.active {
background: rgba(var(--primary-rgb), 0.1);
border: 1px solid var(--primary);
}
.date-label {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 500;
}
.date-value {
font-size: 15px;
color: var(--text-primary);
font-weight: 600;
}
}
.date-separator {
font-size: 14px;
color: var(--text-tertiary);
padding: 0 4px;
}
}
.calendar-container {
margin-bottom: 20px;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding: 0 4px;
.calendar-nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
&:active {
transform: scale(0.95);
}
}
.calendar-month {
font-size: 15px;
&.highlight {
color: var(--primary);
font-weight: 600;
color: var(--text-primary);
&.clickable {
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}
}
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 8px;
.detail-inline-btn {
border: none;
background: var(--bg-secondary);
color: var(--primary);
border-radius: 6px;
padding: 4px 8px;
font-size: 12px;
line-height: 1;
cursor: pointer;
.calendar-weekday {
text-align: center;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
padding: 8px 0;
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
&:hover:not(:disabled) {
background: var(--bg-hover);
}
}
.calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(6, 40px);
gap: 4px;
.calendar-day {
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--text-primary);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
position: relative;
&.empty {
cursor: default;
}
&:not(.empty):hover {
background: var(--bg-hover);
}
&.in-range {
background: rgba(var(--primary-rgb), 0.08);
}
&.start,
&.end {
background: var(--primary);
color: #fff;
font-weight: 600;
&:hover {
background: var(--primary-hover);
}
}
}
}
.year-month-picker {
padding: 4px 0;
.year-selector {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.year-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.calendar-nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
}
}
.month-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.month-btn {
padding: 10px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
}
.date-picker-actions {
.copy-btn {
display: flex;
gap: 12px;
justify-content: flex-end;
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;
button {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:active {
transform: scale(0.98);
}
}
.cancel-btn {
&:hover {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
}
&:hover {
background: var(--bg-hover);
&:hover .copy-btn {
opacity: 1;
}
}
.detail-record-empty {
padding: 10px 12px;
border-radius: 8px;
background: var(--bg-secondary);
font-size: 12px;
color: var(--text-secondary);
}
.detail-record-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.detail-record-item {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px 10px;
background: var(--bg-primary);
.record-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 12px;
.label {
color: var(--text-secondary);
width: 56px;
flex-shrink: 0;
}
.value {
color: var(--text-primary);
flex: 1;
text-align: right;
word-break: break-all;
&.path {
text-align: left;
}
}
.confirm-btn {
background: var(--primary);
color: #fff;
border: none;
&:hover {
background: var(--primary-hover);
}
.detail-inline-btn {
flex-shrink: 0;
}
}
}
.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 {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 120px;
color: var(--text-secondary);
}
.table-skeleton-list {
display: grid;
gap: 8px;
padding: 4px 0;
}
.table-skeleton-item {
display: grid;
grid-template-columns: 20px 36px minmax(160px, 2fr) repeat(3, minmax(80px, 1fr));
align-items: center;
gap: 12px;
padding: 10px 8px;
border-radius: 8px;
background: color-mix(in srgb, var(--bg-secondary) 80%, transparent);
}
.skeleton-shimmer {
position: relative;
overflow: hidden;
border-radius: 8px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.35) 50%,
rgba(255, 255, 255, 0.08) 100%
);
background-size: 220% 100%;
animation: exportSkeletonShimmer 1.2s linear infinite;
}
.skeleton-dot {
width: 16px;
height: 16px;
border-radius: 6px;
}
.skeleton-avatar {
width: 36px;
height: 36px;
border-radius: 8px;
}
.skeleton-line {
display: inline-block;
height: 12px;
}
.skeleton-line.w-12 { width: 48%; min-width: 42px; }
.skeleton-line.w-20 { width: 22%; min-width: 36px; }
.skeleton-line.w-30 { width: 32%; min-width: 120px; }
.skeleton-line.w-40 { width: 45%; min-width: 80px; }
.skeleton-line.w-60 { width: 62%; min-width: 110px; }
.skeleton-line.w-100 { width: 100%; }
.skeleton-line.h-32 { height: 32px; border-radius: 10px; }
.export-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 1000;
}
.export-dialog {
width: min(1080px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 14px 14px 12px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dialog-body {
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 2px;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
h3 {
margin: 0;
color: var(--text-primary);
font-size: 18px;
}
}
.close-icon-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 8px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary);
}
.dialog-section {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 12px;
background: var(--bg-secondary);
h4 {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.4px;
}
}
.section-header-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
h4 {
margin-bottom: 0;
}
}
.time-range-trigger {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: 999px;
color: var(--text-primary);
font-size: 12px;
min-height: 32px;
padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
&:hover {
border-color: rgba(var(--primary-rgb), 0.45);
color: var(--primary);
}
.time-range-arrow {
color: var(--text-tertiary);
font-weight: 700;
line-height: 1;
}
}
.scope-tag-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.scope-tag {
border-radius: 999px;
background: rgba(var(--primary-rgb), 0.15);
color: var(--primary);
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
}
.scope-count {
font-size: 12px;
color: var(--text-secondary);
}
.scope-list {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 120px;
overflow: auto;
}
.scope-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 999px;
padding: 4px 9px;
font-size: 12px;
color: var(--text-primary);
}
.format-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 6px;
}
.format-note {
margin: 0 0 8px;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
}
.format-card {
width: 100%;
min-height: 0;
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 10px;
text-align: left;
background: var(--bg-primary);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
.format-label {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.35;
}
.format-desc {
margin-top: 1px;
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.35;
}
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
}
}
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: var(--text-primary);
}
.date-range-row {
margin-top: 10px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
label {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12px;
color: var(--text-secondary);
}
input {
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
padding: 8px;
}
}
.media-check-grid {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(3, minmax(100px, 1fr));
gap: 8px;
label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-primary);
}
input[type='checkbox'] {
accent-color: var(--primary);
}
}
.display-name-options {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}
.display-name-item {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px;
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
background: var(--bg-primary);
text-align: left;
cursor: pointer;
color: inherit;
font: inherit;
appearance: none;
-webkit-appearance: none;
&:focus-visible {
outline: 2px solid rgba(var(--primary-rgb), 0.35);
outline-offset: 1px;
}
span {
font-size: 12px;
color: var(--text-primary);
font-weight: 600;
}
small {
color: var(--text-secondary);
font-size: 11px;
line-height: 1.4;
}
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
}
}
.dialog-actions {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
background: var(--card-bg);
}
.primary-btn,
.secondary-btn {
border-radius: 8px;
padding: 7px 12px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border-color);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.primary-btn {
border-color: var(--primary);
background: var(--primary);
color: #fff;
&:hover {
background: var(--primary-hover);
}
&:disabled {
opacity: 0.65;
cursor: not-allowed;
}
}
.secondary-btn {
background: var(--bg-secondary);
color: var(--text-primary);
&:hover {
border-color: var(--primary);
color: var(--primary);
}
}
.time-range-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 1015;
}
.time-range-dialog {
width: min(480px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
overflow-y: auto;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, var(--bg-primary));
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.time-range-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
h4 {
margin: 0;
font-size: 14px;
color: var(--text-primary);
}
}
.time-range-preset-list {
display: flex;
flex-wrap: nowrap;
gap: 4px;
overflow-x: auto;
padding-bottom: 2px;
&::-webkit-scrollbar {
height: 4px;
}
}
.time-range-preset-item {
flex: 0 0 auto;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
min-height: 30px;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
color: var(--primary);
}
}
.time-range-calendar-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.time-range-mode-banner {
border-radius: 8px;
padding: 6px 8px;
font-size: 11px;
line-height: 1.4;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
&.range {
border-color: rgba(var(--primary-rgb), 0.4);
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
}
}
.time-range-calendar-panel {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
padding: 7px;
}
.time-range-calendar-panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.time-range-calendar-date-label {
display: flex;
flex-direction: column;
gap: 2px;
span {
font-size: 11px;
color: var(--text-secondary);
}
small {
font-size: 10px;
color: var(--text-tertiary);
}
}
.time-range-date-input {
width: 100%;
min-width: 0;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
height: 24px;
padding: 0 7px;
font-size: 11px;
&.invalid {
border-color: #e84d4d;
box-shadow: 0 0 0 1px rgba(232, 77, 77, 0.2);
}
}
.time-range-calendar-nav {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-primary);
button {
width: 20px;
height: 20px;
border-radius: 5px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
padding: 0;
line-height: 1;
}
}
.time-range-calendar-weekdays {
margin-top: 6px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
span {
text-align: center;
font-size: 10px;
color: var(--text-tertiary);
}
}
.time-range-calendar-days {
margin-top: 4px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.time-range-calendar-day {
border: 1px solid transparent;
border-radius: 6px;
min-height: 20px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 10px;
cursor: pointer;
padding: 0;
&.outside {
color: var(--text-quaternary);
opacity: 0.75;
}
&.selected {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.14);
color: var(--primary);
font-weight: 600;
}
}
.time-range-custom-row {
display: none;
label {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12px;
color: var(--text-secondary);
}
input {
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
padding: 8px;
}
}
.time-range-dialog-actions {
display: flex;
justify-content: flex-end;
}
@keyframes exportSpin {
@@ -1142,93 +2594,157 @@
}
}
// 媒体导出选项卡片样式
.setting-subtitle {
font-size: 12px;
color: var(--text-tertiary);
margin: 4px 0 12px 0;
}
.media-options-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
}
.media-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
}
.media-switch-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.media-switch-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.media-switch-desc {
font-size: 11px;
color: var(--text-tertiary);
}
.media-option-divider {
height: 1px;
background: var(--border-color);
margin-left: 16px;
}
.media-checkbox-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
cursor: pointer;
transition: background 0.2s;
&:hover:not(.disabled) {
background: var(--bg-hover);
@keyframes exportSkeletonShimmer {
0% {
background-position: 220% 0;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
&:disabled {
cursor: not-allowed;
}
100% {
background-position: -20% 0;
}
}
.media-checkbox-info {
display: flex;
flex-direction: column;
gap: 2px;
@keyframes exportDots {
0% {
width: 0;
}
100% {
width: 1.8em;
}
}
.media-checkbox-title {
font-size: 14px;
color: var(--text-primary);
@keyframes exportTaskBadgePulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.35);
}
70% {
transform: scale(1.02);
box-shadow: 0 0 0 6px rgba(255, 77, 79, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0);
}
}
.media-checkbox-desc {
font-size: 11px;
color: var(--text-tertiary);
@media (max-width: 1360px) {
.global-export-controls {
padding: 10px;
gap: 8px;
}
.format-grid {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.display-name-options {
grid-template-columns: 1fr;
}
.media-check-grid {
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
}
// 全局样式已在 main.scss 中定义
@media (max-width: 760px) {
.global-export-controls {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.task-center-control {
grid-column: 1 / -1;
}
}
@media (max-width: 720px) {
.export-section-title {
font-size: 14px;
}
.table-wrap {
--contacts-message-col-width: 104px;
--contacts-action-col-width: 236px;
}
.table-wrap .contacts-list-header {
gap: 8px;
padding: 8px 10px 6px;
}
.table-wrap .contacts-list-header-main {
gap: 6px;
}
.table-wrap .contacts-list-header-actions {
gap: 6px;
}
.table-wrap .contacts-list {
padding: 0 10px 10px;
min-height: 300px;
height: min(56vh, 560px);
}
.table-wrap .row-message-count {
min-width: var(--contacts-message-col-width);
}
.table-wrap .row-message-stats {
gap: 6px;
}
.table-wrap .row-message-stat {
font-size: 10px;
}
.table-wrap .row-message-count-value {
font-size: 11px;
}
.table-wrap .row-message-stat.total .row-message-count-value {
font-size: 12px;
}
.export-dialog-overlay {
padding: 10px;
}
.export-dialog {
width: calc(100vw - 20px);
max-height: calc(100vh - 20px);
padding: 12px 10px 10px;
}
.format-grid {
grid-template-columns: 1fr;
}
.date-range-row {
grid-template-columns: 1fr;
}
.time-range-preset-list {
gap: 4px;
}
.time-range-calendar-grid {
grid-template-columns: 1fr;
}
.task-center-modal-overlay {
padding: 12px 10px;
}
.task-center-modal {
width: calc(100vw - 20px);
max-height: calc(100vh - 56px);
}
.task-actions {
width: 84px;
}
.export-session-detail-panel {
width: calc(100vw - 12px);
}
}
+4676 -1040
View File
@@ -1,19 +1,71 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState, type WheelEvent } from 'react'
import { useLocation } from 'react-router-dom'
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { createPortal } from 'react-dom'
import {
Aperture,
Calendar,
Check,
CheckSquare,
CircleHelp,
Copy,
Database,
Download,
ExternalLink,
FolderOpen,
Hash,
Image as ImageIcon,
Loader2,
AlertTriangle,
ClipboardList,
MessageSquare,
MessageSquareText,
Mic,
RefreshCw,
Search,
Square,
Video,
WandSparkles,
X
} from 'lucide-react'
import type { ChatSession as AppChatSession, ContactInfo } from '../types/models'
import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron'
import * as configService from '../services/config'
import {
emitExportSessionStatus,
emitSingleExportDialogStatus,
onExportSessionStatusRequest,
onOpenSingleExport
} from '../services/exportBridge'
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import './ExportPage.scss'
interface ChatSession {
username: string
displayName?: string
avatarUrl?: string
summary: string
lastTimestamp: number
}
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
type TaskStatus = 'queued' | 'running' | 'success' | 'error'
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji'
type ContentCardType = ContentType | 'sns'
type SessionLayout = 'shared' | 'per-session'
type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname'
type DateRangePreset =
| 'all'
| 'today'
| 'yesterday'
| 'last3days'
| 'last7days'
| 'last30days'
| 'last1year'
| 'last2years'
| 'custom'
type CalendarCell = { date: Date; inCurrentMonth: boolean }
type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson'
interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
format: TextExportFormat
dateRange: { start: Date; end: Date } | null
useAllTime: boolean
exportAvatars: boolean
@@ -25,64 +77,1166 @@ interface ExportOptions {
exportVoiceAsText: boolean
excelCompactColumns: boolean
txtColumns: string[]
displayNamePreference: 'group-nickname' | 'remark' | 'nickname'
displayNamePreference: DisplayNamePreference
exportConcurrency: number
}
interface ExportResult {
success: boolean
successCount?: number
failCount?: number
error?: string
interface SessionRow extends AppChatSession {
kind: ConversationTab
wechatId?: string
hasSession: boolean
}
type SessionLayout = 'shared' | 'per-session'
interface TaskProgress {
current: number
total: number
currentName: string
phase: ExportProgress['phase'] | ''
phaseLabel: string
phaseProgress: number
phaseTotal: number
}
type TaskPerfStage = 'collect' | 'build' | 'write' | 'other'
interface TaskSessionPerformance {
sessionId: string
sessionName: string
startedAt: number
finishedAt?: number
elapsedMs: number
lastPhase?: ExportProgress['phase']
lastPhaseStartedAt?: number
}
interface TaskPerformance {
stages: Record<TaskPerfStage, number>
sessions: Record<string, TaskSessionPerformance>
}
interface ExportTaskPayload {
sessionIds: string[]
outputDir: string
options?: ElectronExportOptions
scope: TaskScope
contentType?: ContentType
sessionNames: string[]
snsOptions?: {
format: SnsTimelineExportFormat
exportImages?: boolean
exportLivePhotos?: boolean
exportVideos?: boolean
startTime?: number
endTime?: number
}
}
interface ExportTask {
id: string
title: string
status: TaskStatus
settledSessionIds?: string[]
createdAt: number
startedAt?: number
finishedAt?: number
error?: string
payload: ExportTaskPayload
progress: TaskProgress
performance?: TaskPerformance
}
interface ExportDialogState {
open: boolean
scope: TaskScope
contentType?: ContentType
sessionIds: string[]
sessionNames: string[]
title: string
}
interface TimeRangeDialogDraft {
preset: DateRangePreset
useAllTime: boolean
dateRange: { start: Date; end: Date }
startPanelMonth: Date
endPanelMonth: Date
}
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const contentTypeLabels: Record<ContentType, string> = {
text: '聊天文本',
voice: '语音',
image: '图片',
video: '视频',
emoji: '表情包'
}
const getContentTypeLabel = (type: ContentType): string => {
return contentTypeLabels[type] || type
}
const formatOptions: Array<{ value: TextExportFormat; label: string; desc: string }> = [
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
]
const displayNameOptions: Array<{ value: DisplayNamePreference; label: string; desc: string }> = [
{ value: 'group-nickname', label: '群昵称优先', desc: '仅群聊有效,私聊显示备注/昵称' },
{ value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' },
{ value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' }
]
const writeLayoutOptions: Array<{ value: configService.ExportWriteLayout; label: string; desc: string }> = [
{
value: 'A',
label: 'A(类型分目录)',
desc: '聊天文本、语音、视频、表情包、图片分别创建文件夹'
},
{
value: 'B',
label: 'B(文本根目录+媒体按会话)',
desc: '聊天文本在根目录;媒体按类型目录后再按会话分目录'
},
{
value: 'C',
label: 'C(按会话分目录)',
desc: '每个会话一个目录,目录内包含文本与媒体文件'
}
]
const createEmptyProgress = (): TaskProgress => ({
current: 0,
total: 0,
currentName: '',
phase: '',
phaseLabel: '',
phaseProgress: 0,
phaseTotal: 0
})
const createEmptyTaskPerformance = (): TaskPerformance => ({
stages: {
collect: 0,
build: 0,
write: 0,
other: 0
},
sessions: {}
})
const isTextBatchTask = (task: ExportTask): boolean => (
task.payload.scope === 'content' && task.payload.contentType === 'text'
)
const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => {
if (phase === 'preparing') return 'collect'
if (phase === 'writing') return 'write'
if (phase === 'exporting' || phase === 'exporting-media' || phase === 'exporting-voice') return 'build'
return 'other'
}
const cloneTaskPerformance = (performance?: TaskPerformance): TaskPerformance => ({
stages: {
collect: performance?.stages.collect || 0,
build: performance?.stages.build || 0,
write: performance?.stages.write || 0,
other: performance?.stages.other || 0
},
sessions: Object.fromEntries(
Object.entries(performance?.sessions || {}).map(([sessionId, session]) => [sessionId, { ...session }])
)
})
const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => {
const idx = task.payload.sessionIds.indexOf(sessionId)
if (idx >= 0) {
return task.payload.sessionNames[idx] || fallback || sessionId
}
return fallback || sessionId
}
const applyProgressToTaskPerformance = (
task: ExportTask,
payload: ExportProgress,
now: number
): TaskPerformance | undefined => {
if (!isTextBatchTask(task)) return task.performance
const sessionId = String(payload.currentSessionId || '').trim()
if (!sessionId) return task.performance || createEmptyTaskPerformance()
const performance = cloneTaskPerformance(task.performance)
const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId)
const existing = performance.sessions[sessionId]
const session: TaskSessionPerformance = existing
? { ...existing, sessionName: existing.sessionName || sessionName }
: {
sessionId,
sessionName,
startedAt: now,
elapsedMs: 0
}
if (!session.finishedAt && session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
const delta = Math.max(0, now - session.lastPhaseStartedAt)
performance.stages[resolvePerfStageByPhase(session.lastPhase)] += delta
}
session.elapsedMs = Math.max(session.elapsedMs, now - session.startedAt)
if (payload.phase === 'complete') {
session.finishedAt = now
session.lastPhase = undefined
session.lastPhaseStartedAt = undefined
} else {
session.lastPhase = payload.phase
session.lastPhaseStartedAt = now
}
performance.sessions[sessionId] = session
return performance
}
const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => {
if (!isTextBatchTask(task) || !task.performance) return task.performance
const performance = cloneTaskPerformance(task.performance)
for (const session of Object.values(performance.sessions)) {
if (session.finishedAt) continue
if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
const delta = Math.max(0, now - session.lastPhaseStartedAt)
performance.stages[resolvePerfStageByPhase(session.lastPhase)] += delta
}
session.elapsedMs = Math.max(session.elapsedMs, now - session.startedAt)
session.finishedAt = now
session.lastPhase = undefined
session.lastPhaseStartedAt = undefined
}
return performance
}
const getTaskPerformanceStageTotals = (
performance: TaskPerformance | undefined,
now: number
): Record<TaskPerfStage, number> => {
const totals: Record<TaskPerfStage, number> = {
collect: performance?.stages.collect || 0,
build: performance?.stages.build || 0,
write: performance?.stages.write || 0,
other: performance?.stages.other || 0
}
if (!performance) return totals
for (const session of Object.values(performance.sessions)) {
if (session.finishedAt) continue
if (!session.lastPhase || typeof session.lastPhaseStartedAt !== 'number') continue
const delta = Math.max(0, now - session.lastPhaseStartedAt)
totals[resolvePerfStageByPhase(session.lastPhase)] += delta
}
return totals
}
const getTaskPerformanceTopSessions = (
performance: TaskPerformance | undefined,
now: number,
limit = 5
): Array<TaskSessionPerformance & { liveElapsedMs: number }> => {
if (!performance) return []
return Object.values(performance.sessions)
.map((session) => {
const liveElapsedMs = session.finishedAt
? session.elapsedMs
: Math.max(session.elapsedMs, now - session.startedAt)
return {
...session,
liveElapsedMs
}
})
.sort((a, b) => b.liveElapsedMs - a.liveElapsedMs)
.slice(0, limit)
}
const formatDurationMs = (ms: number): string => {
const totalSeconds = Math.max(0, Math.floor(ms / 1000))
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours}小时${minutes}分${seconds}秒`
}
if (minutes > 0) {
return `${minutes}分${seconds}秒`
}
return `${seconds}秒`
}
const getTaskStatusLabel = (task: ExportTask): string => {
if (task.status === 'queued') return '排队中'
if (task.status === 'running') return '进行中'
if (task.status === 'success') return '已完成'
return '失败'
}
const formatAbsoluteDate = (timestamp: number): string => {
const d = new Date(timestamp)
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
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 formatYmdHmDateTime = (timestamp?: number): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '—'
const d = new Date(timestamp)
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
const h = `${d.getHours()}`.padStart(2, '0')
const min = `${d.getMinutes()}`.padStart(2, '0')
return `${y}-${m}-${day} ${h}:${min}`
}
const formatPathBrief = (value: string, maxLength = 52): string => {
const normalized = String(value || '')
if (normalized.length <= maxLength) return normalized
const headLength = Math.max(10, Math.floor(maxLength * 0.55))
const tailLength = Math.max(8, maxLength - headLength - 1)
return `${normalized.slice(0, headLength)}…${normalized.slice(-tailLength)}`
}
const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
if (!timestamp) return ''
const diff = Math.max(0, now - timestamp)
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < hour) {
const minutes = Math.max(1, Math.floor(diff / minute))
return `${minutes} 分钟前`
}
if (diff < day) {
const hours = Math.max(1, Math.floor(diff / hour))
return `${hours} 小时前`
}
return formatAbsoluteDate(timestamp)
}
const startOfDay = (date: Date): Date => {
const next = new Date(date)
next.setHours(0, 0, 0, 0)
return next
}
const endOfDay = (date: Date): Date => {
const next = new Date(date)
next.setHours(23, 59, 59, 999)
return next
}
const createDefaultDateRange = (): { start: Date; end: Date } => {
const now = new Date()
return {
start: startOfDay(now),
end: now
}
}
const createDateRangeByPreset = (
preset: Exclude<DateRangePreset, 'all' | 'custom'>,
now = new Date()
): { start: Date; end: Date } => {
const end = new Date(now)
const baseStart = startOfDay(now)
if (preset === 'today') {
return { start: baseStart, end }
}
if (preset === 'yesterday') {
const yesterday = new Date(baseStart)
yesterday.setDate(yesterday.getDate() - 1)
return {
start: yesterday,
end: endOfDay(yesterday)
}
}
if (preset === 'last1year' || preset === 'last2years') {
const yearsBack = preset === 'last1year' ? 1 : 2
const start = new Date(baseStart)
const expectedMonth = start.getMonth()
start.setFullYear(start.getFullYear() - yearsBack)
// Handle leap-year fallback (e.g. Feb 29 -> Feb 28).
if (start.getMonth() !== expectedMonth) {
start.setDate(0)
}
return { start, end }
}
const daysBack = preset === 'last3days' ? 2 : preset === 'last7days' ? 6 : 29
const start = new Date(baseStart)
start.setDate(start.getDate() - daysBack)
return { start, end }
}
const formatDateInputValue = (date: Date): string => {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${y}-${m}-${d}`
}
const parseDateInputValue = (raw: string): Date | null => {
const text = String(raw || '').trim()
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
if (!matched) return null
const year = Number(matched[1])
const month = Number(matched[2])
const day = Number(matched[3])
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
if (month < 1 || month > 12 || day < 1 || day > 31) return null
const parsed = new Date(year, month - 1, day)
if (
parsed.getFullYear() !== year ||
parsed.getMonth() !== month - 1 ||
parsed.getDate() !== day
) {
return null
}
return parsed
}
const toMonthStart = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), 1)
const addMonths = (date: Date, delta: number): Date => {
const next = new Date(date)
next.setMonth(next.getMonth() + delta)
return toMonthStart(next)
}
const isSameDay = (left: Date, right: Date): boolean => (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
)
const buildCalendarCells = (monthStart: Date): CalendarCell[] => {
const firstDay = new Date(monthStart.getFullYear(), monthStart.getMonth(), 1)
const startOffset = firstDay.getDay()
const gridStart = new Date(firstDay)
gridStart.setDate(gridStart.getDate() - startOffset)
const cells: CalendarCell[] = []
for (let index = 0; index < 42; index += 1) {
const current = new Date(gridStart)
current.setDate(gridStart.getDate() + index)
cells.push({
date: current,
inCurrentMonth: current.getMonth() === monthStart.getMonth()
})
}
return cells
}
const formatCalendarMonthTitle = (date: Date): string => `${date.getFullYear()}年${date.getMonth() + 1}月`
const WEEKDAY_SHORT_LABELS = ['日', '一', '二', '三', '四', '五', '六']
const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => {
if (session.username.endsWith('@chatroom')) return 'group'
if (session.username.startsWith('gh_')) return 'official'
if (contact?.type === 'official') return 'official'
if (contact?.type === 'former_friend') return 'former_friend'
return 'private'
}
const toKindByContact = (contact: ContactInfo): ConversationTab => {
if (contact.type === 'group') return 'group'
if (contact.type === 'official') return 'official'
if (contact.type === 'former_friend') return 'former_friend'
return 'private'
}
const isContentScopeSession = (session: SessionRow): boolean => (
session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend'
)
const exportKindPriority: Record<ConversationTab, number> = {
private: 0,
group: 1,
former_friend: 2,
official: 3
}
const getAvatarLetter = (name: string): string => {
if (!name) return '?'
return [...name][0] || '?'
}
const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean => {
if (tab === 'private') return contact.type === 'friend'
if (tab === 'group') return contact.type === 'group'
if (tab === 'official') return contact.type === 'official'
return contact.type === 'former_friend'
}
const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const CONTACT_ENRICH_TIMEOUT_MS = 7000
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000
const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000
const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000
type SessionDataSource = 'cache' | 'network' | null
type ContactsDataSource = 'cache' | 'network' | null
interface ContactsLoadSession {
requestId: string
startedAt: number
attempt: number
timeoutMs: number
}
interface ContactsLoadIssue {
kind: 'timeout' | 'error'
title: string
message: string
reason: string
errorDetail?: string
occurredAt: 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
transferMessages?: number
redPacketMessages?: number
callMessages?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
relationStatsLoaded?: boolean
statsUpdatedAt?: number
statsStale?: boolean
firstMessageTime?: number
latestMessageTime?: number
messageTables: { dbName: string; tableName: string; count: number }[]
}
interface SessionExportMetric {
totalMessages: number
voiceMessages: number
imageMessages: number
videoMessages: number
emojiMessages: number
transferMessages: number
redPacketMessages: number
callMessages: number
firstTimestamp?: number
lastTimestamp?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
}
interface SessionContentMetric {
totalMessages?: number
voiceMessages?: number
imageMessages?: number
videoMessages?: number
emojiMessages?: number
transferMessages?: number
redPacketMessages?: number
callMessages?: number
}
interface SessionExportCacheMeta {
updatedAt: number
stale: boolean
includeRelations: boolean
source: 'memory' | 'disk' | 'fresh'
}
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
let timer: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
promise,
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), timeoutMs)
})
])
} finally {
if (timer) {
clearTimeout(timer)
}
}
}
const toContactMapFromCaches = (
contacts: configService.ContactsListCacheContact[],
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
): Record<string, ContactInfo> => {
const map: Record<string, ContactInfo> = {}
for (const contact of contacts || []) {
if (!contact?.username) continue
map[contact.username] = {
...contact,
avatarUrl: avatarEntries[contact.username]?.avatarUrl
}
}
return map
}
const mergeAvatarCacheIntoContacts = (
sourceContacts: ContactInfo[],
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
): ContactInfo[] => {
if (!sourceContacts.length || Object.keys(avatarEntries).length === 0) {
return sourceContacts
}
let changed = false
const merged = sourceContacts.map((contact) => {
const cachedAvatar = avatarEntries[contact.username]?.avatarUrl
if (!cachedAvatar || contact.avatarUrl) {
return contact
}
changed = true
return {
...contact,
avatarUrl: cachedAvatar
}
})
return changed ? merged : sourceContacts
}
const upsertAvatarCacheFromContacts = (
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>,
sourceContacts: ContactInfo[],
options?: { prune?: boolean; markCheckedUsernames?: string[]; now?: number }
): {
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
changed: boolean
updatedAt: number | null
} => {
const nextCache = { ...avatarEntries }
const now = options?.now || Date.now()
const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean))
const usernamesInSource = new Set<string>()
let changed = false
for (const contact of sourceContacts) {
const username = String(contact.username || '').trim()
if (!username) continue
usernamesInSource.add(username)
const prev = nextCache[username]
const avatarUrl = String(contact.avatarUrl || '').trim()
if (!avatarUrl) continue
const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt
const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now)
if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) {
nextCache[username] = {
avatarUrl,
updatedAt,
checkedAt
}
changed = true
}
}
for (const username of markCheckedSet) {
const prev = nextCache[username]
if (!prev) continue
if (prev.checkedAt !== now) {
nextCache[username] = {
...prev,
checkedAt: now
}
changed = true
}
}
if (options?.prune) {
for (const username of Object.keys(nextCache)) {
if (usernamesInSource.has(username)) continue
delete nextCache[username]
changed = true
}
}
return {
avatarEntries: nextCache,
changed,
updatedAt: changed ? now : null
}
}
const toSessionRowsWithContacts = (
sessions: AppChatSession[],
contactMap: Record<string, ContactInfo>
): SessionRow[] => {
const sessionMap = new Map<string, AppChatSession>()
for (const session of sessions || []) {
sessionMap.set(session.username, session)
}
const contacts = Object.values(contactMap)
.filter((contact) => (
contact.type === 'friend' ||
contact.type === 'group' ||
contact.type === 'official' ||
contact.type === 'former_friend'
))
if (contacts.length > 0) {
return contacts
.map((contact) => {
const session = sessionMap.get(contact.username)
const latestTs = session?.sortTimestamp || session?.lastTimestamp || 0
return {
...(session || {
username: contact.username,
type: 0,
unreadCount: 0,
summary: '',
sortTimestamp: latestTs,
lastTimestamp: latestTs,
lastMsgType: 0
}),
username: contact.username,
kind: toKindByContact(contact),
wechatId: contact.username,
displayName: contact.displayName || session?.displayName || contact.username,
avatarUrl: contact.avatarUrl || session?.avatarUrl,
hasSession: Boolean(session)
} as SessionRow
})
.sort((a, b) => {
const latestA = a.sortTimestamp || a.lastTimestamp || 0
const latestB = b.sortTimestamp || b.lastTimestamp || 0
if (latestA !== latestB) return latestB - latestA
return (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN')
})
}
return sessions
.map((session) => {
const contact = contactMap[session.username]
return {
...session,
kind: toKindByContactType(session, contact),
wechatId: contact?.username || session.username,
displayName: contact?.displayName || session.displayName || session.username,
avatarUrl: contact?.avatarUrl || session.avatarUrl,
hasSession: true
} as SessionRow
})
.sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0))
}
const normalizeMessageCount = (value: unknown): number | undefined => {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed < 0) return undefined
return Math.floor(parsed)
}
const WriteLayoutSelector = memo(function WriteLayoutSelector({
writeLayout,
onChange,
sessionNameWithTypePrefix,
onSessionNameWithTypePrefixChange
}: {
writeLayout: configService.ExportWriteLayout
onChange: (value: configService.ExportWriteLayout) => Promise<void>
sessionNameWithTypePrefix: boolean
onSessionNameWithTypePrefixChange: (enabled: boolean) => Promise<void>
}) {
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!isOpen) return
const handleOutsideClick = (event: MouseEvent) => {
if (containerRef.current?.contains(event.target as Node)) return
setIsOpen(false)
}
document.addEventListener('mousedown', handleOutsideClick)
return () => document.removeEventListener('mousedown', handleOutsideClick)
}, [isOpen])
const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)'
return (
<div className="write-layout-control" ref={containerRef}>
<span className="control-label">写入目录方式</span>
<button
className={`layout-trigger ${isOpen ? 'active' : ''}`}
type="button"
onClick={() => setIsOpen(prev => !prev)}
>
{writeLayoutLabel}
</button>
<div className={`layout-dropdown ${isOpen ? 'open' : ''}`}>
{writeLayoutOptions.map(option => (
<button
key={option.value}
className={`layout-option ${writeLayout === option.value ? 'active' : ''}`}
type="button"
onClick={async () => {
await onChange(option.value)
setIsOpen(false)
}}
>
<span className="layout-option-label">{option.label}</span>
<span className="layout-option-desc">{option.desc}</span>
</button>
))}
<div className="layout-prefix-toggle">
<div className="layout-prefix-copy">
<span className="layout-prefix-label">聊天文本文件和会话文件夹带前缀</span>
<span className="layout-prefix-desc">开启后使用群聊_、私聊_、公众号_、曾经的好友_前缀</span>
</div>
<button
type="button"
className={`layout-prefix-switch ${sessionNameWithTypePrefix ? 'on' : ''}`}
onClick={async () => {
await onSessionNameWithTypePrefixChange(!sessionNameWithTypePrefix)
}}
aria-label="聊天文本文件和会话文件夹带前缀"
aria-pressed={sessionNameWithTypePrefix}
>
<span className="layout-prefix-switch-thumb" />
</button>
</div>
</div>
</div>
)
})
const SectionInfoTooltip = memo(function SectionInfoTooltip({
label,
heading,
messages
}: {
label: string
heading: string
messages: string[]
}) {
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!isOpen) return
const handleOutsideClick = (event: MouseEvent) => {
if (containerRef.current?.contains(event.target as Node)) return
setIsOpen(false)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleOutsideClick)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen])
return (
<div className="section-info-tooltip" ref={containerRef}>
<button
type="button"
className={`section-info-trigger ${isOpen ? 'active' : ''}`}
onClick={() => setIsOpen(prev => !prev)}
aria-label={`查看${label}说明`}
aria-expanded={isOpen}
>
<CircleHelp size={14} />
</button>
{isOpen && (
<div className="section-info-popover" role="dialog" aria-label={`${label}说明`}>
<h4>{heading}</h4>
{messages.map(message => (
<p key={message}>{message}</p>
))}
</div>
)}
</div>
)
})
interface TaskCenterModalProps {
isOpen: boolean
tasks: ExportTask[]
taskRunningCount: number
taskQueuedCount: number
expandedPerfTaskId: string | null
nowTick: number
onClose: () => void
onTogglePerfTask: (taskId: string) => void
}
const TaskCenterModal = memo(function TaskCenterModal({
isOpen,
tasks,
taskRunningCount,
taskQueuedCount,
expandedPerfTaskId,
nowTick,
onClose,
onTogglePerfTask
}: TaskCenterModalProps) {
if (!isOpen) return null
return (
<div
className="task-center-modal-overlay"
onClick={onClose}
>
<div
className="task-center-modal"
role="dialog"
aria-modal="true"
aria-label="任务中心"
onClick={(event) => event.stopPropagation()}
>
<div className="task-center-modal-header">
<div className="task-center-modal-title">
<h3>任务中心</h3>
<span>进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 总计 {tasks.length}</span>
</div>
<button
className="close-icon-btn"
type="button"
onClick={onClose}
aria-label="关闭任务中心"
>
<X size={16} />
</button>
</div>
<div className="task-center-modal-body">
{tasks.length === 0 ? (
<div className="task-empty">暂无任务。点击会话导出或卡片导出后会在这里创建任务。</div>
) : (
<div className="task-list">
{tasks.map(task => {
const canShowPerfDetail = isTextBatchTask(task) && Boolean(task.performance)
const isPerfExpanded = expandedPerfTaskId === task.id
const stageTotals = canShowPerfDetail
? getTaskPerformanceStageTotals(task.performance, nowTick)
: null
const stageTotalMs = stageTotals
? stageTotals.collect + stageTotals.build + stageTotals.write + stageTotals.other
: 0
const topSessions = isPerfExpanded
? getTaskPerformanceTopSessions(task.performance, nowTick, 5)
: []
const normalizedProgressTotal = task.progress.total > 0 ? task.progress.total : 0
const normalizedProgressCurrent = normalizedProgressTotal > 0
? Math.max(0, Math.min(normalizedProgressTotal, task.progress.current))
: 0
const completedSessionTotal = normalizedProgressTotal > 0
? normalizedProgressTotal
: task.payload.sessionIds.length
const completedSessionCount = Math.min(
completedSessionTotal,
(task.settledSessionIds || []).length
)
const currentSessionRatio = task.progress.phaseTotal > 0
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
: null
return (
<div key={task.id} className={`task-card ${task.status}`}>
<div className="task-main">
<div className="task-title">{task.title}</div>
<div className="task-meta">
<span className={`task-status ${task.status}`}>{getTaskStatusLabel(task)}</span>
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
</div>
{task.status === 'running' && (
<>
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${normalizedProgressTotal > 0 ? (normalizedProgressCurrent / normalizedProgressTotal) * 100 : 0}%` }}
/>
</div>
<div className="task-progress-text">
{completedSessionTotal > 0
? `已完成 ${completedSessionCount} / ${completedSessionTotal}`
: '处理中'}
{task.status === 'running' && currentSessionRatio !== null
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)`
: ''}
{task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''}
</div>
</>
)}
{canShowPerfDetail && stageTotals && (
<div className="task-perf-summary">
<span>累计耗时 {formatDurationMs(stageTotalMs)}</span>
{task.progress.total > 0 && (
<span>平均/会话 {formatDurationMs(Math.floor(stageTotalMs / Math.max(1, task.progress.total)))}</span>
)}
</div>
)}
{canShowPerfDetail && isPerfExpanded && stageTotals && (
<div className="task-perf-panel">
<div className="task-perf-title">阶段耗时分布</div>
{[
{ key: 'collect' as const, label: '收集消息' },
{ key: 'build' as const, label: '构建消息' },
{ key: 'write' as const, label: '写入文件' },
{ key: 'other' as const, label: '其他' }
].map(item => {
const value = stageTotals[item.key]
const ratio = stageTotalMs > 0 ? Math.min(100, (value / stageTotalMs) * 100) : 0
return (
<div className="task-perf-row" key={item.key}>
<div className="task-perf-row-head">
<span>{item.label}</span>
<span>{formatDurationMs(value)}</span>
</div>
<div className="task-perf-row-track">
<div className="task-perf-row-fill" style={{ width: `${ratio}%` }} />
</div>
</div>
)
})}
<div className="task-perf-title">最慢会话 Top5</div>
{topSessions.length === 0 ? (
<div className="task-perf-empty">暂无会话耗时数据</div>
) : (
<div className="task-perf-session-list">
{topSessions.map((session, index) => (
<div className="task-perf-session-item" key={session.sessionId}>
<span className="task-perf-session-rank">
{index + 1}. {session.sessionName || session.sessionId}
{!session.finishedAt ? '(进行中)' : ''}
</span>
<span className="task-perf-session-time">{formatDurationMs(session.liveElapsedMs)}</span>
</div>
))}
</div>
)}
</div>
)}
{task.status === 'error' && <div className="task-error">{task.error || '任务失败'}</div>}
</div>
<div className="task-actions">
{canShowPerfDetail && (
<button
className={`task-action-btn ${isPerfExpanded ? 'primary' : ''}`}
type="button"
onClick={() => onTogglePerfTask(task.id)}
>
{isPerfExpanded ? '收起详情' : '性能详情'}
</button>
)}
<button className="task-action-btn" onClick={() => task.payload.outputDir && void window.electronAPI.shell.openPath(task.payload.outputDir)}>
<FolderOpen size={14} /> 目录
</button>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
)
})
function ExportPage() {
const location = useLocation()
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const [sessions, setSessions] = useState<ChatSession[]>([])
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
const isExportRoute = location.pathname === '/export'
const [isLoading, setIsLoading] = useState(true)
const [isSessionEnriching, setIsSessionEnriching] = useState(false)
const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true)
const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true)
const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false)
const [expandedPerfTaskId, setExpandedPerfTaskId] = useState<string | null>(null)
const [sessions, setSessions] = useState<SessionRow[]>([])
const [sessionDataSource, setSessionDataSource] = useState<SessionDataSource>(null)
const [sessionContactsUpdatedAt, setSessionContactsUpdatedAt] = useState<number | null>(null)
const [sessionAvatarUpdatedAt, setSessionAvatarUpdatedAt] = useState<number | null>(null)
const [searchKeyword, setSearchKeyword] = useState('')
const [exportFolder, setExportFolder] = useState<string>('')
const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 })
const [exportResult, setExportResult] = useState<ExportResult | null>(null)
const [showDatePicker, setShowDatePicker] = useState(false)
const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true)
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
const [showPreExportDialog, setShowPreExportDialog] = useState(false)
const [preExportStats, setPreExportStats] = useState<{
totalMessages: number; voiceMessages: number; cachedVoiceCount: number;
needTranscribeCount: number; mediaMessages: number; estimatedSeconds: number
} | null>(null)
const [isLoadingStats, setIsLoadingStats] = useState(false)
const [pendingLayout, setPendingLayout] = useState<SessionLayout>('shared')
const exportStartTime = useRef<number>(0)
const [elapsedSeconds, setElapsedSeconds] = useState(0)
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
const preselectAppliedRef = useRef(false)
const statsRequestIdRef = useRef(0)
const [activeTab, setActiveTab] = useState<ConversationTab>('private')
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
const [contactsList, setContactsList] = useState<ContactInfo[]>([])
const [isContactsListLoading, setIsContactsListLoading] = useState(true)
const [, setContactsDataSource] = useState<ContactsDataSource>(null)
const [contactsUpdatedAt, setContactsUpdatedAt] = useState<number | null>(null)
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null)
const [sessionMessageCounts, setSessionMessageCounts] = useState<Record<string, number>>({})
const [isLoadingSessionCounts, setIsLoadingSessionCounts] = useState(false)
const [sessionContentMetrics, setSessionContentMetrics] = useState<Record<string, SessionContentMetric>>({})
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const [contactsLoadSession, setContactsLoadSession] = useState<ContactsLoadSession | null>(null)
const [contactsLoadIssue, setContactsLoadIssue] = useState<ContactsLoadIssue | null>(null)
const [showContactsDiagnostics, setShowContactsDiagnostics] = useState(false)
const [contactsDiagnosticTick, setContactsDiagnosticTick] = useState(Date.now())
const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false)
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
const [isLoadingSessionDetail, setIsLoadingSessionDetail] = useState(false)
const [isLoadingSessionDetailExtra, setIsLoadingSessionDetailExtra] = useState(false)
const [isRefreshingSessionDetailStats, setIsRefreshingSessionDetailStats] = useState(false)
const [isLoadingSessionRelationStats, setIsLoadingSessionRelationStats] = useState(false)
const [copiedDetailField, setCopiedDetailField] = useState<string | null>(null)
const preselectSessionIds = useMemo(() => {
const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null
const rawList = Array.isArray(state?.preselectSessionIds)
? state?.preselectSessionIds
: (typeof state?.preselectSessionId === 'string' ? [state.preselectSessionId] : [])
return rawList
.filter((item): item is string => typeof item === 'string')
.map(item => item.trim())
.filter(Boolean)
}, [location.state])
const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
const [sessionNameWithTypePrefix, setSessionNameWithTypePrefix] = useState(true)
const [snsExportFormat, setSnsExportFormat] = useState<SnsTimelineExportFormat>('html')
const [snsExportImages, setSnsExportImages] = useState(false)
const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false)
const [snsExportVideos, setSnsExportVideos] = useState(false)
const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false)
const [timeRangePreset, setTimeRangePreset] = useState<DateRangePreset>('all')
const [timeRangeDialogDraft, setTimeRangeDialogDraft] = useState<TimeRangeDialogDraft | null>(null)
const [timeRangeDateInput, setTimeRangeDateInput] = useState<{ start: string; end: string }>({ start: '', end: '' })
const [timeRangeDateInputError, setTimeRangeDateInputError] = useState<{ start: boolean; end: boolean }>({ start: false, end: false })
const [options, setOptions] = useState<ExportOptions>({
format: 'excel',
format: 'json',
dateRange: {
start: new Date(new Date().setHours(0, 0, 0, 0)),
end: new Date()
@@ -101,105 +1255,995 @@ function ExportPage() {
exportConcurrency: 2
})
const buildDateRangeFromPreset = (preset: string) => {
const now = new Date()
if (preset === 'all') {
return { useAllTime: true, dateRange: { start: now, end: now } }
}
let rangeMs = 0
if (preset === '7d') rangeMs = 7 * 24 * 60 * 60 * 1000
if (preset === '30d') rangeMs = 30 * 24 * 60 * 60 * 1000
if (preset === '90d') rangeMs = 90 * 24 * 60 * 60 * 1000
if (preset === 'today' || rangeMs === 0) {
const start = new Date(now)
start.setHours(0, 0, 0, 0)
return { useAllTime: false, dateRange: { start, end: now } }
}
const start = new Date(now.getTime() - rangeMs)
start.setHours(0, 0, 0, 0)
return { useAllTime: false, dateRange: { start, end: now } }
}
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
open: false,
scope: 'single',
sessionIds: [],
sessionNames: [],
title: ''
})
const loadSessions = useCallback(async () => {
setIsLoading(true)
try {
const result = await window.electronAPI.chat.connect()
if (!result.success) {
console.error('连接失败:', result.error)
setIsLoading(false)
return
}
const sessionsResult = await window.electronAPI.chat.getSessions()
if (sessionsResult.success && sessionsResult.sessions) {
setSessions(sessionsResult.sessions)
setFilteredSessions(sessionsResult.sessions)
}
} catch (e) {
console.error('加载会话失败:', e)
} finally {
setIsLoading(false)
const [tasks, setTasks] = useState<ExportTask[]>([])
const [lastExportBySession, setLastExportBySession] = useState<Record<string, number>>({})
const [lastExportByContent, setLastExportByContent] = useState<Record<string, number>>({})
const [exportRecordsBySession, setExportRecordsBySession] = useState<Record<string, configService.ExportSessionRecordEntry[]>>({})
const [lastSnsExportPostCount, setLastSnsExportPostCount] = useState(0)
const [snsStats, setSnsStats] = useState<{ totalPosts: number; totalFriends: number }>({
totalPosts: 0,
totalFriends: 0
})
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
const [nowTick, setNowTick] = useState(Date.now())
const [isContactsListAtTop, setIsContactsListAtTop] = useState(true)
const tabCounts = useContactTypeCountsStore(state => state.tabCounts)
const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading)
const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady)
const ensureSharedTabCountsLoaded = useContactTypeCountsStore(state => state.ensureLoaded)
const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts)
const progressUnsubscribeRef = useRef<(() => void) | null>(null)
const runningTaskIdRef = useRef<string | null>(null)
const tasksRef = useRef<ExportTask[]>([])
const hasSeededSnsStatsRef = useRef(false)
const sessionLoadTokenRef = useRef(0)
const preselectAppliedRef = useRef(false)
const exportCacheScopeRef = useRef('default')
const exportCacheScopeReadyRef = useRef(false)
const contactsLoadVersionRef = useRef(0)
const contactsLoadAttemptRef = useRef(0)
const contactsLoadTimeoutTimerRef = useRef<number | null>(null)
const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
const contactsVirtuosoRef = useRef<VirtuosoHandle | null>(null)
const sessionTableSectionRef = useRef<HTMLDivElement | null>(null)
const detailRequestSeqRef = useRef(0)
const sessionsRef = useRef<SessionRow[]>([])
const contactsListSizeRef = useRef(0)
const contactsUpdatedAtRef = useRef<number | null>(null)
const sessionsHydratedAtRef = useRef(0)
const snsStatsHydratedAtRef = useRef(0)
const inProgressSessionIdsRef = useRef<string[]>([])
const activeTaskCountRef = useRef(0)
const hasBaseConfigReadyRef = useRef(false)
const sessionCountRequestIdRef = useRef(0)
const activeTabRef = useRef<ConversationTab>('private')
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
if (exportCacheScopeReadyRef.current) {
return exportCacheScopeRef.current
}
const [myWxid, dbPath] = await Promise.all([
configService.getMyWxid(),
configService.getDbPath()
])
const scopeKey = dbPath || myWxid
? `${dbPath || ''}::${myWxid || ''}`
: 'default'
exportCacheScopeRef.current = scopeKey
exportCacheScopeReadyRef.current = true
return scopeKey
}, [])
const loadContactsCaches = useCallback(async (scopeKey: string) => {
const [contactsItem, avatarItem] = await Promise.all([
configService.getContactsListCache(scopeKey),
configService.getContactsAvatarCache(scopeKey)
])
return {
contactsItem,
avatarItem
}
}, [])
const loadExportPath = useCallback(async () => {
useEffect(() => {
let cancelled = false
void (async () => {
try {
const value = await configService.getContactsLoadTimeoutMs()
if (!cancelled) {
setContactsLoadTimeoutMs(value)
}
} catch (error) {
console.error('读取通讯录超时配置失败:', error)
}
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
}, [contactsLoadTimeoutMs])
const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => {
const scopeKey = options?.scopeKey || await ensureExportCacheScope()
const loadVersion = contactsLoadVersionRef.current + 1
contactsLoadVersionRef.current = loadVersion
contactsLoadAttemptRef.current += 1
const startedAt = Date.now()
const timeoutMs = contactsLoadTimeoutMsRef.current
const requestId = `export-contacts-${startedAt}-${contactsLoadAttemptRef.current}`
setContactsLoadSession({
requestId,
startedAt,
attempt: contactsLoadAttemptRef.current,
timeoutMs
})
setContactsLoadIssue(null)
setShowContactsDiagnostics(false)
if (contactsLoadTimeoutTimerRef.current) {
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
contactsLoadTimeoutTimerRef.current = null
}
const timeoutTimerId = window.setTimeout(() => {
if (contactsLoadVersionRef.current !== loadVersion) return
const elapsedMs = Date.now() - startedAt
setContactsLoadIssue({
kind: 'timeout',
title: '联系人列表加载超时',
message: `等待超过 ${timeoutMs}ms,联系人列表仍未返回。`,
reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。',
occurredAt: Date.now(),
elapsedMs
})
}, timeoutMs)
contactsLoadTimeoutTimerRef.current = timeoutTimerId
setIsContactsListLoading(true)
try {
const savedPath = await configService.getExportPath()
const contactsResult = await window.electronAPI.chat.getContacts()
if (contactsLoadVersionRef.current !== loadVersion) return
if (contactsResult.success && contactsResult.contacts) {
if (contactsLoadTimeoutTimerRef.current === timeoutTimerId) {
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
contactsLoadTimeoutTimerRef.current = null
}
const contactsWithAvatarCache = mergeAvatarCacheIntoContacts(
contactsResult.contacts,
contactsAvatarCacheRef.current
)
setContactsList(contactsWithAvatarCache)
syncContactTypeCounts(contactsWithAvatarCache)
setContactsDataSource('network')
setContactsUpdatedAt(Date.now())
setContactsLoadIssue(null)
setIsContactsListLoading(false)
const upsertResult = upsertAvatarCacheFromContacts(
contactsAvatarCacheRef.current,
contactsWithAvatarCache,
{ prune: true }
)
contactsAvatarCacheRef.current = upsertResult.avatarEntries
if (upsertResult.updatedAt) {
setAvatarCacheUpdatedAt(upsertResult.updatedAt)
}
void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch((error) => {
console.error('写入导出页头像缓存失败:', error)
})
void configService.setContactsListCache(
scopeKey,
contactsWithAvatarCache.map(contact => ({
username: contact.username,
displayName: contact.displayName,
remark: contact.remark,
nickname: contact.nickname,
type: contact.type
}))
).catch((error) => {
console.error('写入导出页通讯录缓存失败:', error)
})
return
}
const elapsedMs = Date.now() - startedAt
setContactsLoadIssue({
kind: 'error',
title: '联系人列表加载失败',
message: '联系人接口返回失败,未拿到联系人列表。',
reason: 'chat.getContacts 返回 success=false。',
errorDetail: contactsResult.error || '未知错误',
occurredAt: Date.now(),
elapsedMs
})
} catch (error) {
console.error('加载导出页联系人失败:', error)
const elapsedMs = Date.now() - startedAt
setContactsLoadIssue({
kind: 'error',
title: '联系人列表加载失败',
message: '联系人请求执行异常。',
reason: '调用 chat.getContacts 发生异常。',
errorDetail: String(error),
occurredAt: Date.now(),
elapsedMs
})
} finally {
if (contactsLoadTimeoutTimerRef.current === timeoutTimerId) {
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
contactsLoadTimeoutTimerRef.current = null
}
if (contactsLoadVersionRef.current === loadVersion) {
setIsContactsListLoading(false)
}
}
}, [ensureExportCacheScope, syncContactTypeCounts])
useEffect(() => {
if (!isExportRoute) return
let cancelled = false
void (async () => {
const scopeKey = await ensureExportCacheScope()
if (cancelled) return
let cachedContactsCount = 0
let cachedContactsUpdatedAt = 0
try {
const [cacheItem, avatarCacheItem] = await Promise.all([
configService.getContactsListCache(scopeKey),
configService.getContactsAvatarCache(scopeKey)
])
cachedContactsCount = Array.isArray(cacheItem?.contacts) ? cacheItem.contacts.length : 0
cachedContactsUpdatedAt = Number(cacheItem?.updatedAt || 0)
const avatarCacheMap = avatarCacheItem?.avatars || {}
contactsAvatarCacheRef.current = avatarCacheMap
setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null)
if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) {
const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({
...contact,
avatarUrl: avatarCacheMap[contact.username]?.avatarUrl
}))
setContactsList(cachedContacts)
syncContactTypeCounts(cachedContacts)
setContactsDataSource('cache')
setContactsUpdatedAt(cacheItem.updatedAt || null)
setIsContactsListLoading(false)
}
} catch (error) {
console.error('读取导出页联系人缓存失败:', error)
}
const latestContactsUpdatedAt = Math.max(
Number(contactsUpdatedAtRef.current || 0),
cachedContactsUpdatedAt
)
const hasFreshContactSnapshot = (contactsListSizeRef.current > 0 || cachedContactsCount > 0) &&
latestContactsUpdatedAt > 0 &&
Date.now() - latestContactsUpdatedAt <= EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS
if (!cancelled && !hasFreshContactSnapshot) {
void loadContactsList({ scopeKey })
}
})()
return () => {
cancelled = true
}
}, [isExportRoute, ensureExportCacheScope, loadContactsList, syncContactTypeCounts])
useEffect(() => {
if (isExportRoute) return
contactsLoadVersionRef.current += 1
}, [isExportRoute])
useEffect(() => {
if (contactsLoadTimeoutTimerRef.current) {
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
contactsLoadTimeoutTimerRef.current = null
}
return () => {
if (contactsLoadTimeoutTimerRef.current) {
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
contactsLoadTimeoutTimerRef.current = null
}
}
}, [])
useEffect(() => {
if (!contactsLoadIssue || contactsList.length > 0) return
if (!(isContactsListLoading && contactsLoadIssue.kind === 'timeout')) return
const timer = window.setInterval(() => {
setContactsDiagnosticTick(Date.now())
}, 500)
return () => window.clearInterval(timer)
}, [contactsList.length, isContactsListLoading, contactsLoadIssue])
useEffect(() => {
tasksRef.current = tasks
}, [tasks])
useEffect(() => {
sessionsRef.current = sessions
}, [sessions])
useEffect(() => {
contactsListSizeRef.current = contactsList.length
}, [contactsList.length])
useEffect(() => {
contactsUpdatedAtRef.current = contactsUpdatedAt
}, [contactsUpdatedAt])
useEffect(() => {
if (!expandedPerfTaskId) return
const target = tasks.find(task => task.id === expandedPerfTaskId)
if (!target || !isTextBatchTask(target)) {
setExpandedPerfTaskId(null)
}
}, [tasks, expandedPerfTaskId])
useEffect(() => {
hasSeededSnsStatsRef.current = hasSeededSnsStats
}, [hasSeededSnsStats])
const preselectSessionIds = useMemo(() => {
const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null
const rawList = Array.isArray(state?.preselectSessionIds)
? state?.preselectSessionIds
: (typeof state?.preselectSessionId === 'string' ? [state.preselectSessionId] : [])
return rawList
.filter((item): item is string => typeof item === 'string')
.map(item => item.trim())
.filter(Boolean)
}, [location.state])
useEffect(() => {
if (!isExportRoute) return
const timer = setInterval(() => setNowTick(Date.now()), 60 * 1000)
return () => clearInterval(timer)
}, [isExportRoute])
useEffect(() => {
if (!isTaskCenterOpen || !expandedPerfTaskId) return
const target = tasks.find(task => task.id === expandedPerfTaskId)
if (!target || target.status !== 'running' || !isTextBatchTask(target)) return
const timer = window.setInterval(() => setNowTick(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [isTaskCenterOpen, expandedPerfTaskId, tasks])
const loadBaseConfig = useCallback(async (): Promise<boolean> => {
setIsBaseConfigLoading(true)
let isReady = true
try {
const [savedPath, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, exportCacheScope] = await Promise.all([
configService.getExportPath(),
configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency(),
configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap(),
configService.getExportSessionRecordMap(),
configService.getExportLastSnsPostCount(),
configService.getExportWriteLayout(),
configService.getExportSessionNamePrefixEnabled(),
ensureExportCacheScope()
])
const cachedSnsStats = await configService.getExportSnsStatsCache(exportCacheScope)
if (savedPath) {
setExportFolder(savedPath)
} else {
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
setExportFolder(downloadsPath)
}
} catch (e) {
console.error('加载导出路径失败:', e)
}
}, [])
const loadExportDefaults = useCallback(async () => {
try {
const [
savedFormat,
savedRange,
savedMedia,
savedVoiceAsText,
savedExcelCompactColumns,
savedTxtColumns,
savedConcurrency
] = await Promise.all([
configService.getExportDefaultFormat(),
configService.getExportDefaultDateRange(),
configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency()
])
setWriteLayout(savedWriteLayout)
setSessionNameWithTypePrefix(savedSessionNameWithTypePrefix)
setLastExportBySession(savedSessionMap)
setLastExportByContent(savedContentMap)
setExportRecordsBySession(savedSessionRecordMap)
setLastSnsExportPostCount(savedSnsPostCount)
await configService.setExportDefaultFormat('json')
if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) {
setSnsStats({
totalPosts: cachedSnsStats.totalPosts || 0,
totalFriends: cachedSnsStats.totalFriends || 0
})
snsStatsHydratedAtRef.current = Date.now()
hasSeededSnsStatsRef.current = true
setHasSeededSnsStats(true)
}
const preset = savedRange || 'today'
const rangeDefaults = buildDateRangeFromPreset(preset)
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
setOptions((prev) => ({
setOptions(prev => ({
...prev,
format: (savedFormat as ExportOptions['format']) || 'excel',
useAllTime: rangeDefaults.useAllTime,
dateRange: rangeDefaults.dateRange,
exportMedia: savedMedia ?? false,
exportVoiceAsText: savedVoiceAsText ?? false,
excelCompactColumns: savedExcelCompactColumns ?? true,
format: 'json',
exportMedia: savedMedia ?? prev.exportMedia,
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
txtColumns,
exportConcurrency: savedConcurrency ?? 2
exportConcurrency: savedConcurrency ?? prev.exportConcurrency
}))
} catch (e) {
console.error('加载导出默认设置失败:', e)
} catch (error) {
isReady = false
console.error('加载导出配置失败:', error)
} finally {
setIsBaseConfigLoading(false)
}
if (isReady) {
hasBaseConfigReadyRef.current = true
}
return isReady
}, [ensureExportCacheScope])
const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => {
if (!options?.silent) {
setIsSnsStatsLoading(true)
}
const applyStats = async (next: { totalPosts: number; totalFriends: number } | null) => {
if (!next) return
const normalized = {
totalPosts: Number.isFinite(next.totalPosts) ? Math.max(0, Math.floor(next.totalPosts)) : 0,
totalFriends: Number.isFinite(next.totalFriends) ? Math.max(0, Math.floor(next.totalFriends)) : 0
}
setSnsStats(normalized)
snsStatsHydratedAtRef.current = Date.now()
hasSeededSnsStatsRef.current = true
setHasSeededSnsStats(true)
if (exportCacheScopeReadyRef.current) {
await configService.setExportSnsStatsCache(exportCacheScopeRef.current, normalized)
}
}
try {
const fastResult = await withTimeout(window.electronAPI.sns.getExportStatsFast(), 2200)
if (fastResult?.success && fastResult.data) {
const fastStats = {
totalPosts: fastResult.data.totalPosts || 0,
totalFriends: fastResult.data.totalFriends || 0
}
if (fastStats.totalPosts > 0 || hasSeededSnsStatsRef.current) {
await applyStats(fastStats)
}
}
if (options?.full) {
const result = await withTimeout(window.electronAPI.sns.getExportStats(), 9000)
if (result?.success && result.data) {
await applyStats({
totalPosts: result.data.totalPosts || 0,
totalFriends: result.data.totalFriends || 0
})
}
}
} catch (error) {
console.error('加载朋友圈导出统计失败:', error)
} finally {
if (!options?.silent) {
setIsSnsStatsLoading(false)
}
}
}, [])
const mergeSessionContentMetrics = useCallback((input: Record<string, SessionExportMetric | SessionContentMetric | undefined>) => {
const entries = Object.entries(input)
if (entries.length === 0) return
const nextMessageCounts: Record<string, number> = {}
const nextMetrics: Record<string, SessionContentMetric> = {}
for (const [sessionIdRaw, metricRaw] of entries) {
const sessionId = String(sessionIdRaw || '').trim()
if (!sessionId || !metricRaw) continue
const totalMessages = normalizeMessageCount(metricRaw.totalMessages)
const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages)
const imageMessages = normalizeMessageCount(metricRaw.imageMessages)
const videoMessages = normalizeMessageCount(metricRaw.videoMessages)
const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages)
const transferMessages = normalizeMessageCount(metricRaw.transferMessages)
const redPacketMessages = normalizeMessageCount(metricRaw.redPacketMessages)
const callMessages = normalizeMessageCount(metricRaw.callMessages)
if (
typeof totalMessages !== 'number' &&
typeof voiceMessages !== 'number' &&
typeof imageMessages !== 'number' &&
typeof videoMessages !== 'number' &&
typeof emojiMessages !== 'number' &&
typeof transferMessages !== 'number' &&
typeof redPacketMessages !== 'number' &&
typeof callMessages !== 'number'
) {
continue
}
nextMetrics[sessionId] = {
totalMessages,
voiceMessages,
imageMessages,
videoMessages,
emojiMessages,
transferMessages,
redPacketMessages,
callMessages
}
if (typeof totalMessages === 'number') {
nextMessageCounts[sessionId] = totalMessages
}
}
if (Object.keys(nextMessageCounts).length > 0) {
setSessionMessageCounts(prev => {
let changed = false
const merged = { ...prev }
for (const [sessionId, count] of Object.entries(nextMessageCounts)) {
if (merged[sessionId] === count) continue
merged[sessionId] = count
changed = true
}
return changed ? merged : prev
})
}
if (Object.keys(nextMetrics).length > 0) {
setSessionContentMetrics(prev => {
let changed = false
const merged = { ...prev }
for (const [sessionId, metric] of Object.entries(nextMetrics)) {
const previous = merged[sessionId] || {}
const nextMetric: SessionContentMetric = {
totalMessages: typeof metric.totalMessages === 'number' ? metric.totalMessages : previous.totalMessages,
voiceMessages: typeof metric.voiceMessages === 'number' ? metric.voiceMessages : previous.voiceMessages,
imageMessages: typeof metric.imageMessages === 'number' ? metric.imageMessages : previous.imageMessages,
videoMessages: typeof metric.videoMessages === 'number' ? metric.videoMessages : previous.videoMessages,
emojiMessages: typeof metric.emojiMessages === 'number' ? metric.emojiMessages : previous.emojiMessages,
transferMessages: typeof metric.transferMessages === 'number' ? metric.transferMessages : previous.transferMessages,
redPacketMessages: typeof metric.redPacketMessages === 'number' ? metric.redPacketMessages : previous.redPacketMessages,
callMessages: typeof metric.callMessages === 'number' ? metric.callMessages : previous.callMessages
}
if (
previous.totalMessages === nextMetric.totalMessages &&
previous.voiceMessages === nextMetric.voiceMessages &&
previous.imageMessages === nextMetric.imageMessages &&
previous.videoMessages === nextMetric.videoMessages &&
previous.emojiMessages === nextMetric.emojiMessages &&
previous.transferMessages === nextMetric.transferMessages &&
previous.redPacketMessages === nextMetric.redPacketMessages &&
previous.callMessages === nextMetric.callMessages
) {
continue
}
merged[sessionId] = nextMetric
changed = true
}
return changed ? merged : prev
})
}
}, [])
const loadSessionMessageCounts = useCallback(async (
sourceSessions: SessionRow[],
priorityTab: ConversationTab,
options?: {
scopeKey?: string
seededCounts?: Record<string, number>
}
): Promise<Record<string, number>> => {
const requestId = sessionCountRequestIdRef.current + 1
sessionCountRequestIdRef.current = requestId
const isStale = () => sessionCountRequestIdRef.current !== requestId
const exportableSessions = sourceSessions.filter(session => session.hasSession)
const seededHintCounts = exportableSessions.reduce<Record<string, number>>((acc, session) => {
const nextCount = normalizeMessageCount(session.messageCountHint)
if (typeof nextCount === 'number') {
acc[session.username] = nextCount
}
return acc
}, {})
const seededPersistentCounts = Object.entries(options?.seededCounts || {}).reduce<Record<string, number>>((acc, [sessionId, countRaw]) => {
const nextCount = normalizeMessageCount(countRaw)
if (typeof nextCount === 'number') {
acc[sessionId] = nextCount
}
return acc
}, {})
const seededCounts = { ...seededHintCounts, ...seededPersistentCounts }
const accumulatedCounts: Record<string, number> = { ...seededCounts }
setSessionMessageCounts(seededCounts)
if (Object.keys(seededCounts).length > 0) {
mergeSessionContentMetrics(
Object.entries(seededCounts).reduce<Record<string, SessionContentMetric>>((acc, [sessionId, count]) => {
acc[sessionId] = { totalMessages: count }
return acc
}, {})
)
}
if (exportableSessions.length === 0) {
setIsLoadingSessionCounts(false)
return { ...accumulatedCounts }
}
const prioritizedSessionIds = exportableSessions
.filter(session => session.kind === priorityTab)
.map(session => session.username)
const prioritizedSet = new Set(prioritizedSessionIds)
const remainingSessionIds = exportableSessions
.filter(session => !prioritizedSet.has(session.username))
.map(session => session.username)
const applyCounts = (input: Record<string, number> | undefined) => {
if (!input || isStale()) return
const normalized = Object.entries(input).reduce<Record<string, number>>((acc, [sessionId, count]) => {
const nextCount = normalizeMessageCount(count)
if (typeof nextCount === 'number') {
acc[sessionId] = nextCount
}
return acc
}, {})
if (Object.keys(normalized).length === 0) return
for (const [sessionId, count] of Object.entries(normalized)) {
accumulatedCounts[sessionId] = count
}
setSessionMessageCounts(prev => ({ ...prev, ...normalized }))
mergeSessionContentMetrics(
Object.entries(normalized).reduce<Record<string, SessionContentMetric>>((acc, [sessionId, count]) => {
acc[sessionId] = { totalMessages: count }
return acc
}, {})
)
}
setIsLoadingSessionCounts(true)
try {
if (prioritizedSessionIds.length > 0) {
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds)
if (isStale()) return { ...accumulatedCounts }
if (priorityResult.success) {
applyCounts(priorityResult.counts)
}
}
if (remainingSessionIds.length > 0) {
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds)
if (isStale()) return { ...accumulatedCounts }
if (remainingResult.success) {
applyCounts(remainingResult.counts)
}
}
} catch (error) {
console.error('导出页加载会话消息总数失败:', error)
} finally {
if (!isStale()) {
setIsLoadingSessionCounts(false)
if (options?.scopeKey && Object.keys(accumulatedCounts).length > 0) {
try {
await configService.setExportSessionMessageCountCache(options.scopeKey, accumulatedCounts)
} catch (cacheError) {
console.error('写入导出页会话总消息缓存失败:', cacheError)
}
}
}
}
return { ...accumulatedCounts }
}, [mergeSessionContentMetrics])
const loadSessions = useCallback(async () => {
const loadToken = Date.now()
sessionLoadTokenRef.current = loadToken
sessionsHydratedAtRef.current = 0
setIsLoading(true)
setIsSessionEnriching(false)
sessionCountRequestIdRef.current += 1
setSessionMessageCounts({})
setSessionContentMetrics({})
setIsLoadingSessionCounts(false)
const isStale = () => sessionLoadTokenRef.current !== loadToken
try {
const scopeKey = await ensureExportCacheScope()
if (isStale()) return
const [
cachedContactsPayload,
cachedMessageCountsPayload
] = await Promise.all([
loadContactsCaches(scopeKey),
configService.getExportSessionMessageCountCache(scopeKey)
])
if (isStale()) return
const {
contactsItem: cachedContactsItem,
avatarItem: cachedAvatarItem
} = cachedContactsPayload
const cachedContacts = cachedContactsItem?.contacts || []
const cachedAvatarEntries = cachedAvatarItem?.avatars || {}
const cachedContactMap = toContactMapFromCaches(cachedContacts, cachedAvatarEntries)
if (cachedContacts.length > 0) {
syncContactTypeCounts(Object.values(cachedContactMap))
setSessions(toSessionRowsWithContacts([], cachedContactMap))
setSessionDataSource('cache')
setIsLoading(false)
}
setSessionContactsUpdatedAt(cachedContactsItem?.updatedAt || null)
setSessionAvatarUpdatedAt(cachedAvatarItem?.updatedAt || null)
const connectResult = await window.electronAPI.chat.connect()
if (!connectResult.success) {
console.error('连接失败:', connectResult.error)
if (!isStale()) setIsLoading(false)
return
}
const sessionsResult = await window.electronAPI.chat.getSessions()
if (isStale()) return
if (sessionsResult.success && sessionsResult.sessions) {
const rawSessions = sessionsResult.sessions
const baseSessions = toSessionRowsWithContacts(rawSessions, cachedContactMap)
const exportableSessionIds = baseSessions
.filter((session) => session.hasSession)
.map((session) => session.username)
const exportableSessionIdSet = new Set(exportableSessionIds)
const cachedMessageCounts = Object.entries(cachedMessageCountsPayload?.counts || {}).reduce<Record<string, number>>((acc, [sessionId, countRaw]) => {
if (!exportableSessionIdSet.has(sessionId)) return acc
const nextCount = normalizeMessageCount(countRaw)
if (typeof nextCount === 'number') {
acc[sessionId] = nextCount
}
return acc
}, {})
const cachedCountAsMetrics = Object.entries(cachedMessageCounts).reduce<Record<string, SessionContentMetric>>((acc, [sessionId, count]) => {
acc[sessionId] = { totalMessages: count }
return acc
}, {})
if (isStale()) return
if (Object.keys(cachedMessageCounts).length > 0) {
setSessionMessageCounts(cachedMessageCounts)
}
if (Object.keys(cachedCountAsMetrics).length > 0) {
mergeSessionContentMetrics(cachedCountAsMetrics)
}
setSessions(baseSessions)
sessionsHydratedAtRef.current = Date.now()
void (async () => {
await loadSessionMessageCounts(baseSessions, activeTabRef.current, {
scopeKey,
seededCounts: cachedMessageCounts
})
if (isStale()) return
})()
setSessionDataSource(cachedContacts.length > 0 ? 'cache' : 'network')
if (cachedContacts.length === 0) {
setSessionContactsUpdatedAt(Date.now())
}
setIsLoading(false)
// 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。
setIsSessionEnriching(true)
void (async () => {
try {
let contactMap = { ...cachedContactMap }
let avatarEntries = { ...cachedAvatarEntries }
let hasFreshNetworkData = false
let hasNetworkContactsSnapshot = false
if (isStale()) return
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS)
if (isStale()) return
const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : []
if (contactsFromNetwork.length > 0) {
hasFreshNetworkData = true
hasNetworkContactsSnapshot = true
const contactsWithCachedAvatar = mergeAvatarCacheIntoContacts(contactsFromNetwork, avatarEntries)
const nextContactMap = contactsWithCachedAvatar.reduce<Record<string, ContactInfo>>((map, contact) => {
map[contact.username] = contact
return map
}, {})
for (const [username, cachedContact] of Object.entries(cachedContactMap)) {
if (!nextContactMap[username]) {
nextContactMap[username] = cachedContact
}
}
contactMap = nextContactMap
syncContactTypeCounts(Object.values(contactMap))
const refreshAt = Date.now()
setSessionContactsUpdatedAt(refreshAt)
const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, Object.values(contactMap), {
prune: true,
now: refreshAt
})
avatarEntries = upsertResult.avatarEntries
if (upsertResult.updatedAt) {
setSessionAvatarUpdatedAt(upsertResult.updatedAt)
}
}
const sourceContacts = Object.values(contactMap)
const sourceByUsername = new Map<string, ContactInfo>()
for (const contact of sourceContacts) {
if (!contact?.username) continue
sourceByUsername.set(contact.username, contact)
}
const rawSessionMap = rawSessions.reduce<Record<string, AppChatSession>>((map, session) => {
map[session.username] = session
return map
}, {})
const candidateUsernames = sourceContacts.length > 0
? sourceContacts.map(contact => contact.username)
: baseSessions.map(session => session.username)
const needsEnrichment = candidateUsernames
.filter(Boolean)
.filter((username) => {
const currentContact = sourceByUsername.get(username)
const session = rawSessionMap[username]
const currentAvatarUrl = currentContact?.avatarUrl || session?.avatarUrl
return !currentAvatarUrl
})
let extraContactMap: Record<string, { displayName?: string; avatarUrl?: string }> = {}
if (needsEnrichment.length > 0) {
for (let i = 0; i < needsEnrichment.length; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) {
if (isStale()) return
const batch = needsEnrichment.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE)
if (batch.length === 0) continue
try {
const enrichResult = await withTimeout(
window.electronAPI.chat.enrichSessionsContactInfo(batch, {
skipDisplayName: true,
onlyMissingAvatar: true
}),
CONTACT_ENRICH_TIMEOUT_MS
)
if (isStale()) return
if (enrichResult?.success && enrichResult.contacts) {
extraContactMap = {
...extraContactMap,
...enrichResult.contacts
}
hasFreshNetworkData = true
for (const [username, enriched] of Object.entries(enrichResult.contacts)) {
const current = sourceByUsername.get(username)
if (!current) continue
sourceByUsername.set(username, {
...current,
displayName: enriched.displayName || current.displayName,
avatarUrl: enriched.avatarUrl || current.avatarUrl
})
}
}
} catch (batchError) {
console.error('导出页分批补充会话联系人信息失败:', batchError)
}
const batchContacts = batch
.map(username => sourceByUsername.get(username))
.filter((contact): contact is ContactInfo => Boolean(contact))
const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, batchContacts, {
markCheckedUsernames: batch
})
avatarEntries = upsertResult.avatarEntries
if (upsertResult.updatedAt) {
setSessionAvatarUpdatedAt(upsertResult.updatedAt)
}
await new Promise(resolve => setTimeout(resolve, 0))
}
}
const contactsForPersist = Array.from(sourceByUsername.values())
if (hasNetworkContactsSnapshot && contactsForPersist.length > 0) {
const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, contactsForPersist, {
prune: true
})
avatarEntries = upsertResult.avatarEntries
if (upsertResult.updatedAt) {
setSessionAvatarUpdatedAt(upsertResult.updatedAt)
}
}
contactMap = contactsForPersist.reduce<Record<string, ContactInfo>>((map, contact) => {
map[contact.username] = contact
return map
}, contactMap)
if (isStale()) return
const nextSessions = toSessionRowsWithContacts(rawSessions, contactMap)
.map((session) => {
const extra = extraContactMap[session.username]
const displayName = extra?.displayName || session.displayName || session.username
const avatarUrl = extra?.avatarUrl || session.avatarUrl || avatarEntries[session.username]?.avatarUrl
if (displayName === session.displayName && avatarUrl === session.avatarUrl) {
return session
}
return {
...session,
displayName,
avatarUrl
}
})
.sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0))
const contactsCachePayload = Object.values(contactMap).map((contact) => ({
username: contact.username,
displayName: contact.displayName || contact.username,
remark: contact.remark,
nickname: contact.nickname,
type: contact.type
}))
const persistAt = Date.now()
setSessions(nextSessions)
sessionsHydratedAtRef.current = persistAt
if (hasNetworkContactsSnapshot && contactsCachePayload.length > 0) {
await configService.setContactsListCache(scopeKey, contactsCachePayload)
setSessionContactsUpdatedAt(persistAt)
}
if (Object.keys(avatarEntries).length > 0) {
await configService.setContactsAvatarCache(scopeKey, avatarEntries)
setSessionAvatarUpdatedAt(persistAt)
}
if (hasFreshNetworkData) {
setSessionDataSource('network')
}
} catch (enrichError) {
console.error('导出页补充会话联系人信息失败:', enrichError)
} finally {
if (!isStale()) setIsSessionEnriching(false)
}
})()
} else {
setIsLoading(false)
}
} catch (error) {
console.error('加载会话失败:', error)
if (!isStale()) setIsLoading(false)
} finally {
if (!isStale()) setIsLoading(false)
}
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, syncContactTypeCounts])
useEffect(() => {
loadSessions()
loadExportPath()
loadExportDefaults()
}, [loadSessions, loadExportPath, loadExportDefaults])
if (!isExportRoute) return
const now = Date.now()
const hasFreshSessionSnapshot = hasBaseConfigReadyRef.current &&
sessionsRef.current.length > 0 &&
now - sessionsHydratedAtRef.current <= EXPORT_REENTER_SESSION_SOFT_REFRESH_MS
const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current &&
now - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS
void loadBaseConfig()
void ensureSharedTabCountsLoaded()
if (!hasFreshSessionSnapshot) {
void loadSessions()
}
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
const timer = window.setTimeout(() => {
if (!hasFreshSnsSnapshot) {
void loadSnsStats({ full: true })
}
}, 120)
return () => window.clearTimeout(timer)
}, [isExportRoute, ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats])
useEffect(() => {
if (isExportRoute) return
// 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。
sessionLoadTokenRef.current = Date.now()
sessionCountRequestIdRef.current += 1
setIsSessionEnriching(false)
setIsLoadingSessionCounts(false)
}, [isExportRoute])
useEffect(() => {
activeTabRef.current = activeTab
}, [activeTab])
useEffect(() => {
preselectAppliedRef.current = false
@@ -215,994 +2259,2586 @@ function ExportPage() {
if (matched.length > 0) {
setSelectedSessions(new Set(matched))
setSearchKeyword('')
}
}, [sessions, preselectSessionIds])
useEffect(() => {
const handleChange = () => {
setSelectedSessions(new Set())
setSearchKeyword('')
setExportResult(null)
setSessions([])
setFilteredSessions([])
loadSessions()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadSessions])
const selectedCount = selectedSessions.size
useEffect(() => {
const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string; phaseProgress?: number; phaseTotal?: number; phaseLabel?: string }) => {
setExportProgress({
current: payload.current,
total: payload.total,
currentName: payload.currentSession,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0
})
})
return () => {
removeListener?.()
}
}, [])
// 导出计时器
useEffect(() => {
if (!isExporting) return
const timer = setInterval(() => {
setElapsedSeconds(Math.floor((Date.now() - exportStartTime.current) / 1000))
}, 1000)
return () => clearInterval(timer)
}, [isExporting])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) {
setShowDisplayNameSelect(false)
const toggleSelectSession = (sessionId: string) => {
const target = sessions.find(session => session.username === sessionId)
if (!target?.hasSession) return
setSelectedSessions(prev => {
const next = new Set(prev)
if (next.has(sessionId)) {
next.delete(sessionId)
} else {
next.add(sessionId)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showDisplayNameSelect])
useEffect(() => {
if (!searchKeyword.trim()) {
setFilteredSessions(sessions)
return
}
const lower = searchKeyword.toLowerCase()
setFilteredSessions(sessions.filter(s =>
s.displayName?.toLowerCase().includes(lower) ||
s.username.toLowerCase().includes(lower)
))
}, [searchKeyword, sessions])
const toggleSession = (username: string) => {
const newSet = new Set(selectedSessions)
if (newSet.has(username)) {
newSet.delete(username)
} else {
newSet.add(username)
}
setSelectedSessions(newSet)
return next
})
}
const toggleSelectAll = () => {
if (selectedSessions.size === filteredSessions.length) {
setSelectedSessions(new Set())
} else {
setSelectedSessions(new Set(filteredSessions.map(s => s.username)))
}
}
const toggleSelectAllVisible = () => {
const visibleIds = filteredContacts
.filter(contact => sessionRowByUsername.get(contact.username)?.hasSession)
.map(contact => contact.username)
if (visibleIds.length === 0) return
const getAvatarLetter = (name: string) => {
if (!name) return '?'
return [...name][0] || '?'
}
const formatDate = (date: Date) => {
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
}
const handleFormatChange = (format: ExportOptions['format']) => {
setOptions((prev) => {
const next = { ...prev, format }
if (format === 'html') {
return {
...next,
exportMedia: true,
exportImages: true,
exportVoices: true,
exportVideos: true,
exportEmojis: true
setSelectedSessions(prev => {
const next = new Set(prev)
const allSelected = visibleIds.every(id => next.has(id))
if (allSelected) {
for (const id of visibleIds) {
next.delete(id)
}
} else {
for (const id of visibleIds) {
next.add(id)
}
}
return next
})
}
const openExportFolder = async () => {
if (exportFolder) {
await window.electronAPI.shell.openPath(exportFolder)
const clearSelection = () => setSelectedSessions(new Set())
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open'>) => {
setExportDialog({ open: true, ...payload })
setIsTimeRangeDialogOpen(false)
setTimeRangePreset('all')
setOptions(prev => {
const nextDateRange = prev.dateRange ?? createDefaultDateRange()
const next: ExportOptions = {
...prev,
useAllTime: true,
dateRange: nextDateRange
}
if (payload.scope === 'sns') {
return next
}
if (payload.scope === 'content' && payload.contentType) {
if (payload.contentType === 'text') {
next.exportMedia = false
next.exportImages = false
next.exportVoices = false
next.exportVideos = false
next.exportEmojis = false
next.exportAvatars = true
} else {
next.exportMedia = true
next.exportImages = payload.contentType === 'image'
next.exportVoices = payload.contentType === 'voice'
next.exportVideos = payload.contentType === 'video'
next.exportEmojis = payload.contentType === 'emoji'
next.exportVoiceAsText = false
}
}
return next
})
}, [])
const closeExportDialog = useCallback(() => {
setExportDialog(prev => ({ ...prev, open: false }))
setIsTimeRangeDialogOpen(false)
setTimeRangeDialogDraft(null)
setTimeRangeDateInput({ start: '', end: '' })
setTimeRangeDateInputError({ start: false, end: false })
}, [])
const buildTimeRangeDialogDraft = useCallback((): TimeRangeDialogDraft => {
const dateRange = options.dateRange ?? createDefaultDateRange()
return {
preset: timeRangePreset,
useAllTime: options.useAllTime,
dateRange: {
start: new Date(dateRange.start),
end: new Date(dateRange.end)
},
startPanelMonth: toMonthStart(dateRange.start),
endPanelMonth: toMonthStart(dateRange.end)
}
}, [options.dateRange, options.useAllTime, timeRangePreset])
const openTimeRangeDialog = useCallback(() => {
const draft = buildTimeRangeDialogDraft()
setTimeRangeDialogDraft(draft)
setIsTimeRangeDialogOpen(true)
}, [buildTimeRangeDialogDraft])
const closeTimeRangeDialog = useCallback(() => {
setIsTimeRangeDialogOpen(false)
setTimeRangeDialogDraft(null)
setTimeRangeDateInput({ start: '', end: '' })
setTimeRangeDateInputError({ start: false, end: false })
}, [])
const applyTimeRangePresetToDraft = useCallback((preset: Exclude<DateRangePreset, 'custom'>) => {
setTimeRangeDialogDraft(prev => {
const base = prev ?? buildTimeRangeDialogDraft()
if (preset === 'all') {
const previewRange = createDefaultDateRange()
return {
...base,
preset,
useAllTime: true,
dateRange: {
start: previewRange.start,
end: previewRange.end
},
startPanelMonth: toMonthStart(previewRange.start),
endPanelMonth: toMonthStart(previewRange.end)
}
}
const range = createDateRangeByPreset(preset)
return {
...base,
preset,
useAllTime: false,
dateRange: {
start: range.start,
end: range.end
},
startPanelMonth: toMonthStart(range.start),
endPanelMonth: toMonthStart(range.end)
}
})
}, [buildTimeRangeDialogDraft])
const handleTimeRangePresetClick = useCallback((preset: Exclude<DateRangePreset, 'custom'>) => {
applyTimeRangePresetToDraft(preset)
}, [applyTimeRangePresetToDraft])
const updateTimeRangeDraftStart = useCallback((targetDate: Date) => {
const start = startOfDay(targetDate)
setTimeRangeDialogDraft(prev => {
const base = prev ?? buildTimeRangeDialogDraft()
const nextEnd = base.dateRange.end < start ? endOfDay(start) : base.dateRange.end
return {
...base,
preset: 'custom',
useAllTime: false,
dateRange: {
start,
end: nextEnd
},
startPanelMonth: toMonthStart(start),
endPanelMonth: toMonthStart(nextEnd)
}
})
}, [buildTimeRangeDialogDraft])
const updateTimeRangeDraftEnd = useCallback((targetDate: Date) => {
const end = endOfDay(targetDate)
setTimeRangeDialogDraft(prev => {
const base = prev ?? buildTimeRangeDialogDraft()
const isAllTimeMode = base.useAllTime
const nextStart = isAllTimeMode
? startOfDay(targetDate)
: base.dateRange.start
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
return {
...base,
preset: 'custom',
useAllTime: false,
dateRange: {
start: nextStart,
end: nextEnd
},
startPanelMonth: toMonthStart(nextStart),
endPanelMonth: toMonthStart(nextEnd)
}
})
}, [buildTimeRangeDialogDraft])
const commitTimeRangeStartFromInput = useCallback(() => {
const parsed = parseDateInputValue(timeRangeDateInput.start)
if (!parsed) {
setTimeRangeDateInputError(prev => ({ ...prev, start: true }))
return
}
setTimeRangeDateInputError(prev => ({ ...prev, start: false }))
updateTimeRangeDraftStart(parsed)
}, [timeRangeDateInput.start, updateTimeRangeDraftStart])
const commitTimeRangeEndFromInput = useCallback(() => {
const parsed = parseDateInputValue(timeRangeDateInput.end)
if (!parsed) {
setTimeRangeDateInputError(prev => ({ ...prev, end: true }))
return
}
setTimeRangeDateInputError(prev => ({ ...prev, end: false }))
updateTimeRangeDraftEnd(parsed)
}, [timeRangeDateInput.end, updateTimeRangeDraftEnd])
const shiftTimeRangePanelMonth = useCallback((panel: 'start' | 'end', delta: number) => {
setTimeRangeDialogDraft(prev => {
const base = prev ?? buildTimeRangeDialogDraft()
if (panel === 'start') {
return {
...base,
startPanelMonth: addMonths(base.startPanelMonth, delta)
}
}
return {
...base,
endPanelMonth: addMonths(base.endPanelMonth, delta)
}
})
}, [buildTimeRangeDialogDraft])
const commitTimeRangeDialogDraft = useCallback(() => {
const draft = timeRangeDialogDraft ?? buildTimeRangeDialogDraft()
setTimeRangePreset(draft.preset)
setOptions(prev => ({
...prev,
useAllTime: draft.useAllTime,
dateRange: {
start: new Date(draft.dateRange.start),
end: new Date(draft.dateRange.end)
}
}))
closeTimeRangeDialog()
}, [buildTimeRangeDialogDraft, closeTimeRangeDialog, timeRangeDialogDraft])
const timeRangeSummaryLabel = useMemo(() => {
if (options.useAllTime) return '默认导出全部时间'
if (timeRangePreset === 'today') return '今天'
if (timeRangePreset === 'yesterday') return '昨天'
if (timeRangePreset === 'last3days') return '最近3天'
if (timeRangePreset === 'last7days') return '最近一周'
if (timeRangePreset === 'last30days') return '最近30 天'
if (timeRangePreset === 'last1year') return '最近一年'
if (timeRangePreset === 'last2years') return '最近两年'
if (options.dateRange) {
return `${formatDateInputValue(options.dateRange.start)} 至 ${formatDateInputValue(options.dateRange.end)}`
}
return '自定义时间范围'
}, [options.useAllTime, options.dateRange, timeRangePreset])
const activeTimeRangeDialogDraft = timeRangeDialogDraft ?? buildTimeRangeDialogDraft()
const isRangeModeActive = !activeTimeRangeDialogDraft.useAllTime
const timeRangeModeText = isRangeModeActive
? '当前导出模式:按时间范围导出'
: '当前导出模式:全部时间导出(选择下方日期将切换为按时间范围导出)'
useEffect(() => {
if (!isTimeRangeDialogOpen) return
setTimeRangeDateInput({
start: formatDateInputValue(activeTimeRangeDialogDraft.dateRange.start),
end: formatDateInputValue(activeTimeRangeDialogDraft.dateRange.end)
})
setTimeRangeDateInputError({ start: false, end: false })
}, [
isTimeRangeDialogOpen,
activeTimeRangeDialogDraft.dateRange.start.getTime(),
activeTimeRangeDialogDraft.dateRange.end.getTime()
])
const isTimeRangePresetActive = useCallback((preset: DateRangePreset): boolean => {
if (preset === 'all') return activeTimeRangeDialogDraft.useAllTime
return !activeTimeRangeDialogDraft.useAllTime && activeTimeRangeDialogDraft.preset === preset
}, [activeTimeRangeDialogDraft])
const startPanelCells = useMemo(
() => buildCalendarCells(activeTimeRangeDialogDraft.startPanelMonth),
[activeTimeRangeDialogDraft.startPanelMonth]
)
const endPanelCells = useMemo(
() => buildCalendarCells(activeTimeRangeDialogDraft.endPanelMonth),
[activeTimeRangeDialogDraft.endPanelMonth]
)
useEffect(() => {
const unsubscribe = onOpenSingleExport((payload) => {
void (async () => {
const sessionId = typeof payload?.sessionId === 'string'
? payload.sessionId.trim()
: ''
if (!sessionId) return
const sessionName = typeof payload?.sessionName === 'string'
? payload.sessionName.trim()
: ''
const displayName = sessionName || sessionId
const requestId = typeof payload?.requestId === 'string'
? payload.requestId.trim()
: ''
const emitStatus = (
status: 'initializing' | 'opened' | 'failed',
message?: string
) => {
if (!requestId) return
emitSingleExportDialogStatus({ requestId, status, message })
}
try {
if (!hasBaseConfigReadyRef.current) {
emitStatus('initializing')
const ready = await loadBaseConfig()
if (!ready) {
emitStatus('failed', '导出模块初始化失败,请重试')
return
}
}
setSelectedSessions(new Set([sessionId]))
openExportDialog({
scope: 'single',
sessionIds: [sessionId],
sessionNames: [displayName],
title: `导出会话:${displayName}`
})
emitStatus('opened')
} catch (error) {
console.error('聊天页唤起导出弹窗失败:', error)
emitStatus('failed', String(error))
}
})()
})
return unsubscribe
}, [loadBaseConfig, openExportDialog])
const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => {
const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared'
const exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
const base: ElectronExportOptions = {
format: options.format,
exportAvatars: options.exportAvatars,
exportMedia: exportMediaEnabled,
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText,
excelCompactColumns: options.excelCompactColumns,
txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency,
sessionLayout,
sessionNameWithTypePrefix,
dateRange: options.useAllTime
? null
: options.dateRange
? {
start: Math.floor(options.dateRange.start.getTime() / 1000),
end: Math.floor(options.dateRange.end.getTime() / 1000)
}
: null
}
if (scope === 'content' && contentType) {
if (contentType === 'text') {
const fastTextFormat: TextExportFormat = options.format === 'excel' ? 'arkme-json' : options.format
const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? options.exportConcurrency))
return {
...base,
format: fastTextFormat,
contentType,
exportConcurrency: textExportConcurrency,
exportAvatars: true,
exportMedia: false,
exportImages: false,
exportVoices: false,
exportVideos: false,
exportEmojis: false
}
}
return {
...base,
contentType,
exportMedia: true,
exportImages: contentType === 'image',
exportVoices: contentType === 'voice',
exportVideos: contentType === 'video',
exportEmojis: contentType === 'emoji',
exportVoiceAsText: false
}
}
return base
}
const buildSnsExportOptions = () => {
const format: SnsTimelineExportFormat = snsExportFormat
const dateRange = options.useAllTime
? null
: options.dateRange
? {
startTime: Math.floor(options.dateRange.start.getTime() / 1000),
endTime: Math.floor(options.dateRange.end.getTime() / 1000)
}
: null
return {
format,
exportImages: snsExportImages,
exportLivePhotos: snsExportLivePhotos,
exportVideos: snsExportVideos,
startTime: dateRange?.startTime,
endTime: dateRange?.endTime
}
}
const runExport = async (sessionLayout: SessionLayout) => {
if (selectedSessions.size === 0 || !exportFolder) return
const markSessionExported = useCallback((sessionIds: string[], timestamp: number) => {
setLastExportBySession(prev => {
const next = { ...prev }
for (const id of sessionIds) {
next[id] = timestamp
}
void configService.setExportLastSessionRunMap(next)
return next
})
}, [])
setIsExporting(true)
setExportProgress({ current: 0, total: selectedSessions.size, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 })
setExportResult(null)
exportStartTime.current = Date.now()
setElapsedSeconds(0)
const markContentExported = useCallback((sessionIds: string[], contentTypes: ContentType[], timestamp: number) => {
setLastExportByContent(prev => {
const next = { ...prev }
for (const id of sessionIds) {
for (const type of contentTypes) {
next[`${id}::${type}`] = timestamp
}
}
void configService.setExportLastContentRunMap(next)
return next
})
}, [])
try {
const sessionList = Array.from(selectedSessions)
const exportOptions = {
format: options.format,
exportAvatars: options.exportAvatars,
exportMedia: options.exportMedia,
exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices,
exportVideos: options.exportMedia && options.exportVideos,
exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
excelCompactColumns: options.excelCompactColumns,
txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency,
sessionLayout,
dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000),
// 将结束日期设置为当天的 23:59:59,确保包含当天的所有记录
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
} : null
const resolveTaskExportContentLabel = useCallback((payload: ExportTaskPayload): string => {
if (payload.scope === 'content' && payload.contentType) {
return getContentTypeLabel(payload.contentType)
}
if (payload.scope === 'sns') return '朋友圈'
const labels: string[] = ['聊天文本']
const opts = payload.options
if (opts?.exportMedia) {
if (opts.exportImages) labels.push('图片')
if (opts.exportVoices) labels.push('语音')
if (opts.exportVideos) labels.push('视频')
if (opts.exportEmojis) labels.push('表情包')
}
return Array.from(new Set(labels)).join('、')
}, [])
const markSessionExportRecords = useCallback((
sessionIds: string[],
content: string,
outputDir: string,
exportTime: number
) => {
const normalizedContent = String(content || '').trim()
const normalizedOutputDir = String(outputDir || '').trim()
const normalizedExportTime = Number.isFinite(exportTime) ? Math.max(0, Math.floor(exportTime)) : Date.now()
if (!normalizedContent || !normalizedOutputDir) return
if (!Array.isArray(sessionIds) || sessionIds.length === 0) return
setExportRecordsBySession(prev => {
const next: Record<string, configService.ExportSessionRecordEntry[]> = { ...prev }
let changed = false
for (const rawSessionId of sessionIds) {
const sessionId = String(rawSessionId || '').trim()
if (!sessionId) continue
const existingList = Array.isArray(next[sessionId]) ? [...next[sessionId]] : []
const lastRecord = existingList[existingList.length - 1]
if (
lastRecord &&
lastRecord.content === normalizedContent &&
lastRecord.outputDir === normalizedOutputDir &&
Math.abs(Number(lastRecord.exportTime || 0) - normalizedExportTime) <= 2000
) {
continue
}
existingList.push({
exportTime: normalizedExportTime,
content: normalizedContent,
outputDir: normalizedOutputDir
})
next[sessionId] = existingList.slice(-80)
changed = true
}
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html' || options.format === 'weclone') {
const result = await window.electronAPI.export.exportSessions(
sessionList,
exportFolder,
exportOptions
)
setExportResult(result)
if (!changed) return prev
void configService.setExportSessionRecordMap(next)
return next
})
}, [])
const inferContentTypesFromOptions = (opts: ElectronExportOptions): ContentType[] => {
const types: ContentType[] = ['text']
if (opts.exportMedia) {
if (opts.exportVoices) types.push('voice')
if (opts.exportImages) types.push('image')
if (opts.exportVideos) types.push('video')
if (opts.exportEmojis) types.push('emoji')
}
return types
}
const updateTask = useCallback((taskId: string, updater: (task: ExportTask) => ExportTask) => {
setTasks(prev => prev.map(task => (task.id === taskId ? updater(task) : task)))
}, [])
const runNextTask = useCallback(async () => {
if (runningTaskIdRef.current) return
const queue = [...tasksRef.current].reverse()
const next = queue.find(task => task.status === 'queued')
if (!next) return
runningTaskIdRef.current = next.id
updateTask(next.id, task => ({
...task,
status: 'running',
settledSessionIds: [],
startedAt: Date.now(),
finishedAt: undefined,
error: undefined,
performance: isTextBatchTask(task)
? (task.performance || createEmptyTaskPerformance())
: task.performance
}))
const taskExportContentLabel = resolveTaskExportContentLabel(next.payload)
progressUnsubscribeRef.current?.()
const settledSessionIdsFromProgress = new Set<string>()
if (next.payload.scope === 'sns') {
progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => {
updateTask(next.id, task => {
if (task.status !== 'running') return task
return {
...task,
progress: {
current: payload.current || 0,
total: payload.total || 0,
currentName: '',
phase: 'exporting',
phaseLabel: payload.status || '',
phaseProgress: payload.total > 0 ? payload.current : 0,
phaseTotal: payload.total || 0
}
}
})
})
} else {
progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => {
const now = Date.now()
const currentSessionId = String(payload.currentSessionId || '').trim()
if (payload.phase === 'complete' && currentSessionId && !settledSessionIdsFromProgress.has(currentSessionId)) {
settledSessionIdsFromProgress.add(currentSessionId)
const phaseLabel = String(payload.phaseLabel || '')
const isFailed = phaseLabel.includes('失败')
if (!isFailed) {
const contentTypes = next.payload.contentType
? [next.payload.contentType]
: (next.payload.options ? inferContentTypesFromOptions(next.payload.options) : [])
markSessionExported([currentSessionId], now)
if (contentTypes.length > 0) {
markContentExported([currentSessionId], contentTypes, now)
}
markSessionExportRecords([currentSessionId], taskExportContentLabel, next.payload.outputDir, now)
}
}
updateTask(next.id, task => {
if (task.status !== 'running') return task
const performance = applyProgressToTaskPerformance(task, payload, now)
const settledSessionIds = task.settledSessionIds || []
const nextSettledSessionIds = (
payload.phase === 'complete' &&
currentSessionId &&
!settledSessionIds.includes(currentSessionId)
)
? [...settledSessionIds, currentSessionId]
: settledSessionIds
return {
...task,
progress: {
current: payload.current,
total: payload.total,
currentName: payload.currentSession,
phase: payload.phase,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0
},
settledSessionIds: nextSettledSessionIds,
performance
}
})
})
}
try {
if (next.payload.scope === 'sns') {
const snsOptions = next.payload.snsOptions || { format: 'html' as SnsTimelineExportFormat, exportImages: false, exportLivePhotos: false, exportVideos: false }
const result = await window.electronAPI.sns.exportTimeline({
outputDir: next.payload.outputDir,
format: snsOptions.format,
exportImages: snsOptions.exportImages,
exportLivePhotos: snsOptions.exportLivePhotos,
exportVideos: snsOptions.exportVideos,
startTime: snsOptions.startTime,
endTime: snsOptions.endTime
})
if (!result.success) {
updateTask(next.id, task => ({
...task,
status: 'error',
finishedAt: Date.now(),
error: result.error || '朋友圈导出失败',
performance: finalizeTaskPerformance(task, Date.now())
}))
} else {
const doneAt = Date.now()
const exportedPosts = Math.max(0, result.postCount || 0)
const mergedExportedCount = Math.max(lastSnsExportPostCount, exportedPosts)
setLastSnsExportPostCount(mergedExportedCount)
await configService.setExportLastSnsPostCount(mergedExportedCount)
await loadSnsStats({ full: true })
updateTask(next.id, task => ({
...task,
status: 'success',
finishedAt: doneAt,
progress: {
...task.progress,
current: exportedPosts,
total: exportedPosts,
phaseLabel: '完成',
phaseProgress: 1,
phaseTotal: 1
},
performance: finalizeTaskPerformance(task, doneAt)
}))
}
} else {
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` })
if (!next.payload.options) {
throw new Error('导出参数缺失')
}
const result = await window.electronAPI.export.exportSessions(
next.payload.sessionIds,
next.payload.outputDir,
next.payload.options
)
if (!result.success) {
updateTask(next.id, task => ({
...task,
status: 'error',
finishedAt: Date.now(),
error: result.error || '导出失败',
performance: finalizeTaskPerformance(task, Date.now())
}))
} else {
const doneAt = Date.now()
const contentTypes = next.payload.contentType
? [next.payload.contentType]
: inferContentTypesFromOptions(next.payload.options)
const successSessionIds = Array.isArray(result.successSessionIds)
? result.successSessionIds
: []
if (successSessionIds.length > 0) {
const unsettledSuccessSessionIds = successSessionIds.filter((sessionId) => !settledSessionIdsFromProgress.has(sessionId))
if (unsettledSuccessSessionIds.length > 0) {
markSessionExported(unsettledSuccessSessionIds, doneAt)
markSessionExportRecords(unsettledSuccessSessionIds, taskExportContentLabel, next.payload.outputDir, doneAt)
if (contentTypes.length > 0) {
markContentExported(unsettledSuccessSessionIds, contentTypes, doneAt)
}
}
}
updateTask(next.id, task => ({
...task,
status: 'success',
finishedAt: doneAt,
progress: {
...task.progress,
current: task.progress.total || next.payload.sessionIds.length,
total: task.progress.total || next.payload.sessionIds.length,
phaseLabel: '完成',
phaseProgress: 1,
phaseTotal: 1
},
performance: finalizeTaskPerformance(task, doneAt)
}))
}
}
} catch (e) {
console.error('导出过程中发生异常:', e)
setExportResult({ success: false, error: String(e) })
} catch (error) {
const doneAt = Date.now()
updateTask(next.id, task => ({
...task,
status: 'error',
finishedAt: doneAt,
error: String(error),
performance: finalizeTaskPerformance(task, doneAt)
}))
} finally {
setIsExporting(false)
progressUnsubscribeRef.current?.()
progressUnsubscribeRef.current = null
runningTaskIdRef.current = null
void runNextTask()
}
}, [
updateTask,
markSessionExported,
markSessionExportRecords,
markContentExported,
resolveTaskExportContentLabel,
loadSnsStats,
lastSnsExportPostCount
])
useEffect(() => {
void runNextTask()
}, [tasks, runNextTask])
useEffect(() => {
return () => {
progressUnsubscribeRef.current?.()
progressUnsubscribeRef.current = null
}
}, [])
const createTask = async () => {
if (!exportDialog.open || !exportFolder) return
if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return
const exportOptions = exportDialog.scope === 'sns'
? undefined
: buildExportOptions(exportDialog.scope, exportDialog.contentType)
const snsOptions = exportDialog.scope === 'sns'
? buildSnsExportOptions()
: undefined
const title =
exportDialog.scope === 'single'
? `${exportDialog.sessionNames[0] || '会话'} 导出`
: exportDialog.scope === 'multi'
? `批量导出(${exportDialog.sessionIds.length} 个会话)`
: exportDialog.scope === 'sns'
? '朋友圈批量导出'
: `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出`
const task: ExportTask = {
id: createTaskId(),
title,
status: 'queued',
settledSessionIds: [],
createdAt: Date.now(),
payload: {
sessionIds: exportDialog.sessionIds,
sessionNames: exportDialog.sessionNames,
outputDir: exportFolder,
options: exportOptions,
scope: exportDialog.scope,
contentType: exportDialog.contentType,
snsOptions
},
progress: createEmptyProgress(),
performance: exportDialog.scope === 'content' && exportDialog.contentType === 'text'
? createEmptyTaskPerformance()
: undefined
}
setTasks(prev => [task, ...prev])
closeExportDialog()
await configService.setExportDefaultFormat(options.format)
await configService.setExportDefaultMedia(Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis))
await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText)
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
await configService.setExportDefaultTxtColumns(options.txtColumns)
await configService.setExportDefaultConcurrency(options.exportConcurrency)
}
const startExport = async () => {
if (selectedSessions.size === 0 || !exportFolder) return
const openSingleExport = useCallback((session: SessionRow) => {
if (!session.hasSession) return
openExportDialog({
scope: 'single',
sessionIds: [session.username],
sessionNames: [session.displayName || session.username],
title: `导出会话:${session.displayName || session.username}`
})
}, [openExportDialog])
const resolveSessionExistingMessageCount = useCallback((session: SessionRow): number => {
const counted = normalizeMessageCount(sessionMessageCounts[session.username])
if (typeof counted === 'number') return counted
const hinted = normalizeMessageCount(session.messageCountHint)
if (typeof hinted === 'number') return hinted
return 0
}, [sessionMessageCounts])
const orderSessionsForExport = useCallback((source: SessionRow[]): SessionRow[] => {
return source
.filter((session) => session.hasSession && isContentScopeSession(session))
.map((session) => ({
session,
count: resolveSessionExistingMessageCount(session)
}))
.filter((item) => item.count > 0)
.sort((a, b) => {
const kindDiff = exportKindPriority[a.session.kind] - exportKindPriority[b.session.kind]
if (kindDiff !== 0) return kindDiff
if (a.count !== b.count) return b.count - a.count
const tsA = a.session.sortTimestamp || a.session.lastTimestamp || 0
const tsB = b.session.sortTimestamp || b.session.lastTimestamp || 0
if (tsA !== tsB) return tsB - tsA
return (a.session.displayName || a.session.username)
.localeCompare(b.session.displayName || b.session.username, 'zh-Hans-CN')
})
.map((item) => item.session)
}, [resolveSessionExistingMessageCount])
const openBatchExport = () => {
const selectedSet = new Set(selectedSessions)
const selectedRows = sessions.filter((session) => selectedSet.has(session.username))
const orderedRows = orderSessionsForExport(selectedRows)
if (orderedRows.length === 0) {
window.alert('所选会话暂无可导出的消息(总消息数为 0)')
return
}
const ids = orderedRows.map((session) => session.username)
const names = orderedRows.map((session) => session.displayName || session.username)
openExportDialog({
scope: 'multi',
sessionIds: ids,
sessionNames: names,
title: `批量导出(${ids.length} 个会话)`
})
}
const openContentExport = (contentType: ContentType) => {
const orderedRows = orderSessionsForExport(sessions)
if (orderedRows.length === 0) {
window.alert('当前会话列表暂无可导出的消息(总消息数为 0)')
return
}
const ids = orderedRows.map((session) => session.username)
const names = orderedRows.map((session) => session.displayName || session.username)
openExportDialog({
scope: 'content',
contentType,
sessionIds: ids,
sessionNames: names,
title: `${contentTypeLabels[contentType]}批量导出`
})
}
const openSnsExport = () => {
openExportDialog({
scope: 'sns',
sessionIds: [],
sessionNames: ['全部朋友圈动态'],
title: '朋友圈批量导出'
})
}
const runningSessionIds = useMemo(() => {
const set = new Set<string>()
for (const task of tasks) {
if (task.status !== 'running') continue
const settled = new Set(task.settledSessionIds || [])
for (const id of task.payload.sessionIds) {
if (settled.has(id)) continue
set.add(id)
}
}
return set
}, [tasks])
const queuedSessionIds = useMemo(() => {
const set = new Set<string>()
for (const task of tasks) {
if (task.status !== 'queued') continue
for (const id of task.payload.sessionIds) {
set.add(id)
}
}
return set
}, [tasks])
const inProgressSessionIds = useMemo(() => {
const set = new Set<string>()
for (const task of tasks) {
if (task.status !== 'running' && task.status !== 'queued') continue
for (const id of task.payload.sessionIds) {
set.add(id)
}
}
return Array.from(set).sort()
}, [tasks])
const activeTaskCount = useMemo(
() => tasks.filter(task => task.status === 'running' || task.status === 'queued').length,
[tasks]
)
const inProgressSessionIdsKey = useMemo(
() => inProgressSessionIds.join('||'),
[inProgressSessionIds]
)
const inProgressStatusKey = useMemo(
() => `${activeTaskCount}::${inProgressSessionIdsKey}`,
[activeTaskCount, inProgressSessionIdsKey]
)
useEffect(() => {
inProgressSessionIdsRef.current = inProgressSessionIds
}, [inProgressSessionIds])
useEffect(() => {
activeTaskCountRef.current = activeTaskCount
}, [activeTaskCount])
useEffect(() => {
emitExportSessionStatus({
inProgressSessionIds: inProgressSessionIdsRef.current,
activeTaskCount: activeTaskCountRef.current
})
}, [inProgressStatusKey])
useEffect(() => {
const unsubscribe = onExportSessionStatusRequest(() => {
emitExportSessionStatus({
inProgressSessionIds: inProgressSessionIdsRef.current,
activeTaskCount: activeTaskCountRef.current
})
})
return unsubscribe
}, [])
const runningCardTypes = useMemo(() => {
const set = new Set<ContentCardType>()
for (const task of tasks) {
if (task.status !== 'running') continue
if (task.payload.scope === 'sns') {
set.add('sns')
continue
}
if (task.payload.scope === 'content' && task.payload.contentType) {
set.add(task.payload.contentType)
}
}
return set
}, [tasks])
const contentCards = useMemo(() => {
const scopeSessions = sessions.filter(isContentScopeSession)
const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts)
const sessionCards = [
{ type: 'text' as ContentType, icon: MessageSquareText },
{ type: 'voice' as ContentType, icon: Mic },
{ type: 'image' as ContentType, icon: ImageIcon },
{ type: 'video' as ContentType, icon: Video },
{ type: 'emoji' as ContentType, icon: WandSparkles }
].map(item => {
let exported = 0
for (const session of scopeSessions) {
if (lastExportByContent[`${session.username}::${item.type}`]) {
exported += 1
}
}
return {
...item,
label: contentTypeLabels[item.type],
stats: [
{ label: '已导出', value: exported, unit: '个对话' }
]
}
})
const snsCard = {
type: 'sns' as ContentCardType,
icon: Aperture,
label: '朋友圈',
headerCount: snsStats.totalPosts,
stats: [
{ label: '已导出', value: snsExportedCount, unit: '条' }
]
}
return [...sessionCards, snsCard]
}, [sessions, lastExportByContent, snsStats, lastSnsExportPostCount])
const activeTabLabel = useMemo(() => {
if (activeTab === 'private') return '私聊'
if (activeTab === 'group') return '群聊'
if (activeTab === 'former_friend') return '曾经的好友'
return '公众号'
}, [activeTab])
const sessionRowByUsername = useMemo(() => {
const map = new Map<string, SessionRow>()
for (const session of sessions) {
map.set(session.username, session)
}
return map
}, [sessions])
const filteredContacts = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase()
const contacts = contactsList
.filter((contact) => {
if (!matchesContactTab(contact, activeTab)) return false
if (!keyword) return true
return (
(contact.displayName || '').toLowerCase().includes(keyword) ||
(contact.remark || '').toLowerCase().includes(keyword) ||
contact.username.toLowerCase().includes(keyword)
)
})
const indexedContacts = contacts.map((contact, index) => ({
contact,
index,
count: (() => {
const counted = normalizeMessageCount(sessionMessageCounts[contact.username])
if (typeof counted === 'number') return counted
const hinted = normalizeMessageCount(sessionRowByUsername.get(contact.username)?.messageCountHint)
return hinted
})()
}))
indexedContacts.sort((a, b) => {
const aHasCount = typeof a.count === 'number'
const bHasCount = typeof b.count === 'number'
if (aHasCount && bHasCount) {
const diff = (b.count as number) - (a.count as number)
if (diff !== 0) return diff
} else if (aHasCount) {
return -1
} else if (bHasCount) {
return 1
}
// 无统计值或同分时保持原顺序,避免列表频繁跳动。
return a.index - b.index
})
return indexedContacts.map(item => item.contact)
}, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername])
useEffect(() => {
contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' })
setIsContactsListAtTop(true)
}, [activeTab, searchKeyword])
const contactByUsername = useMemo(() => {
const map = new Map<string, ContactInfo>()
for (const contact of contactsList) {
map.set(contact.username, contact)
}
return map
}, [contactsList])
const currentSessionExportRecords = useMemo(() => {
const sessionId = String(sessionDetail?.wxid || '').trim()
if (!sessionId) return [] as configService.ExportSessionRecordEntry[]
const records = Array.isArray(exportRecordsBySession[sessionId]) ? exportRecordsBySession[sessionId] : []
return [...records]
.sort((a, b) => Number(b.exportTime || 0) - Number(a.exportTime || 0))
.slice(0, 20)
}, [sessionDetail?.wxid, exportRecordsBySession])
const applySessionDetailStats = useCallback((
sessionId: string,
metric: SessionExportMetric,
cacheMeta?: SessionExportCacheMeta,
relationLoadedOverride?: boolean
) => {
mergeSessionContentMetrics({ [sessionId]: metric })
setSessionDetail((prev) => {
if (!prev || prev.wxid !== sessionId) return prev
const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded)
return {
...prev,
messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : prev.messageCount,
voiceMessages: Number.isFinite(metric.voiceMessages) ? metric.voiceMessages : prev.voiceMessages,
imageMessages: Number.isFinite(metric.imageMessages) ? metric.imageMessages : prev.imageMessages,
videoMessages: Number.isFinite(metric.videoMessages) ? metric.videoMessages : prev.videoMessages,
emojiMessages: Number.isFinite(metric.emojiMessages) ? metric.emojiMessages : prev.emojiMessages,
transferMessages: Number.isFinite(metric.transferMessages) ? metric.transferMessages : prev.transferMessages,
redPacketMessages: Number.isFinite(metric.redPacketMessages) ? metric.redPacketMessages : prev.redPacketMessages,
callMessages: Number.isFinite(metric.callMessages) ? metric.callMessages : prev.callMessages,
groupMemberCount: Number.isFinite(metric.groupMemberCount) ? metric.groupMemberCount : prev.groupMemberCount,
groupMyMessages: Number.isFinite(metric.groupMyMessages) ? metric.groupMyMessages : prev.groupMyMessages,
groupActiveSpeakers: Number.isFinite(metric.groupActiveSpeakers) ? metric.groupActiveSpeakers : prev.groupActiveSpeakers,
privateMutualGroups: relationLoaded && Number.isFinite(metric.privateMutualGroups)
? metric.privateMutualGroups
: prev.privateMutualGroups,
groupMutualFriends: relationLoaded && Number.isFinite(metric.groupMutualFriends)
? metric.groupMutualFriends
: prev.groupMutualFriends,
relationStatsLoaded: relationLoaded,
statsUpdatedAt: cacheMeta?.updatedAt ?? prev.statsUpdatedAt,
statsStale: typeof cacheMeta?.stale === 'boolean' ? cacheMeta.stale : prev.statsStale,
firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : prev.firstMessageTime,
latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : prev.latestMessageTime
}
})
}, [mergeSessionContentMetrics])
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 cachedMetric = sessionContentMetrics[normalizedSessionId]
const countedCount = normalizeMessageCount(sessionMessageCounts[normalizedSessionId])
const metricCount = normalizeMessageCount(cachedMetric?.totalMessages)
const metricVoice = normalizeMessageCount(cachedMetric?.voiceMessages)
const metricImage = normalizeMessageCount(cachedMetric?.imageMessages)
const metricVideo = normalizeMessageCount(cachedMetric?.videoMessages)
const metricEmoji = normalizeMessageCount(cachedMetric?.emojiMessages)
const metricTransfer = normalizeMessageCount(cachedMetric?.transferMessages)
const metricRedPacket = normalizeMessageCount(cachedMetric?.redPacketMessages)
const metricCall = normalizeMessageCount(cachedMetric?.callMessages)
const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0
? Math.floor(mappedSession.messageCountHint)
: undefined
const initialMessageCount = countedCount ?? metricCount ?? hintedCount
setCopiedDetailField(null)
setIsRefreshingSessionDetailStats(false)
setIsLoadingSessionRelationStats(false)
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: initialMessageCount ?? (sameSession ? prev.messageCount : Number.NaN),
voiceMessages: metricVoice ?? (sameSession ? prev?.voiceMessages : undefined),
imageMessages: metricImage ?? (sameSession ? prev?.imageMessages : undefined),
videoMessages: metricVideo ?? (sameSession ? prev?.videoMessages : undefined),
emojiMessages: metricEmoji ?? (sameSession ? prev?.emojiMessages : undefined),
transferMessages: metricTransfer ?? (sameSession ? prev?.transferMessages : undefined),
redPacketMessages: metricRedPacket ?? (sameSession ? prev?.redPacketMessages : undefined),
callMessages: metricCall ?? (sameSession ? prev?.callMessages : 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,
relationStatsLoaded: sameSession ? prev?.relationStatsLoaded : false,
statsUpdatedAt: sameSession ? prev?.statsUpdatedAt : undefined,
statsStale: sameSession ? prev?.statsStale : undefined,
firstMessageTime: sameSession ? prev?.firstMessageTime : undefined,
latestMessageTime: sameSession ? prev?.latestMessageTime : undefined,
messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : []
}
})
setIsLoadingSessionDetail(true)
setIsLoadingSessionDetailExtra(true)
// 先获取预估统计
const requestId = ++statsRequestIdRef.current
setIsLoadingStats(true)
setPreExportStats(null)
setShowPreExportDialog(true)
try {
const sessionList = Array.from(selectedSessions)
const exportOptions = {
format: options.format,
exportVoiceAsText: options.exportVoiceAsText,
exportMedia: options.exportMedia,
exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices,
exportVideos: options.exportMedia && options.exportVideos,
exportEmojis: options.exportMedia && options.exportEmojis,
dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000),
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
} : null
const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId)
if (requestSeq !== detailRequestSeqRef.current) return
if (result.success && result.detail) {
const fastMessageCount = normalizeMessageCount(result.detail.messageCount)
if (typeof fastMessageCount === 'number') {
setSessionMessageCounts((prev) => {
if (prev[normalizedSessionId] === fastMessageCount) return prev
return {
...prev,
[normalizedSessionId]: fastMessageCount
}
})
mergeSessionContentMetrics({
[normalizedSessionId]: {
totalMessages: fastMessageCount
}
})
}
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,
transferMessages: prev?.transferMessages,
redPacketMessages: prev?.redPacketMessages,
callMessages: prev?.callMessages,
privateMutualGroups: prev?.privateMutualGroups,
groupMemberCount: prev?.groupMemberCount,
groupMyMessages: prev?.groupMyMessages,
groupActiveSpeakers: prev?.groupActiveSpeakers,
groupMutualFriends: prev?.groupMutualFriends,
relationStatsLoaded: prev?.relationStatsLoaded,
statsUpdatedAt: prev?.statsUpdatedAt,
statsStale: prev?.statsStale,
firstMessageTime: prev?.firstMessageTime,
latestMessageTime: prev?.latestMessageTime,
messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : []
}))
}
const stats = await window.electronAPI.export.getExportStats(sessionList, exportOptions)
if (statsRequestIdRef.current !== requestId) return
setPreExportStats(stats)
} catch (e) {
console.error('获取导出统计失败:', e)
if (statsRequestIdRef.current !== requestId) return
setPreExportStats(null)
} catch (error) {
console.error('导出页加载会话详情失败:', error)
} finally {
if (statsRequestIdRef.current !== requestId) return
setIsLoadingStats(false)
if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingSessionDetail(false)
}
}
}
const confirmExport = () => {
statsRequestIdRef.current++
setIsLoadingStats(false)
setShowPreExportDialog(false)
setPreExportStats(null)
try {
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true }
)
])
if (options.exportMedia && selectedSessions.size > 1) {
setShowMediaLayoutPrompt(true)
if (requestSeq !== detailRequestSeqRef.current) return
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) {
const detail = extraResultSettled.value.detail
if (detail) {
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
return {
...prev,
firstMessageTime: detail.firstMessageTime,
latestMessageTime: detail.latestMessageTime,
messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : []
}
})
}
}
let refreshIncludeRelations = false
let shouldRefreshStats = false
if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) {
const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined
const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
refreshIncludeRelations = Boolean(cacheMeta?.includeRelations)
if (metric) {
applySessionDetailStats(normalizedSessionId, metric, cacheMeta, refreshIncludeRelations)
} else if (cacheMeta) {
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
return {
...prev,
relationStatsLoaded: refreshIncludeRelations || prev.relationStatsLoaded,
statsUpdatedAt: cacheMeta.updatedAt,
statsStale: cacheMeta.stale
}
})
}
shouldRefreshStats = Array.isArray(statsResultSettled.value.needsRefresh) &&
statsResultSettled.value.needsRefresh.includes(normalizedSessionId)
}
if (shouldRefreshStats) {
setIsRefreshingSessionDetailStats(true)
void (async () => {
try {
const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: refreshIncludeRelations, forceRefresh: true, preferAccurateSpecialTypes: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
if (freshResult.success && freshResult.data) {
const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
if (metric) {
applySessionDetailStats(
normalizedSessionId,
metric,
cacheMeta,
refreshIncludeRelations ? true : undefined
)
}
}
} catch (error) {
console.error('导出页刷新会话统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsRefreshingSessionDetailStats(false)
}
}
})()
}
} catch (error) {
console.error('导出页加载会话详情补充统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingSessionDetailExtra(false)
}
}
}, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername])
const loadSessionRelationStats = useCallback(async () => {
const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
if (!normalizedSessionId || isLoadingSessionRelationStats) return
const requestSeq = detailRequestSeqRef.current
setIsLoadingSessionRelationStats(true)
try {
const relationResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
const metric = relationResult.success && relationResult.data
? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined
: undefined
const cacheMeta = relationResult.success
? relationResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
: undefined
if (metric) {
applySessionDetailStats(normalizedSessionId, metric, cacheMeta, true)
}
const needRefresh = relationResult.success &&
Array.isArray(relationResult.needsRefresh) &&
relationResult.needsRefresh.includes(normalizedSessionId)
if (needRefresh) {
setIsRefreshingSessionDetailStats(true)
void (async () => {
try {
const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
if (freshResult.success && freshResult.data) {
const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
if (freshMetric) {
applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true)
}
}
} catch (error) {
console.error('导出页刷新会话关系统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsRefreshingSessionDetailStats(false)
}
}
})()
}
} catch (error) {
console.error('导出页加载会话关系统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingSessionRelationStats(false)
}
}
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
const closeSessionDetailPanel = useCallback(() => {
detailRequestSeqRef.current += 1
setShowSessionDetailPanel(false)
setIsLoadingSessionDetail(false)
setIsLoadingSessionDetailExtra(false)
setIsRefreshingSessionDetailStats(false)
setIsLoadingSessionRelationStats(false)
}, [])
const openSessionDetail = useCallback((sessionId: string) => {
if (!sessionId) return
setShowSessionDetailPanel(true)
void loadSessionDetail(sessionId)
}, [loadSessionDetail])
useEffect(() => {
if (!showSessionDetailPanel) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeSessionDetailPanel()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [closeSessionDetailPanel, showSessionDetailPanel])
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 contactsIssueElapsedMs = useMemo(() => {
if (!contactsLoadIssue) return 0
if (isContactsListLoading && contactsLoadSession) {
return Math.max(contactsLoadIssue.elapsedMs, contactsDiagnosticTick - contactsLoadSession.startedAt)
}
return contactsLoadIssue.elapsedMs
}, [contactsDiagnosticTick, isContactsListLoading, contactsLoadIssue, contactsLoadSession])
const contactsDiagnosticsText = useMemo(() => {
if (!contactsLoadIssue || !contactsLoadSession) return ''
return [
`请求ID: ${contactsLoadSession.requestId}`,
`请求序号: 第 ${contactsLoadSession.attempt} 次`,
`阈值配置: ${contactsLoadSession.timeoutMs}ms`,
`当前状态: ${contactsLoadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`,
`累计耗时: ${(contactsIssueElapsedMs / 1000).toFixed(1)}s`,
`发生时间: ${new Date(contactsLoadIssue.occurredAt).toLocaleString()}`,
'阶段: chat.getContacts',
`原因: ${contactsLoadIssue.reason}`,
`错误详情: ${contactsLoadIssue.errorDetail || '无'}`
].join('\n')
}, [contactsIssueElapsedMs, contactsLoadIssue, contactsLoadSession])
const copyContactsDiagnostics = useCallback(async () => {
if (!contactsDiagnosticsText) return
try {
await navigator.clipboard.writeText(contactsDiagnosticsText)
alert('诊断信息已复制')
} catch (error) {
console.error('复制诊断信息失败:', error)
alert('复制失败,请手动复制诊断信息')
}
}, [contactsDiagnosticsText])
const sessionContactsUpdatedAtLabel = useMemo(() => {
if (!sessionContactsUpdatedAt) return ''
return new Date(sessionContactsUpdatedAt).toLocaleString()
}, [sessionContactsUpdatedAt])
const sessionAvatarUpdatedAtLabel = useMemo(() => {
if (!sessionAvatarUpdatedAt) return ''
return new Date(sessionAvatarUpdatedAt).toLocaleString()
}, [sessionAvatarUpdatedAt])
const sessionAvatarCachedCount = useMemo(() => {
return sessions.reduce((count, session) => (session.avatarUrl ? count + 1 : count), 0)
}, [sessions])
const visibleSelectableCount = useMemo(() => (
filteredContacts.reduce((count, contact) => (
sessionRowByUsername.get(contact.username)?.hasSession ? count + 1 : count
), 0)
), [filteredContacts, sessionRowByUsername])
const isAllVisibleSelected = visibleSelectableCount > 0 && selectedCount === visibleSelectableCount
const canCreateTask = exportDialog.scope === 'sns'
? Boolean(exportFolder)
: Boolean(exportFolder) && exportDialog.sessionIds.length > 0
const scopeLabel = exportDialog.scope === 'single'
? '单会话'
: exportDialog.scope === 'multi'
? '多会话'
: exportDialog.scope === 'sns'
? '朋友圈批量'
: `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']})`
const scopeCountLabel = exportDialog.scope === 'sns'
? `共 ${snsStats.totalPosts} 条朋友圈动态`
: `共 ${exportDialog.sessionIds.length} 个会话`
const snsFormatOptions: Array<{ value: SnsTimelineExportFormat; label: string; desc: string }> = [
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'json', label: 'JSON', desc: '原始结构化格式(兼容旧导入)' },
{ value: 'arkmejson', label: 'ArkmeJSON', desc: '增强结构化格式,包含互动身份字段' }
]
const formatCandidateOptions = exportDialog.scope === 'sns'
? snsFormatOptions
: formatOptions
const isContentScopeDialog = exportDialog.scope === 'content'
const isContentTextDialog = isContentScopeDialog && exportDialog.contentType === 'text'
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
const shouldShowMediaSection = !isContentScopeDialog
const shouldShowDisplayNameSection = !(
exportDialog.scope === 'sns' ||
(
exportDialog.scope === 'content' &&
(
exportDialog.contentType === 'voice' ||
exportDialog.contentType === 'image' ||
exportDialog.contentType === 'video' ||
exportDialog.contentType === 'emoji'
)
)
)
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
const isSnsCardStatsLoading = !hasSeededSnsStats
const taskRunningCount = tasks.filter(task => task.status === 'running').length
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
const hasFilteredContacts = filteredContacts.length > 0
const closeTaskCenter = useCallback(() => {
setIsTaskCenterOpen(false)
setExpandedPerfTaskId(null)
}, [])
const toggleTaskPerfDetail = useCallback((taskId: string) => {
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
}, [])
const renderContactRow = useCallback((_: number, contact: ContactInfo) => {
const matchedSession = sessionRowByUsername.get(contact.username)
const canExport = Boolean(matchedSession?.hasSession)
const checked = canExport && selectedSessions.has(contact.username)
const isRunning = canExport && runningSessionIds.has(contact.username)
const isQueued = canExport && queuedSessionIds.has(contact.username)
const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : ''
const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
const displayedMessageCount = countedMessages ?? hintedMessages
const messageCountLabel = !canExport
? '--'
: typeof displayedMessageCount === 'number'
? displayedMessageCount.toLocaleString('zh-CN')
: '获取中'
return (
<div className={`contact-row ${checked ? 'selected' : ''}`}>
<div className="contact-item">
<div className="row-select-cell">
<button
className={`select-icon-btn ${checked ? 'checked' : ''}`}
type="button"
disabled={!canExport}
onClick={() => toggleSelectSession(contact.username)}
title={canExport ? (checked ? '取消选择' : '选择会话') : '该联系人暂无会话记录'}
>
{checked ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</div>
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" loading="lazy" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
<div className="contact-remark">{contact.username}</div>
</div>
<div className="row-message-count">
<div className="row-message-stats">
<strong className={`row-message-count-value ${typeof displayedMessageCount === 'number' ? '' : 'muted'}`}>
{messageCountLabel}
</strong>
</div>
</div>
<div className="row-action-cell">
<div className="row-action-main">
<button
className="row-open-chat-btn"
disabled={!canExport}
title={canExport ? '在新窗口打开该会话' : '该联系人暂无会话记录'}
onClick={() => {
if (!canExport) return
void window.electronAPI.window.openSessionChatWindow(contact.username)
}}
>
<ExternalLink size={13} />
打开对话
</button>
<button
className={`row-detail-btn ${showSessionDetailPanel && sessionDetail?.wxid === contact.username ? 'active' : ''}`}
onClick={() => openSessionDetail(contact.username)}
>
详情
</button>
<button
className={`row-export-btn ${isRunning ? 'running' : ''} ${!canExport ? 'no-session' : ''}`}
disabled={!canExport || isRunning}
onClick={() => {
if (!matchedSession || !matchedSession.hasSession) return
openSingleExport({
...matchedSession,
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
})
}}
>
{isRunning ? (
<>
<Loader2 size={14} className="spin" />
导出中
</>
) : !canExport ? '暂无会话' : isQueued ? '排队中' : '单会话导出'}
</button>
</div>
{recent && <span className="row-export-time">{recent}</span>}
</div>
</div>
</div>
)
}, [
lastExportBySession,
nowTick,
openSessionDetail,
openSingleExport,
queuedSessionIds,
runningSessionIds,
selectedSessions,
sessionDetail?.wxid,
sessionMessageCounts,
sessionRowByUsername,
showSessionDetailPanel,
toggleSelectSession
])
const handleContactsListWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => {
const deltaY = event.deltaY
if (!deltaY) return
const sectionTop = sessionTableSectionRef.current?.getBoundingClientRect().top ?? 0
const sectionPinned = sectionTop <= 8
if (deltaY > 0 && !sectionPinned) {
event.preventDefault()
window.scrollBy({ top: deltaY, behavior: 'auto' })
return
}
const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared'
runExport(layout)
}
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month + 1, 0).getDate()
}
const getFirstDayOfMonth = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month, 1).getDay()
}
const generateCalendar = () => {
const daysInMonth = getDaysInMonth(calendarDate)
const firstDay = getFirstDayOfMonth(calendarDate)
const days: (number | null)[] = []
for (let i = 0; i < firstDay; i++) {
days.push(null)
if (deltaY < 0 && isContactsListAtTop) {
event.preventDefault()
window.scrollBy({ top: deltaY, behavior: 'auto' })
}
for (let i = 1; i <= daysInMonth; i++) {
days.push(i)
}, [isContactsListAtTop])
useEffect(() => {
if (hasFilteredContacts) return
setIsContactsListAtTop(true)
}, [hasFilteredContacts])
const chooseExportFolder = useCallback(async () => {
const result = await window.electronAPI.dialog.openFile({
title: '选择导出目录',
properties: ['openDirectory']
})
if (!result.canceled && result.filePaths.length > 0) {
const nextPath = result.filePaths[0]
setExportFolder(nextPath)
await configService.setExportPath(nextPath)
}
return days
}
const handleDateSelect = (day: number) => {
const year = calendarDate.getFullYear()
const month = calendarDate.getMonth()
const selectedDate = new Date(year, month, day)
// 设置时间为当天的开始或结束
selectedDate.setHours(selectingStart ? 0 : 23, selectingStart ? 0 : 59, selectingStart ? 0 : 59, selectingStart ? 0 : 999)
const now = new Date()
// 如果选择的日期晚于当前时间,限制为当前时间
if (selectedDate > now) {
selectedDate.setTime(now.getTime())
}
if (selectingStart) {
// 选择开始日期
const currentEnd = options.dateRange?.end || new Date()
// 如果选择的开始日期晚于结束日期,则同时更新结束日期
if (selectedDate > currentEnd) {
const newEnd = new Date(selectedDate)
newEnd.setHours(23, 59, 59, 999)
// 确保结束日期也不晚于当前时间
if (newEnd > now) {
newEnd.setTime(now.getTime())
}
setOptions({
...options,
dateRange: { start: selectedDate, end: newEnd }
})
} else {
setOptions({
...options,
dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() }
})
}
setSelectingStart(false)
} else {
// 选择结束日期
const currentStart = options.dateRange?.start || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
// 如果选择的结束日期早于开始日期,则同时更新开始日期
if (selectedDate < currentStart) {
const newStart = new Date(selectedDate)
newStart.setHours(0, 0, 0, 0)
setOptions({
...options,
dateRange: { start: newStart, end: selectedDate }
})
} else {
setOptions({
...options,
dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate }
})
}
setSelectingStart(true)
}
}
const formatOptions = [
{ value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' },
{ value: 'json', label: 'JSON', icon: FileJson, desc: '详细格式,包含完整消息信息' },
{ value: 'html', label: 'HTML', icon: FileText, desc: '网页格式,可直接浏览' },
{ value: 'txt', label: 'TXT', icon: Table, desc: '纯文本,通用格式' },
{ value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' },
{ value: 'weclone', label: 'WeClone CSV', icon: Table, desc: 'WeClone 兼容字段格式(CSV)' },
{ value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' }
]
const displayNameOptions = [
{
value: 'group-nickname',
label: '群昵称优先',
desc: '仅群聊有效,私聊显示备注/昵称'
},
{
value: 'remark',
label: '备注优先',
desc: '有备注显示备注,否则显示昵称'
},
{
value: 'nickname',
label: '微信昵称',
desc: '始终显示微信昵称'
}
]
const displayNameOption = displayNameOptions.find(option => option.value === options.displayNamePreference)
const displayNameLabel = displayNameOption?.label || '备注优先'
}, [])
return (
<div className="export-page">
<div className="session-panel">
<div className="panel-header">
<h2>选择会话</h2>
<button className="icon-btn" onClick={loadSessions} disabled={isLoading}>
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
</button>
</div>
<div className="search-bar">
<Search size={16} />
<input
type="text"
placeholder="搜索联系人或群组..."
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
/>
{searchKeyword && (
<button className="clear-btn" onClick={() => setSearchKeyword('')}>
<X size={14} />
</button>
)}
</div>
<div className="select-actions">
<button className="select-all-btn" onClick={toggleSelectAll}>
{selectedSessions.size === filteredSessions.length && filteredSessions.length > 0 ? '取消全选' : '全选'}
</button>
<span className="selected-count">已选 {selectedSessions.size} 个</span>
</div>
{isLoading ? (
<div className="loading-state">
<Loader2 size={24} className="spin" />
<span>加载中...</span>
</div>
) : filteredSessions.length === 0 ? (
<div className="empty-state">
<span>暂无会话</span>
</div>
) : (
<div className="export-session-list">
{filteredSessions.map(session => (
<div
key={session.username}
className={`export-session-item ${selectedSessions.has(session.username) ? 'selected' : ''}`}
onClick={() => toggleSession(session.username)}
>
<div className="check-box">
{selectedSessions.has(session.username) && <Check size={14} />}
</div>
<div className="export-avatar">
{session.avatarUrl ? (
<img src={session.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(session.displayName || session.username)}</span>
)}
</div>
<div className="export-session-info">
<div className="export-session-name">{session.displayName || session.username}</div>
<div className="export-session-summary">{session.summary || '暂无消息'}</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="settings-panel">
<div className="panel-header">
<h2>导出设置</h2>
</div>
<div className="settings-content">
<div className="setting-section">
<h3>导出格式</h3>
<div className="format-options">
{formatOptions.map(fmt => (
<div
key={fmt.value}
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
onClick={() => handleFormatChange(fmt.value as ExportOptions['format'])}
>
<fmt.icon size={24} />
<span className="format-label">{fmt.label}</span>
<span className="format-desc">{fmt.desc}</span>
</div>
))}
</div>
</div>
<div className="setting-section">
<h3>时间范围</h3>
<p className="setting-subtitle">选择要导出的消息时间区间</p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
<span className="media-switch-title">导出全部时间</span>
<span className="media-switch-desc">关闭此项以选择特定的起止日期</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={options.useAllTime}
onChange={e => setOptions({ ...options, useAllTime: e.target.checked })}
/>
<span className="switch-slider"></span>
</label>
</div>
{!options.useAllTime && options.dateRange && (
<>
<div className="media-option-divider"></div>
<div className="time-range-picker-item" onClick={() => setShowDatePicker(true)}>
<div className="time-picker-info">
<Calendar size={16} />
<span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span>
</div>
<ChevronDown size={14} />
</div>
</>
)}
</div>
</div>
{/* 发送者名称显示偏好 */}
{(options.format === 'html' || options.format === 'json' || options.format === 'txt') && (
<div className="setting-section">
<h3>发送者名称显示</h3>
<p className="setting-subtitle">选择导出时优先显示的名称</p>
<div className="select-field" ref={displayNameDropdownRef}>
<div className="export-board-page">
<div className="export-top-panel">
<div className="global-export-controls">
<div className="path-control">
<span className="control-label">导出位置</span>
<div className="path-inline-row">
<div className="path-value">
<button
className="path-link"
type="button"
className={`select-trigger ${showDisplayNameSelect ? 'open' : ''}`}
onClick={() => setShowDisplayNameSelect(!showDisplayNameSelect)}
title={exportFolder}
onClick={() => void chooseExportFolder()}
>
<span className="select-value">{displayNameLabel}</span>
<ChevronDown size={16} />
{exportFolder || '未设置'}
</button>
<button className="path-change-btn" type="button" onClick={() => void chooseExportFolder()}>
更换
</button>
{showDisplayNameSelect && (
<div className="select-dropdown">
{displayNameOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${options.displayNamePreference === option.value ? 'active' : ''}`}
onClick={() => {
setOptions({
...options,
displayNamePreference: option.value as ExportOptions['displayNamePreference']
})
setShowDisplayNameSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
)}
<div className="setting-section">
<h3>媒体文件</h3>
<p className="setting-subtitle">导出图片/语音/视频/表情并在记录内写入相对路径</p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
<span className="media-switch-title">导出媒体文件</span>
<span className="media-switch-desc">会创建子文件夹并保存媒体资源</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={options.exportMedia}
onChange={e => setOptions({ ...options, exportMedia: e.target.checked })}
/>
<span className="switch-slider"></span>
</label>
</div>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title">图片</span>
<span className="media-checkbox-desc">已有文件直接复制,缺失时尝试解密</span>
</div>
<input
type="checkbox"
checked={options.exportImages}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportImages: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title">语音</span>
<span className="media-checkbox-desc">缺失时会解码生成 MP3</span>
</div>
<input
type="checkbox"
checked={options.exportVoices}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportVoices: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className="media-checkbox-row">
<div className="media-checkbox-info">
<span className="media-checkbox-title">语音转文字</span>
<span className="media-checkbox-desc">将语音消息转换为文字导出</span>
</div>
<input
type="checkbox"
checked={options.exportVoiceAsText}
onChange={e => setOptions({ ...options, exportVoiceAsText: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title">视频</span>
<span className="media-checkbox-desc">直接复制视频文件到导出目录</span>
</div>
<input
type="checkbox"
checked={options.exportVideos}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportVideos: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title">表情</span>
<span className="media-checkbox-desc">本地无缓存时尝试下载</span>
</div>
<input
type="checkbox"
checked={options.exportEmojis}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportEmojis: e.target.checked })}
/>
</label>
<button className="secondary-btn" onClick={() => exportFolder && void window.electronAPI.shell.openPath(exportFolder)}>
<ExternalLink size={14} /> 打开
</button>
</div>
</div>
<div className="setting-section">
<h3>头像</h3>
<p className="setting-subtitle">可选导出头像索引,关闭则不下载头像</p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
<span className="media-switch-title">导出头像</span>
<span className="media-switch-desc">用于展示发送者头像,可能会读取或下载头像文件</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={options.exportAvatars}
onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })}
/>
<span className="switch-slider"></span>
</label>
<WriteLayoutSelector
writeLayout={writeLayout}
onChange={async (value) => {
setWriteLayout(value)
await configService.setExportWriteLayout(value)
}}
sessionNameWithTypePrefix={sessionNameWithTypePrefix}
onSessionNameWithTypePrefixChange={async (enabled) => {
setSessionNameWithTypePrefix(enabled)
await configService.setExportSessionNamePrefixEnabled(enabled)
}}
/>
<div className="task-center-control">
<span className="control-label">任务中心</span>
<div className="task-center-inline">
<div className="task-summary">
<span>进行中 {taskRunningCount}</span>
<span>排队 {taskQueuedCount}</span>
<span>总计 {tasks.length}</span>
</div>
<button
className={`task-open-btn ${taskRunningCount > 0 ? 'active-running' : ''}`}
type="button"
onClick={() => setIsTaskCenterOpen(true)}
>
任务卡片
{taskRunningCount > 0 && <span className="task-running-badge">{taskRunningCount}</span>}
</button>
</div>
</div>
<div className="setting-section">
<h3>导出位置</h3>
<div className="export-path-display">
<FolderOpen size={16} />
<span>{exportFolder || '未设置'}</span>
</div>
<button
className="select-folder-btn"
onClick={async () => {
try {
const result = await window.electronAPI.dialog.openFile({
title: '选择导出目录',
properties: ['openDirectory']
})
if (!result.canceled && result.filePaths.length > 0) {
setExportFolder(result.filePaths[0])
await configService.setExportPath(result.filePaths[0])
}
} catch (e) {
console.error('选择目录失败:', e)
}
}}
>
<FolderOpen size={16} />
<span>选择导出目录</span>
</button>
</div>
</div>
<div className="export-action">
<button
className="export-btn"
onClick={startExport}
disabled={selectedSessions.size === 0 || !exportFolder || isExporting}
>
{isExporting ? (
<>
<Loader2 size={18} className="spin" />
<span>导出中 ({exportProgress.current}/{exportProgress.total})</span>
</>
) : (
<>
<Download size={18} />
<span>开始导出</span>
</>
)}
</button>
</div>
</div>
{/* 媒体导出布局选择弹窗 */}
{showMediaLayoutPrompt && (
<div className="export-overlay" onClick={() => setShowMediaLayoutPrompt(false)}>
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
<h3>导出文件夹布局</h3>
<p className="layout-subtitle">检测到同时导出多个会话并包含媒体文件,请选择存放方式:</p>
<div className="layout-options">
<button
className="layout-option-btn primary"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('shared')
}}
>
<span className="layout-title">所有会话在同一文件夹</span>
<span className="layout-desc">媒体会按会话名归档到 media 子目录</span>
</button>
<button
className="layout-option-btn"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('per-session')
}}
>
<span className="layout-title">每个会话一个文件夹</span>
<span className="layout-desc">每个会话单独包含导出文件和媒体</span>
</button>
</div>
<div className="layout-actions">
<button className="layout-cancel-btn" onClick={() => setShowMediaLayoutPrompt(false)}>
取消
</button>
</div>
</div>
</div>
)}
<TaskCenterModal
isOpen={isTaskCenterOpen}
tasks={tasks}
taskRunningCount={taskRunningCount}
taskQueuedCount={taskQueuedCount}
expandedPerfTaskId={expandedPerfTaskId}
nowTick={nowTick}
onClose={closeTaskCenter}
onTogglePerfTask={toggleTaskPerfDetail}
/>
{/* 导出前预估弹窗 */}
{showPreExportDialog && (
<div className="export-overlay">
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
<h3>导出预估</h3>
{isLoadingStats ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '24px 0', justifyContent: 'center' }}>
<Loader2 size={20} className="spin" />
<span style={{ fontSize: 14, color: 'var(--text-secondary)' }}>正在统计消息,可直接点击“直接导出”跳过等待</span>
<div className="export-section-title-row">
<h3 className="export-section-title">按类型批量导出</h3>
<SectionInfoTooltip
label="按类型批量导出"
heading="按类型批量导出说明"
messages={[
'按数据类型统一导出,适合横向汇总同类内容,比如集中导出图片、语音或视频。',
'发起前可先设置导出时间范围和格式,能减少无关数据,导出结果更聚焦。',
'每个类型卡片中展示到已导出会话数,统计范围会涵盖下方按会话导出。'
]}
/>
</div>
<div className="content-card-grid">
{contentCards.map(card => {
const Icon = card.icon
const isCardStatsLoading = card.type === 'sns'
? isSnsCardStatsLoading
: false
const isCardRunning = runningCardTypes.has(card.type)
return (
<div key={card.type} className="content-card">
<div className="card-header">
<div className="card-title"><Icon size={16} /> {card.label}</div>
{card.type === 'sns' && (
<div className="card-title-meta">
{isCardStatsLoading ? (
<span className="count-loading">
统计中<span className="animated-ellipsis" aria-hidden="true">...</span>
</span>
) : `${card.headerCount.toLocaleString()} 条`}
</div>
)}
</div>
) : preExportStats ? (
<div style={{ padding: '12px 0' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 24px', fontSize: 14 }}>
<div>
<span style={{ color: 'var(--text-secondary)' }}>会话数</span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{selectedSessions.size}</div>
<div className="card-stats">
{card.stats.map((stat) => (
<div key={stat.label} className="stat-item">
<span>{stat.label}</span>
<strong>
{isCardStatsLoading ? (
<span className="count-loading">
统计中<span className="animated-ellipsis" aria-hidden="true">...</span>
</span>
) : `${stat.value.toLocaleString()} ${stat.unit}`}
</strong>
</div>
<div>
<span style={{ color: 'var(--text-secondary)' }}>总消息</span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{preExportStats.totalMessages.toLocaleString()}</div>
))}
</div>
<button
className={`card-export-btn ${isCardRunning ? 'running' : ''}`}
disabled={isCardRunning}
onClick={() => {
if (card.type === 'sns') {
openSnsExport()
return
}
openContentExport(card.type)
}}
>
{isCardRunning ? (
<>
<span>批量导出中</span>
<Loader2 size={14} className="spin" />
</>
) : '批量导出'}
</button>
</div>
)
})}
</div>
<div className="export-section-title-row">
<h3 className="export-section-title">按会话导出</h3>
<SectionInfoTooltip
label="按会话导出"
heading="按会话导出说明"
messages={[
'按会话维度导出完整上下文,适合按客户、项目或群组进行归档。',
'你可以先在列表中筛选目标会话,再批量导出,结果会保留每个会话的结构与时间线。'
]}
/>
</div>
<div className="session-table-section" ref={sessionTableSectionRef}>
<div className="session-table-layout">
<div className="table-wrap">
<div className="session-table-sticky">
<div className="table-toolbar">
<div className="table-tabs" role="tablist" aria-label="会话类型">
<button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}>
私聊 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.private}
</button>
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}>
群聊 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.group}
</button>
<button className={`tab-btn ${activeTab === 'official' ? 'active' : ''}`} onClick={() => setActiveTab('official')}>
公众号 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.official}
</button>
<button className={`tab-btn ${activeTab === 'former_friend' ? 'active' : ''}`} onClick={() => setActiveTab('former_friend')}>
曾经的好友 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.former_friend}
</button>
</div>
<div className="toolbar-actions">
<div className="search-input-wrap">
<Search size={14} />
<input
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
placeholder={`搜索${activeTabLabel}联系人...`}
/>
{searchKeyword && (
<button className="clear-search" onClick={() => setSearchKeyword('')}>
<X size={12} />
</button>
)}
</div>
{options.exportVoiceAsText && preExportStats.voiceMessages > 0 && (
<>
<div>
<span style={{ color: 'var(--text-secondary)' }}>语音消息</span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{preExportStats.voiceMessages}</div>
</div>
<div>
<span style={{ color: 'var(--text-secondary)' }}>已有缓存</span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2, color: 'var(--primary)' }}>{preExportStats.cachedVoiceCount}</div>
</div>
</>
<button className="secondary-btn" onClick={() => void loadContactsList()} disabled={isContactsListLoading}>
<RefreshCw size={14} className={isContactsListLoading ? 'spin' : ''} />
刷新
</button>
</div>
</div>
{contactsList.length > 0 && isContactsListLoading && (
<div className="table-stage-hint">
<Loader2 size={14} className="spin" />
联系人列表同步中…
</div>
)}
{hasFilteredContacts && (
<div className="contacts-list-header">
<span className="contacts-list-header-select">
<button
className={`select-icon-btn ${isAllVisibleSelected ? 'checked' : ''}`}
type="button"
onClick={toggleSelectAllVisible}
disabled={visibleSelectableCount === 0}
title={isAllVisibleSelected ? '取消全选当前筛选联系人' : '全选当前筛选联系人'}
>
{isAllVisibleSelected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</span>
<span className="contacts-list-header-main">
<span className="contacts-list-header-main-label">联系人(头像/名称/微信号)</span>
</span>
<span className="contacts-list-header-count">总消息数</span>
<span className="contacts-list-header-actions">
{selectedCount > 0 && (
<>
<button
className="selection-clear-btn"
type="button"
onClick={clearSelection}
>
清空
</button>
<button
className="selection-export-btn"
type="button"
onClick={openBatchExport}
>
<span>批量导出</span>
<span className="selection-export-count">{selectedCount}</span>
</button>
</>
)}
</span>
</div>
)}
</div>
{contactsList.length === 0 && contactsLoadIssue ? (
<div className="load-issue-state">
<div className="issue-card">
<div className="issue-title">
<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>
{options.exportVoiceAsText && preExportStats.needTranscribeCount > 0 && (
<div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-warning, #e6a23c)' }}>⚠</span>
{' '}需要转写 <b>{preExportStats.needTranscribeCount}</b> 条语音,预计耗时约 <b>{preExportStats.estimatedSeconds > 60
? `${Math.round(preExportStats.estimatedSeconds / 60)} 分钟`
: `${preExportStats.estimatedSeconds} 秒`
}</b>
</div>
)}
{options.exportVoiceAsText && preExportStats.voiceMessages > 0 && preExportStats.needTranscribeCount === 0 && (
<div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-success, #67c23a)' }}>✓</span>
{' '}所有 {preExportStats.voiceMessages} 条语音已有转写缓存,无需重新转写
</div>
)}
</div>
) : isContactsListLoading && contactsList.length === 0 ? (
<div className="loading-state">
<Loader2 size={32} className="spin" />
<span>联系人加载中...</span>
</div>
) : !hasFilteredContacts ? (
<div className="empty-state">
<span>暂无联系人</span>
</div>
) : (
<p style={{ fontSize: 14, color: 'var(--text-secondary)', padding: '16px 0' }}>统计信息获取失败,仍可继续导出</p>
)}
<div className="layout-actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
<button className="layout-cancel-btn" onClick={() => { statsRequestIdRef.current++; setIsLoadingStats(false); setShowPreExportDialog(false); setPreExportStats(null) }}>
取消
</button>
<button className="layout-option-btn primary" onClick={confirmExport}>
<span className="layout-title">{isLoadingStats ? '直接导出' : '开始导出'}</span>
</button>
</div>
</div>
</div>
)}
{/* 导出进度弹窗 */}
{isExporting && (
<div className="export-overlay">
<div className="export-progress-modal">
<div className="progress-spinner">
<Loader2 size={32} className="spin" />
</div>
<h3>正在导出</h3>
<p className="progress-text">{exportProgress.currentName}</p>
{exportProgress.phaseLabel && (
<p className="progress-phase-label" style={{ fontSize: 13, color: 'var(--text-secondary)', margin: '4px 0 8px' }}>
{exportProgress.phaseLabel}
</p>
)}
{exportProgress.phaseTotal > 0 && (
<div className="progress-bar" style={{ marginBottom: 8 }}>
<div
className="progress-fill"
style={{ width: `${(exportProgress.phaseProgress / exportProgress.phaseTotal) * 100}%`, background: 'var(--primary-light, #79bbff)' }}
<div
className="contacts-list"
onWheelCapture={handleContactsListWheelCapture}
>
<Virtuoso
ref={contactsVirtuosoRef}
className="contacts-virtuoso"
data={filteredContacts}
computeItemKey={(_, contact) => contact.username}
itemContent={renderContactRow}
atTopStateChange={setIsContactsListAtTop}
overscan={420}
/>
</div>
)}
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${exportProgress.total > 0 ? (exportProgress.current / exportProgress.total) * 100 : 0}%` }}
/>
</div>
<p className="progress-count">
{exportProgress.current} / {exportProgress.total} 个会话
<span style={{ marginLeft: 12, fontSize: 12, color: 'var(--text-secondary)' }}>
{elapsedSeconds > 0 && `已用 ${elapsedSeconds >= 60 ? `${Math.floor(elapsedSeconds / 60)}分${elapsedSeconds % 60}秒` : `${elapsedSeconds}秒`}`}
</span>
</p>
</div>
</div>
)}
{/* 导出结果弹窗 */}
{exportResult && (
<div className="export-overlay">
<div className="export-result-modal">
<div className={`result-icon ${exportResult.success ? 'success' : 'error'}`}>
{exportResult.success ? <CheckCircle size={48} /> : <XCircle size={48} />}
</div>
<h3>{exportResult.success ? '导出完成' : '导出失败'}</h3>
{exportResult.success ? (
<p className="result-text">
成功导出 {exportResult.successCount} 个会话
{exportResult.failCount ? `,${exportResult.failCount} 个失败` : ''}
</p>
) : (
<p className="result-text error">{exportResult.error}</p>
)}
<div className="result-actions">
{exportResult.success && (
<button className="open-folder-btn" onClick={openExportFolder}>
<ExternalLink size={16} />
<span>打开文件夹</span>
</button>
)}
<button className="close-btn" onClick={() => setExportResult(null)}>
关闭
</button>
</div>
</div>
</div>
)}
{/* 日期选择弹窗 */}
{showDatePicker && (
<div className="export-overlay" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
<div className="date-picker-modal" onClick={e => e.stopPropagation()}>
<h3>选择时间范围</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: '8px 0 16px 0' }}>
点击选择开始和结束日期,系统会自动调整确保时间顺序正确
</p>
<div className="quick-select">
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
{showSessionDetailPanel && (
<div
className="export-session-detail-overlay"
onClick={closeSessionDetailPanel}
>
<aside
className="export-session-detail-panel"
role="dialog"
aria-modal="true"
aria-label="会话详情"
onClick={(event) => event.stopPropagation()}
>
最近7天
</button>
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
>
最近30天
</button>
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 90 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
>
最近90天
</button>
</div>
<div className="date-display">
<div
className={`date-display-item ${selectingStart ? 'active' : ''}`}
onClick={() => setSelectingStart(true)}
>
<span className="date-label">开始日期</span>
<span className="date-value">
{options.dateRange?.start?.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})}
</span>
</div>
<span className="date-separator">至</span>
<div
className={`date-display-item ${!selectingStart ? 'active' : ''}`}
onClick={() => setSelectingStart(false)}
>
<span className="date-label">结束日期</span>
<span className="date-value">
{options.dateRange?.end?.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})}
</span>
</div>
</div>
<div className="calendar-container">
<div className="calendar-header">
<button
className="calendar-nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
>
<ChevronLeft size={18} />
</button>
<span className="calendar-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
</span>
<button
className="calendar-nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
>
<ChevronRight size={18} />
</button>
</div>
{showYearMonthPicker ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="calendar-nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
<ChevronLeft size={16} />
</button>
<span className="year-label">{calendarDate.getFullYear()}年</span>
<button className="calendar-nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
<ChevronRight size={16} />
</button>
<div className="detail-header">
<div className="detail-header-main">
<div className="detail-header-avatar">
{sessionDetail?.avatarUrl ? (
<img src={sessionDetail.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(sessionDetail?.displayName || sessionDetail?.wxid || '')}</span>
)}
</div>
<div className="month-grid">
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
<button
key={i}
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
onClick={() => {
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
setShowYearMonthPicker(false)
}}
>{name}</button>
))}
<div className="detail-header-meta">
<h4>{sessionDetail?.displayName || '会话详情'}</h4>
<div className="detail-header-id">{sessionDetail?.wxid || ''}</div>
</div>
</div>
<button className="close-btn" onClick={closeSessionDetailPanel}>
<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">
<ClipboardList size={14} />
<span>导出记录(最近 20 条)</span>
</div>
{currentSessionExportRecords.length === 0 ? (
<div className="detail-record-empty">暂无导出记录</div>
) : (
<div className="detail-record-list">
{currentSessionExportRecords.map((record, index) => (
<div className="detail-record-item" key={`${record.exportTime}-${record.content}-${index}`}>
<div className="record-row">
<span className="label">导出时间</span>
<span className="value">{formatYmdHmDateTime(record.exportTime)}</span>
</div>
<div className="record-row">
<span className="label">导出内容</span>
<span className="value">{record.content}</span>
</div>
<div className="record-row">
<span className="label">导出目录</span>
<span className="value path" title={record.outputDir}>{formatPathBrief(record.outputDir)}</span>
<button
className="detail-inline-btn"
type="button"
onClick={() => void window.electronAPI.shell.openPath(record.outputDir)}
>
打开
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="detail-section">
<div className="section-title">
<MessageSquare size={14} />
<span>消息统计(导出口径)</span>
</div>
<div className="detail-stats-meta">
{isRefreshingSessionDetailStats
? '统计刷新中...'
: sessionDetail.statsUpdatedAt
? `${sessionDetail.statsStale ? '缓存于' : '更新于'} ${formatYmdHmDateTime(sessionDetail.statsUpdatedAt)}${sessionDetail.statsStale ? '(将后台刷新)' : ''}`
: (isLoadingSessionDetailExtra ? '统计加载中...' : '暂无统计缓存')}
</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>
<div className="detail-item">
<span className="label">转账消息数</span>
<span className="value">
{Number.isFinite(sessionDetail.transferMessages)
? (sessionDetail.transferMessages as number).toLocaleString()
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">红包消息数</span>
<span className="value">
{Number.isFinite(sessionDetail.redPacketMessages)
? (sessionDetail.redPacketMessages as number).toLocaleString()
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">通话消息数</span>
<span className="value">
{Number.isFinite(sessionDetail.callMessages)
? (sessionDetail.callMessages 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">
{sessionDetail.relationStatsLoaded
? (Number.isFinite(sessionDetail.groupMutualFriends)
? (sessionDetail.groupMutualFriends as number).toLocaleString()
: '—')
: (
<button
className="detail-inline-btn"
onClick={() => { void loadSessionRelationStats() }}
disabled={isLoadingSessionRelationStats || isLoadingSessionDetailExtra}
>
{isLoadingSessionRelationStats ? '加载中...' : '点击加载'}
</button>
)}
</span>
</div>
</>
) : (
<div className="detail-item">
<span className="label">共同群聊数</span>
<span className="value">
{sessionDetail.relationStatsLoaded
? (Number.isFinite(sessionDetail.privateMutualGroups)
? (sessionDetail.privateMutualGroups as number).toLocaleString()
: '—')
: (
<button
className="detail-inline-btn"
onClick={() => { void loadSessionRelationStats() }}
disabled={isLoadingSessionRelationStats || isLoadingSessionDetailExtra}
>
{isLoadingSessionRelationStats ? '加载中...' : '点击加载'}
</button>
)}
</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="calendar-weekdays">
{['日', '一', '二', '三', '四', '五', '六'].map(day => (
<div key={day} className="calendar-weekday">{day}</div>
))}
</div>
<div className="calendar-days">
{generateCalendar().map((day, index) => {
if (day === null) {
return <div key={`empty-${index}`} className="calendar-day empty" />
}
<div className="detail-empty">暂无详情</div>
)}
</aside>
</div>
)}
</div>
</div>
const currentDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
const isStart = options.dateRange?.start?.toDateString() === currentDate.toDateString()
const isEnd = options.dateRange?.end?.toDateString() === currentDate.toDateString()
const isInRange = options.dateRange?.start && options.dateRange?.end && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end
const today = new Date()
today.setHours(0, 0, 0, 0)
const isFuture = currentDate > today
{exportDialog.open && createPortal(
<div className="export-dialog-overlay" onClick={closeExportDialog}>
<div className="export-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<div className="dialog-header">
<h3>{exportDialog.title}</h3>
<button className="close-icon-btn" onClick={closeExportDialog}><X size={16} /></button>
</div>
return (
<div
key={day}
className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''} ${isFuture ? 'disabled' : ''}`}
onClick={() => !isFuture && handleDateSelect(day)}
style={{ cursor: isFuture ? 'not-allowed' : 'pointer', opacity: isFuture ? 0.3 : 1 }}
>
{day}
</div>
)
})}
<div className="dialog-body">
<div className="dialog-section">
<h4>导出范围</h4>
<div className="scope-tag-row">
<span className="scope-tag">{scopeLabel}</span>
<span className="scope-count">{scopeCountLabel}</span>
</div>
<div className="scope-list">
{exportDialog.sessionNames.slice(0, 20).map(name => (
<span key={name} className="scope-item">{name}</span>
))}
{exportDialog.sessionNames.length > 20 && <span className="scope-item">... 还有 {exportDialog.sessionNames.length - 20} 个</span>}
</div>
</div>
</>
{shouldShowFormatSection && (
<div className="dialog-section">
<h4>{exportDialog.scope === 'sns' ? '朋友圈导出格式选择' : '对话文本导出格式选择'}</h4>
{isContentTextDialog && (
<div className="format-note">说明:此模式默认导出头像,不导出图片、语音、视频、表情包等媒体内容。</div>
)}
<div className="format-grid">
{formatCandidateOptions.map(option => (
<button
key={option.value}
className={`format-card ${exportDialog.scope === 'sns'
? (snsExportFormat === option.value ? 'active' : '')
: (options.format === option.value ? 'active' : '')}`}
onClick={() => {
if (exportDialog.scope === 'sns') {
setSnsExportFormat(option.value as SnsTimelineExportFormat)
} else {
setOptions(prev => ({ ...prev, format: option.value as TextExportFormat }))
}
}}
>
<div className="format-label">{option.label}</div>
<div className="format-desc">{option.desc}</div>
</button>
))}
</div>
</div>
)}
<div className="dialog-section">
<div className="section-header-action">
<h4>时间范围</h4>
<button
type="button"
className="time-range-trigger"
onClick={openTimeRangeDialog}
>
<span>{timeRangeSummaryLabel}</span>
<span className="time-range-arrow">&gt;</span>
</button>
</div>
</div>
{shouldShowMediaSection && (
<div className="dialog-section">
<h4>{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体与头像'}</h4>
<div className="media-check-grid">
{exportDialog.scope === 'sns' ? (
<>
<label><input type="checkbox" checked={snsExportImages} onChange={event => setSnsExportImages(event.target.checked)} /> 图片</label>
<label><input type="checkbox" checked={snsExportLivePhotos} onChange={event => setSnsExportLivePhotos(event.target.checked)} /> 实况图</label>
<label><input type="checkbox" checked={snsExportVideos} onChange={event => setSnsExportVideos(event.target.checked)} /> 视频</label>
</>
) : (
<>
<label><input type="checkbox" checked={options.exportImages} onChange={event => setOptions(prev => ({ ...prev, exportImages: event.target.checked }))} /> 图片</label>
<label><input type="checkbox" checked={options.exportVoices} onChange={event => setOptions(prev => ({ ...prev, exportVoices: event.target.checked }))} /> 语音</label>
<label><input type="checkbox" checked={options.exportVideos} onChange={event => setOptions(prev => ({ ...prev, exportVideos: event.target.checked }))} /> 视频</label>
<label><input type="checkbox" checked={options.exportEmojis} onChange={event => setOptions(prev => ({ ...prev, exportEmojis: event.target.checked }))} /> 表情包</label>
<label><input type="checkbox" checked={options.exportVoiceAsText} onChange={event => setOptions(prev => ({ ...prev, exportVoiceAsText: event.target.checked }))} /> 语音转文字</label>
<label><input type="checkbox" checked={options.exportAvatars} onChange={event => setOptions(prev => ({ ...prev, exportAvatars: event.target.checked }))} /> 导出头像</label>
</>
)}
</div>
{exportDialog.scope === 'sns' && (
<div className="format-note">全不勾选时仅导出文本信息,不导出媒体文件。</div>
)}
</div>
)}
{shouldShowDisplayNameSection && (
<div className="dialog-section">
<h4>发送者名称显示</h4>
<div className="display-name-options" role="radiogroup" aria-label="发送者名称显示">
{displayNameOptions.map(option => {
const isActive = options.displayNamePreference === option.value
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={isActive}
className={`display-name-item ${isActive ? 'active' : ''}`}
onClick={() => setOptions(prev => ({ ...prev, displayNamePreference: option.value }))}
>
<span>{option.label}</span>
<small>{option.desc}</small>
</button>
)
})}
</div>
</div>
)}
</div>
<div className="date-picker-actions">
<button className="cancel-btn" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
取消
</button>
<button className="confirm-btn" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
确定
<div className="dialog-actions">
<button className="secondary-btn" onClick={closeExportDialog}>取消</button>
<button className="primary-btn" onClick={() => void createTask()} disabled={!canCreateTask}>
<Download size={14} /> 创建导出任务
</button>
</div>
{isTimeRangeDialogOpen && (
<div className="time-range-dialog-overlay" onClick={closeTimeRangeDialog}>
<div className="time-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<div className="time-range-dialog-header">
<h4>时间范围设置</h4>
<button
type="button"
className="close-icon-btn"
onClick={closeTimeRangeDialog}
aria-label="关闭时间范围设置"
>
<X size={14} />
</button>
</div>
<div className="time-range-preset-list">
{([
{ value: 'all', label: '全部时间' },
{ value: 'today', label: '今天' },
{ value: 'yesterday', label: '昨天' },
{ value: 'last3days', label: '最近3天' },
{ value: 'last7days', label: '最近一周' },
{ value: 'last30days', label: '最近30 天' },
{ value: 'last1year', label: '最近一年' }
] as Array<{ value: Exclude<DateRangePreset, 'custom'>; label: string }>).map((preset) => {
const isActive = isTimeRangePresetActive(preset.value)
return (
<button
key={preset.value}
type="button"
className={`time-range-preset-item ${isActive ? 'active' : ''}`}
onClick={() => handleTimeRangePresetClick(preset.value)}
>
<span>{preset.label}</span>
{isActive && <Check size={14} />}
</button>
)
})}
</div>
<div className={`time-range-mode-banner ${isRangeModeActive ? 'range' : 'all'}`}>
{timeRangeModeText}
</div>
<div className="time-range-calendar-grid">
<section className="time-range-calendar-panel">
<div className="time-range-calendar-panel-header">
<div className="time-range-calendar-date-label">
<span>起始日期</span>
<input
type="text"
className={`time-range-date-input ${timeRangeDateInputError.start ? 'invalid' : ''}`}
value={timeRangeDateInput.start}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setTimeRangeDateInput(prev => ({ ...prev, start: nextValue }))
if (timeRangeDateInputError.start) {
setTimeRangeDateInputError(prev => ({ ...prev, start: false }))
}
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitTimeRangeStartFromInput()
}}
onBlur={() => {
commitTimeRangeStartFromInput()
}}
/>
</div>
<div className="time-range-calendar-nav">
<button type="button" onClick={() => shiftTimeRangePanelMonth('start', -1)} aria-label="上个月">‹</button>
<span>{formatCalendarMonthTitle(activeTimeRangeDialogDraft.startPanelMonth)}</span>
<button type="button" onClick={() => shiftTimeRangePanelMonth('start', 1)} aria-label="下个月">›</button>
</div>
</div>
<div className="time-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`start-weekday-${label}`}>{label}</span>
))}
</div>
<div className="time-range-calendar-days">
{startPanelCells.map((cell) => {
const isSelected = !activeTimeRangeDialogDraft.useAllTime &&
isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.start)
return (
<button
key={`start-${cell.date.getTime()}`}
type="button"
className={`time-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${isSelected ? 'selected' : ''}`}
onClick={() => updateTimeRangeDraftStart(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
<section className="time-range-calendar-panel">
<div className="time-range-calendar-panel-header">
<div className="time-range-calendar-date-label">
<span>截止日期</span>
<input
type="text"
className={`time-range-date-input ${timeRangeDateInputError.end ? 'invalid' : ''}`}
value={timeRangeDateInput.end}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setTimeRangeDateInput(prev => ({ ...prev, end: nextValue }))
if (timeRangeDateInputError.end) {
setTimeRangeDateInputError(prev => ({ ...prev, end: false }))
}
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitTimeRangeEndFromInput()
}}
onBlur={() => {
commitTimeRangeEndFromInput()
}}
/>
</div>
<div className="time-range-calendar-nav">
<button type="button" onClick={() => shiftTimeRangePanelMonth('end', -1)} aria-label="上个月">‹</button>
<span>{formatCalendarMonthTitle(activeTimeRangeDialogDraft.endPanelMonth)}</span>
<button type="button" onClick={() => shiftTimeRangePanelMonth('end', 1)} aria-label="下个月">›</button>
</div>
</div>
<div className="time-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`end-weekday-${label}`}>{label}</span>
))}
</div>
<div className="time-range-calendar-days">
{endPanelCells.map((cell) => {
const isSelected = !activeTimeRangeDialogDraft.useAllTime &&
isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.end)
return (
<button
key={`end-${cell.date.getTime()}`}
type="button"
className={`time-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${isSelected ? 'selected' : ''}`}
onClick={() => updateTimeRangeDraftEnd(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
</div>
<div className="time-range-dialog-actions">
<button type="button" className="secondary-btn" onClick={closeTimeRangeDialog}>
取消
</button>
<button type="button" className="primary-btn" onClick={commitTimeRangeDialogDraft}>
确认
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>,
document.body
)}
</div>
)
+2 -1
View File
@@ -30,7 +30,7 @@ interface GroupMessageRank {
}
type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
interface MemberMessageExportOptions {
format: MemberExportFormat
@@ -119,6 +119,7 @@ function GroupAnalyticsPage() {
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
+22 -6
View File
@@ -36,6 +36,18 @@ interface WxidOption {
modifiedTime: number
}
const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
const base = String(error || '自动获取密钥失败').trim()
const tailLogs = Array.isArray(logs)
? logs
.map(item => String(item || '').trim())
.filter(Boolean)
.slice(-6)
: []
if (tailLogs.length === 0) return base
return `${base};最近状态:${tailLogs.join(' | ')}`
}
function SettingsPage() {
const {
isDbConnected,
@@ -103,12 +115,12 @@ function SettingsPage() {
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
const [exportDefaultFormat, setExportDefaultFormat] = useState('json')
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(4)
const [notificationEnabled, setNotificationEnabled] = useState(true)
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
@@ -286,7 +298,6 @@ function SettingsPage() {
const savedWhisperModelDir = await configService.getWhisperModelDir()
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
const savedExportDefaultFormat = await configService.getExportDefaultFormat()
const savedExportDefaultDateRange = await configService.getExportDefaultDateRange()
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
@@ -327,12 +338,13 @@ function SettingsPage() {
setLogEnabled(savedLogEnabled)
setAutoTranscribeVoice(savedAutoTranscribe)
setTranscribeLanguages(savedTranscribeLanguages)
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
setExportDefaultFormat('json')
await configService.setExportDefaultFormat('json')
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
setExportDefaultMedia(savedExportDefaultMedia ?? false)
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 4)
setNotificationEnabled(savedNotificationEnabled)
setNotificationPosition(savedNotificationPosition)
@@ -725,7 +737,10 @@ function SettingsPage() {
setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信')
} else {
showMessage(result.error || '自动获取密钥失败', false)
if (result.error?.includes('尚未完成登录')) {
setDbKeyStatus('请先在微信完成登录后重试')
}
showMessage(formatDbKeyFailureMessage(result.error, result.logs), false)
}
}
} catch (e: any) {
@@ -1546,6 +1561,7 @@ function SettingsPage() {
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
+95 -18
View File
@@ -23,7 +23,7 @@
========================================= */
.sns-main-viewport {
flex: 1;
overflow-y: scroll;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
@@ -35,7 +35,9 @@
padding: 20px 24px 60px 24px;
display: flex;
flex-direction: column;
gap: 24px;
gap: 0;
min-height: 0;
height: 100%;
}
.feed-header {
@@ -44,12 +46,50 @@
justify-content: space-between;
margin-bottom: 8px;
padding: 0 4px;
z-index: 2;
background: var(--sns-bg-color);
border-bottom: 1px solid var(--border-color);
padding-top: 10px;
padding-bottom: 10px;
h2 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--text-primary);
.feed-header-main {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
h2 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--text-primary);
}
}
.feed-stats-line {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
&.loading {
opacity: 0.7;
}
&.error {
color: #d94f45;
}
}
.feed-stats-retry {
border: none;
background: transparent;
color: inherit;
font-size: 13px;
padding: 0;
line-height: 1.4;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.header-actions {
@@ -85,6 +125,13 @@
}
}
.sns-posts-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-top: 16px;
}
.posts-list {
display: flex;
flex-direction: column;
@@ -1031,9 +1078,11 @@
margin-bottom: 0;
/* Remove margin to merge */
.contact-name {
color: var(--primary);
font-weight: 600;
.contact-meta {
.contact-name {
color: var(--primary);
font-weight: 600;
}
}
/* If the NEXT item is also selected */
@@ -1056,13 +1105,20 @@
/* Compensate for missing border */
}
.contact-name {
.contact-meta {
flex: 1;
font-size: 14px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
.contact-name {
font-size: 14px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
@@ -1909,10 +1965,31 @@
font-size: 12px;
color: var(--text-tertiary);
margin: 0;
padding-left: 24px;
line-height: 1.4;
}
.export-media-check-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-primary);
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
}
input[type='checkbox'] {
margin: 0;
}
}
.export-progress {
display: flex;
flex-direction: column;
@@ -2091,4 +2168,4 @@
cursor: not-allowed;
}
}
}
}
+370 -117
View File
@@ -5,6 +5,11 @@ import './SnsPage.scss'
import { SnsPost } from '../types/sns'
import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel'
import * as configService from '../services/config'
const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const SNS_PAGE_CACHE_POST_LIMIT = 200
const SNS_PAGE_CACHE_SCOPE_FALLBACK = '__default__'
interface Contact {
username: string
@@ -13,11 +18,29 @@ interface Contact {
type?: 'friend' | 'former_friend' | 'sns_only'
}
interface SnsOverviewStats {
totalPosts: number
totalFriends: number
myPosts: number | null
earliestTime: number | null
latestTime: number | null
}
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
export default function SnsPage() {
const [posts, setPosts] = useState<SnsPost[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const loadingRef = useRef(false)
const [overviewStats, setOverviewStats] = useState<SnsOverviewStats>({
totalPosts: 0,
totalFriends: 0,
myPosts: null,
earliestTime: null,
latestTime: null
})
const [overviewStatsStatus, setOverviewStatsStatus] = useState<OverviewStatsStatus>('loading')
// Filter states
const [searchKeyword, setSearchKeyword] = useState('')
@@ -35,9 +58,11 @@ export default function SnsPage() {
// 导出相关状态
const [showExportDialog, setShowExportDialog] = useState(false)
const [exportFormat, setExportFormat] = useState<'json' | 'html'>('html')
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html')
const [exportFolder, setExportFolder] = useState('')
const [exportMedia, setExportMedia] = useState(false)
const [exportImages, setExportImages] = useState(false)
const [exportLivePhotos, setExportLivePhotos] = useState(false)
const [exportVideos, setExportVideos] = useState(false)
const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' })
const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null)
@@ -56,12 +81,34 @@ export default function SnsPage() {
const [hasNewer, setHasNewer] = useState(false)
const [loadingNewer, setLoadingNewer] = useState(false)
const postsRef = useRef<SnsPost[]>([])
const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats)
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
const selectedUsernamesRef = useRef<string[]>(selectedUsernames)
const searchKeywordRef = useRef(searchKeyword)
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
const cacheScopeKeyRef = useRef('')
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const contactsLoadTokenRef = useRef(0)
// Sync posts ref
useEffect(() => {
postsRef.current = posts
}, [posts])
useEffect(() => {
overviewStatsRef.current = overviewStats
}, [overviewStats])
useEffect(() => {
overviewStatsStatusRef.current = overviewStatsStatus
}, [overviewStatsStatus])
useEffect(() => {
selectedUsernamesRef.current = selectedUsernames
}, [selectedUsernames])
useEffect(() => {
searchKeywordRef.current = searchKeyword
}, [searchKeyword])
useEffect(() => {
jumpTargetDateRef.current = jumpTargetDate
}, [jumpTargetDate])
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
useLayoutEffect(() => {
const snapshot = scrollAdjustmentRef.current;
@@ -75,6 +122,163 @@ export default function SnsPage() {
}
}, [posts])
const formatDateOnly = (timestamp: number | null): string => {
if (!timestamp || timestamp <= 0) return '--'
const date = new Date(timestamp * 1000)
if (Number.isNaN(date.getTime())) return '--'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const isDefaultViewNow = useCallback(() => {
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
}, [])
const ensureSnsCacheScopeKey = useCallback(async () => {
if (cacheScopeKeyRef.current) return cacheScopeKeyRef.current
const wxid = (await configService.getMyWxid())?.trim() || SNS_PAGE_CACHE_SCOPE_FALLBACK
const scopeKey = `sns_page:${wxid}`
cacheScopeKeyRef.current = scopeKey
return scopeKey
}, [])
const persistSnsPageCache = useCallback(async (patch?: { posts?: SnsPost[]; overviewStats?: SnsOverviewStats }) => {
if (!isDefaultViewNow()) return
try {
const scopeKey = await ensureSnsCacheScopeKey()
if (!scopeKey) return
const existingCache = await configService.getSnsPageCache(scopeKey)
let postsToStore = patch?.posts ?? postsRef.current
if (!patch?.posts && postsToStore.length === 0) {
if (existingCache && Array.isArray(existingCache.posts) && existingCache.posts.length > 0) {
postsToStore = existingCache.posts as SnsPost[]
}
}
const overviewToStore = patch?.overviewStats
?? (overviewStatsStatusRef.current === 'ready'
? overviewStatsRef.current
: existingCache?.overviewStats ?? overviewStatsRef.current)
await configService.setSnsPageCache(scopeKey, {
overviewStats: overviewToStore,
posts: postsToStore.slice(0, SNS_PAGE_CACHE_POST_LIMIT)
})
} catch (error) {
console.error('Failed to persist SNS page cache:', error)
}
}, [ensureSnsCacheScopeKey, isDefaultViewNow])
const hydrateSnsPageCache = useCallback(async () => {
try {
const scopeKey = await ensureSnsCacheScopeKey()
const cached = await configService.getSnsPageCache(scopeKey)
if (!cached) return
if (Date.now() - cached.updatedAt > SNS_PAGE_CACHE_TTL_MS) return
const cachedOverview = cached.overviewStats
if (cachedOverview) {
const cachedTotalPosts = Math.max(0, Number(cachedOverview.totalPosts || 0))
const cachedTotalFriends = Math.max(0, Number(cachedOverview.totalFriends || 0))
const hasCachedPosts = Array.isArray(cached.posts) && cached.posts.length > 0
const hasOverviewData = cachedTotalPosts > 0 || cachedTotalFriends > 0
setOverviewStats({
totalPosts: cachedTotalPosts,
totalFriends: cachedTotalFriends,
myPosts: typeof cachedOverview.myPosts === 'number' && Number.isFinite(cachedOverview.myPosts) && cachedOverview.myPosts >= 0
? Math.floor(cachedOverview.myPosts)
: null,
earliestTime: cachedOverview.earliestTime ?? null,
latestTime: cachedOverview.latestTime ?? null
})
// 只有明确有统计值(或确实无帖子)时才把缓存视为 ready,避免历史异常 0 卡住显示。
setOverviewStatsStatus(hasOverviewData || !hasCachedPosts ? 'ready' : 'loading')
}
if (Array.isArray(cached.posts) && cached.posts.length > 0) {
const cachedPosts = cached.posts
.filter((raw): raw is SnsPost => {
if (!raw || typeof raw !== 'object') return false
const row = raw as Record<string, unknown>
return typeof row.id === 'string' && typeof row.createTime === 'number'
})
.slice(0, SNS_PAGE_CACHE_POST_LIMIT)
.sort((a, b) => b.createTime - a.createTime)
if (cachedPosts.length > 0) {
setPosts(cachedPosts)
setHasMore(true)
setHasNewer(false)
}
}
} catch (error) {
console.error('Failed to hydrate SNS page cache:', error)
}
}, [ensureSnsCacheScopeKey])
const loadOverviewStats = useCallback(async () => {
setOverviewStatsStatus('loading')
try {
const statsResult = await window.electronAPI.sns.getExportStats()
if (!statsResult.success || !statsResult.data) {
throw new Error(statsResult.error || '获取朋友圈统计失败')
}
const totalPosts = Math.max(0, Number(statsResult.data.totalPosts || 0))
const totalFriends = Math.max(0, Number(statsResult.data.totalFriends || 0))
const myPosts = (typeof statsResult.data.myPosts === 'number' && Number.isFinite(statsResult.data.myPosts) && statsResult.data.myPosts >= 0)
? Math.floor(statsResult.data.myPosts)
: null
let earliestTime: number | null = null
let latestTime: number | null = null
if (totalPosts > 0) {
const [latestResult, earliestResult] = await Promise.all([
window.electronAPI.sns.getTimeline(1, 0),
window.electronAPI.sns.getTimeline(1, Math.max(totalPosts - 1, 0))
])
const latestTs = Number(latestResult.timeline?.[0]?.createTime || 0)
const earliestTs = Number(earliestResult.timeline?.[0]?.createTime || 0)
if (latestResult.success && Number.isFinite(latestTs) && latestTs > 0) {
latestTime = Math.floor(latestTs)
}
if (earliestResult.success && Number.isFinite(earliestTs) && earliestTs > 0) {
earliestTime = Math.floor(earliestTs)
}
}
const nextOverviewStats = {
totalPosts,
totalFriends,
myPosts,
earliestTime,
latestTime
}
setOverviewStats(nextOverviewStats)
setOverviewStatsStatus('ready')
void persistSnsPageCache({ overviewStats: nextOverviewStats })
} catch (error) {
console.error('Failed to load SNS overview stats:', error)
setOverviewStatsStatus('error')
}
}, [persistSnsPageCache])
const renderOverviewStats = () => {
if (overviewStatsStatus === 'error') {
return (
<button type="button" className="feed-stats-retry" onClick={() => { void loadOverviewStats() }}>
统计失败,点击重试
</button>
)
}
if (overviewStatsStatus === 'loading') {
return '统计中...'
}
const myPostsLabel = overviewStats.myPosts === null ? '--' : String(overviewStats.myPosts)
return `共 ${overviewStats.totalPosts} 条 | 我的朋友圈 ${myPostsLabel} 条 | ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} | ${overviewStats.totalFriends} 位好友`
}
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
const { reset = false, direction = 'older' } = options
if (loadingRef.current) return
@@ -119,7 +323,9 @@ export default function SnsPage() {
const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id));
if (uniqueNewer.length > 0) {
setPosts(prev => [...uniqueNewer, ...prev].sort((a, b) => b.createTime - a.createTime));
const merged = [...uniqueNewer, ...currentPosts].sort((a, b) => b.createTime - a.createTime)
setPosts(merged);
void persistSnsPageCache({ posts: merged })
}
setHasNewer(result.timeline.length >= limit);
} else {
@@ -149,6 +355,7 @@ export default function SnsPage() {
if (result.success && result.timeline) {
if (reset) {
setPosts(result.timeline)
void persistSnsPageCache({ posts: result.timeline })
setHasMore(result.timeline.length >= limit)
// Check for newer items above topTs
@@ -165,7 +372,9 @@ export default function SnsPage() {
}
} else {
if (result.timeline.length > 0) {
setPosts(prev => [...prev, ...result.timeline!].sort((a, b) => b.createTime - a.createTime))
const merged = [...postsRef.current, ...result.timeline!].sort((a, b) => b.createTime - a.createTime)
setPosts(merged)
void persistSnsPageCache({ posts: merged })
}
if (result.timeline.length < limit) {
setHasMore(false)
@@ -179,22 +388,16 @@ export default function SnsPage() {
setLoadingNewer(false)
loadingRef.current = false
}
}, [selectedUsernames, searchKeyword, jumpTargetDate])
}, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedUsernames])
// Load Contacts(合并好友+曾经好友+朋友圈发布者,enrichSessionsContactInfo 补充头像)
// Load Contacts(仅加载好友/曾经好友,不再统计朋友圈条数)
const loadContacts = useCallback(async () => {
const requestToken = ++contactsLoadTokenRef.current
setContactsLoading(true)
try {
// 并行获取联系人列表和朋友圈发布者列表
const [contactsResult, snsResult] = await Promise.all([
window.electronAPI.chat.getContacts(),
window.electronAPI.sns.getSnsUsernames()
])
// 以联系人为基础,按 username 去重
const contactsResult = await window.electronAPI.chat.getContacts()
const contactMap = new Map<string, Contact>()
// 好友和曾经的好友
if (contactsResult.success && contactsResult.contacts) {
for (const c of contactsResult.contacts) {
if (c.type === 'friend' || c.type === 'former_friend') {
@@ -208,55 +411,61 @@ export default function SnsPage() {
}
}
// 朋友圈发布者(补充不在联系人列表中的用户)
if (snsResult.success && snsResult.usernames) {
for (const u of snsResult.usernames) {
if (!contactMap.has(u)) {
contactMap.set(u, { username: u, displayName: u, type: 'sns_only' })
}
}
}
let contactsList = Array.from(contactMap.values())
const allUsernames = Array.from(contactMap.keys())
if (requestToken !== contactsLoadTokenRef.current) return
setContacts(contactsList)
const allUsernames = contactsList.map(c => c.username)
// 用 enrichSessionsContactInfo 统一补充头像和显示名
if (allUsernames.length > 0) {
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
if (enriched.success && enriched.contacts) {
for (const [username, extra] of Object.entries(enriched.contacts) as [string, { displayName?: string; avatarUrl?: string }][]) {
const c = contactMap.get(username)
if (c) {
c.displayName = extra.displayName || c.displayName
c.avatarUrl = extra.avatarUrl || c.avatarUrl
contactsList = contactsList.map(contact => {
const extra = enriched.contacts?.[contact.username]
if (!extra) return contact
return {
...contact,
displayName: extra.displayName || contact.displayName,
avatarUrl: extra.avatarUrl || contact.avatarUrl
}
}
})
if (requestToken !== contactsLoadTokenRef.current) return
setContacts(contactsList)
}
}
setContacts(Array.from(contactMap.values()))
} catch (error) {
if (requestToken !== contactsLoadTokenRef.current) return
console.error('Failed to load contacts:', error)
} finally {
setContactsLoading(false)
if (requestToken === contactsLoadTokenRef.current) {
setContactsLoading(false)
}
}
}, [])
// Initial Load & Listeners
useEffect(() => {
void hydrateSnsPageCache()
loadContacts()
}, [loadContacts])
loadOverviewStats()
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats])
useEffect(() => {
const handleChange = () => {
cacheScopeKeyRef.current = ''
// wxid changed, reset everything
setPosts([]); setHasMore(true); setHasNewer(false);
setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined);
void hydrateSnsPageCache()
loadContacts();
loadOverviewStats();
loadPosts({ reset: true });
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadContacts, loadPosts])
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts])
useEffect(() => {
const timer = setTimeout(() => {
@@ -285,10 +494,15 @@ export default function SnsPage() {
return (
<div className="sns-page-layout">
<div className="sns-main-viewport" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
<div className="sns-main-viewport">
<div className="sns-feed-container">
<div className="feed-header">
<h2>朋友圈</h2>
<div className="feed-header-main">
<h2>朋友圈</h2>
<div className={`feed-stats-line ${overviewStatsStatus}`}>
{renderOverviewStats()}
</div>
</div>
<div className="header-actions">
<button
onClick={async () => {
@@ -325,6 +539,7 @@ export default function SnsPage() {
onClick={() => {
setRefreshSpin(true)
loadPosts({ reset: true })
loadOverviewStats()
setTimeout(() => setRefreshSpin(false), 800)
}}
disabled={loading || loadingNewer}
@@ -336,75 +551,84 @@ export default function SnsPage() {
</div>
</div>
{loadingNewer && (
<div className="status-indicator loading-newer">
<RefreshCw size={16} className="spinning" />
<span>正在检查更新的动态...</span>
</div>
)}
{!loadingNewer && hasNewer && (
<div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
有新动态,点击查看
</div>
)}
<div className="posts-list">
{posts.map(post => (
<SnsPostItem
key={post.id}
post={{ ...post, isProtected: triggerInstalled === true }}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={(p) => setDebugPost(p)}
onDelete={(postId) => setPosts(prev => prev.filter(p => p.id !== postId))}
/>
))}
</div>
{loading && posts.length === 0 && (
<div className="initial-loading">
<div className="loading-pulse">
<div className="pulse-circle"></div>
<span>正在加载朋友圈...</span>
<div className="sns-posts-scroll" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
{loadingNewer && (
<div className="status-indicator loading-newer">
<RefreshCw size={16} className="spinning" />
<span>正在检查更新的动态...</span>
</div>
</div>
)}
)}
{loading && posts.length > 0 && (
<div className="status-indicator loading-more">
<RefreshCw size={16} className="spinning" />
<span>正在加载更多...</span>
</div>
)}
{!loadingNewer && hasNewer && (
<div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
有新动态,点击查看
</div>
)}
{!hasMore && posts.length > 0 && (
<div className="status-indicator no-more">{
selectedUsernames.length === 1 &&
contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend'
? '在时间的长河里刻舟求剑'
: '或许过往已无可溯洄,但好在还有可以与你相遇的明天'
}</div>
)}
{!loading && posts.length === 0 && (
<div className="no-results">
<div className="no-results-icon"><Search size={48} /></div>
<p>未找到相关动态</p>
{(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && (
<button onClick={() => {
setSearchKeyword(''); setSelectedUsernames([]); setJumpTargetDate(undefined);
}} className="reset-inline">
重置筛选条件
</button>
)}
<div className="posts-list">
{posts.map(post => (
<SnsPostItem
key={post.id}
post={{ ...post, isProtected: triggerInstalled === true }}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={(p) => setDebugPost(p)}
onDelete={(postId) => {
setPosts(prev => {
const next = prev.filter(p => p.id !== postId)
void persistSnsPageCache({ posts: next })
return next
})
loadOverviewStats()
}}
/>
))}
</div>
)}
{loading && posts.length === 0 && (
<div className="initial-loading">
<div className="loading-pulse">
<div className="pulse-circle"></div>
<span>正在加载朋友圈...</span>
</div>
</div>
)}
{loading && posts.length > 0 && (
<div className="status-indicator loading-more">
<RefreshCw size={16} className="spinning" />
<span>正在加载更多...</span>
</div>
)}
{!hasMore && posts.length > 0 && (
<div className="status-indicator no-more">{
selectedUsernames.length === 1 &&
contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend'
? '在时间的长河里刻舟求剑'
: '或许过往已无可溯洄,但好在还有可以与你相遇的明天'
}</div>
)}
{!loading && posts.length === 0 && (
<div className="no-results">
<div className="no-results-icon"><Search size={48} /></div>
<p>未找到相关动态</p>
{(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && (
<button onClick={() => {
setSearchKeyword(''); setSelectedUsernames([]); setJumpTargetDate(undefined);
}} className="reset-inline">
重置筛选条件
</button>
)}
</div>
)}
</div>
</div>
</div>
@@ -597,6 +821,15 @@ export default function SnsPage() {
<span>JSON</span>
<small>结构化数据</small>
</button>
<button
className={`format-option ${exportFormat === 'arkmejson' ? 'active' : ''}`}
onClick={() => setExportFormat('arkmejson')}
disabled={isExporting}
>
<FileJson size={20} />
<span>ArkmeJSON</span>
<small>结构化数据(含互动身份)</small>
</button>
</div>
</div>
@@ -658,22 +891,40 @@ export default function SnsPage() {
{/* 媒体导出 */}
<div className="export-section">
<div className="export-toggle-row">
<div className="toggle-label">
<Image size={16} />
<span>导出媒体文件(图片/视频)</span>
</div>
<button
className={`toggle-switch${exportMedia ? ' active' : ''}`}
onClick={() => !isExporting && setExportMedia(!exportMedia)}
disabled={isExporting}
>
<span className="toggle-knob" />
</button>
<label className="export-label">
<Image size={14} />
媒体文件(可多选)
</label>
<div className="export-media-check-grid">
<label>
<input
type="checkbox"
checked={exportImages}
onChange={(e) => setExportImages(e.target.checked)}
disabled={isExporting}
/>
图片
</label>
<label>
<input
type="checkbox"
checked={exportLivePhotos}
onChange={(e) => setExportLivePhotos(e.target.checked)}
disabled={isExporting}
/>
实况图
</label>
<label>
<input
type="checkbox"
checked={exportVideos}
onChange={(e) => setExportVideos(e.target.checked)}
disabled={isExporting}
/>
视频
</label>
</div>
{exportMedia && (
<p className="export-media-hint">媒体文件将保存到输出目录的 media 子目录中,可能需要较长时间</p>
)}
<p className="export-media-hint">全不勾选时仅导出文本信息,不导出媒体文件</p>
</div>
{/* 同步提示 */}
@@ -723,7 +974,9 @@ export default function SnsPage() {
format: exportFormat,
usernames: selectedUsernames.length > 0 ? selectedUsernames : undefined,
keyword: searchKeyword || undefined,
exportMedia,
exportImages,
exportLivePhotos,
exportVideos,
startTime: exportDateRange.start ? Math.floor(new Date(exportDateRange.start).getTime() / 1000) : undefined,
endTime: exportDateRange.end ? Math.floor(new Date(exportDateRange.end + 'T23:59:59').getTime() / 1000) : undefined
})
+16 -1
View File
@@ -23,6 +23,18 @@ interface WelcomePageProps {
standalone?: boolean
}
const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
const base = String(error || '自动获取密钥失败').trim()
const tailLogs = Array.isArray(logs)
? logs
.map(item => String(item || '').trim())
.filter(Boolean)
.slice(-6)
: []
if (tailLogs.length === 0) return base
return `${base};最近状态:${tailLogs.join(' | ')}`
}
function WelcomePage({ standalone = false }: WelcomePageProps) {
const navigate = useNavigate()
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
@@ -292,7 +304,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信')
} else {
setError(result.error || '自动获取密钥失败')
if (result.error?.includes('尚未完成登录')) {
setDbKeyStatus('请先在微信完成登录后重试')
}
setError(formatDbKeyFailureMessage(result.error, result.logs))
}
}
} catch (e) {