+
-
-
+
+
+
+
+
+
私聊数据分析
+
+ WeFlow 可以分析你的好友聊天记录,生成详细的统计报表。
+ 你可以选择加载上次的分析结果,或者重新开始一次新的私聊分析。
+
-
+
+
+
+
+
diff --git a/src/pages/AnnualReportPage.tsx b/src/pages/AnnualReportPage.tsx
index ded4362..018fbdb 100644
--- a/src/pages/AnnualReportPage.tsx
+++ b/src/pages/AnnualReportPage.tsx
@@ -1,6 +1,12 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
+import {
+ finishBackgroundTask,
+ isBackgroundTaskCancelRequested,
+ registerBackgroundTask,
+ updateBackgroundTask
+} from '../services/backgroundTaskMonitor'
import './AnnualReportPage.scss'
type YearOption = number | 'all'
@@ -49,8 +55,17 @@ function AnnualReportPage() {
useEffect(() => {
let disposed = false
let taskId = ''
+ let uiTaskId = ''
const applyLoadPayload = (payload: YearsLoadPayload) => {
+ if (uiTaskId) {
+ updateBackgroundTask(uiTaskId, {
+ detail: payload.statusText || '正在加载可用年份',
+ progressText: payload.done
+ ? '已完成'
+ : `${Array.isArray(payload.years) ? payload.years.length : 0} 个年份`
+ })
+ }
if (payload.strategy) setLoadStrategy(payload.strategy)
if (payload.phase) setLoadPhase(payload.phase)
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
@@ -91,6 +106,14 @@ function AnnualReportPage() {
setIsLoadingMoreYears(false)
setHasYearsLoadFinished(true)
setLoadPhase('done')
+ if (uiTaskId) {
+ finishBackgroundTask(uiTaskId, payload.canceled ? 'canceled' : 'completed', {
+ detail: payload.canceled
+ ? '年度报告年份加载已停止'
+ : `年度报告年份加载完成,共 ${years.length} 个年份`,
+ progressText: payload.canceled ? '已停止' : `${years.length} 个年份`
+ })
+ }
} else {
setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false)
@@ -105,6 +128,18 @@ function AnnualReportPage() {
})
const startLoad = async () => {
+ uiTaskId = registerBackgroundTask({
+ sourcePage: 'annualReport',
+ title: '年度报告年份加载',
+ detail: '准备使用原生快速模式加载年份',
+ progressText: '初始化',
+ cancelable: true,
+ onCancel: async () => {
+ if (taskId) {
+ await window.electronAPI.annualReport.cancelAvailableYearsLoad(taskId)
+ }
+ }
+ })
setIsLoading(true)
setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false)
@@ -120,6 +155,9 @@ function AnnualReportPage() {
try {
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
if (!startResult.success || !startResult.taskId) {
+ finishBackgroundTask(uiTaskId, 'failed', {
+ detail: startResult.error || '加载年度数据失败'
+ })
setLoadError(startResult.error || '加载年度数据失败')
setIsLoading(false)
setIsLoadingMoreYears(false)
@@ -131,6 +169,9 @@ function AnnualReportPage() {
}
} catch (e) {
console.error(e)
+ finishBackgroundTask(uiTaskId, 'failed', {
+ detail: String(e)
+ })
setLoadError(String(e))
setIsLoading(false)
setIsLoadingMoreYears(false)
diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx
index b5e726e..ab5fd0d 100644
--- a/src/pages/AnnualReportWindow.tsx
+++ b/src/pages/AnnualReportWindow.tsx
@@ -2,6 +2,12 @@ import { useState, useEffect, useRef } from 'react'
import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react'
import html2canvas from 'html2canvas'
import { useThemeStore } from '../stores/themeStore'
+import {
+ finishBackgroundTask,
+ isBackgroundTaskCancelRequested,
+ registerBackgroundTask,
+ updateBackgroundTask
+} from '../services/backgroundTaskMonitor'
import './AnnualReportWindow.scss'
// SVG 背景图案 (用于导出)
@@ -158,6 +164,13 @@ function AnnualReportWindow() {
}, [])
const generateReport = async (year: number) => {
+ const taskId = registerBackgroundTask({
+ sourcePage: 'annualReport',
+ title: '年度报告生成',
+ detail: `正在生成 ${formatYearLabel(year)} 年度报告`,
+ progressText: '初始化',
+ cancelable: true
+ })
setIsLoading(true)
setError(null)
setLoadingProgress(0)
@@ -165,25 +178,46 @@ function AnnualReportWindow() {
const removeProgressListener = window.electronAPI.annualReport.onProgress?.((payload: { status: string; progress: number }) => {
setLoadingProgress(payload.progress)
setLoadingStage(payload.status)
+ updateBackgroundTask(taskId, {
+ detail: payload.status || '正在生成年度报告',
+ progressText: `${Math.max(0, Math.round(payload.progress || 0))}%`
+ })
})
try {
const result = await window.electronAPI.annualReport.generateReport(year)
removeProgressListener?.()
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', {
+ detail: '已停止后续加载,当前报告结果未继续写入页面'
+ })
+ setIsLoading(false)
+ return
+ }
setLoadingProgress(100)
setLoadingStage('完成')
if (result.success && result.data) {
+ finishBackgroundTask(taskId, 'completed', {
+ detail: '年度报告生成完成',
+ progressText: '100%'
+ })
setTimeout(() => {
setReportData(result.data!)
setIsLoading(false)
}, 300)
} else {
+ finishBackgroundTask(taskId, 'failed', {
+ detail: result.error || '生成年度报告失败'
+ })
setError(result.error || '生成报告失败')
setIsLoading(false)
}
} catch (e) {
removeProgressListener?.()
+ finishBackgroundTask(taskId, 'failed', {
+ detail: String(e)
+ })
setError(String(e))
setIsLoading(false)
}
diff --git a/src/pages/ChatAnalyticsHubPage.scss b/src/pages/ChatAnalyticsHubPage.scss
new file mode 100644
index 0000000..4d970cd
--- /dev/null
+++ b/src/pages/ChatAnalyticsHubPage.scss
@@ -0,0 +1,123 @@
+.chat-analytics-hub-page {
+ min-height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 40px 24px;
+}
+
+.chat-analytics-hub-content {
+ width: min(860px, 100%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+}
+
+.chat-analytics-hub-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 14px;
+ border-radius: 999px;
+ background: var(--primary-light);
+ color: var(--primary);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.chat-analytics-hub-content h1 {
+ margin: 20px 0 12px;
+ font-size: 32px;
+ line-height: 1.2;
+ color: var(--text-primary);
+}
+
+.chat-analytics-hub-desc {
+ max-width: 620px;
+ margin: 0 0 32px;
+ color: var(--text-secondary);
+ font-size: 15px;
+ line-height: 1.7;
+}
+
+.chat-analytics-hub-grid {
+ width: 100%;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 20px;
+}
+
+.chat-analytics-entry-card {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ text-align: left;
+ gap: 14px;
+ min-height: 260px;
+ padding: 28px;
+ border: 1px solid var(--border-color);
+ border-radius: 20px;
+ background:
+ linear-gradient(180deg, rgba(7, 193, 96, 0.08) 0%, rgba(7, 193, 96, 0.02) 100%),
+ var(--card-bg);
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
+
+ &:hover {
+ transform: translateY(-4px);
+ border-color: rgba(7, 193, 96, 0.35);
+ box-shadow: 0 20px 36px rgba(7, 193, 96, 0.12);
+ }
+
+ .entry-card-icon {
+ width: 52px;
+ height: 52px;
+ border-radius: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(7, 193, 96, 0.12);
+ color: #07c160;
+
+ &.group {
+ background: rgba(24, 119, 242, 0.12);
+ color: #1877f2;
+ }
+ }
+
+ .entry-card-header {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ }
+
+ h2 {
+ margin: 0;
+ font-size: 24px;
+ line-height: 1.2;
+ }
+
+ p {
+ margin: 0;
+ color: var(--text-secondary);
+ font-size: 14px;
+ line-height: 1.7;
+ }
+
+ .entry-card-cta {
+ margin-top: auto;
+ color: var(--primary);
+ font-size: 13px;
+ font-weight: 600;
+ }
+}
+
+@media (max-width: 900px) {
+ .chat-analytics-hub-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/pages/ChatAnalyticsHubPage.tsx b/src/pages/ChatAnalyticsHubPage.tsx
new file mode 100644
index 0000000..6c4456e
--- /dev/null
+++ b/src/pages/ChatAnalyticsHubPage.tsx
@@ -0,0 +1,59 @@
+import { ArrowRight, BarChart3, MessageSquare, Users } from 'lucide-react'
+import { useNavigate } from 'react-router-dom'
+import './ChatAnalyticsHubPage.scss'
+
+function ChatAnalyticsHubPage() {
+ const navigate = useNavigate()
+
+ return (
+
+
+
+
+ 聊天分析
+
+
+
选择你要进入的分析视角
+
+ 私聊分析更适合看好友聊天统计和趋势,群聊分析则用于查看群成员、发言排行和活跃时段。
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default ChatAnalyticsHubPage
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index a663b67..357e1df 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -14,6 +14,12 @@ import JumpToDatePopover from '../components/JumpToDatePopover'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
import * as configService from '../services/config'
+import {
+ finishBackgroundTask,
+ isBackgroundTaskCancelRequested,
+ registerBackgroundTask,
+ updateBackgroundTask
+} from '../services/backgroundTaskMonitor'
import {
emitOpenSingleExport,
onExportSessionStatus,
@@ -1067,6 +1073,13 @@ function ChatPage(props: ChatPageProps) {
const loadSessionDetail = useCallback(async (sessionId: string) => {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return
+ const taskId = registerBackgroundTask({
+ sourcePage: 'chat',
+ title: '聊天页会话详情统计',
+ detail: `准备读取 ${sessionMapRef.current.get(normalizedSessionId)?.displayName || normalizedSessionId} 的详情`,
+ progressText: '基础信息',
+ cancelable: true
+ })
const requestSeq = ++detailRequestSeqRef.current
const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId)
@@ -1130,8 +1143,23 @@ function ChatPage(props: ChatPageProps) {
}
try {
+ updateBackgroundTask(taskId, {
+ detail: '正在读取会话基础详情',
+ progressText: '基础信息'
+ })
const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId)
- if (requestSeq !== detailRequestSeqRef.current) return
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', {
+ detail: '已停止后续加载,当前基础查询结束后未继续补充统计'
+ })
+ return
+ }
+ if (requestSeq !== detailRequestSeqRef.current) {
+ finishBackgroundTask(taskId, 'canceled', {
+ detail: '会话已切换,旧详情任务已停止'
+ })
+ return
+ }
if (result.success && result.detail) {
setSessionDetail((prev) => ({
wxid: normalizedSessionId,
@@ -1170,6 +1198,10 @@ function ChatPage(props: ChatPageProps) {
}
try {
+ updateBackgroundTask(taskId, {
+ detail: '正在读取补充信息与导出统计',
+ progressText: '补充统计'
+ })
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
window.electronAPI.chat.getExportSessionStats(
@@ -1178,7 +1210,18 @@ function ChatPage(props: ChatPageProps) {
)
])
- if (requestSeq !== detailRequestSeqRef.current) return
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', {
+ detail: '已停止后续加载,补充统计结果未继续写入'
+ })
+ return
+ }
+ if (requestSeq !== detailRequestSeqRef.current) {
+ finishBackgroundTask(taskId, 'canceled', {
+ detail: '会话已切换,旧补充统计任务已停止'
+ })
+ return
+ }
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) {
const detail = extraResultSettled.value.detail
@@ -1214,8 +1257,15 @@ function ChatPage(props: ChatPageProps) {
})
}
}
+ finishBackgroundTask(taskId, 'completed', {
+ detail: '聊天页会话详情统计完成',
+ progressText: '已完成'
+ })
} catch (e) {
console.error('加载会话详情补充统计失败:', e)
+ finishBackgroundTask(taskId, 'failed', {
+ detail: String(e)
+ })
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingDetailExtra(false)
@@ -1228,13 +1278,31 @@ function ChatPage(props: ChatPageProps) {
if (!normalizedSessionId || isLoadingRelationStats) return
const requestSeq = detailRequestSeqRef.current
+ const taskId = registerBackgroundTask({
+ sourcePage: 'chat',
+ title: '聊天页关系统计补算',
+ detail: `正在补算 ${normalizedSessionId} 的共同好友与关联数据`,
+ progressText: '关系统计',
+ cancelable: true
+ })
setIsLoadingRelationStats(true)
try {
const relationResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
)
- if (requestSeq !== detailRequestSeqRef.current) return
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', {
+ detail: '已停止后续加载,当前关系统计查询结束后未继续刷新'
+ })
+ return
+ }
+ if (requestSeq !== detailRequestSeqRef.current) {
+ finishBackgroundTask(taskId, 'canceled', {
+ detail: '会话已切换,旧关系统计任务已停止'
+ })
+ return
+ }
const metric = relationResult.success && relationResult.data
? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined
@@ -1254,11 +1322,26 @@ function ChatPage(props: ChatPageProps) {
setIsRefreshingDetailStats(true)
void (async () => {
try {
+ updateBackgroundTask(taskId, {
+ detail: '正在刷新关系统计结果',
+ progressText: '关系统计刷新'
+ })
const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
)
- if (requestSeq !== detailRequestSeqRef.current) return
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', {
+ detail: '已停止后续加载,刷新结果未继续写入'
+ })
+ return
+ }
+ if (requestSeq !== detailRequestSeqRef.current) {
+ finishBackgroundTask(taskId, 'canceled', {
+ detail: '会话已切换,旧关系统计刷新任务已停止'
+ })
+ return
+ }
if (freshResult.success && freshResult.data) {
const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
@@ -1266,17 +1349,32 @@ function ChatPage(props: ChatPageProps) {
applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true)
}
}
+ finishBackgroundTask(taskId, 'completed', {
+ detail: '聊天页关系统计补算完成',
+ progressText: '已完成'
+ })
} catch (error) {
console.error('刷新会话关系统计失败:', error)
+ finishBackgroundTask(taskId, 'failed', {
+ detail: String(error)
+ })
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsRefreshingDetailStats(false)
}
}
})()
+ } else {
+ finishBackgroundTask(taskId, 'completed', {
+ detail: '聊天页关系统计补算完成',
+ progressText: '已完成'
+ })
}
} catch (error) {
console.error('加载会话关系统计失败:', error)
+ finishBackgroundTask(taskId, 'failed', {
+ detail: String(error)
+ })
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingRelationStats(false)
@@ -3225,7 +3323,7 @@ function ChatPage(props: ChatPageProps) {
const handleGroupAnalytics = useCallback(() => {
if (!currentSessionId || !isGroupChatSession(currentSessionId)) return
- navigate('/group-analytics', {
+ navigate('/analytics/group', {
state: {
preselectGroupIds: [currentSessionId]
}
diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss
index f0ca4c9..ab340f4 100644
--- a/src/pages/ExportPage.scss
+++ b/src/pages/ExportPage.scss
@@ -254,6 +254,168 @@
}
}
+.session-load-detail-summary {
+ padding: 12px 12px 0;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.session-load-detail-summary-text {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--text-secondary);
+
+ strong {
+ font-size: 18px;
+ color: var(--text-primary);
+ }
+
+ em {
+ font-style: normal;
+ color: var(--text-tertiary);
+ }
+}
+
+.session-load-detail-note {
+ margin: 8px 12px 0;
+ font-size: 12px;
+ line-height: 1.6;
+ color: var(--text-tertiary);
+}
+
+.session-load-detail-stop-btn,
+.session-load-detail-task-stop-btn {
+ border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color));
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--bg-secondary));
+ color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary));
+ cursor: pointer;
+ white-space: nowrap;
+
+ &:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ }
+}
+
+.session-load-detail-stop-btn {
+ padding: 8px 12px;
+ font-size: 12px;
+}
+
+.session-load-detail-task-list {
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.session-load-detail-task-item {
+ border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary));
+ padding: 10px 12px;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+
+ &.status-cancel_requested {
+ border-color: color-mix(in srgb, var(--warning, #f59e0b) 36%, var(--border-color));
+ }
+
+ &.status-failed {
+ border-color: color-mix(in srgb, var(--danger, #ef4444) 36%, var(--border-color));
+ }
+}
+
+.session-load-detail-task-main {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+
+ p {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.55;
+ color: var(--text-secondary);
+ }
+}
+
+.session-load-detail-task-title-row {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--text-secondary);
+
+ strong {
+ font-size: 13px;
+ color: var(--text-primary);
+ }
+}
+
+.session-load-detail-task-source {
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+}
+
+.session-load-detail-task-status {
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--bg-secondary) 80%, transparent);
+ color: var(--text-secondary);
+
+ &.status-running {
+ color: var(--primary);
+ background: rgba(var(--primary-rgb), 0.1);
+ }
+
+ &.status-cancel_requested {
+ color: #b45309;
+ background: rgba(245, 158, 11, 0.14);
+ }
+
+ &.status-completed {
+ color: #166534;
+ background: rgba(34, 197, 94, 0.14);
+ }
+
+ &.status-failed {
+ color: #b91c1c;
+ background: rgba(239, 68, 68, 0.14);
+ }
+}
+
+.session-load-detail-task-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ font-size: 11px;
+ color: var(--text-tertiary);
+}
+
+.session-load-detail-task-stop-btn {
+ padding: 7px 10px;
+ font-size: 12px;
+ flex-shrink: 0;
+}
+
+.session-load-detail-empty {
+ padding: 12px;
+ font-size: 12px;
+ color: var(--text-tertiary);
+}
+
.session-load-detail-table {
display: flex;
flex-direction: column;
@@ -744,6 +906,7 @@
width: 100%;
border: none;
background: transparent;
+ color: var(--text-primary);
text-align: left;
padding: 8px 10px;
border-radius: 8px;
@@ -765,6 +928,7 @@
.layout-option-label {
font-size: 13px;
font-weight: 600;
+ color: inherit;
}
.layout-option-desc {
@@ -1399,15 +1563,10 @@
}
.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;
}
@@ -1458,20 +1617,31 @@
.table-tabs {
display: flex;
gap: 8px;
- flex-wrap: wrap;
+ flex-wrap: nowrap;
+ align-items: center;
.tab-btn {
+ flex: 0 0 auto;
+ width: auto;
+ max-width: max-content;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
- padding: 7px 12px;
+ padding: 7px 6px;
border-radius: 999px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
display: inline-flex;
align-items: center;
- gap: 4px;
+ justify-content: center;
+
+ .tab-btn-content {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ line-height: 1;
+ }
&.active {
border-color: var(--primary);
@@ -1547,14 +1717,21 @@
}
.table-wrap {
+ --contacts-native-scrollbar-compensation: 18px;
--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-avatar-col-width: 44px;
--contacts-inline-padding: 12px;
+ --contacts-column-gap: 12px;
+ --contacts-name-text-width: 10em;
+ --contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width));
+ --contacts-left-sticky-width: calc(var(--contacts-select-col-width) + var(--contacts-main-col-width) + var(--contacts-column-gap));
--contacts-message-col-width: 120px;
--contacts-media-col-width: 72px;
--contacts-action-col-width: 140px;
+ --contacts-table-min-width: 1200px;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: 10px;
@@ -1563,24 +1740,51 @@
flex: 1;
display: flex;
flex-direction: column;
+ background: var(--bg-secondary);
}
.table-wrap {
+ .table-scroll-shell {
+ overflow: hidden;
+ }
+
+ .table-scroll-viewport {
+ min-height: 0;
+ overflow-x: auto;
+ overflow-y: visible;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ background: var(--bg-secondary);
+ padding-bottom: var(--contacts-native-scrollbar-compensation);
+ margin-bottom: calc(-1 * var(--contacts-native-scrollbar-compensation));
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ }
+
+ .table-scroll-content {
+ min-width: max(100%, var(--contacts-table-min-width));
+ }
+
.session-table-sticky {
position: sticky;
top: 0;
z-index: 20;
- background: var(--card-bg);
+ background: var(--bg-secondary);
}
.loading-state,
.empty-state {
+ width: 100%;
+ min-width: max(100%, var(--contacts-table-min-width));
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
+ background: var(--bg-secondary);
color: var(--text-tertiary);
font-size: 14px;
@@ -1590,14 +1794,17 @@
}
.load-issue-state {
+ width: 100%;
+ min-width: max(100%, var(--contacts-table-min-width));
flex: 1;
padding: 14px;
overflow-y: auto;
+ background: var(--bg-secondary);
}
.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));
+ background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--bg-secondary));
border-radius: 12px;
padding: 14px;
color: var(--text-primary);
@@ -1671,7 +1878,7 @@
.issue-diagnostics {
margin-top: 12px;
border-radius: 8px;
- background: var(--bg-primary);
+ background: color-mix(in srgb, var(--bg-secondary) 92%, var(--bg-primary));
border: 1px dashed var(--border-color);
padding: 10px;
font-size: 12px;
@@ -1682,17 +1889,37 @@
}
.contacts-list-header {
+ --contacts-header-bg: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary));
display: flex;
align-items: center;
- gap: 12px;
+ gap: var(--contacts-column-gap);
padding: 10px var(--contacts-inline-padding) 8px;
+ min-width: max(100%, var(--contacts-table-min-width));
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));
+ background: var(--contacts-header-bg);
font-size: 12px;
color: var(--text-tertiary);
font-weight: 600;
letter-spacing: 0.01em;
flex-shrink: 0;
+
+ &.is-draggable {
+ cursor: grab;
+ }
+
+ &.is-dragging {
+ cursor: grabbing;
+ user-select: none;
+ }
+ }
+
+ .contacts-list-header-left {
+ display: flex;
+ align-items: center;
+ gap: var(--contacts-column-gap);
+ width: var(--contacts-left-sticky-width);
+ min-width: var(--contacts-left-sticky-width);
+ max-width: var(--contacts-left-sticky-width);
}
.contacts-list-header-select {
@@ -1705,8 +1932,10 @@
}
.contacts-list-header-main {
- flex: 1;
- min-width: 0;
+ flex: 0 0 var(--contacts-main-col-width);
+ width: var(--contacts-main-col-width);
+ min-width: var(--contacts-main-col-width);
+ max-width: var(--contacts-main-col-width);
display: flex;
align-items: center;
gap: 8px;
@@ -1721,21 +1950,30 @@
.contacts-list-header-count {
width: var(--contacts-message-col-width);
+ min-width: var(--contacts-message-col-width);
+ display: flex;
+ align-items: center;
+ justify-content: center;
text-align: center;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ box-sizing: border-box;
}
.contacts-list-header-media {
width: var(--contacts-media-col-width);
min-width: var(--contacts-media-col-width);
+ display: flex;
+ align-items: center;
+ justify-content: center;
text-align: center;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ box-sizing: border-box;
}
.contacts-list-header-actions {
@@ -1749,8 +1987,8 @@
flex-shrink: 0;
position: sticky;
right: 0;
- z-index: 8;
- background: var(--bg-primary);
+ z-index: 13;
+ background: var(--contacts-header-bg);
white-space: nowrap;
&::before {
@@ -1761,30 +1999,70 @@
left: -8px;
width: 8px;
pointer-events: none;
- background: linear-gradient(to right, transparent, var(--bg-primary));
+ background: linear-gradient(to right, transparent, var(--contacts-header-bg));
}
}
.contacts-list {
+ width: 100%;
+ min-width: max(100%, var(--contacts-table-min-width));
flex: 1;
min-height: var(--contacts-default-list-height);
height: var(--contacts-default-list-height);
- overflow: hidden;
+ position: relative;
+ overflow-x: clip;
+ overflow-y: auto;
+ overscroll-behavior: contain;
padding: 0 0 12px;
- }
-
- .contacts-virtuoso {
- height: 100%;
+ background: var(--bg-secondary);
&::-webkit-scrollbar {
width: 6px;
}
- &::-webkit-scrollbar-thumb {
- background: var(--text-tertiary);
- border-radius: 3px;
- opacity: 0.3;
+ &::-webkit-scrollbar-track {
+ background: transparent;
}
+
+ &::-webkit-scrollbar-thumb {
+ background: color-mix(in srgb, var(--text-tertiary) 72%, transparent);
+ border-radius: 999px;
+ }
+ }
+
+ .contacts-virtuoso {
+ width: 100%;
+ }
+
+ .table-bottom-scrollbar {
+ flex: 0 0 auto;
+ overflow-x: auto;
+ overflow-y: hidden;
+ background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary));
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in srgb, var(--text-tertiary) 70%, transparent) transparent;
+
+ &::-webkit-scrollbar {
+ height: 10px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--text-tertiary) 70%, transparent);
+ }
+ }
+
+ .table-bottom-scrollbar-inner {
+ height: 1px;
+ }
+
+ .table-bottom-scrollbar {
+ height: 16px;
+ border-top: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
}
.selection-clear-btn {
@@ -1848,30 +2126,50 @@
padding-bottom: 4px;
&.selected .contact-item {
- background: rgba(var(--primary-rgb), 0.08);
+ background: var(--contacts-row-bg);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 52%, transparent);
}
- &.selected .row-action-cell {
- background: rgba(var(--primary-rgb), 0.08);
+ &.selected .contact-item:hover {
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 60%, transparent);
}
}
.contact-item {
+ --contacts-row-bg: var(--bg-secondary);
display: flex;
align-items: center;
- gap: 12px;
- padding: 12px var(--contacts-inline-padding);
+ gap: var(--contacts-column-gap);
+ padding: 12px 6px 12px var(--contacts-inline-padding);
+ min-width: max(100%, var(--contacts-table-min-width));
height: 72px;
box-sizing: border-box;
border-radius: 10px;
transition: all 0.2s;
cursor: default;
+ background: var(--contacts-row-bg);
+ box-shadow: inset 0 0 0 1px transparent;
&:hover {
- background: var(--bg-hover);
+ background: var(--contacts-row-bg);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-tertiary) 24%, transparent);
}
}
+ .row-left-sticky {
+ position: sticky;
+ left: 0;
+ z-index: 11;
+ display: flex;
+ align-items: center;
+ align-self: stretch;
+ gap: var(--contacts-column-gap);
+ width: var(--contacts-left-sticky-width);
+ min-width: var(--contacts-left-sticky-width);
+ max-width: var(--contacts-left-sticky-width);
+ background: var(--contacts-row-bg);
+ }
+
.row-select-cell {
width: var(--contacts-select-col-width);
min-width: var(--contacts-select-col-width);
@@ -1905,8 +2203,10 @@
}
.contact-info {
- flex: 1;
- min-width: 0;
+ flex: 0 0 var(--contacts-name-text-width);
+ width: var(--contacts-name-text-width);
+ min-width: var(--contacts-name-text-width);
+ max-width: var(--contacts-name-text-width);
}
.contact-name {
@@ -1949,6 +2249,7 @@
gap: 4px;
flex-shrink: 0;
text-align: center;
+ box-sizing: border-box;
}
.row-media-metric {
@@ -1959,6 +2260,7 @@
align-items: center;
flex-shrink: 0;
text-align: center;
+ box-sizing: border-box;
}
.row-media-metric-value {
@@ -1982,6 +2284,7 @@
background: transparent;
margin: 0;
padding: 0;
+ width: 100%;
min-height: 14px;
display: inline-flex;
align-items: center;
@@ -2104,11 +2407,12 @@
min-width: 1300px;
border-collapse: separate;
border-spacing: 0;
+ background: var(--bg-secondary);
thead th {
position: sticky;
top: 0;
- background: color-mix(in srgb, var(--bg-primary) 75%, var(--bg-secondary));
+ background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary));
z-index: 4;
font-size: 12px;
text-align: left;
@@ -2231,24 +2535,16 @@
display: flex;
flex-direction: column;
align-items: flex-end;
+ justify-content: center;
+ align-self: stretch;
gap: 4px;
width: var(--contacts-action-col-width);
+ min-width: var(--contacts-action-col-width);
flex-shrink: 0;
position: sticky;
right: 0;
- z-index: 6;
- background: var(--bg-primary);
-
- &::before {
- content: '';
- position: absolute;
- top: -12px;
- bottom: -12px;
- left: -8px;
- width: 8px;
- pointer-events: none;
- background: linear-gradient(to right, transparent, var(--bg-primary));
- }
+ z-index: 10;
+ background: var(--contacts-row-bg);
.row-action-main {
display: inline-flex;
@@ -3929,6 +4225,8 @@
.table-wrap {
--contacts-inline-padding: 10px;
+ --contacts-name-text-width: 10em;
+ --contacts-main-col-width: calc(44px + 10px + var(--contacts-name-text-width));
--contacts-message-col-width: 104px;
--contacts-media-col-width: 62px;
--contacts-action-col-width: 140px;
diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx
index 085af11..cd5183f 100644
--- a/src/pages/ExportPage.tsx
+++ b/src/pages/ExportPage.tsx
@@ -1,4 +1,4 @@
-import { memo, useCallback, useEffect, useMemo, useRef, useState, type WheelEvent } from 'react'
+import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type UIEvent, type WheelEvent } from 'react'
import { useLocation } from 'react-router-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { createPortal } from 'react-dom'
@@ -30,6 +30,7 @@ import {
} from 'lucide-react'
import type { ChatSession as AppChatSession, ContactInfo } from '../types/models'
import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron'
+import type { BackgroundTaskRecord } from '../types/backgroundTask'
import * as configService from '../services/config'
import {
emitExportSessionStatus,
@@ -37,6 +38,11 @@ import {
onExportSessionStatusRequest,
onOpenSingleExport
} from '../services/exportBridge'
+import {
+ requestCancelBackgroundTask,
+ requestCancelBackgroundTasks,
+ subscribeBackgroundTasks
+} from '../services/backgroundTaskMonitor'
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
@@ -176,6 +182,24 @@ const contentTypeLabels: Record
= {
emoji: '表情包'
}
+const backgroundTaskSourceLabels: Record = {
+ export: '导出页',
+ chat: '聊天页',
+ analytics: '分析页',
+ sns: '朋友圈页',
+ groupAnalytics: '群分析页',
+ annualReport: '年度报告',
+ other: '其他页面'
+}
+
+const backgroundTaskStatusLabels: Record = {
+ running: '运行中',
+ cancel_requested: '停止中',
+ completed: '已完成',
+ failed: '失败',
+ canceled: '已停止'
+}
+
const conversationTabLabels: Record = {
private: '私聊',
group: '群聊',
@@ -1422,6 +1446,7 @@ function ExportPage() {
const [sessionMutualFriendsMetrics, setSessionMutualFriendsMetrics] = useState>({})
const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState(null)
const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('')
+ const [backgroundTasks, setBackgroundTasks] = useState([])
const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState('B')
@@ -1487,6 +1512,12 @@ function ExportPage() {
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
const [nowTick, setNowTick] = useState(Date.now())
const [isContactsListAtTop, setIsContactsListAtTop] = useState(true)
+ const [isContactsHeaderDragging, setIsContactsHeaderDragging] = useState(false)
+ const [contactsListScrollParent, setContactsListScrollParent] = useState(null)
+ const [contactsHorizontalScrollMetrics, setContactsHorizontalScrollMetrics] = useState({
+ viewportWidth: 0,
+ contentWidth: 0
+ })
const tabCounts = useContactTypeCountsStore(state => state.tabCounts)
const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading)
const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady)
@@ -1508,6 +1539,16 @@ function ExportPage() {
const contactsAvatarCacheRef = useRef>({})
const contactsVirtuosoRef = useRef(null)
const sessionTableSectionRef = useRef(null)
+ const contactsHorizontalViewportRef = useRef(null)
+ const contactsHorizontalContentRef = useRef(null)
+ const contactsBottomScrollbarRef = useRef(null)
+ const contactsScrollSyncSourceRef = useRef<'viewport' | 'bottom' | null>(null)
+ const contactsHeaderDragStateRef = useRef({
+ pointerId: -1,
+ startClientX: 0,
+ startScrollLeft: 0,
+ didDrag: false
+ })
const sessionFormatDropdownRef = useRef(null)
const detailRequestSeqRef = useRef(0)
const sessionsRef = useRef([])
@@ -1560,6 +1601,10 @@ function ExportPage() {
endIndex: -1
})
+ const handleContactsListScrollParentRef = useCallback((node: HTMLDivElement | null) => {
+ setContactsListScrollParent(prev => (prev === node ? prev : node))
+ }, [])
+
const ensureExportCacheScope = useCallback(async (): Promise => {
if (exportCacheScopeReadyRef.current) {
return exportCacheScopeRef.current
@@ -1903,6 +1948,10 @@ function ExportPage() {
return () => window.clearInterval(timer)
}, [contactsList.length, isContactsListLoading, contactsLoadIssue])
+ useEffect(() => {
+ return subscribeBackgroundTasks(setBackgroundTasks)
+ }, [])
+
useEffect(() => {
tasksRef.current = tasks
}, [tasks])
@@ -3843,11 +3892,9 @@ function ExportPage() {
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: base.exportAvatars,
@@ -5491,6 +5538,16 @@ function ExportPage() {
alert('复制失败,请手动复制诊断信息')
}
}, [contactsDiagnosticsText])
+ const handleCancelBackgroundTask = useCallback((taskId: string) => {
+ requestCancelBackgroundTask(taskId)
+ }, [])
+ const handleCancelAllNonExportTasks = useCallback(() => {
+ requestCancelBackgroundTasks(task => (
+ task.sourcePage !== 'export' &&
+ task.cancelable &&
+ (task.status === 'running' || task.status === 'cancel_requested')
+ ))
+ }, [])
const sessionContactsUpdatedAtLabel = useMemo(() => {
if (!sessionContactsUpdatedAt) return ''
@@ -5563,6 +5620,36 @@ function ExportPage() {
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
const taskCenterAlertCount = taskRunningCount + taskQueuedCount
const hasFilteredContacts = filteredContacts.length > 0
+ const contactsTableMinWidth = useMemo(() => {
+ const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + 140 + (8 * 12)
+ const snsWidth = shouldShowSnsColumn ? 72 + 12 : 0
+ const mutualFriendsWidth = shouldShowMutualFriendsColumn ? 72 + 12 : 0
+ return baseWidth + snsWidth + mutualFriendsWidth
+ }, [shouldShowMutualFriendsColumn, shouldShowSnsColumn])
+ const contactsTableStyle = useMemo(() => (
+ {
+ ['--contacts-table-min-width' as const]: `${contactsTableMinWidth}px`
+ } as CSSProperties
+ ), [contactsTableMinWidth])
+ const hasContactsHorizontalOverflow = contactsHorizontalScrollMetrics.contentWidth - contactsHorizontalScrollMetrics.viewportWidth > 1
+ const contactsBottomScrollbarInnerStyle = useMemo(() => ({
+ width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px`
+ }), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth])
+ const nonExportBackgroundTasks = useMemo(() => (
+ backgroundTasks.filter(task => task.sourcePage !== 'export')
+ ), [backgroundTasks])
+ const runningNonExportTaskCount = useMemo(() => (
+ nonExportBackgroundTasks.filter(task => task.status === 'running' || task.status === 'cancel_requested').length
+ ), [nonExportBackgroundTasks])
+ const cancelableNonExportTaskCount = useMemo(() => (
+ nonExportBackgroundTasks.filter(task => (
+ task.cancelable &&
+ (task.status === 'running' || task.status === 'cancel_requested')
+ )).length
+ ), [nonExportBackgroundTasks])
+ const nonExportBackgroundTasksUpdatedAt = useMemo(() => (
+ nonExportBackgroundTasks.reduce((latest, task) => Math.max(latest, task.updatedAt || 0), 0)
+ ), [nonExportBackgroundTasks])
const sessionLoadDetailUpdatedAt = useMemo(() => {
let latest = 0
for (const row of sessionLoadDetailRows) {
@@ -5588,6 +5675,136 @@ function ExportPage() {
row.mutualFriends.statusLabel.startsWith('加载中')
))
), [sessionLoadDetailRows])
+ const syncContactsHorizontalScroll = useCallback((source: 'viewport' | 'bottom', scrollLeft: number) => {
+ if (contactsScrollSyncSourceRef.current && contactsScrollSyncSourceRef.current !== source) return
+
+ contactsScrollSyncSourceRef.current = source
+ const viewport = contactsHorizontalViewportRef.current
+ const bottomScrollbar = contactsBottomScrollbarRef.current
+
+ if (source !== 'viewport' && viewport && Math.abs(viewport.scrollLeft - scrollLeft) > 1) {
+ viewport.scrollLeft = scrollLeft
+ }
+
+ if (source !== 'bottom' && bottomScrollbar && Math.abs(bottomScrollbar.scrollLeft - scrollLeft) > 1) {
+ bottomScrollbar.scrollLeft = scrollLeft
+ }
+
+ window.requestAnimationFrame(() => {
+ if (contactsScrollSyncSourceRef.current === source) {
+ contactsScrollSyncSourceRef.current = null
+ }
+ })
+ }, [])
+ const handleContactsHorizontalViewportScroll = useCallback((event: UIEvent) => {
+ syncContactsHorizontalScroll('viewport', event.currentTarget.scrollLeft)
+ }, [syncContactsHorizontalScroll])
+ const handleContactsBottomScrollbarScroll = useCallback((event: UIEvent) => {
+ syncContactsHorizontalScroll('bottom', event.currentTarget.scrollLeft)
+ }, [syncContactsHorizontalScroll])
+ const resetContactsHeaderDrag = useCallback((currentTarget?: HTMLDivElement | null) => {
+ const dragState = contactsHeaderDragStateRef.current
+ if (currentTarget && dragState.pointerId >= 0 && currentTarget.hasPointerCapture(dragState.pointerId)) {
+ currentTarget.releasePointerCapture(dragState.pointerId)
+ }
+ dragState.pointerId = -1
+ dragState.startClientX = 0
+ dragState.startScrollLeft = 0
+ dragState.didDrag = false
+ setIsContactsHeaderDragging(false)
+ }, [])
+ const handleContactsHeaderPointerDown = useCallback((event: PointerEvent) => {
+ if (!hasContactsHorizontalOverflow || event.pointerType === 'touch') return
+ if (event.button !== 0) return
+ if (event.target instanceof Element && event.target.closest('button, a, input, textarea, select, label, [role="button"]')) {
+ return
+ }
+
+ contactsHeaderDragStateRef.current = {
+ pointerId: event.pointerId,
+ startClientX: event.clientX,
+ startScrollLeft: contactsHorizontalViewportRef.current?.scrollLeft ?? 0,
+ didDrag: false
+ }
+ event.currentTarget.setPointerCapture(event.pointerId)
+ setIsContactsHeaderDragging(true)
+ }, [hasContactsHorizontalOverflow])
+ const handleContactsHeaderPointerMove = useCallback((event: PointerEvent) => {
+ const dragState = contactsHeaderDragStateRef.current
+ if (dragState.pointerId !== event.pointerId) return
+
+ const viewport = contactsHorizontalViewportRef.current
+ const content = contactsHorizontalContentRef.current
+ if (!viewport || !content) return
+
+ const deltaX = event.clientX - dragState.startClientX
+ if (!dragState.didDrag && Math.abs(deltaX) < 4) return
+
+ dragState.didDrag = true
+ const maxScrollLeft = Math.max(0, content.scrollWidth - viewport.clientWidth)
+ const nextScrollLeft = Math.max(0, Math.min(dragState.startScrollLeft - deltaX, maxScrollLeft))
+
+ viewport.scrollLeft = nextScrollLeft
+ syncContactsHorizontalScroll('viewport', nextScrollLeft)
+ event.preventDefault()
+ }, [syncContactsHorizontalScroll])
+ const handleContactsHeaderPointerUp = useCallback((event: PointerEvent) => {
+ if (contactsHeaderDragStateRef.current.pointerId !== event.pointerId) return
+ resetContactsHeaderDrag(event.currentTarget)
+ }, [resetContactsHeaderDrag])
+ const handleContactsHeaderPointerCancel = useCallback((event: PointerEvent) => {
+ if (contactsHeaderDragStateRef.current.pointerId !== event.pointerId) return
+ resetContactsHeaderDrag(event.currentTarget)
+ }, [resetContactsHeaderDrag])
+ useEffect(() => {
+ const viewport = contactsHorizontalViewportRef.current
+ const content = contactsHorizontalContentRef.current
+ if (!viewport || !content) return
+
+ const syncMetrics = () => {
+ const viewportWidth = Math.round(viewport.clientWidth)
+ const contentWidth = Math.round(content.scrollWidth)
+
+ setContactsHorizontalScrollMetrics((prev) => (
+ prev.viewportWidth === viewportWidth && prev.contentWidth === contentWidth
+ ? prev
+ : { viewportWidth, contentWidth }
+ ))
+
+ const maxScrollLeft = Math.max(0, contentWidth - viewportWidth)
+ const clampedScrollLeft = Math.min(viewport.scrollLeft, maxScrollLeft)
+
+ if (Math.abs(viewport.scrollLeft - clampedScrollLeft) > 1) {
+ viewport.scrollLeft = clampedScrollLeft
+ }
+
+ const bottomScrollbar = contactsBottomScrollbarRef.current
+ if (bottomScrollbar) {
+ const nextScrollLeft = Math.min(bottomScrollbar.scrollLeft, maxScrollLeft)
+ if (Math.abs(bottomScrollbar.scrollLeft - nextScrollLeft) > 1) {
+ bottomScrollbar.scrollLeft = nextScrollLeft
+ }
+ if (Math.abs(nextScrollLeft - clampedScrollLeft) > 1) {
+ bottomScrollbar.scrollLeft = clampedScrollLeft
+ }
+ }
+ }
+
+ syncMetrics()
+
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', syncMetrics)
+ return () => window.removeEventListener('resize', syncMetrics)
+ }
+
+ const resizeObserver = new ResizeObserver(syncMetrics)
+ resizeObserver.observe(viewport)
+ resizeObserver.observe(content)
+
+ return () => {
+ resizeObserver.disconnect()
+ }
+ }, [])
const closeTaskCenter = useCallback(() => {
setIsTaskCenterOpen(false)
setExpandedPerfTaskId(null)
@@ -5664,27 +5881,29 @@ function ExportPage() {
return (
-
-
-
-
- {contact.avatarUrl ? (
-

- ) : (
-
{getAvatarLetter(contact.displayName)}
- )}
-
-
-
{contact.displayName}
-
{contact.alias || contact.username}
+
+
+
+
+
+ {contact.avatarUrl ? (
+

+ ) : (
+
{getAvatarLetter(contact.displayName)}
+ )}
+
+
+
{contact.displayName}
+
{contact.alias || contact.username}
+
@@ -5796,7 +6015,7 @@ function ExportPage() {
})
}}
>
- {!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '单会话导出'}
+ {!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '导出'}
{hasRecentExport && {recentExportTime}}
@@ -6115,157 +6334,198 @@ function ExportPage() {
-
-
-
-
-
-
-
-
-
-
-
-
- setSearchKeyword(event.target.value)}
- placeholder={`搜索${activeTabLabel}联系人...`}
- />
- {searchKeyword && (
-
- )}
-
-
-
+
+
+
+
+
+
- {contactsList.length > 0 && isContactsListLoading && (
-
-
- 联系人列表同步中…
-
- )}
-
- {hasFilteredContacts && (
-
-
-
- {contactsList.length === 0 && contactsLoadIssue ? (
-
-
-
-
-
{contactsLoadIssue.title}
+
+
+
+
+ {contactsList.length > 0 && isContactsListLoading && (
+
+
+ 联系人列表同步中…
+
+ )}
+
+ {hasFilteredContacts && (
+
+
+
+
+ {isAllVisibleSelected ? : }
+
+
+
+ {contactsHeaderMainLabel}
+
+
+ 总消息数
+ 表情包
+ 语音
+ 图片
+ 视频
+ {shouldShowSnsColumn && (
+ 朋友圈
+ )}
+ {shouldShowMutualFriendsColumn && (
+ 共同好友
+ )}
+
+ {selectedCount > 0 && (
+ <>
+
+ 清空
+
+
+ 批量导出
+ {selectedCount}
+
+ >
+ )}
+
+
+ )}
-
{contactsLoadIssue.message}
-
{contactsLoadIssue.reason}
-
- - 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
- - 可能原因2:contact.db 数据量较大,首次查询时间过长。
- - 可能原因3:数据库连接状态异常或 IPC 调用卡住。
-
-
- void loadContactsList()}>
-
- 重试加载
-
- setShowContactsDiagnostics(prev => !prev)}>
-
- {showContactsDiagnostics ? '收起诊断详情' : '查看诊断详情'}
-
-
- 复制诊断信息
-
-
- {showContactsDiagnostics && (
-
{contactsDiagnosticsText}
+
+ {contactsList.length === 0 && contactsLoadIssue ? (
+
+
+
+
+
{contactsLoadIssue.title}
+
+
{contactsLoadIssue.message}
+
{contactsLoadIssue.reason}
+
+ - 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
+ - 可能原因2:contact.db 数据量较大,首次查询时间过长。
+ - 可能原因3:数据库连接状态异常或 IPC 调用卡住。
+
+
+ void loadContactsList()}>
+
+ 重试加载
+
+ setShowContactsDiagnostics(prev => !prev)}>
+
+ {showContactsDiagnostics ? '收起诊断详情' : '查看诊断详情'}
+
+
+ 复制诊断信息
+
+
+ {showContactsDiagnostics && (
+
{contactsDiagnosticsText}
+ )}
+
+
+ ) : isContactsListLoading && contactsList.length === 0 ? (
+
+
+ 联系人加载中...
+
+ ) : !hasFilteredContacts ? (
+
+ 暂无联系人
+
+ ) : (
+
+ contact.username}
+ fixedItemHeight={76}
+ itemContent={renderContactRow}
+ rangeChanged={handleContactsRangeChanged}
+ atTopStateChange={setIsContactsListAtTop}
+ overscan={420}
+ />
+
)}
- ) : isContactsListLoading && contactsList.length === 0 ? (
-
-
- 联系人加载中...
-
- ) : !hasFilteredContacts ? (
-
- 暂无联系人
-
- ) : (
+
+
+ {hasFilteredContacts && hasContactsHorizontalOverflow && (
-
contact.username}
- itemContent={renderContactRow}
- rangeChanged={handleContactsRangeChanged}
- atTopStateChange={setIsContactsListAtTop}
- overscan={420}
- />
+
)}
@@ -6303,6 +6563,67 @@ function ExportPage() {
+
+ 其他页面后台任务
+
+
+ {runningNonExportTaskCount}
+ 个任务正在占用后台读取资源
+ {nonExportBackgroundTasksUpdatedAt > 0 && (
+ 最近更新时间 {new Date(nonExportBackgroundTasksUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false })}
+ )}
+
+
+ 中断其他页面加载
+
+
+
+ 停止请求会阻止其他页面继续发起后续统计或补算;当前已经发出的单次查询,会在返回后结束。
+
+ {nonExportBackgroundTasks.length > 0 ? (
+
+ {nonExportBackgroundTasks.map((task) => (
+
+
+
+
+ {backgroundTaskSourceLabels[task.sourcePage] || backgroundTaskSourceLabels.other}
+
+ {task.title}
+
+ {backgroundTaskStatusLabels[task.status]}
+
+
+
{task.detail || '暂无详细说明'}
+
+ 开始:{formatLoadDetailTime(task.startedAt)}
+ 更新:{formatLoadDetailTime(task.updatedAt)}
+ {task.progressText && 进度:{task.progressText}}
+
+
+
handleCancelBackgroundTask(task.id)}
+ disabled={!task.cancelable || (task.status !== 'running' && task.status !== 'cancel_requested')}
+ >
+ 停止
+
+
+ ))}
+
+ ) : (
+
+ 当前没有检测到其他页面后台任务
+
+ )}
+
+
总消息数
diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss
index b9cd651..d7f5184 100644
--- a/src/pages/GroupAnalyticsPage.scss
+++ b/src/pages/GroupAnalyticsPage.scss
@@ -1,6 +1,14 @@
+.group-analytics-shell {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-height: 100%;
+}
+
.group-analytics-page {
display: flex;
- height: 100%;
+ flex: 1;
+ min-height: 0;
gap: 16px;
&.standalone {
diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx
index 05a616d..ae372ad 100644
--- a/src/pages/GroupAnalyticsPage.tsx
+++ b/src/pages/GroupAnalyticsPage.tsx
@@ -4,7 +4,14 @@ import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, C
import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker'
+import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
import * as configService from '../services/config'
+import {
+ finishBackgroundTask,
+ isBackgroundTaskCancelRequested,
+ registerBackgroundTask,
+ updateBackgroundTask
+} from '../services/backgroundTaskMonitor'
import './GroupAnalyticsPage.scss'
interface GroupChatInfo {
@@ -176,15 +183,39 @@ function GroupAnalyticsPage() {
}, [])
const loadGroups = useCallback(async () => {
+ const taskId = registerBackgroundTask({
+ sourcePage: 'groupAnalytics',
+ title: '群列表加载',
+ detail: '正在读取群聊列表',
+ progressText: '群聊列表',
+ cancelable: true
+ })
setIsLoading(true)
try {
const result = await window.electronAPI.groupAnalytics.getGroupChats()
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', {
+ detail: '已停止后续加载,群聊列表结果未继续写入'
+ })
+ return
+ }
if (result.success && result.data) {
setGroups(result.data)
setFilteredGroups(result.data)
+ finishBackgroundTask(taskId, 'completed', {
+ detail: `群聊列表加载完成,共 ${result.data.length} 个群`,
+ progressText: `${result.data.length} 个群`
+ })
+ } else {
+ finishBackgroundTask(taskId, 'failed', {
+ detail: result.error || '加载群聊列表失败'
+ })
}
} catch (e) {
console.error(e)
+ finishBackgroundTask(taskId, 'failed', {
+ detail: String(e)
+ })
} finally {
setIsLoading(false)
}
@@ -314,6 +345,13 @@ function GroupAnalyticsPage() {
const loadFunctionData = async (func: AnalysisFunction) => {
if (!selectedGroup) return
+ const taskId = registerBackgroundTask({
+ sourcePage: 'groupAnalytics',
+ title: `群分析:${func}`,
+ detail: `正在读取 ${selectedGroup.displayName || selectedGroup.username} 的分析数据`,
+ progressText: func,
+ cancelable: true
+ })
setFunctionLoading(true)
// 计算时间戳
@@ -323,33 +361,96 @@ function GroupAnalyticsPage() {
try {
switch (func) {
case 'members': {
+ updateBackgroundTask(taskId, {
+ detail: '正在读取群成员列表',
+ progressText: '成员列表'
+ })
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群成员列表未继续写入' })
+ return
+ }
if (result.success && result.data) setMembers(result.data)
+ finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
+ detail: result.success ? `群成员列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取群成员列表失败'),
+ progressText: result.success ? `${result.data?.length || 0} 人` : '失败'
+ })
break
}
case 'memberExport': {
+ updateBackgroundTask(taskId, {
+ detail: '正在读取导出成员列表',
+ progressText: '成员导出'
+ })
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员导出列表未继续写入' })
+ return
+ }
if (result.success && result.data) setMembers(result.data)
+ finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
+ detail: result.success ? `成员导出列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取成员导出列表失败'),
+ progressText: result.success ? `${result.data?.length || 0} 人` : '失败'
+ })
break
}
case 'ranking': {
+ updateBackgroundTask(taskId, {
+ detail: '正在计算群消息排行',
+ progressText: '消息排行'
+ })
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息排行未继续写入' })
+ return
+ }
if (result.success && result.data) setRankings(result.data)
+ finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
+ detail: result.success ? `群消息排行加载完成,共 ${result.data?.length || 0} 条` : (result.error || '读取群消息排行失败'),
+ progressText: result.success ? `${result.data?.length || 0} 条` : '失败'
+ })
break
}
case 'activeHours': {
+ updateBackgroundTask(taskId, {
+ detail: '正在计算群活跃时段',
+ progressText: '活跃时段'
+ })
const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime)
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群活跃时段未继续写入' })
+ return
+ }
if (result.success && result.data) setActiveHours(result.data.hourlyDistribution)
+ finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
+ detail: result.success ? '群活跃时段加载完成' : (result.error || '读取群活跃时段失败'),
+ progressText: result.success ? '24 小时分布' : '失败'
+ })
break
}
case 'mediaStats': {
+ updateBackgroundTask(taskId, {
+ detail: '正在统计群消息类型',
+ progressText: '消息类型'
+ })
const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime)
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息类型统计未继续写入' })
+ return
+ }
if (result.success && result.data) setMediaStats(result.data)
+ finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
+ detail: result.success ? `群消息类型统计完成,共 ${result.data?.total || 0} 条` : (result.error || '读取群消息类型统计失败'),
+ progressText: result.success ? `${result.data?.total || 0} 条` : '失败'
+ })
break
}
}
} catch (e) {
console.error(e)
+ finishBackgroundTask(taskId, 'failed', {
+ detail: String(e)
+ })
} finally {
setFunctionLoading(false)
}
@@ -1085,11 +1186,14 @@ function GroupAnalyticsPage() {
}
return (
-
- {renderGroupList()}
-
setIsResizing(true)} />
-
- {renderDetailPanel()}
+
+
+
+ {renderGroupList()}
+
setIsResizing(true)} />
+
+ {renderDetailPanel()}
+
{renderMemberModal()}
diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss
index 8dc2525..fa710b6 100644
--- a/src/pages/SettingsPage.scss
+++ b/src/pages/SettingsPage.scss
@@ -1,17 +1,38 @@
+.settings-modal-overlay {
+ position: fixed;
+ top: 41px;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 2050;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 28px 32px;
+ background: rgba(15, 23, 42, 0.28);
+ backdrop-filter: blur(10px);
+}
+
.settings-page {
display: flex;
flex-direction: column;
- height: 100%;
- margin: -24px;
+ width: min(1160px, calc(100vw - 96px));
+ height: min(820px, calc(100vh - 120px));
+ max-height: 100%;
padding: 24px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 24px;
+ box-shadow: 0 28px 80px rgba(15, 23, 42, 0.22);
overflow: hidden;
}
.settings-header {
display: flex;
- align-items: center;
+ align-items: flex-start;
justify-content: space-between;
- margin-bottom: 20px;
+ gap: 20px;
+ margin-bottom: 14px;
flex-shrink: 0;
h1 {
@@ -22,51 +43,91 @@
}
}
+.settings-title-block {
+ display: flex;
+ flex-direction: column;
+}
+
.settings-actions {
display: flex;
+ align-items: center;
gap: 12px;
}
+.settings-close-btn {
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
+
+ &:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border-color: rgba(139, 115, 85, 0.28);
+ }
+}
+
+.settings-layout {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ gap: 20px;
+ overflow: hidden;
+}
+
.settings-tabs {
display: flex;
- gap: 4px;
- padding: 4px;
- background: var(--bg-tertiary);
- border-radius: 12px;
- margin-bottom: 20px;
- flex-shrink: 0;
- width: fit-content;
-}
-
-.tab-btn {
- display: flex;
- align-items: center;
+ flex-direction: column;
gap: 6px;
- padding: 10px 18px;
- border: none;
- border-radius: 8px;
- font-size: 14px;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.2s;
- background: transparent;
- color: var(--text-secondary);
+ padding: 12px;
+ width: 220px;
+ flex-shrink: 0;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 20px;
+ overflow-y: auto;
- &:hover {
- color: var(--text-primary);
- background: var(--bg-secondary);
- }
+ .tab-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ justify-content: flex-start;
+ padding: 11px 14px;
+ border: none;
+ border-radius: 12px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ background: transparent;
+ color: var(--text-secondary);
- &.active {
- background: var(--card-bg);
- color: var(--primary);
- box-shadow: var(--shadow-sm);
+ &:hover {
+ color: var(--text-primary);
+ background: var(--bg-secondary);
+ }
+
+ &.active {
+ background: var(--card-bg);
+ color: var(--primary);
+ box-shadow: var(--shadow-sm);
+ }
}
}
.settings-body {
flex: 1;
overflow-y: auto;
+ min-width: 0;
padding-right: 8px;
&::-webkit-scrollbar {
@@ -85,8 +146,10 @@
.tab-content {
background: var(--bg-secondary);
- border-radius: 16px;
+ border: 1px solid var(--border-color);
+ border-radius: 20px;
padding: 24px;
+ min-height: 100%;
.section-desc {
font-size: 13px;
@@ -932,7 +995,7 @@
padding: 10px 24px;
border-radius: 9999px;
font-size: 14px;
- z-index: 100;
+ z-index: 2200;
animation: slideDown 0.3s ease;
&.success {
@@ -946,6 +1009,27 @@
}
}
+@media (max-width: 960px) {
+ .settings-modal-overlay {
+ padding: 20px;
+ }
+
+ .settings-page {
+ width: min(100%, calc(100vw - 40px));
+ height: min(100%, calc(100vh - 82px));
+ padding: 20px;
+ }
+
+ .settings-layout {
+ flex-direction: column;
+ }
+
+ .settings-tabs {
+ width: 100%;
+ max-height: 220px;
+ }
+}
+
@keyframes slideDown {
from {
opacity: 0;
@@ -1784,54 +1868,106 @@
.model-status-card {
display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 16px;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 14px;
}
.model-info {
- flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
min-width: 0;
+ .model-name-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ }
+
.model-name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
- margin-bottom: 6px;
}
- .model-path {
+ .model-size {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 9px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 600;
+ }
+
+ .model-meta {
display: flex;
flex-direction: column;
- gap: 4px;
+ align-items: flex-start;
+ gap: 10px;
.status-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
+ width: fit-content;
+ padding: 4px 10px;
+ border-radius: 999px;
font-size: 12px;
- font-weight: 500;
+ font-weight: 600;
&.success {
+ background: rgba(16, 185, 129, 0.1);
+ border: 1px solid rgba(16, 185, 129, 0.2);
color: #10b981;
}
&.warning {
+ background: rgba(245, 158, 11, 0.1);
+ border: 1px solid rgba(245, 158, 11, 0.2);
color: #f59e0b;
}
}
+ .model-path-block {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 10px 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ background: var(--bg-secondary);
+ }
+
+ .path-label {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-tertiary);
+ letter-spacing: 0.02em;
+ }
+
.path-text {
font-size: 12px;
color: var(--text-tertiary);
font-family: monospace;
- word-break: break-all;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
}
}
.model-actions {
- flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ align-items: flex-start;
+ padding-top: 14px;
+ border-top: 1px solid var(--border-color);
.btn-download {
display: inline-flex;
@@ -1866,16 +2002,18 @@
.download-status {
display: flex;
flex-direction: column;
- gap: 6px;
- width: 280px;
+ gap: 8px;
+ width: 100%;
+ max-width: 420px;
.status-header,
.progress-info {
- // specific layout class
display: flex;
justify-content: space-between;
- align-items: center; // Align vertically
+ align-items: center;
width: 100%;
+ gap: 12px;
+ flex-wrap: wrap;
}
.percent {
@@ -1889,6 +2027,7 @@
.details {
display: flex;
align-items: center;
+ flex-wrap: wrap;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
@@ -1963,10 +2102,12 @@
.path-selector {
display: flex;
gap: 8px;
+ flex-wrap: wrap;
input {
margin-bottom: 0 !important;
flex: 1;
+ min-width: 220px;
font-family: monospace;
font-size: 12px;
}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index 8afa61b..9c5e664 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -10,7 +10,7 @@ import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic,
- ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2
+ ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2, X
} from 'lucide-react'
import { Avatar } from '../components/Avatar'
import './SettingsPage.scss'
@@ -36,7 +36,11 @@ interface WxidOption {
modifiedTime: number
}
-function SettingsPage() {
+interface SettingsPageProps {
+ onClose?: () => void
+}
+
+function SettingsPage({ onClose }: SettingsPageProps = {}) {
const location = useLocation()
const {
isDbConnected,
@@ -195,6 +199,17 @@ function SettingsPage() {
setActiveTab(initialTab)
}, [location.state])
+ useEffect(() => {
+ if (!onClose) return
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onClose()
+ }
+ }
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [onClose])
+
useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message)
@@ -1410,6 +1425,8 @@ function SettingsPage() {
)
+ const resolvedWhisperModelPath = whisperModelDir || whisperModelStatus?.modelPath || ''
+
const renderModelsTab = () => (
@@ -1424,42 +1441,52 @@ function SettingsPage() {
-
SenseVoiceSmall (245 MB)
-
+
+
SenseVoiceSmall
+
245 MB
+
+
{whisperModelStatus?.exists ? (
已安装
) : (
未安装
)}
- {whisperModelDir &&
{whisperModelDir}
}
+ {resolvedWhisperModelPath && (
+
+
模型目录
+
{resolvedWhisperModelPath}
+
+ )}
-
- {!whisperModelStatus?.exists && !isWhisperDownloading && (
-
- 下载模型
-
- )}
- {isWhisperDownloading && (
-
-
-
{Math.round(whisperDownloadProgress)}%
- {whisperProgressData.total > 0 && (
-
- {formatBytes(whisperProgressData.downloaded)} / {formatBytes(whisperProgressData.total)}
- ({formatBytes(whisperProgressData.speed)}/s)
-
- )}
+ {(!whisperModelStatus?.exists || isWhisperDownloading) && (
+
+ {!whisperModelStatus?.exists && !isWhisperDownloading && (
+
+ 下载模型
+
+ )}
+ {isWhisperDownloading && (
+
+
+ {Math.round(whisperDownloadProgress)}%
+ {whisperProgressData.total > 0 && (
+
+ {formatBytes(whisperProgressData.downloaded)} / {formatBytes(whisperProgressData.total)}
+ ({formatBytes(whisperProgressData.speed)}/s)
+
+ )}
+
+
-
-
- )}
-
+ )}
+
+ )}
@@ -2049,66 +2076,80 @@ function SettingsPage() {
)
return (
-
- {message &&
{message.text}
}
+
onClose?.()}>
+
event.stopPropagation()}>
+ {message &&
{message.text}
}
- {/* 多账号选择对话框 */}
- {showWxidSelect && wxidOptions.length > 1 && (
-
setShowWxidSelect(false)}>
-
e.stopPropagation()}>
-
-
检测到多个微信账号
-
请选择要使用的账号
-
-
- {wxidOptions.map((opt) => (
-
handleSelectWxid(opt.wxid)}
- >
- {opt.wxid}
- 最后修改 {new Date(opt.modifiedTime).toLocaleString()}
-
- ))}
-
-
-
setShowWxidSelect(false)}>取消
+ {/* 多账号选择对话框 */}
+ {showWxidSelect && wxidOptions.length > 1 && (
+
setShowWxidSelect(false)}>
+
e.stopPropagation()}>
+
+
检测到多个微信账号
+
请选择要使用的账号
+
+
+ {wxidOptions.map((opt) => (
+
handleSelectWxid(opt.wxid)}
+ >
+ {opt.wxid}
+ 最后修改 {new Date(opt.modifiedTime).toLocaleString()}
+
+ ))}
+
+
+ setShowWxidSelect(false)}>取消
+
-
- )}
+ )}
-
-
设置
-
-
- {isTesting ? '测试中...' : '测试连接'}
-
+
+
+
设置
+
+
+
+ {isTesting ? '测试中...' : '测试连接'}
+
+ {onClose && (
+
+
+
+ )}
+
+
+
+
+
+ {tabs.map(tab => (
+ setActiveTab(tab.id)}
+ >
+
+ {tab.label}
+
+ ))}
+
+
+
+ {activeTab === 'appearance' && renderAppearanceTab()}
+ {activeTab === 'notification' && renderNotificationTab()}
+ {activeTab === 'database' && renderDatabaseTab()}
+ {activeTab === 'models' && renderModelsTab()}
+ {activeTab === 'cache' && renderCacheTab()}
+ {activeTab === 'api' && renderApiTab()}
+ {activeTab === 'analytics' && renderAnalyticsTab()}
+ {activeTab === 'security' && renderSecurityTab()}
+ {activeTab === 'about' && renderAboutTab()}
+
-
-
- {tabs.map(tab => (
- setActiveTab(tab.id)}>
-
- {tab.label}
-
- ))}
-
-
-
- {activeTab === 'appearance' && renderAppearanceTab()}
- {activeTab === 'notification' && renderNotificationTab()}
- {activeTab === 'database' && renderDatabaseTab()}
- {activeTab === 'models' && renderModelsTab()}
- {activeTab === 'cache' && renderCacheTab()}
- {activeTab === 'api' && renderApiTab()}
- {activeTab === 'analytics' && renderAnalyticsTab()}
- {activeTab === 'security' && renderSecurityTab()}
- {activeTab === 'about' && renderAboutTab()}
-
-
)
}
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss
index d6d24ad..45fcfbc 100644
--- a/src/pages/SnsPage.scss
+++ b/src/pages/SnsPage.scss
@@ -1,7 +1,7 @@
/* Global Variables */
:root {
--sns-max-width: 800px;
- --sns-panel-width: 320px;
+ --sns-panel-width: 380px;
--sns-bg-color: var(--bg-primary);
--sns-card-bg: var(--bg-secondary);
--sns-border-radius-lg: 16px;
@@ -263,6 +263,48 @@
padding-top: 16px;
}
+.feed-contact-filter-bar {
+ margin: 10px 4px 0;
+ padding: 10px 12px;
+ border: 1px solid color-mix(in srgb, var(--primary) 28%, var(--border-color));
+ border-radius: 12px;
+ background: rgba(var(--primary-rgb), 0.08);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+
+ .feed-contact-filter-label {
+ font-size: 12px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ }
+
+ .feed-contact-filter-summary {
+ font-size: 13px;
+ color: var(--text-primary);
+ font-weight: 600;
+ min-width: 0;
+ }
+
+ .feed-contact-filter-clear {
+ margin-left: auto;
+ border: none;
+ background: transparent;
+ color: var(--primary);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ padding: 0;
+ white-space: nowrap;
+
+ &:hover {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+ }
+}
+
.posts-list {
display: flex;
flex-direction: column;
@@ -1211,6 +1253,13 @@
font-variant-numeric: tabular-nums;
}
+ .contact-interaction-hint {
+ padding: 10px 16px 0;
+ font-size: 11px;
+ line-height: 1.5;
+ color: var(--text-tertiary);
+ }
+
.contact-list-scroll {
flex: 1;
overflow-y: auto;
@@ -1218,23 +1267,75 @@
display: flex;
flex-direction: column;
gap: 0;
- /* Remove gap to allow borders to merge */
.contact-row {
display: flex;
align-items: center;
- gap: 12px;
- padding: 10px;
- border-radius: var(--sns-border-radius-md);
- cursor: pointer;
- transition: background 0.2s ease, transform 0.2s ease;
- border: 1px solid transparent;
+ gap: 8px;
margin-bottom: 4px;
+ border-radius: var(--sns-border-radius-md);
+ transition: transform 0.2s ease;
&:hover {
- background: var(--hover-bg);
transform: translateX(2px);
- z-index: 10;
+ }
+
+ &.is-selected .contact-main-btn {
+ background: rgba(var(--primary-rgb), 0.06);
+ border-color: color-mix(in srgb, var(--primary) 20%, var(--border-color));
+ }
+
+ &.is-active .contact-main-btn {
+ background: rgba(var(--primary-rgb), 0.12);
+ border-color: color-mix(in srgb, var(--primary) 48%, var(--border-color));
+ box-shadow: inset 0 0 0 1px rgba(var(--primary-rgb), 0.18);
+ }
+
+ &.is-active .contact-name {
+ color: var(--text-primary);
+ }
+
+ .contact-select-btn {
+ width: 32px;
+ height: 32px;
+ flex-shrink: 0;
+ border: none;
+ background: transparent;
+ border-radius: 8px;
+ color: var(--text-tertiary);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: background 0.2s ease, color 0.2s ease;
+
+ &:hover {
+ background: rgba(var(--primary-rgb), 0.1);
+ color: var(--primary);
+ }
+
+ &.checked {
+ color: var(--primary);
+ }
+ }
+
+ .contact-main-btn {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ border-radius: var(--sns-border-radius-md);
+ border: 1px solid transparent;
+ background: transparent;
+ cursor: pointer;
+ transition: background 0.2s ease, border-color 0.2s ease;
+ text-align: left;
+
+ &:hover {
+ background: var(--hover-bg);
+ }
}
.contact-meta {
@@ -1282,6 +1383,51 @@
}
}
+ .contact-batch-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px 14px;
+ border-top: 1px solid var(--border-color);
+ background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary));
+ }
+
+ .contact-batch-summary {
+ flex: 1;
+ min-width: 0;
+ font-size: 12px;
+ color: var(--text-secondary);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .contact-batch-btn {
+ border: 1px solid var(--border-color);
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ border-radius: var(--sns-border-radius-md);
+ height: 32px;
+ padding: 0 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
+
+ &:hover {
+ border-color: var(--text-tertiary);
+ background: var(--hover-bg);
+ color: var(--text-primary);
+ }
+
+ &.primary {
+ border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
+ background: rgba(var(--primary-rgb), 0.12);
+ color: var(--primary);
+ }
+ }
+
.empty-state {
text-align: center;
color: var(--text-tertiary);
diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx
index 0482240..fb8f009 100644
--- a/src/pages/SnsPage.tsx
+++ b/src/pages/SnsPage.tsx
@@ -9,6 +9,12 @@ import type { ContactSnsTimelineTarget } from '../components/Sns/contactSnsTimel
import JumpToDatePopover from '../components/JumpToDatePopover'
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
import * as configService from '../services/config'
+import {
+ finishBackgroundTask,
+ isBackgroundTaskCancelRequested,
+ registerBackgroundTask,
+ updateBackgroundTask
+} from '../services/backgroundTaskMonitor'
import {
createExportDateRangeSelectionFromPreset,
getExportDateRangeLabel,
@@ -57,6 +63,7 @@ interface SnsOverviewStats {
}
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
+type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] }
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
@@ -117,6 +124,7 @@ export default function SnsPage() {
total: 0,
running: false
})
+ const [selectedContactUsernames, setSelectedContactUsernames] = useState
([])
const [currentUserProfile, setCurrentUserProfile] = useState(() => readSidebarUserProfileCache() || {
wxid: '',
displayName: ''
@@ -134,6 +142,7 @@ export default function SnsPage() {
// 导出相关状态
const [showExportDialog, setShowExportDialog] = useState(false)
+ const [exportScope, setExportScope] = useState({ kind: 'all' })
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html')
const [exportFolder, setExportFolder] = useState('')
const [exportImages, setExportImages] = useState(false)
@@ -164,9 +173,11 @@ export default function SnsPage() {
const overviewStatsStatusRef = useRef(overviewStatsStatus)
const searchKeywordRef = useRef(searchKeyword)
const jumpTargetDateRef = useRef(jumpTargetDate)
+ const selectedContactUsernamesRef = useRef(selectedContactUsernames)
const cacheScopeKeyRef = useRef('')
const snsUserPostCountsCacheScopeKeyRef = useRef('')
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
+ const pendingResetFeedRef = useRef(false)
const contactsLoadTokenRef = useRef(0)
const contactsCountHydrationTokenRef = useRef(0)
const contactsCountBatchTimerRef = useRef(null)
@@ -180,6 +191,13 @@ export default function SnsPage() {
useEffect(() => {
contactsRef.current = contacts
}, [contacts])
+ useEffect(() => {
+ const contactLookup = new Set(contacts.map((contact) => contact.username))
+ setSelectedContactUsernames((prev) => {
+ const next = prev.filter((username) => contactLookup.has(username))
+ return next.length === prev.length ? prev : next
+ })
+ }, [contacts])
useEffect(() => {
overviewStatsRef.current = overviewStats
}, [overviewStats])
@@ -192,6 +210,9 @@ export default function SnsPage() {
useEffect(() => {
jumpTargetDateRef.current = jumpTargetDate
}, [jumpTargetDate])
+ useEffect(() => {
+ selectedContactUsernamesRef.current = selectedContactUsernames
+ }, [selectedContactUsernames])
useEffect(() => {
if (!showJumpPopover) {
setJumpPopoverDate(jumpTargetDate || new Date())
@@ -370,6 +391,31 @@ export default function SnsPage() {
return contacts.find((contact) => contact.username === normalizedTargetUsername) || null
}, [authorTimelineTarget, contacts])
+ const exportSelectedContactsSummary = useMemo(() => {
+ if (exportScope.kind !== 'selected' || exportScope.usernames.length === 0) return ''
+ const contactMap = new Map(contacts.map((contact) => [contact.username, contact]))
+ const names = exportScope.usernames.map((username) => contactMap.get(username)?.displayName || username)
+ if (names.length <= 2) return names.join('、')
+ return `${names.slice(0, 2).join('、')} 等 ${names.length} 位联系人`
+ }, [contacts, exportScope])
+
+ const selectedFeedContactsSummary = useMemo(() => {
+ if (selectedContactUsernames.length === 0) return ''
+ const contactMap = new Map(contacts.map((contact) => [contact.username, contact]))
+ const names = selectedContactUsernames.map((username) => contactMap.get(username)?.displayName || username)
+ if (names.length <= 2) return names.join('、')
+ return `${names.slice(0, 2).join('、')} 等 ${names.length} 人`
+ }, [contacts, selectedContactUsernames])
+
+ const selectedContactUsernameSet = useMemo(() => (
+ new Set(selectedContactUsernames.map((username) => normalizeAccountId(username)))
+ ), [selectedContactUsernames])
+
+ const visiblePosts = useMemo(() => {
+ if (selectedContactUsernameSet.size === 0) return posts
+ return posts.filter((post) => selectedContactUsernameSet.has(normalizeAccountId(post.username)))
+ }, [posts, selectedContactUsernameSet])
+
const myTimelineCount = useMemo(() => {
if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') {
return normalizePostCount(resolvedCurrentUserContact.postCount)
@@ -383,6 +429,10 @@ export default function SnsPage() {
: overviewStatsStatus === 'loading' || contactsLoading
)
+ const canStartExport = Boolean(exportFolder) && !isExporting && (
+ exportScope.kind === 'all' || exportScope.usernames.length > 0
+ )
+
const openCurrentUserTimeline = useCallback(() => {
if (!resolvedCurrentUserContact) return
setAuthorTimelineTarget({
@@ -393,7 +443,11 @@ export default function SnsPage() {
}, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact])
const isDefaultViewNow = useCallback(() => {
- return !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
+ return (
+ !searchKeywordRef.current.trim() &&
+ !jumpTargetDateRef.current &&
+ selectedContactUsernamesRef.current.length === 0
+ )
}, [])
const ensureSnsCacheScopeKey = useCallback(async () => {
@@ -555,9 +609,23 @@ export default function SnsPage() {
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDateRangeSelection), [exportDateRangeSelection])
+ const openExportDialog = useCallback((scope: SnsExportScope) => {
+ setExportScope(scope)
+ setExportResult(null)
+ setExportProgress(null)
+ setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all'))
+ setIsExportDateRangeDialogOpen(false)
+ setShowExportDialog(true)
+ }, [])
+
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
const { reset = false, direction = 'older' } = options
- if (loadingRef.current) return
+ if (loadingRef.current) {
+ if (reset) {
+ pendingResetFeedRef.current = true
+ }
+ return
+ }
loadingRef.current = true
if (direction === 'newer') setLoadingNewer(true)
@@ -565,13 +633,19 @@ export default function SnsPage() {
try {
const limit = 20
+ const currentSearchKeyword = searchKeywordRef.current
+ const currentJumpTargetDate = jumpTargetDateRef.current
+ const currentSelectedContactUsernames = selectedContactUsernamesRef.current
+ const selectedUsernames = currentSelectedContactUsernames.length > 0
+ ? [...currentSelectedContactUsernames]
+ : undefined
let startTs: number | undefined = undefined
let endTs: number | undefined = undefined
if (reset) {
// If jumping to date, set endTs to end of that day
- if (jumpTargetDate) {
- endTs = Math.floor(jumpTargetDate.getTime() / 1000) + 86399
+ if (currentJumpTargetDate) {
+ endTs = Math.floor(currentJumpTargetDate.getTime() / 1000) + 86399
}
} else if (direction === 'newer') {
const currentPosts = postsRef.current
@@ -581,8 +655,8 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.getTimeline(
limit,
0,
- undefined,
- searchKeyword,
+ selectedUsernames,
+ currentSearchKeyword,
topTs + 1,
undefined
);
@@ -622,8 +696,8 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.getTimeline(
limit,
0,
- undefined,
- searchKeyword,
+ selectedUsernames,
+ currentSearchKeyword,
startTs, // default undefined
endTs
)
@@ -637,7 +711,7 @@ export default function SnsPage() {
// Check for newer items above topTs
const topTs = result.timeline[0]?.createTime || 0;
if (topTs > 0) {
- const checkResult = await window.electronAPI.sns.getTimeline(1, 0, undefined, searchKeyword, topTs + 1, undefined);
+ const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, currentSearchKeyword, topTs + 1, undefined);
setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0));
} else {
setHasNewer(false);
@@ -663,8 +737,12 @@ export default function SnsPage() {
setLoading(false)
setLoadingNewer(false)
loadingRef.current = false
+ if (pendingResetFeedRef.current) {
+ pendingResetFeedRef.current = false
+ void loadPosts({ reset: true })
+ }
}
- }, [jumpTargetDate, persistSnsPageCache, searchKeyword])
+ }, [persistSnsPageCache])
const stopContactsCountHydration = useCallback((resetProgress = false) => {
contactsCountHydrationTokenRef.current += 1
@@ -728,9 +806,23 @@ export default function SnsPage() {
})
if (pendingTargets.length === 0) return
+ const taskId = registerBackgroundTask({
+ sourcePage: 'sns',
+ title: '朋友圈联系人计数补算',
+ detail: `正在补算 ${pendingTargets.length} 个联系人朋友圈条数`,
+ progressText: `${preResolved}/${totalTargets}`,
+ cancelable: true
+ })
+
let normalizedCounts: Record = {}
try {
const result = await window.electronAPI.sns.getUserPostCounts()
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', {
+ detail: '已停止后续加载,当前计数查询结束后不再继续分批写入'
+ })
+ return
+ }
if (runToken !== contactsCountHydrationTokenRef.current) return
if (result.success && result.counts) {
normalizedCounts = Object.fromEntries(
@@ -747,12 +839,28 @@ export default function SnsPage() {
}
} catch (error) {
console.error('Failed to load contact post counts:', error)
+ finishBackgroundTask(taskId, 'failed', {
+ detail: String(error)
+ })
+ return
}
let resolved = preResolved
let cursor = 0
const applyBatch = () => {
if (runToken !== contactsCountHydrationTokenRef.current) return
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', {
+ detail: `已停止后续加载,已完成 ${resolved}/${totalTargets}`
+ })
+ contactsCountBatchTimerRef.current = null
+ setContactsCountProgress({
+ resolved,
+ total: totalTargets,
+ running: false
+ })
+ return
+ }
const batch = pendingTargets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE)
if (batch.length === 0) {
@@ -762,6 +870,10 @@ export default function SnsPage() {
running: false
})
contactsCountBatchTimerRef.current = null
+ finishBackgroundTask(taskId, 'completed', {
+ detail: '联系人朋友圈条数补算完成',
+ progressText: `${totalTargets}/${totalTargets}`
+ })
return
}
@@ -789,6 +901,10 @@ export default function SnsPage() {
total: totalTargets,
running: resolved < totalTargets
})
+ updateBackgroundTask(taskId, {
+ detail: `已完成 ${resolved}/${totalTargets} 个联系人朋友圈条数补算`,
+ progressText: `${resolved}/${totalTargets}`
+ })
if (cursor < totalTargets) {
contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS)
@@ -803,6 +919,13 @@ export default function SnsPage() {
// Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序)
const loadContacts = useCallback(async () => {
const requestToken = ++contactsLoadTokenRef.current
+ const taskId = registerBackgroundTask({
+ sourcePage: 'sns',
+ title: '朋友圈联系人列表加载',
+ detail: '准备读取联系人缓存与最近会话',
+ progressText: '初始化',
+ cancelable: true
+ })
stopContactsCountHydration(true)
setContactsLoading(true)
try {
@@ -845,10 +968,20 @@ export default function SnsPage() {
})
}
+ updateBackgroundTask(taskId, {
+ detail: '正在读取联系人与最近会话数据',
+ progressText: '联系人快照'
+ })
const [contactsResult, sessionsResult] = await Promise.all([
window.electronAPI.chat.getContacts(),
window.electronAPI.chat.getSessions()
])
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', {
+ detail: '已停止后续加载,当前联系人查询结束后未继续补齐'
+ })
+ return
+ }
const contactMap = new Map()
const sessionTimestampMap = new Map()
@@ -904,7 +1037,17 @@ export default function SnsPage() {
// 用 enrichSessionsContactInfo 统一补充头像和显示名
if (allUsernames.length > 0) {
+ updateBackgroundTask(taskId, {
+ detail: '正在补齐联系人显示名与头像',
+ progressText: '联系人补齐'
+ })
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
+ if (isBackgroundTaskCancelRequested(taskId)) {
+ finishBackgroundTask(taskId, 'canceled', {
+ detail: '已停止后续加载,联系人补齐未继续写入'
+ })
+ return
+ }
if (enriched.success && enriched.contacts) {
contactsList = contactsList.map((contact) => {
const extra = enriched.contacts?.[contact.username]
@@ -931,10 +1074,17 @@ export default function SnsPage() {
})
}
}
+ finishBackgroundTask(taskId, 'completed', {
+ detail: `朋友圈联系人列表加载完成,共 ${contactsList.length} 人`,
+ progressText: `${contactsList.length} 人`
+ })
} catch (error) {
if (requestToken !== contactsLoadTokenRef.current) return
console.error('Failed to load contacts:', error)
stopContactsCountHydration(true)
+ finishBackgroundTask(taskId, 'failed', {
+ detail: String(error)
+ })
} finally {
if (requestToken === contactsLoadTokenRef.current) {
setContactsLoading(false)
@@ -962,6 +1112,23 @@ export default function SnsPage() {
})
}, [])
+ const toggleContactSelected = useCallback((contact: Contact) => {
+ setSelectedContactUsernames((prev) => (
+ prev.includes(contact.username)
+ ? prev.filter((username) => username !== contact.username)
+ : [...prev, contact.username]
+ ))
+ }, [])
+
+ const clearSelectedContacts = useCallback(() => {
+ setSelectedContactUsernames([])
+ }, [])
+
+ const openSelectedContactsExport = useCallback(() => {
+ if (selectedContactUsernames.length === 0) return
+ openExportDialog({ kind: 'selected', usernames: [...selectedContactUsernames] })
+ }, [openExportDialog, selectedContactUsernames])
+
const handlePostDelete = useCallback((postId: string, username: string) => {
setPosts(prev => {
const next = prev.filter(p => p.id !== postId)
@@ -1029,6 +1196,7 @@ export default function SnsPage() {
stopContactsCountHydration(true)
setContacts([])
setPosts([]); setHasMore(true); setHasNewer(false);
+ setSelectedContactUsernames([])
setSearchKeyword(''); setJumpTargetDate(undefined);
void hydrateSnsPageCache()
loadContacts();
@@ -1046,6 +1214,21 @@ export default function SnsPage() {
return () => clearTimeout(timer)
}, [searchKeyword, jumpTargetDate, loadPosts])
+ const selectedContactUsernamesKey = useMemo(
+ () => selectedContactUsernames.join('||'),
+ [selectedContactUsernames]
+ )
+
+ const hasInitializedSelectedFeedFilterRef = useRef(false)
+
+ useEffect(() => {
+ if (!hasInitializedSelectedFeedFilterRef.current) {
+ hasInitializedSelectedFeedFilterRef.current = true
+ return
+ }
+ loadPosts({ reset: true })
+ }, [loadPosts, selectedContactUsernamesKey])
+
const handleScroll = (e: React.UIEvent) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) {
@@ -1186,13 +1369,7 @@ export default function SnsPage() {
{
- setExportResult(null)
- setExportProgress(null)
- setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all'))
- setIsExportDateRangeDialogOpen(false)
- setShowExportDialog(true)
- }}
+ onClick={() => openExportDialog({ kind: 'all' })}
className="icon-btn export-btn"
title="导出朋友圈"
>
@@ -1214,6 +1391,20 @@ export default function SnsPage() {
+ {selectedContactUsernames.length > 0 && (
+
+ 仅显示
+ {selectedFeedContactsSummary} 的动态
+
+ 清空筛选
+
+
+ )}
+
{loadingNewer && (
@@ -1229,7 +1420,7 @@ export default function SnsPage() {
)}
- {posts.map(post => (
+ {visiblePosts.map(post => (
- {loading && posts.length === 0 && (
+ {loading && visiblePosts.length === 0 && (
@@ -1256,24 +1447,26 @@ export default function SnsPage() {
)}
- {loading && posts.length > 0 && (
+ {loading && visiblePosts.length > 0 && (
正在加载更多...
)}
- {!hasMore && posts.length > 0 && (
+ {!hasMore && visiblePosts.length > 0 && (
或许过往已无可溯洄,但好在还有可以与你相遇的明天
)}
- {!loading && posts.length === 0 && (
+ {!loading && visiblePosts.length === 0 && (
未找到相关动态
- {(searchKeyword || jumpTargetDate) && (
+ {(searchKeyword || jumpTargetDate || selectedContactUsernames.length > 0) && (
{
- setSearchKeyword(''); setJumpTargetDate(undefined);
+ setSearchKeyword('')
+ setJumpTargetDate(undefined)
+ clearSelectedContacts()
}} className="reset-inline">
重置筛选条件
@@ -1299,7 +1492,12 @@ export default function SnsPage() {
setContactSearch={setContactSearch}
loading={contactsLoading}
contactsCountProgress={contactsCountProgress}
+ selectedContactUsernames={selectedContactUsernames}
+ activeContactUsername={authorTimelineTarget?.username}
onOpenContactTimeline={openContactTimeline}
+ onToggleContactSelected={toggleContactSelected}
+ onClearSelectedContacts={clearSelectedContacts}
+ onExportSelectedContacts={openSelectedContactsExport}
/>
{/* Dialogs and Overlays */}
@@ -1444,9 +1642,12 @@ export default function SnsPage() {
{/* 筛选条件提示 */}
- {searchKeyword && (
+ {(searchKeyword || exportScope.kind === 'selected') && (
- 筛选导出
+ 导出范围
+ {exportScope.kind === 'selected' && (
+ 联系人: {exportSelectedContactsSummary}
+ )}
{searchKeyword && 关键词: "{searchKeyword}"}
)}
@@ -1572,7 +1773,7 @@ export default function SnsPage() {
{/* 同步提示 */}
- 将同步主页面的关键词搜索
+ {exportScope.kind === 'selected' ? '将同步主页面的关键词搜索,并仅导出所选联系人' : '将同步主页面的关键词搜索'}
{/* 进度条 */}
@@ -1599,7 +1800,7 @@ export default function SnsPage() {
{
setIsExporting(true)
setExportProgress({ current: 0, total: 0, status: '准备导出...' })
@@ -1614,6 +1815,7 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.exportTimeline({
outputDir: exportFolder,
format: exportFormat,
+ usernames: exportScope.kind === 'selected' ? exportScope.usernames : undefined,
keyword: searchKeyword || undefined,
exportImages,
exportLivePhotos,
diff --git a/src/services/backgroundTaskMonitor.ts b/src/services/backgroundTaskMonitor.ts
new file mode 100644
index 0000000..2b41f6d
--- /dev/null
+++ b/src/services/backgroundTaskMonitor.ts
@@ -0,0 +1,149 @@
+import type {
+ BackgroundTaskInput,
+ BackgroundTaskRecord,
+ BackgroundTaskStatus,
+ BackgroundTaskUpdate
+} from '../types/backgroundTask'
+
+type BackgroundTaskListener = (tasks: BackgroundTaskRecord[]) => void
+
+const tasks = new Map()
+const cancelHandlers = new Map void | Promise>()
+const listeners = new Set()
+let taskSequence = 0
+
+const ACTIVE_STATUSES = new Set(['running', 'cancel_requested'])
+const MAX_SETTLED_TASKS = 24
+
+const buildTaskId = (): string => {
+ taskSequence += 1
+ return `bg-task-${Date.now()}-${taskSequence}`
+}
+
+const notifyListeners = () => {
+ const snapshot = getBackgroundTaskSnapshot()
+ for (const listener of listeners) {
+ listener(snapshot)
+ }
+}
+
+const pruneSettledTasks = () => {
+ const settledTasks = [...tasks.values()]
+ .filter(task => !ACTIVE_STATUSES.has(task.status))
+ .sort((a, b) => (b.finishedAt || b.updatedAt) - (a.finishedAt || a.updatedAt))
+
+ for (const staleTask of settledTasks.slice(MAX_SETTLED_TASKS)) {
+ tasks.delete(staleTask.id)
+ }
+}
+
+export const getBackgroundTaskSnapshot = (): BackgroundTaskRecord[] => (
+ [...tasks.values()].sort((a, b) => {
+ const aActive = ACTIVE_STATUSES.has(a.status) ? 1 : 0
+ const bActive = ACTIVE_STATUSES.has(b.status) ? 1 : 0
+ if (aActive !== bActive) return bActive - aActive
+ return b.updatedAt - a.updatedAt
+ })
+)
+
+export const subscribeBackgroundTasks = (listener: BackgroundTaskListener): (() => void) => {
+ listeners.add(listener)
+ listener(getBackgroundTaskSnapshot())
+ return () => {
+ listeners.delete(listener)
+ }
+}
+
+export const registerBackgroundTask = (input: BackgroundTaskInput): string => {
+ const now = Date.now()
+ const taskId = buildTaskId()
+ tasks.set(taskId, {
+ id: taskId,
+ sourcePage: input.sourcePage,
+ title: input.title,
+ detail: input.detail,
+ progressText: input.progressText,
+ cancelable: input.cancelable !== false,
+ cancelRequested: false,
+ status: 'running',
+ startedAt: now,
+ updatedAt: now
+ })
+ if (input.onCancel) {
+ cancelHandlers.set(taskId, input.onCancel)
+ }
+ pruneSettledTasks()
+ notifyListeners()
+ return taskId
+}
+
+export const updateBackgroundTask = (taskId: string, patch: BackgroundTaskUpdate): void => {
+ const existing = tasks.get(taskId)
+ if (!existing) return
+ const nextStatus = patch.status || existing.status
+ const nextUpdatedAt = Date.now()
+ tasks.set(taskId, {
+ ...existing,
+ ...patch,
+ status: nextStatus,
+ updatedAt: nextUpdatedAt,
+ finishedAt: ACTIVE_STATUSES.has(nextStatus) ? undefined : (existing.finishedAt || nextUpdatedAt)
+ })
+ pruneSettledTasks()
+ notifyListeners()
+}
+
+export const finishBackgroundTask = (
+ taskId: string,
+ status: Extract,
+ patch?: Omit
+): void => {
+ const existing = tasks.get(taskId)
+ if (!existing) return
+ const now = Date.now()
+ tasks.set(taskId, {
+ ...existing,
+ ...patch,
+ status,
+ updatedAt: now,
+ finishedAt: now,
+ cancelRequested: status === 'canceled' ? true : existing.cancelRequested
+ })
+ cancelHandlers.delete(taskId)
+ pruneSettledTasks()
+ notifyListeners()
+}
+
+export const requestCancelBackgroundTask = (taskId: string): boolean => {
+ const existing = tasks.get(taskId)
+ if (!existing || !existing.cancelable || !ACTIVE_STATUSES.has(existing.status)) return false
+ tasks.set(taskId, {
+ ...existing,
+ status: 'cancel_requested',
+ cancelRequested: true,
+ detail: existing.detail || '停止请求已发出,当前查询完成后会结束后续加载',
+ updatedAt: Date.now()
+ })
+ const cancelHandler = cancelHandlers.get(taskId)
+ if (cancelHandler) {
+ void Promise.resolve(cancelHandler()).catch(() => {})
+ }
+ notifyListeners()
+ return true
+}
+
+export const requestCancelBackgroundTasks = (predicate: (task: BackgroundTaskRecord) => boolean): number => {
+ let canceledCount = 0
+ for (const task of tasks.values()) {
+ if (!predicate(task)) continue
+ if (requestCancelBackgroundTask(task.id)) {
+ canceledCount += 1
+ }
+ }
+ return canceledCount
+}
+
+export const isBackgroundTaskCancelRequested = (taskId: string): boolean => {
+ const task = tasks.get(taskId)
+ return Boolean(task?.cancelRequested)
+}
diff --git a/src/types/backgroundTask.ts b/src/types/backgroundTask.ts
new file mode 100644
index 0000000..df8315e
--- /dev/null
+++ b/src/types/backgroundTask.ts
@@ -0,0 +1,46 @@
+export type BackgroundTaskSourcePage =
+ | 'export'
+ | 'chat'
+ | 'analytics'
+ | 'sns'
+ | 'groupAnalytics'
+ | 'annualReport'
+ | 'other'
+
+export type BackgroundTaskStatus =
+ | 'running'
+ | 'cancel_requested'
+ | 'completed'
+ | 'failed'
+ | 'canceled'
+
+export interface BackgroundTaskRecord {
+ id: string
+ sourcePage: BackgroundTaskSourcePage
+ title: string
+ detail?: string
+ progressText?: string
+ cancelable: boolean
+ cancelRequested: boolean
+ status: BackgroundTaskStatus
+ startedAt: number
+ updatedAt: number
+ finishedAt?: number
+}
+
+export interface BackgroundTaskInput {
+ sourcePage: BackgroundTaskSourcePage
+ title: string
+ detail?: string
+ progressText?: string
+ cancelable?: boolean
+ onCancel?: () => void | Promise
+}
+
+export interface BackgroundTaskUpdate {
+ title?: string
+ detail?: string
+ progressText?: string
+ status?: BackgroundTaskStatus
+ cancelable?: boolean
+}