Merge branch 'hicccc77:dev' into dev

This commit is contained in:
xuncha
2026-03-05 14:03:06 +08:00
committed by GitHub
51 changed files with 21690 additions and 3118 deletions

View File

@@ -26,6 +26,48 @@
margin: 0 0 48px;
}
.page-desc.load-summary {
margin: 0 0 28px;
}
.page-desc.load-summary.complete {
color: var(--text-secondary);
}
.load-telemetry {
width: min(760px, 100%);
padding: 12px 14px;
margin: 0 0 28px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
text-align: left;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
p {
margin: 4px 0;
}
.label {
color: var(--text-tertiary);
}
}
.load-telemetry.loading {
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
}
.load-telemetry.complete {
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
}
.load-telemetry.compact {
margin: 12px 0 0;
width: min(560px, 100%);
}
.report-sections {
display: flex;
flex-direction: column;
@@ -83,6 +125,14 @@
color: var(--text-tertiary);
}
.year-grid-with-status {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.year-grid {
display: flex;
flex-wrap: wrap;
@@ -95,7 +145,39 @@
.report-section .year-grid {
justify-content: flex-start;
max-width: none;
margin-bottom: 24px;
margin-bottom: 0;
}
.year-grid-with-status .year-grid {
flex: 1;
}
.year-load-status {
display: inline-flex;
align-items: center;
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
margin-top: 6px;
flex-shrink: 0;
}
.year-load-status.complete {
color: color-mix(in srgb, var(--primary) 80%, var(--text-secondary));
}
.dot-ellipsis {
display: inline-block;
width: 0;
overflow: hidden;
vertical-align: bottom;
animation: dot-ellipsis 1.2s steps(4, end) infinite;
}
.year-load-status.complete .dot-ellipsis,
.page-desc.load-summary.complete .dot-ellipsis {
animation: none;
width: 0;
}
.year-card {
@@ -185,3 +267,7 @@
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes dot-ellipsis {
to { width: 1.4em; }
}

View File

@@ -4,6 +4,28 @@ import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
import './AnnualReportPage.scss'
type YearOption = number | 'all'
type YearsLoadPayload = {
years?: number[]
done: boolean
error?: string
canceled?: boolean
strategy?: 'cache' | 'native' | 'hybrid'
phase?: 'cache' | 'native' | 'scan' | 'done'
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}
const formatLoadElapsed = (ms: number) => {
const totalSeconds = Math.max(0, ms) / 1000
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`
const minutes = Math.floor(totalSeconds / 60)
const seconds = Math.floor(totalSeconds % 60)
return `${minutes}m ${String(seconds).padStart(2, '0')}s`
}
function AnnualReportPage() {
const navigate = useNavigate()
@@ -11,32 +33,117 @@ function AnnualReportPage() {
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isLoadingMoreYears, setIsLoadingMoreYears] = useState(false)
const [hasYearsLoadFinished, setHasYearsLoadFinished] = useState(false)
const [loadStrategy, setLoadStrategy] = useState<'cache' | 'native' | 'hybrid'>('native')
const [loadPhase, setLoadPhase] = useState<'cache' | 'native' | 'scan' | 'done'>('native')
const [loadStatusText, setLoadStatusText] = useState('准备加载年份数据...')
const [nativeElapsedMs, setNativeElapsedMs] = useState(0)
const [scanElapsedMs, setScanElapsedMs] = useState(0)
const [totalElapsedMs, setTotalElapsedMs] = useState(0)
const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false)
const [nativeTimedOut, setNativeTimedOut] = useState(false)
const [isGenerating, setIsGenerating] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
useEffect(() => {
loadAvailableYears()
}, [])
let disposed = false
let taskId = ''
const loadAvailableYears = async () => {
setIsLoading(true)
setLoadError(null)
try {
const result = await window.electronAPI.annualReport.getAvailableYears()
if (result.success && result.data && result.data.length > 0) {
setAvailableYears(result.data)
setSelectedYear((prev) => prev ?? result.data[0])
setSelectedPairYear((prev) => prev ?? result.data[0])
} else if (!result.success) {
setLoadError(result.error || '加载年度数据失败')
const applyLoadPayload = (payload: YearsLoadPayload) => {
if (payload.strategy) setLoadStrategy(payload.strategy)
if (payload.phase) setLoadPhase(payload.phase)
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
if (typeof payload.nativeElapsedMs === 'number' && Number.isFinite(payload.nativeElapsedMs)) {
setNativeElapsedMs(Math.max(0, payload.nativeElapsedMs))
}
if (typeof payload.scanElapsedMs === 'number' && Number.isFinite(payload.scanElapsedMs)) {
setScanElapsedMs(Math.max(0, payload.scanElapsedMs))
}
if (typeof payload.totalElapsedMs === 'number' && Number.isFinite(payload.totalElapsedMs)) {
setTotalElapsedMs(Math.max(0, payload.totalElapsedMs))
}
if (typeof payload.switched === 'boolean') setHasSwitchedStrategy(payload.switched)
if (typeof payload.nativeTimedOut === 'boolean') setNativeTimedOut(payload.nativeTimedOut)
const years = Array.isArray(payload.years) ? payload.years : []
if (years.length > 0) {
setAvailableYears(years)
setSelectedYear((prev) => {
if (prev === 'all') return prev
if (typeof prev === 'number' && years.includes(prev)) return prev
return years[0]
})
setSelectedPairYear((prev) => {
if (prev === 'all') return prev
if (typeof prev === 'number' && years.includes(prev)) return prev
return years[0]
})
setIsLoading(false)
}
if (payload.error && !payload.canceled) {
setLoadError(payload.error || '加载年度数据失败')
}
if (payload.done) {
setIsLoading(false)
setIsLoadingMoreYears(false)
setHasYearsLoadFinished(true)
setLoadPhase('done')
} else {
setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false)
}
} catch (e) {
console.error(e)
setLoadError(String(e))
} finally {
setIsLoading(false)
}
}
const stopListen = window.electronAPI.annualReport.onAvailableYearsProgress((payload) => {
if (disposed) return
if (taskId && payload.taskId !== taskId) return
if (!taskId) taskId = payload.taskId
applyLoadPayload(payload)
})
const startLoad = async () => {
setIsLoading(true)
setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false)
setLoadStrategy('native')
setLoadPhase('native')
setLoadStatusText('准备使用原生快速模式加载年份...')
setNativeElapsedMs(0)
setScanElapsedMs(0)
setTotalElapsedMs(0)
setHasSwitchedStrategy(false)
setNativeTimedOut(false)
setLoadError(null)
try {
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
if (!startResult.success || !startResult.taskId) {
setLoadError(startResult.error || '加载年度数据失败')
setIsLoading(false)
setIsLoadingMoreYears(false)
return
}
taskId = startResult.taskId
if (startResult.snapshot) {
applyLoadPayload(startResult.snapshot)
}
} catch (e) {
console.error(e)
setLoadError(String(e))
setIsLoading(false)
setIsLoadingMoreYears(false)
}
}
void startLoad()
return () => {
disposed = true
stopListen()
}
}, [])
const handleGenerateReport = async () => {
if (selectedYear === null) return
@@ -57,16 +164,25 @@ function AnnualReportPage() {
navigate(`/dual-report?year=${yearParam}`)
}
if (isLoading) {
if (isLoading && availableYears.length === 0) {
return (
<div className="annual-report-page">
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>...</p>
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>...</p>
<div className="load-telemetry compact">
<p><span className="label"></span>{getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })}</p>
<p><span className="label"></span>{loadStatusText || '正在加载年份数据...'}</p>
<p>
<span className="label"></span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} {' '}
<span className="label"></span>{formatLoadElapsed(scanElapsedMs)} {' '}
<span className="label"></span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
</div>
)
}
if (availableYears.length === 0) {
if (availableYears.length === 0 && !isLoadingMoreYears) {
return (
<div className="annual-report-page">
<Calendar size={64} style={{ color: 'var(--text-tertiary)', opacity: 0.5 }} />
@@ -87,11 +203,50 @@ function AnnualReportPage() {
return value === 'all' ? '全部时间' : `${value}`
}
const loadedYearCount = availableYears.length
const isYearStatusComplete = hasYearsLoadFinished
const strategyLabel = getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })
const renderYearLoadStatus = () => (
<div className={`year-load-status ${isYearStatusComplete ? 'complete' : 'loading'}`}>
{isYearStatusComplete ? (
<></>
) : (
<>
<span className="dot-ellipsis" aria-hidden="true">...</span>
</>
)}
</div>
)
return (
<div className="annual-report-page">
<Sparkles size={32} className="header-icon" />
<h1 className="page-title"></h1>
<p className="page-desc"></p>
{loadedYearCount > 0 && (
<p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}>
{isYearStatusComplete ? (
<> {loadedYearCount} {formatLoadElapsed(totalElapsedMs)}</>
) : (
<>
{loadedYearCount} <span className="dot-ellipsis" aria-hidden="true">...</span>
{formatLoadElapsed(totalElapsedMs)}
</>
)}
</p>
)}
<div className={`load-telemetry ${isYearStatusComplete ? 'complete' : 'loading'}`}>
<p><span className="label"></span>{strategyLabel}</p>
<p>
<span className="label"></span>
{loadStatusText || (isYearStatusComplete ? '全部年份已加载完毕' : '正在加载年份数据...')}
</p>
<p>
<span className="label"></span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} {' '}
<span className="label"></span>{formatLoadElapsed(scanElapsedMs)} {' '}
<span className="label"></span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
<div className="report-sections">
<section className="report-section">
@@ -102,17 +257,20 @@ function AnnualReportPage() {
</div>
</div>
<div className="year-grid">
{yearOptions.map(option => (
<div
key={option}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
onClick={() => setSelectedYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
<div className="year-grid-with-status">
<div className="year-grid">
{yearOptions.map(option => (
<div
key={option}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
onClick={() => setSelectedYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
</div>
{renderYearLoadStatus()}
</div>
<button
@@ -146,17 +304,20 @@ function AnnualReportPage() {
</div>
</div>
<div className="year-grid">
{yearOptions.map(option => (
<div
key={`pair-${option}`}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
onClick={() => setSelectedPairYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
<div className="year-grid-with-status">
<div className="year-grid">
{yearOptions.map(option => (
<div
key={`pair-${option}`}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
onClick={() => setSelectedPairYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
</div>
{renderYearLoadStatus()}
</div>
<button
@@ -174,4 +335,23 @@ function AnnualReportPage() {
)
}
function getStrategyLabel(params: {
loadStrategy: 'cache' | 'native' | 'hybrid'
loadPhase: 'cache' | 'native' | 'scan' | 'done'
hasYearsLoadFinished: boolean
hasSwitchedStrategy: boolean
nativeTimedOut: boolean
}): string {
const { loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut } = params
if (loadStrategy === 'cache') return '缓存模式(快速)'
if (hasYearsLoadFinished) {
if (loadStrategy === 'native') return '原生快速模式'
if (hasSwitchedStrategy || nativeTimedOut) return '混合策略(原生→扫表)'
return '扫表兼容模式'
}
if (loadPhase === 'native') return '原生快速模式(优先)'
if (loadPhase === 'scan') return '扫表兼容模式(回退)'
return '混合策略'
}
export default AnnualReportPage

View File

@@ -490,6 +490,18 @@
gap: 8px;
-webkit-app-region: no-drag;
.jump-calendar-anchor {
position: relative;
display: flex;
align-items: center;
isolation: isolate;
z-index: 20;
.jump-date-popover {
z-index: 2600;
}
}
.icon-btn {
width: 34px;
height: 34px;
@@ -534,6 +546,22 @@
overflow: hidden;
}
.export-prepare-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 24px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
-webkit-app-region: no-drag;
.spin {
animation: spin 1s linear infinite;
}
}
.message-list {
flex: 1;
background: var(--chat-pattern);
@@ -815,6 +843,24 @@
min-width: 0;
}
.session-sync-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: var(--bg-primary);
color: var(--text-tertiary);
font-size: 11px;
white-space: nowrap;
border: 1px solid var(--border-color);
flex-shrink: 0;
.spin {
animation: spin 0.9s linear infinite;
}
}
.search-box {
flex: 1;
display: flex;
@@ -1592,6 +1638,13 @@
display: flex;
align-items: center;
gap: 8px;
.jump-calendar-anchor {
position: relative;
display: flex;
align-items: center;
isolation: isolate;
}
}
.icon-btn {
@@ -1624,6 +1677,10 @@
opacity: 0.5;
cursor: not-allowed;
}
.spin {
animation: spin 1s linear infinite;
}
}
}
@@ -1651,6 +1708,33 @@
opacity: 0;
pointer-events: none;
}
&.switching .message-list {
opacity: 0.42;
transform: scale(0.995);
filter: saturate(0.72) blur(1px);
pointer-events: none;
}
&.switching .loading-overlay {
background: rgba(127, 127, 127, 0.18);
backdrop-filter: blur(4px);
}
}
.export-prepare-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
.spin {
animation: spin 1s linear infinite;
}
}
.message-list {
@@ -1666,7 +1750,7 @@
background-color: var(--bg-tertiary);
position: relative;
-webkit-app-region: no-drag !important;
transition: opacity 240ms ease, transform 240ms ease;
transition: opacity 240ms ease, transform 240ms ease, filter 220ms ease;
// 滚动条样式
&::-webkit-scrollbar {
@@ -2662,6 +2746,13 @@
opacity: 0.7;
}
}
.detail-stats-meta {
margin-top: -6px;
margin-bottom: 10px;
font-size: 12px;
color: var(--text-tertiary);
}
}
.detail-item {
@@ -2699,6 +2790,26 @@
}
}
.detail-inline-btn {
border: none;
background: var(--bg-secondary);
color: var(--primary);
border-radius: 6px;
padding: 4px 8px;
font-size: 12px;
line-height: 1;
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
&:hover:not(:disabled) {
background: var(--bg-hover);
}
}
.copy-btn {
display: flex;
align-items: center;
@@ -2736,6 +2847,14 @@
gap: 8px;
}
.detail-table-placeholder {
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.table-item {
display: flex;
align-items: center;
@@ -2757,6 +2876,188 @@
}
}
.group-members-panel {
.group-members-toolbar {
padding: 12px 16px 10px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 8px;
}
.group-members-count {
font-size: 12px;
color: var(--text-secondary);
}
.group-members-search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
svg {
color: var(--text-tertiary);
flex-shrink: 0;
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
&::placeholder {
color: var(--text-tertiary);
}
}
}
.group-members-status {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
.spin {
animation: spin 1s linear infinite;
}
&.warning {
color: #b45309;
background: color-mix(in srgb, #f59e0b 10%, transparent);
}
}
.group-members-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 2px;
}
}
.group-member-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--border-color);
&:last-child {
border-bottom: none;
}
}
.group-member-main {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.group-member-avatar {
flex-shrink: 0;
}
.group-member-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.group-member-name-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.group-member-name {
font-size: 13px;
color: var(--text-primary);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-member-id {
font-size: 11px;
color: var(--text-tertiary);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-member-badges {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.member-flag {
width: 18px;
height: 18px;
border-radius: 9999px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
&.owner {
color: #f59e0b;
background: color-mix(in srgb, #f59e0b 16%, transparent);
border-color: color-mix(in srgb, #f59e0b 35%, var(--border-color));
}
&.friend {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 14%, transparent);
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
}
}
.group-member-count {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
&.loading {
color: var(--text-tertiary);
font-weight: 500;
}
&.failed {
color: #b45309;
font-weight: 600;
}
}
}
@keyframes slideInRight {
from {
opacity: 0;
@@ -4133,7 +4434,6 @@
font-weight: 500;
}
}
// 消息信息弹窗
.message-info-overlay {
position: fixed;
@@ -4298,4 +4598,4 @@
user-select: text;
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -148,6 +148,17 @@
svg {
opacity: 0.7;
transition: transform 0.2s;
flex-shrink: 0;
}
.chip-label {
min-width: 0;
}
.chip-count {
margin-left: auto;
text-align: right;
font-variant-numeric: tabular-nums;
}
&:hover {
@@ -177,6 +188,22 @@
padding: 0 20px 12px;
font-size: 13px;
color: var(--text-secondary);
.contacts-cache-meta {
margin-left: 10px;
color: var(--text-tertiary);
font-size: 12px;
&.syncing {
color: var(--primary);
}
}
.avatar-enrich-progress {
margin-left: 10px;
color: var(--text-tertiary);
font-size: 12px;
}
}
.selection-toolbar {
@@ -213,10 +240,103 @@
}
}
.load-issue-state {
flex: 1;
padding: 14px 14px 18px;
overflow-y: auto;
}
.issue-card {
border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color));
background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg));
border-radius: 12px;
padding: 14px;
color: var(--text-primary);
.issue-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary));
margin-bottom: 8px;
}
.issue-message {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.issue-reason {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.issue-hints {
margin: 10px 0 0;
padding-left: 18px;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.6;
}
.issue-actions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.issue-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 8px;
padding: 7px 10px;
font-size: 12px;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--text-primary);
border-color: var(--text-tertiary);
background: var(--bg-hover);
}
&.primary {
background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
color: var(--primary);
}
}
.issue-diagnostics {
margin-top: 12px;
border-radius: 8px;
background: var(--bg-primary);
border: 1px dashed var(--border-color);
padding: 10px;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
}
}
.contacts-list {
flex: 1;
overflow-y: auto;
padding: 0 12px 12px;
position: relative;
&::-webkit-scrollbar {
width: 6px;
@@ -229,15 +349,31 @@
}
}
.contacts-list-virtual {
position: relative;
min-height: 100%;
}
.contact-row {
position: absolute;
left: 0;
right: 0;
height: 76px;
padding-bottom: 4px;
will-change: transform;
}
.contact-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
height: 72px;
box-sizing: border-box;
border-radius: 10px;
transition: all 0.2s;
cursor: pointer;
margin-bottom: 4px;
margin-bottom: 0;
&:hover {
background: var(--bg-hover);

View File

@@ -1,7 +1,9 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList } from 'lucide-react'
import { useChatStore } from '../stores/chatStore'
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import * as configService from '../services/config'
import './ContactsPage.scss'
interface ContactInfo {
@@ -13,12 +15,43 @@ interface ContactInfo {
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
interface ContactEnrichInfo {
displayName?: string
avatarUrl?: string
}
const AVATAR_ENRICH_BATCH_SIZE = 80
const SEARCH_DEBOUNCE_MS = 120
const VIRTUAL_ROW_HEIGHT = 76
const VIRTUAL_OVERSCAN = 10
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
interface ContactsLoadSession {
requestId: string
startedAt: number
attempt: number
timeoutMs: number
}
interface ContactsLoadIssue {
kind: 'timeout' | 'error'
title: string
message: string
reason: string
errorDetail?: string
occurredAt: number
elapsedMs: number
}
type ContactsDataSource = 'cache' | 'network' | null
function ContactsPage() {
const [contacts, setContacts] = useState<ContactInfo[]>([])
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
const [selectedUsernames, setSelectedUsernames] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(true)
const [searchKeyword, setSearchKeyword] = useState('')
const [debouncedSearchKeyword, setDebouncedSearchKeyword] = useState('')
const [contactTypes, setContactTypes] = useState({
friends: true,
groups: false,
@@ -39,79 +72,495 @@ function ContactsPage() {
const [isExporting, setIsExporting] = useState(false)
const [showFormatSelect, setShowFormatSelect] = useState(false)
const formatDropdownRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const loadVersionRef = useRef(0)
const [avatarEnrichProgress, setAvatarEnrichProgress] = useState({
loaded: 0,
total: 0,
running: false
})
const [scrollTop, setScrollTop] = useState(0)
const [listViewportHeight, setListViewportHeight] = useState(480)
const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts)
const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts)
const loadAttemptRef = useRef(0)
const loadTimeoutTimerRef = useRef<number | null>(null)
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const [loadSession, setLoadSession] = useState<ContactsLoadSession | null>(null)
const [loadIssue, setLoadIssue] = useState<ContactsLoadIssue | null>(null)
const [showDiagnostics, setShowDiagnostics] = useState(false)
const [diagnosticTick, setDiagnosticTick] = useState(Date.now())
const [contactsDataSource, setContactsDataSource] = useState<ContactsDataSource>(null)
const [contactsUpdatedAt, setContactsUpdatedAt] = useState<number | null>(null)
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null)
const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const contactsCacheScopeRef = useRef('default')
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
// 加载通讯录
const loadContacts = useCallback(async () => {
setIsLoading(true)
try {
const result = await window.electronAPI.chat.connect()
if (!result.success) {
console.error('连接失败:', result.error)
setIsLoading(false)
return
}
const contactsResult = await window.electronAPI.chat.getContacts()
if (contactsResult.success && contactsResult.contacts) {
const ensureContactsCacheScope = useCallback(async () => {
if (contactsCacheScopeRef.current !== 'default') {
return contactsCacheScopeRef.current
}
const [dbPath, myWxid] = await Promise.all([
configService.getDbPath(),
configService.getMyWxid()
])
const scopeKey = dbPath || myWxid
? `${dbPath || ''}::${myWxid || ''}`
: 'default'
contactsCacheScopeRef.current = scopeKey
return scopeKey
}, [])
// 获取头像URL
const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username)
if (usernames.length > 0) {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (avatarResult.success && avatarResult.contacts) {
contactsResult.contacts.forEach((contact: ContactInfo) => {
const enriched = avatarResult.contacts?.[contact.username]
if (enriched?.avatarUrl) {
contact.avatarUrl = enriched.avatarUrl
}
})
}
useEffect(() => {
let cancelled = false
void (async () => {
try {
const value = await configService.getContactsLoadTimeoutMs()
if (!cancelled) {
setContactsLoadTimeoutMs(value)
}
setContacts(contactsResult.contacts)
setFilteredContacts(contactsResult.contacts)
setSelectedUsernames(new Set())
} catch (error) {
console.error('读取通讯录超时配置失败:', error)
}
} catch (e) {
console.error('加载通讯录失败:', e)
} finally {
setIsLoading(false)
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
loadContacts()
}, [loadContacts])
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
}, [contactsLoadTimeoutMs])
const mergeAvatarCacheIntoContacts = useCallback((sourceContacts: ContactInfo[]): ContactInfo[] => {
const avatarCache = contactsAvatarCacheRef.current
if (!sourceContacts.length || Object.keys(avatarCache).length === 0) {
return sourceContacts
}
let changed = false
const merged = sourceContacts.map((contact) => {
const cachedAvatar = avatarCache[contact.username]?.avatarUrl
if (!cachedAvatar || contact.avatarUrl) {
return contact
}
changed = true
return {
...contact,
avatarUrl: cachedAvatar
}
})
return changed ? merged : sourceContacts
}, [])
const upsertAvatarCacheFromContacts = useCallback((
scopeKey: string,
sourceContacts: ContactInfo[],
options?: { prune?: boolean; markCheckedUsernames?: string[] }
) => {
if (!scopeKey) return
const nextCache = { ...contactsAvatarCacheRef.current }
const now = Date.now()
const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean))
const usernamesInSource = new Set<string>()
let changed = false
for (const contact of sourceContacts) {
const username = String(contact.username || '').trim()
if (!username) continue
usernamesInSource.add(username)
const prev = nextCache[username]
const avatarUrl = String(contact.avatarUrl || '').trim()
if (!avatarUrl) continue
const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt
const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now)
if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) {
nextCache[username] = {
avatarUrl,
updatedAt,
checkedAt
}
changed = true
}
}
for (const username of markCheckedSet) {
const prev = nextCache[username]
if (!prev) continue
if (prev.checkedAt !== now) {
nextCache[username] = {
...prev,
checkedAt: now
}
changed = true
}
}
if (options?.prune) {
for (const username of Object.keys(nextCache)) {
if (usernamesInSource.has(username)) continue
delete nextCache[username]
changed = true
}
}
if (!changed) return
contactsAvatarCacheRef.current = nextCache
setAvatarCacheUpdatedAt(now)
void configService.setContactsAvatarCache(scopeKey, nextCache).catch((error) => {
console.error('写入通讯录头像缓存失败:', error)
})
}, [])
const applyEnrichedContacts = useCallback((enrichedMap: Record<string, ContactEnrichInfo>) => {
if (!enrichedMap || Object.keys(enrichedMap).length === 0) return
setContacts(prev => {
let changed = false
const next = prev.map(contact => {
const enriched = enrichedMap[contact.username]
if (!enriched) return contact
const displayName = enriched.displayName || contact.displayName
const avatarUrl = enriched.avatarUrl || contact.avatarUrl
if (displayName === contact.displayName && avatarUrl === contact.avatarUrl) {
return contact
}
changed = true
return {
...contact,
displayName,
avatarUrl
}
})
return changed ? next : prev
})
setSelectedContact(prev => {
if (!prev) return prev
const enriched = enrichedMap[prev.username]
if (!enriched) return prev
const displayName = enriched.displayName || prev.displayName
const avatarUrl = enriched.avatarUrl || prev.avatarUrl
if (displayName === prev.displayName && avatarUrl === prev.avatarUrl) {
return prev
}
return {
...prev,
displayName,
avatarUrl
}
})
}, [])
const enrichContactsInBackground = useCallback(async (
sourceContacts: ContactInfo[],
loadVersion: number,
scopeKey: string
) => {
const sourceByUsername = new Map<string, ContactInfo>()
for (const contact of sourceContacts) {
if (!contact.username) continue
sourceByUsername.set(contact.username, contact)
}
const now = Date.now()
const usernames = sourceContacts
.map(contact => contact.username)
.filter(Boolean)
.filter((username) => {
const currentContact = sourceByUsername.get(username)
if (!currentContact) return false
const cacheEntry = contactsAvatarCacheRef.current[username]
if (!cacheEntry || !cacheEntry.avatarUrl) {
return !currentContact.avatarUrl
}
if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) {
return true
}
const checkedAt = cacheEntry.checkedAt || 0
return now - checkedAt >= AVATAR_RECHECK_INTERVAL_MS
})
const total = usernames.length
setAvatarEnrichProgress({
loaded: 0,
total,
running: total > 0
})
if (total === 0) return
for (let i = 0; i < total; i += AVATAR_ENRICH_BATCH_SIZE) {
if (loadVersionRef.current !== loadVersion) return
const batch = usernames.slice(i, i + AVATAR_ENRICH_BATCH_SIZE)
if (batch.length === 0) continue
try {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch)
if (loadVersionRef.current !== loadVersion) return
if (avatarResult.success && avatarResult.contacts) {
applyEnrichedContacts(avatarResult.contacts)
for (const [username, enriched] of Object.entries(avatarResult.contacts)) {
const prev = sourceByUsername.get(username)
if (!prev) continue
sourceByUsername.set(username, {
...prev,
displayName: enriched.displayName || prev.displayName,
avatarUrl: enriched.avatarUrl || prev.avatarUrl
})
}
}
const batchContacts = batch
.map(username => sourceByUsername.get(username))
.filter((contact): contact is ContactInfo => Boolean(contact))
upsertAvatarCacheFromContacts(scopeKey, batchContacts, {
markCheckedUsernames: batch
})
} catch (e) {
console.error('分批补全头像失败:', e)
}
const loaded = Math.min(i + batch.length, total)
setAvatarEnrichProgress({
loaded,
total,
running: loaded < total
})
await new Promise(resolve => setTimeout(resolve, 0))
}
}, [applyEnrichedContacts, upsertAvatarCacheFromContacts])
// 加载通讯录
const loadContacts = useCallback(async (options?: { scopeKey?: string }) => {
const scopeKey = options?.scopeKey || await ensureContactsCacheScope()
const loadVersion = loadVersionRef.current + 1
loadVersionRef.current = loadVersion
loadAttemptRef.current += 1
const startedAt = Date.now()
const timeoutMs = contactsLoadTimeoutMsRef.current
const requestId = `contacts-${startedAt}-${loadAttemptRef.current}`
setLoadSession({
requestId,
startedAt,
attempt: loadAttemptRef.current,
timeoutMs
})
setLoadIssue(null)
setShowDiagnostics(false)
if (loadTimeoutTimerRef.current) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
const timeoutTimerId = window.setTimeout(() => {
if (loadVersionRef.current !== loadVersion) return
const elapsedMs = Date.now() - startedAt
setLoadIssue({
kind: 'timeout',
title: '通讯录加载超时',
message: `等待超过 ${timeoutMs}ms联系人列表仍未返回。`,
reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。',
occurredAt: Date.now(),
elapsedMs
})
}, timeoutMs)
loadTimeoutTimerRef.current = timeoutTimerId
setIsLoading(true)
setAvatarEnrichProgress({
loaded: 0,
total: 0,
running: false
})
try {
const contactsResult = await window.electronAPI.chat.getContacts()
if (loadVersionRef.current !== loadVersion) return
if (contactsResult.success && contactsResult.contacts) {
if (loadTimeoutTimerRef.current === timeoutTimerId) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
const contactsWithAvatarCache = mergeAvatarCacheIntoContacts(contactsResult.contacts)
setContacts(contactsWithAvatarCache)
syncContactTypeCounts(contactsWithAvatarCache)
setSelectedUsernames(new Set())
setSelectedContact(prev => {
if (!prev) return prev
return contactsWithAvatarCache.find(contact => contact.username === prev.username) || null
})
const now = Date.now()
setContactsDataSource('network')
setContactsUpdatedAt(now)
setLoadIssue(null)
setIsLoading(false)
upsertAvatarCacheFromContacts(scopeKey, contactsWithAvatarCache, { prune: true })
void configService.setContactsListCache(
scopeKey,
contactsWithAvatarCache.map(contact => ({
username: contact.username,
displayName: contact.displayName,
remark: contact.remark,
nickname: contact.nickname,
type: contact.type
}))
).catch((error) => {
console.error('写入通讯录缓存失败:', error)
})
void enrichContactsInBackground(contactsWithAvatarCache, loadVersion, scopeKey)
return
}
const elapsedMs = Date.now() - startedAt
setLoadIssue({
kind: 'error',
title: '通讯录加载失败',
message: '联系人接口返回失败,未拿到联系人列表。',
reason: 'chat.getContacts 返回 success=false。',
errorDetail: contactsResult.error || '未知错误',
occurredAt: Date.now(),
elapsedMs
})
} catch (e) {
console.error('加载通讯录失败:', e)
const elapsedMs = Date.now() - startedAt
setLoadIssue({
kind: 'error',
title: '通讯录加载失败',
message: '联系人请求执行异常。',
reason: '调用 chat.getContacts 发生异常。',
errorDetail: String(e),
occurredAt: Date.now(),
elapsedMs
})
} finally {
if (loadTimeoutTimerRef.current === timeoutTimerId) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
if (loadVersionRef.current === loadVersion) {
setIsLoading(false)
}
}
}, [
ensureContactsCacheScope,
enrichContactsInBackground,
mergeAvatarCacheIntoContacts,
syncContactTypeCounts,
upsertAvatarCacheFromContacts
])
// 搜索和类型过滤
useEffect(() => {
let filtered = contacts
let cancelled = false
void (async () => {
const scopeKey = await ensureContactsCacheScope()
if (cancelled) return
try {
const [cacheItem, avatarCacheItem] = await Promise.all([
configService.getContactsListCache(scopeKey),
configService.getContactsAvatarCache(scopeKey)
])
const avatarCacheMap = avatarCacheItem?.avatars || {}
contactsAvatarCacheRef.current = avatarCacheMap
setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null)
if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) {
const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({
...contact,
avatarUrl: avatarCacheMap[contact.username]?.avatarUrl
}))
setContacts(cachedContacts)
syncContactTypeCounts(cachedContacts)
setContactsDataSource('cache')
setContactsUpdatedAt(cacheItem.updatedAt || null)
setIsLoading(false)
}
} catch (error) {
console.error('读取通讯录缓存失败:', error)
}
if (!cancelled) {
void loadContacts({ scopeKey })
}
})()
return () => {
cancelled = true
}
}, [ensureContactsCacheScope, loadContacts, syncContactTypeCounts])
// 类型过滤
filtered = filtered.filter(c => {
if (c.type === 'friend' && !contactTypes.friends) return false
if (c.type === 'group' && !contactTypes.groups) return false
if (c.type === 'official' && !contactTypes.officials) return false
if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false
useEffect(() => {
return () => {
if (loadTimeoutTimerRef.current) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
loadVersionRef.current += 1
}
}, [])
useEffect(() => {
if (!loadIssue || contacts.length > 0) return
if (!(isLoading && loadIssue.kind === 'timeout')) return
const timer = window.setInterval(() => {
setDiagnosticTick(Date.now())
}, 500)
return () => window.clearInterval(timer)
}, [contacts.length, isLoading, loadIssue])
useEffect(() => {
const timer = window.setTimeout(() => {
setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase())
}, SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(timer)
}, [searchKeyword])
const filteredContacts = useMemo(() => {
let filtered = contacts.filter(contact => {
if (contact.type === 'friend' && !contactTypes.friends) return false
if (contact.type === 'group' && !contactTypes.groups) return false
if (contact.type === 'official' && !contactTypes.officials) return false
if (contact.type === 'former_friend' && !contactTypes.deletedFriends) return false
return true
})
// 关键词过滤
if (searchKeyword.trim()) {
const lower = searchKeyword.toLowerCase()
filtered = filtered.filter(c =>
c.displayName?.toLowerCase().includes(lower) ||
c.remark?.toLowerCase().includes(lower) ||
c.username.toLowerCase().includes(lower)
if (debouncedSearchKeyword) {
filtered = filtered.filter(contact =>
contact.displayName?.toLowerCase().includes(debouncedSearchKeyword) ||
contact.remark?.toLowerCase().includes(debouncedSearchKeyword) ||
contact.username.toLowerCase().includes(debouncedSearchKeyword)
)
}
setFilteredContacts(filtered)
}, [searchKeyword, contacts, contactTypes])
return filtered
}, [contacts, contactTypes, debouncedSearchKeyword])
// 点击外部关闭下拉菜单
const contactTypeCounts = useMemo(() => toContactTypeCardCounts(sharedTabCounts), [sharedTabCounts])
useEffect(() => {
if (!listRef.current) return
listRef.current.scrollTop = 0
setScrollTop(0)
}, [debouncedSearchKeyword, contactTypes])
useEffect(() => {
const node = listRef.current
if (!node) return
const updateViewportHeight = () => {
setListViewportHeight(Math.max(node.clientHeight, VIRTUAL_ROW_HEIGHT))
}
updateViewportHeight()
const observer = new ResizeObserver(() => updateViewportHeight())
observer.observe(node)
return () => observer.disconnect()
}, [filteredContacts.length, isLoading])
useEffect(() => {
const maxScroll = Math.max(0, filteredContacts.length * VIRTUAL_ROW_HEIGHT - listViewportHeight)
if (scrollTop <= maxScroll) return
setScrollTop(maxScroll)
if (listRef.current) {
listRef.current.scrollTop = maxScroll
}
}, [filteredContacts.length, listViewportHeight, scrollTop])
// 搜索和类型过滤
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
@@ -123,11 +572,85 @@ function ContactsPage() {
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showFormatSelect])
const selectedInFilteredCount = filteredContacts.reduce((count, contact) => {
return selectedUsernames.has(contact.username) ? count + 1 : count
}, 0)
const selectedInFilteredCount = useMemo(() => {
return filteredContacts.reduce((count, contact) => {
return selectedUsernames.has(contact.username) ? count + 1 : count
}, 0)
}, [filteredContacts, selectedUsernames])
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length
const { startIndex, endIndex } = useMemo(() => {
if (filteredContacts.length === 0) {
return { startIndex: 0, endIndex: 0 }
}
const baseStart = Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT)
const visibleCount = Math.ceil(listViewportHeight / VIRTUAL_ROW_HEIGHT)
const nextStart = Math.max(0, baseStart - VIRTUAL_OVERSCAN)
const nextEnd = Math.min(filteredContacts.length, nextStart + visibleCount + VIRTUAL_OVERSCAN * 2)
return {
startIndex: nextStart,
endIndex: nextEnd
}
}, [filteredContacts.length, listViewportHeight, scrollTop])
const visibleContacts = useMemo(() => {
return filteredContacts.slice(startIndex, endIndex)
}, [filteredContacts, startIndex, endIndex])
const onContactsListScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
setScrollTop(event.currentTarget.scrollTop)
}, [])
const issueElapsedMs = useMemo(() => {
if (!loadIssue) return 0
if (isLoading && loadSession) {
return Math.max(loadIssue.elapsedMs, diagnosticTick - loadSession.startedAt)
}
return loadIssue.elapsedMs
}, [diagnosticTick, isLoading, loadIssue, loadSession])
const diagnosticsText = useMemo(() => {
if (!loadIssue || !loadSession) return ''
return [
`请求ID: ${loadSession.requestId}`,
`请求序号: 第 ${loadSession.attempt}`,
`阈值配置: ${loadSession.timeoutMs}ms`,
`当前状态: ${loadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`,
`累计耗时: ${(issueElapsedMs / 1000).toFixed(1)}s`,
`发生时间: ${new Date(loadIssue.occurredAt).toLocaleString()}`,
`阶段: chat.getContacts`,
`原因: ${loadIssue.reason}`,
`错误详情: ${loadIssue.errorDetail || '无'}`
].join('\n')
}, [issueElapsedMs, loadIssue, loadSession])
const copyDiagnostics = useCallback(async () => {
if (!diagnosticsText) return
try {
await navigator.clipboard.writeText(diagnosticsText)
alert('诊断信息已复制')
} catch (error) {
console.error('复制诊断信息失败:', error)
alert('复制失败,请手动复制诊断信息')
}
}, [diagnosticsText])
const contactsUpdatedAtLabel = useMemo(() => {
if (!contactsUpdatedAt) return ''
return new Date(contactsUpdatedAt).toLocaleString()
}, [contactsUpdatedAt])
const avatarCachedCount = useMemo(() => {
return contacts.reduce((count, contact) => (
contact.avatarUrl ? count + 1 : count
), 0)
}, [contacts])
const avatarCacheUpdatedAtLabel = useMemo(() => {
if (!avatarCacheUpdatedAt) return ''
return new Date(avatarCacheUpdatedAt).toLocaleString()
}, [avatarCacheUpdatedAt])
const toggleContactSelected = (username: string, checked: boolean) => {
setSelectedUsernames(prev => {
const next = new Set(prev)
@@ -256,7 +779,7 @@ function ContactsPage() {
>
<Download size={18} />
</button>
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
<button className="icon-btn" onClick={() => void loadContacts()} disabled={isLoading}>
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
</button>
</div>
@@ -280,24 +803,51 @@ function ContactsPage() {
<div className="type-filters">
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
<User size={16} /><span></span>
<User size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.friends}</span>
</label>
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
<Users size={16} /><span></span>
<Users size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.groups}</span>
</label>
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
<MessageSquare size={16} /><span></span>
<MessageSquare size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.officials}</span>
</label>
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
<UserX size={16} /><span></span>
<UserX size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.deletedFriends}</span>
</label>
</div>
<div className="contacts-count">
{filteredContacts.length}
{filteredContacts.length} / {contacts.length}
{contactsUpdatedAt && (
<span className="contacts-cache-meta">
{contactsDataSource === 'cache' ? '缓存' : '最新'} · {contactsUpdatedAtLabel}
</span>
)}
{contacts.length > 0 && (
<span className="contacts-cache-meta">
{avatarCachedCount}/{contacts.length}
{avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''}
</span>
)}
{isLoading && contacts.length > 0 && (
<span className="contacts-cache-meta syncing">...</span>
)}
{avatarEnrichProgress.running && (
<span className="avatar-enrich-progress">
{avatarEnrichProgress.loaded}/{avatarEnrichProgress.total}
</span>
)}
</div>
{exportMode && (
@@ -315,61 +865,105 @@ function ContactsPage() {
</div>
)}
{isLoading ? (
{contacts.length === 0 && loadIssue ? (
<div className="load-issue-state">
<div className="issue-card">
<div className="issue-title">
<AlertTriangle size={18} />
<span>{loadIssue.title}</span>
</div>
<p className="issue-message">{loadIssue.message}</p>
<p className="issue-reason">{loadIssue.reason}</p>
<ul className="issue-hints">
<li>1</li>
<li>2contact.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>

View File

@@ -107,7 +107,16 @@ function DualReportWindow() {
setLoadingStage('完成')
if (result.success && result.data) {
setReportData(result.data)
const normalizedResponse = result.data.response
? {
...result.data.response,
slowest: result.data.response.slowest ?? result.data.response.avg
}
: undefined
setReportData({
...result.data,
response: normalizedResponse
})
setIsLoading(false)
} else {
setError(result.error || '生成报告失败')

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@ interface GroupMessageRank {
}
type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
interface MemberMessageExportOptions {
format: MemberExportFormat
@@ -119,6 +119,7 @@ function GroupAnalyticsPage() {
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON支持 sender 去重与关系统计' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },

View File

@@ -36,6 +36,18 @@ interface WxidOption {
modifiedTime: number
}
const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
const base = String(error || '自动获取密钥失败').trim()
const tailLogs = Array.isArray(logs)
? logs
.map(item => String(item || '').trim())
.filter(Boolean)
.slice(-6)
: []
if (tailLogs.length === 0) return base
return `${base};最近状态:${tailLogs.join(' | ')}`
}
function SettingsPage() {
const {
isDbConnected,
@@ -103,12 +115,12 @@ function SettingsPage() {
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
const [exportDefaultFormat, setExportDefaultFormat] = useState('json')
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(4)
const [notificationEnabled, setNotificationEnabled] = useState(true)
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
@@ -286,7 +298,6 @@ function SettingsPage() {
const savedWhisperModelDir = await configService.getWhisperModelDir()
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
const savedExportDefaultFormat = await configService.getExportDefaultFormat()
const savedExportDefaultDateRange = await configService.getExportDefaultDateRange()
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
@@ -327,12 +338,13 @@ function SettingsPage() {
setLogEnabled(savedLogEnabled)
setAutoTranscribeVoice(savedAutoTranscribe)
setTranscribeLanguages(savedTranscribeLanguages)
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
setExportDefaultFormat('json')
await configService.setExportDefaultFormat('json')
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
setExportDefaultMedia(savedExportDefaultMedia ?? false)
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 4)
setNotificationEnabled(savedNotificationEnabled)
setNotificationPosition(savedNotificationPosition)
@@ -725,7 +737,10 @@ function SettingsPage() {
setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信')
} else {
showMessage(result.error || '自动获取密钥失败', false)
if (result.error?.includes('尚未完成登录')) {
setDbKeyStatus('请先在微信完成登录后重试')
}
showMessage(formatDbKeyFailureMessage(result.error, result.logs), false)
}
}
} catch (e: any) {
@@ -1546,6 +1561,7 @@ function SettingsPage() {
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON支持 sender 去重与关系统计' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式CSV' },

View File

@@ -23,7 +23,7 @@
========================================= */
.sns-main-viewport {
flex: 1;
overflow-y: scroll;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
@@ -35,7 +35,9 @@
padding: 20px 24px 60px 24px;
display: flex;
flex-direction: column;
gap: 24px;
gap: 0;
min-height: 0;
height: 100%;
}
.feed-header {
@@ -44,12 +46,50 @@
justify-content: space-between;
margin-bottom: 8px;
padding: 0 4px;
z-index: 2;
background: var(--sns-bg-color);
border-bottom: 1px solid var(--border-color);
padding-top: 10px;
padding-bottom: 10px;
h2 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--text-primary);
.feed-header-main {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
h2 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--text-primary);
}
}
.feed-stats-line {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
&.loading {
opacity: 0.7;
}
&.error {
color: #d94f45;
}
}
.feed-stats-retry {
border: none;
background: transparent;
color: inherit;
font-size: 13px;
padding: 0;
line-height: 1.4;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.header-actions {
@@ -85,6 +125,13 @@
}
}
.sns-posts-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-top: 16px;
}
.posts-list {
display: flex;
flex-direction: column;
@@ -1031,9 +1078,11 @@
margin-bottom: 0;
/* Remove margin to merge */
.contact-name {
color: var(--primary);
font-weight: 600;
.contact-meta {
.contact-name {
color: var(--primary);
font-weight: 600;
}
}
/* If the NEXT item is also selected */
@@ -1056,13 +1105,20 @@
/* Compensate for missing border */
}
.contact-name {
.contact-meta {
flex: 1;
font-size: 14px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
.contact-name {
font-size: 14px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
@@ -1909,10 +1965,31 @@
font-size: 12px;
color: var(--text-tertiary);
margin: 0;
padding-left: 24px;
line-height: 1.4;
}
.export-media-check-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-primary);
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
}
input[type='checkbox'] {
margin: 0;
}
}
.export-progress {
display: flex;
flex-direction: column;
@@ -2091,4 +2168,4 @@
cursor: not-allowed;
}
}
}
}

View File

@@ -5,6 +5,11 @@ import './SnsPage.scss'
import { SnsPost } from '../types/sns'
import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel'
import * as configService from '../services/config'
const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const SNS_PAGE_CACHE_POST_LIMIT = 200
const SNS_PAGE_CACHE_SCOPE_FALLBACK = '__default__'
interface Contact {
username: string
@@ -13,11 +18,29 @@ interface Contact {
type?: 'friend' | 'former_friend' | 'sns_only'
}
interface SnsOverviewStats {
totalPosts: number
totalFriends: number
myPosts: number | null
earliestTime: number | null
latestTime: number | null
}
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
export default function SnsPage() {
const [posts, setPosts] = useState<SnsPost[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const loadingRef = useRef(false)
const [overviewStats, setOverviewStats] = useState<SnsOverviewStats>({
totalPosts: 0,
totalFriends: 0,
myPosts: null,
earliestTime: null,
latestTime: null
})
const [overviewStatsStatus, setOverviewStatsStatus] = useState<OverviewStatsStatus>('loading')
// Filter states
const [searchKeyword, setSearchKeyword] = useState('')
@@ -35,9 +58,11 @@ export default function SnsPage() {
// 导出相关状态
const [showExportDialog, setShowExportDialog] = useState(false)
const [exportFormat, setExportFormat] = useState<'json' | 'html'>('html')
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html')
const [exportFolder, setExportFolder] = useState('')
const [exportMedia, setExportMedia] = useState(false)
const [exportImages, setExportImages] = useState(false)
const [exportLivePhotos, setExportLivePhotos] = useState(false)
const [exportVideos, setExportVideos] = useState(false)
const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' })
const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null)
@@ -56,12 +81,34 @@ export default function SnsPage() {
const [hasNewer, setHasNewer] = useState(false)
const [loadingNewer, setLoadingNewer] = useState(false)
const postsRef = useRef<SnsPost[]>([])
const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats)
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
const selectedUsernamesRef = useRef<string[]>(selectedUsernames)
const searchKeywordRef = useRef(searchKeyword)
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
const cacheScopeKeyRef = useRef('')
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const contactsLoadTokenRef = useRef(0)
// Sync posts ref
useEffect(() => {
postsRef.current = posts
}, [posts])
useEffect(() => {
overviewStatsRef.current = overviewStats
}, [overviewStats])
useEffect(() => {
overviewStatsStatusRef.current = overviewStatsStatus
}, [overviewStatsStatus])
useEffect(() => {
selectedUsernamesRef.current = selectedUsernames
}, [selectedUsernames])
useEffect(() => {
searchKeywordRef.current = searchKeyword
}, [searchKeyword])
useEffect(() => {
jumpTargetDateRef.current = jumpTargetDate
}, [jumpTargetDate])
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
useLayoutEffect(() => {
const snapshot = scrollAdjustmentRef.current;
@@ -75,6 +122,163 @@ export default function SnsPage() {
}
}, [posts])
const formatDateOnly = (timestamp: number | null): string => {
if (!timestamp || timestamp <= 0) return '--'
const date = new Date(timestamp * 1000)
if (Number.isNaN(date.getTime())) return '--'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const isDefaultViewNow = useCallback(() => {
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
}, [])
const ensureSnsCacheScopeKey = useCallback(async () => {
if (cacheScopeKeyRef.current) return cacheScopeKeyRef.current
const wxid = (await configService.getMyWxid())?.trim() || SNS_PAGE_CACHE_SCOPE_FALLBACK
const scopeKey = `sns_page:${wxid}`
cacheScopeKeyRef.current = scopeKey
return scopeKey
}, [])
const persistSnsPageCache = useCallback(async (patch?: { posts?: SnsPost[]; overviewStats?: SnsOverviewStats }) => {
if (!isDefaultViewNow()) return
try {
const scopeKey = await ensureSnsCacheScopeKey()
if (!scopeKey) return
const existingCache = await configService.getSnsPageCache(scopeKey)
let postsToStore = patch?.posts ?? postsRef.current
if (!patch?.posts && postsToStore.length === 0) {
if (existingCache && Array.isArray(existingCache.posts) && existingCache.posts.length > 0) {
postsToStore = existingCache.posts as SnsPost[]
}
}
const overviewToStore = patch?.overviewStats
?? (overviewStatsStatusRef.current === 'ready'
? overviewStatsRef.current
: existingCache?.overviewStats ?? overviewStatsRef.current)
await configService.setSnsPageCache(scopeKey, {
overviewStats: overviewToStore,
posts: postsToStore.slice(0, SNS_PAGE_CACHE_POST_LIMIT)
})
} catch (error) {
console.error('Failed to persist SNS page cache:', error)
}
}, [ensureSnsCacheScopeKey, isDefaultViewNow])
const hydrateSnsPageCache = useCallback(async () => {
try {
const scopeKey = await ensureSnsCacheScopeKey()
const cached = await configService.getSnsPageCache(scopeKey)
if (!cached) return
if (Date.now() - cached.updatedAt > SNS_PAGE_CACHE_TTL_MS) return
const cachedOverview = cached.overviewStats
if (cachedOverview) {
const cachedTotalPosts = Math.max(0, Number(cachedOverview.totalPosts || 0))
const cachedTotalFriends = Math.max(0, Number(cachedOverview.totalFriends || 0))
const hasCachedPosts = Array.isArray(cached.posts) && cached.posts.length > 0
const hasOverviewData = cachedTotalPosts > 0 || cachedTotalFriends > 0
setOverviewStats({
totalPosts: cachedTotalPosts,
totalFriends: cachedTotalFriends,
myPosts: typeof cachedOverview.myPosts === 'number' && Number.isFinite(cachedOverview.myPosts) && cachedOverview.myPosts >= 0
? Math.floor(cachedOverview.myPosts)
: null,
earliestTime: cachedOverview.earliestTime ?? null,
latestTime: cachedOverview.latestTime ?? null
})
// 只有明确有统计值(或确实无帖子)时才把缓存视为 ready避免历史异常 0 卡住显示。
setOverviewStatsStatus(hasOverviewData || !hasCachedPosts ? 'ready' : 'loading')
}
if (Array.isArray(cached.posts) && cached.posts.length > 0) {
const cachedPosts = cached.posts
.filter((raw): raw is SnsPost => {
if (!raw || typeof raw !== 'object') return false
const row = raw as Record<string, unknown>
return typeof row.id === 'string' && typeof row.createTime === 'number'
})
.slice(0, SNS_PAGE_CACHE_POST_LIMIT)
.sort((a, b) => b.createTime - a.createTime)
if (cachedPosts.length > 0) {
setPosts(cachedPosts)
setHasMore(true)
setHasNewer(false)
}
}
} catch (error) {
console.error('Failed to hydrate SNS page cache:', error)
}
}, [ensureSnsCacheScopeKey])
const loadOverviewStats = useCallback(async () => {
setOverviewStatsStatus('loading')
try {
const statsResult = await window.electronAPI.sns.getExportStats()
if (!statsResult.success || !statsResult.data) {
throw new Error(statsResult.error || '获取朋友圈统计失败')
}
const totalPosts = Math.max(0, Number(statsResult.data.totalPosts || 0))
const totalFriends = Math.max(0, Number(statsResult.data.totalFriends || 0))
const myPosts = (typeof statsResult.data.myPosts === 'number' && Number.isFinite(statsResult.data.myPosts) && statsResult.data.myPosts >= 0)
? Math.floor(statsResult.data.myPosts)
: null
let earliestTime: number | null = null
let latestTime: number | null = null
if (totalPosts > 0) {
const [latestResult, earliestResult] = await Promise.all([
window.electronAPI.sns.getTimeline(1, 0),
window.electronAPI.sns.getTimeline(1, Math.max(totalPosts - 1, 0))
])
const latestTs = Number(latestResult.timeline?.[0]?.createTime || 0)
const earliestTs = Number(earliestResult.timeline?.[0]?.createTime || 0)
if (latestResult.success && Number.isFinite(latestTs) && latestTs > 0) {
latestTime = Math.floor(latestTs)
}
if (earliestResult.success && Number.isFinite(earliestTs) && earliestTs > 0) {
earliestTime = Math.floor(earliestTs)
}
}
const nextOverviewStats = {
totalPosts,
totalFriends,
myPosts,
earliestTime,
latestTime
}
setOverviewStats(nextOverviewStats)
setOverviewStatsStatus('ready')
void persistSnsPageCache({ overviewStats: nextOverviewStats })
} catch (error) {
console.error('Failed to load SNS overview stats:', error)
setOverviewStatsStatus('error')
}
}, [persistSnsPageCache])
const renderOverviewStats = () => {
if (overviewStatsStatus === 'error') {
return (
<button type="button" className="feed-stats-retry" onClick={() => { void loadOverviewStats() }}>
</button>
)
}
if (overviewStatsStatus === 'loading') {
return '统计中...'
}
const myPostsLabel = overviewStats.myPosts === null ? '--' : String(overviewStats.myPosts)
return `${overviewStats.totalPosts} 我的朋友圈 ${myPostsLabel} ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} ${overviewStats.totalFriends} 位好友`
}
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
const { reset = false, direction = 'older' } = options
if (loadingRef.current) return
@@ -119,7 +323,9 @@ export default function SnsPage() {
const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id));
if (uniqueNewer.length > 0) {
setPosts(prev => [...uniqueNewer, ...prev].sort((a, b) => b.createTime - a.createTime));
const merged = [...uniqueNewer, ...currentPosts].sort((a, b) => b.createTime - a.createTime)
setPosts(merged);
void persistSnsPageCache({ posts: merged })
}
setHasNewer(result.timeline.length >= limit);
} else {
@@ -149,6 +355,7 @@ export default function SnsPage() {
if (result.success && result.timeline) {
if (reset) {
setPosts(result.timeline)
void persistSnsPageCache({ posts: result.timeline })
setHasMore(result.timeline.length >= limit)
// Check for newer items above topTs
@@ -165,7 +372,9 @@ export default function SnsPage() {
}
} else {
if (result.timeline.length > 0) {
setPosts(prev => [...prev, ...result.timeline!].sort((a, b) => b.createTime - a.createTime))
const merged = [...postsRef.current, ...result.timeline!].sort((a, b) => b.createTime - a.createTime)
setPosts(merged)
void persistSnsPageCache({ posts: merged })
}
if (result.timeline.length < limit) {
setHasMore(false)
@@ -179,22 +388,16 @@ export default function SnsPage() {
setLoadingNewer(false)
loadingRef.current = false
}
}, [selectedUsernames, searchKeyword, jumpTargetDate])
}, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedUsernames])
// Load Contacts合并好友+曾经好友+朋友圈发布者enrichSessionsContactInfo 补充头像
// Load Contacts仅加载好友/曾经好友,不再统计朋友圈条数
const loadContacts = useCallback(async () => {
const requestToken = ++contactsLoadTokenRef.current
setContactsLoading(true)
try {
// 并行获取联系人列表和朋友圈发布者列表
const [contactsResult, snsResult] = await Promise.all([
window.electronAPI.chat.getContacts(),
window.electronAPI.sns.getSnsUsernames()
])
// 以联系人为基础,按 username 去重
const contactsResult = await window.electronAPI.chat.getContacts()
const contactMap = new Map<string, Contact>()
// 好友和曾经的好友
if (contactsResult.success && contactsResult.contacts) {
for (const c of contactsResult.contacts) {
if (c.type === 'friend' || c.type === 'former_friend') {
@@ -208,55 +411,61 @@ export default function SnsPage() {
}
}
// 朋友圈发布者(补充不在联系人列表中的用户)
if (snsResult.success && snsResult.usernames) {
for (const u of snsResult.usernames) {
if (!contactMap.has(u)) {
contactMap.set(u, { username: u, displayName: u, type: 'sns_only' })
}
}
}
let contactsList = Array.from(contactMap.values())
const allUsernames = Array.from(contactMap.keys())
if (requestToken !== contactsLoadTokenRef.current) return
setContacts(contactsList)
const allUsernames = contactsList.map(c => c.username)
// 用 enrichSessionsContactInfo 统一补充头像和显示名
if (allUsernames.length > 0) {
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
if (enriched.success && enriched.contacts) {
for (const [username, extra] of Object.entries(enriched.contacts) as [string, { displayName?: string; avatarUrl?: string }][]) {
const c = contactMap.get(username)
if (c) {
c.displayName = extra.displayName || c.displayName
c.avatarUrl = extra.avatarUrl || c.avatarUrl
contactsList = contactsList.map(contact => {
const extra = enriched.contacts?.[contact.username]
if (!extra) return contact
return {
...contact,
displayName: extra.displayName || contact.displayName,
avatarUrl: extra.avatarUrl || contact.avatarUrl
}
}
})
if (requestToken !== contactsLoadTokenRef.current) return
setContacts(contactsList)
}
}
setContacts(Array.from(contactMap.values()))
} catch (error) {
if (requestToken !== contactsLoadTokenRef.current) return
console.error('Failed to load contacts:', error)
} finally {
setContactsLoading(false)
if (requestToken === contactsLoadTokenRef.current) {
setContactsLoading(false)
}
}
}, [])
// Initial Load & Listeners
useEffect(() => {
void hydrateSnsPageCache()
loadContacts()
}, [loadContacts])
loadOverviewStats()
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats])
useEffect(() => {
const handleChange = () => {
cacheScopeKeyRef.current = ''
// wxid changed, reset everything
setPosts([]); setHasMore(true); setHasNewer(false);
setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined);
void hydrateSnsPageCache()
loadContacts();
loadOverviewStats();
loadPosts({ reset: true });
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadContacts, loadPosts])
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts])
useEffect(() => {
const timer = setTimeout(() => {
@@ -285,10 +494,15 @@ export default function SnsPage() {
return (
<div className="sns-page-layout">
<div className="sns-main-viewport" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
<div className="sns-main-viewport">
<div className="sns-feed-container">
<div className="feed-header">
<h2></h2>
<div className="feed-header-main">
<h2></h2>
<div className={`feed-stats-line ${overviewStatsStatus}`}>
{renderOverviewStats()}
</div>
</div>
<div className="header-actions">
<button
onClick={async () => {
@@ -325,6 +539,7 @@ export default function SnsPage() {
onClick={() => {
setRefreshSpin(true)
loadPosts({ reset: true })
loadOverviewStats()
setTimeout(() => setRefreshSpin(false), 800)
}}
disabled={loading || loadingNewer}
@@ -336,75 +551,84 @@ export default function SnsPage() {
</div>
</div>
{loadingNewer && (
<div className="status-indicator loading-newer">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>
)}
{!loadingNewer && hasNewer && (
<div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
</div>
)}
<div className="posts-list">
{posts.map(post => (
<SnsPostItem
key={post.id}
post={{ ...post, isProtected: triggerInstalled === true }}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={(p) => setDebugPost(p)}
onDelete={(postId) => setPosts(prev => prev.filter(p => p.id !== postId))}
/>
))}
</div>
{loading && posts.length === 0 && (
<div className="initial-loading">
<div className="loading-pulse">
<div className="pulse-circle"></div>
<span>...</span>
<div className="sns-posts-scroll" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
{loadingNewer && (
<div className="status-indicator loading-newer">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>
</div>
)}
)}
{loading && posts.length > 0 && (
<div className="status-indicator loading-more">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>
)}
{!loadingNewer && hasNewer && (
<div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
</div>
)}
{!hasMore && posts.length > 0 && (
<div className="status-indicator no-more">{
selectedUsernames.length === 1 &&
contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend'
? '在时间的长河里刻舟求剑'
: '或许过往已无可溯洄,但好在还有可以与你相遇的明天'
}</div>
)}
{!loading && posts.length === 0 && (
<div className="no-results">
<div className="no-results-icon"><Search size={48} /></div>
<p></p>
{(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && (
<button onClick={() => {
setSearchKeyword(''); setSelectedUsernames([]); setJumpTargetDate(undefined);
}} className="reset-inline">
</button>
)}
<div className="posts-list">
{posts.map(post => (
<SnsPostItem
key={post.id}
post={{ ...post, isProtected: triggerInstalled === true }}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={(p) => setDebugPost(p)}
onDelete={(postId) => {
setPosts(prev => {
const next = prev.filter(p => p.id !== postId)
void persistSnsPageCache({ posts: next })
return next
})
loadOverviewStats()
}}
/>
))}
</div>
)}
{loading && posts.length === 0 && (
<div className="initial-loading">
<div className="loading-pulse">
<div className="pulse-circle"></div>
<span>...</span>
</div>
</div>
)}
{loading && posts.length > 0 && (
<div className="status-indicator loading-more">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>
)}
{!hasMore && posts.length > 0 && (
<div className="status-indicator no-more">{
selectedUsernames.length === 1 &&
contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend'
? '在时间的长河里刻舟求剑'
: '或许过往已无可溯洄,但好在还有可以与你相遇的明天'
}</div>
)}
{!loading && posts.length === 0 && (
<div className="no-results">
<div className="no-results-icon"><Search size={48} /></div>
<p></p>
{(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && (
<button onClick={() => {
setSearchKeyword(''); setSelectedUsernames([]); setJumpTargetDate(undefined);
}} className="reset-inline">
</button>
)}
</div>
)}
</div>
</div>
</div>
@@ -597,6 +821,15 @@ export default function SnsPage() {
<span>JSON</span>
<small></small>
</button>
<button
className={`format-option ${exportFormat === 'arkmejson' ? 'active' : ''}`}
onClick={() => setExportFormat('arkmejson')}
disabled={isExporting}
>
<FileJson size={20} />
<span>ArkmeJSON</span>
<small></small>
</button>
</div>
</div>
@@ -658,22 +891,40 @@ export default function SnsPage() {
{/* 媒体导出 */}
<div className="export-section">
<div className="export-toggle-row">
<div className="toggle-label">
<Image size={16} />
<span>/</span>
</div>
<button
className={`toggle-switch${exportMedia ? ' active' : ''}`}
onClick={() => !isExporting && setExportMedia(!exportMedia)}
disabled={isExporting}
>
<span className="toggle-knob" />
</button>
<label className="export-label">
<Image size={14} />
</label>
<div className="export-media-check-grid">
<label>
<input
type="checkbox"
checked={exportImages}
onChange={(e) => setExportImages(e.target.checked)}
disabled={isExporting}
/>
</label>
<label>
<input
type="checkbox"
checked={exportLivePhotos}
onChange={(e) => setExportLivePhotos(e.target.checked)}
disabled={isExporting}
/>
</label>
<label>
<input
type="checkbox"
checked={exportVideos}
onChange={(e) => setExportVideos(e.target.checked)}
disabled={isExporting}
/>
</label>
</div>
{exportMedia && (
<p className="export-media-hint"> media </p>
)}
<p className="export-media-hint"></p>
</div>
{/* 同步提示 */}
@@ -723,7 +974,9 @@ export default function SnsPage() {
format: exportFormat,
usernames: selectedUsernames.length > 0 ? selectedUsernames : undefined,
keyword: searchKeyword || undefined,
exportMedia,
exportImages,
exportLivePhotos,
exportVideos,
startTime: exportDateRange.start ? Math.floor(new Date(exportDateRange.start).getTime() / 1000) : undefined,
endTime: exportDateRange.end ? Math.floor(new Date(exportDateRange.end + 'T23:59:59').getTime() / 1000) : undefined
})

View File

@@ -23,6 +23,18 @@ interface WelcomePageProps {
standalone?: boolean
}
const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
const base = String(error || '自动获取密钥失败').trim()
const tailLogs = Array.isArray(logs)
? logs
.map(item => String(item || '').trim())
.filter(Boolean)
.slice(-6)
: []
if (tailLogs.length === 0) return base
return `${base};最近状态:${tailLogs.join(' | ')}`
}
function WelcomePage({ standalone = false }: WelcomePageProps) {
const navigate = useNavigate()
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
@@ -292,7 +304,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信')
} else {
setError(result.error || '自动获取密钥失败')
if (result.error?.includes('尚未完成登录')) {
setDbKeyStatus('请先在微信完成登录后重试')
}
setError(formatDbKeyFailureMessage(result.error, result.logs))
}
}
} catch (e) {