Merge branch 'hicccc77:dev' into dev
This commit is contained in:
51 files changed
+21690
-3118
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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">></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>
|
||||
)
|
||||
|
||||
@@ -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: '网页格式,可直接浏览' },
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user