From 1d1b38210af568ae55c6a6f6c844c5c88e51918b Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 18:05:31 +0800 Subject: [PATCH 01/42] fix(export): add horizontal scrollbar for narrow session table --- src/pages/ExportPage.scss | 52 +++++ src/pages/ExportPage.tsx | 406 ++++++++++++++++++++++++-------------- 2 files changed, 314 insertions(+), 144 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index f0ca4c9..d8f7528 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1555,6 +1555,7 @@ --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; @@ -1566,6 +1567,26 @@ } .table-wrap { + .table-scroll-viewport { + flex: 1; + min-height: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + .table-scroll-content { + min-width: max(100%, var(--contacts-table-min-width)); + width: max-content; + min-height: 100%; + display: flex; + flex-direction: column; + } + .session-table-sticky { position: sticky; top: 0; @@ -1575,6 +1596,7 @@ .loading-state, .empty-state { + width: 100%; flex: 1; display: flex; flex-direction: column; @@ -1590,6 +1612,7 @@ } .load-issue-state { + width: 100%; flex: 1; padding: 14px; overflow-y: auto; @@ -1766,6 +1789,7 @@ } .contacts-list { + width: 100%; flex: 1; min-height: var(--contacts-default-list-height); height: var(--contacts-default-list-height); @@ -1787,6 +1811,34 @@ } } + .table-bottom-scrollbar { + flex: 0 0 auto; + height: 16px; + overflow-x: auto; + overflow-y: hidden; + border-top: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary)); + 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; + } + .selection-clear-btn { border: 1px solid var(--border-color); border-radius: 8px; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 085af11..37c7674 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 UIEvent, type WheelEvent } from 'react' import { useLocation } from 'react-router-dom' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { createPortal } from 'react-dom' @@ -1487,6 +1487,10 @@ function ExportPage() { const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [nowTick, setNowTick] = useState(Date.now()) const [isContactsListAtTop, setIsContactsListAtTop] = useState(true) + 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 +1512,10 @@ 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 sessionFormatDropdownRef = useRef(null) const detailRequestSeqRef = useRef(0) const sessionsRef = useRef([]) @@ -5563,6 +5571,21 @@ 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 sessionLoadDetailUpdatedAt = useMemo(() => { let latest = 0 for (const row of sessionLoadDetailRows) { @@ -5588,6 +5611,82 @@ 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]) + 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) @@ -6115,157 +6214,176 @@ function ExportPage() {
-
-
-
-
- - - -
- -
-
- - setSearchKeyword(event.target.value)} - placeholder={`搜索${activeTabLabel}联系人...`} - /> - {searchKeyword && ( - - )} + + +
+ +
+
+ + setSearchKeyword(event.target.value)} + placeholder={`搜索${activeTabLabel}联系人...`} + /> + {searchKeyword && ( + + )} +
+ +
- + + {contactsList.length > 0 && isContactsListLoading && ( +
+ + 联系人列表同步中… +
+ )} + + {hasFilteredContacts && ( +
+ + + + + {contactsHeaderMainLabel} + + 总消息数 + 表情包 + 语音 + 图片 + 视频 + {shouldShowSnsColumn && ( + 朋友圈 + )} + {shouldShowMutualFriendsColumn && ( + 共同好友 + )} + + {selectedCount > 0 && ( + <> + + + + )} + +
+ )}
+ + {contactsList.length === 0 && contactsLoadIssue ? ( +
+
+
+ + {contactsLoadIssue.title} +
+

{contactsLoadIssue.message}

+

{contactsLoadIssue.reason}

+
    +
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • +
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • +
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • +
+
+ + + +
+ {showContactsDiagnostics && ( +
{contactsDiagnosticsText}
+ )} +
+
+ ) : isContactsListLoading && contactsList.length === 0 ? ( +
+ + 联系人加载中... +
+ ) : !hasFilteredContacts ? ( +
+ 暂无联系人 +
+ ) : ( +
+ contact.username} + itemContent={renderContactRow} + rangeChanged={handleContactsRangeChanged} + atTopStateChange={setIsContactsListAtTop} + overscan={420} + /> +
+ )}
- - {contactsList.length > 0 && isContactsListLoading && ( -
- - 联系人列表同步中… -
- )} - - {hasFilteredContacts && ( -
- - - - - {contactsHeaderMainLabel} - - 总消息数 - 表情包 - 语音 - 图片 - 视频 - {shouldShowSnsColumn && ( - 朋友圈 - )} - {shouldShowMutualFriendsColumn && ( - 共同好友 - )} - - {selectedCount > 0 && ( - <> - - - - )} - -
- )}
- {contactsList.length === 0 && contactsLoadIssue ? ( -
-
-
- - {contactsLoadIssue.title} -
-

{contactsLoadIssue.message}

-

{contactsLoadIssue.reason}

-
    -
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • -
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • -
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • -
-
- - - -
- {showContactsDiagnostics && ( -
{contactsDiagnosticsText}
- )} -
-
- ) : isContactsListLoading && contactsList.length === 0 ? ( -
- - 联系人加载中... -
- ) : !hasFilteredContacts ? ( -
- 暂无联系人 -
- ) : ( + {hasFilteredContacts && hasContactsHorizontalOverflow && (
- contact.username} - itemContent={renderContactRow} - rangeChanged={handleContactsRangeChanged} - atTopStateChange={setIsContactsListAtTop} - overscan={420} - /> +
)}
From 76d69ab7dddd5fdeccf855436ed81a9805e984a8 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 18:40:08 +0800 Subject: [PATCH 02/42] fix(export): improve dark-mode layout dropdown contrast --- src/pages/ExportPage.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index d8f7528..f38524d 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -744,6 +744,7 @@ width: 100%; border: none; background: transparent; + color: var(--text-primary); text-align: left; padding: 8px 10px; border-radius: 8px; @@ -765,6 +766,7 @@ .layout-option-label { font-size: 13px; font-weight: 600; + color: inherit; } .layout-option-desc { From 5affd4e57b2ea2e01b9132f18a2d302e98fd646a Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 18:55:54 +0800 Subject: [PATCH 03/42] fix(export): restore session table body rendering --- src/pages/ExportPage.scss | 13 +++++++------ src/pages/ExportPage.tsx | 1 + 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index f38524d..d9c66df 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1570,10 +1570,9 @@ .table-wrap { .table-scroll-viewport { - flex: 1; min-height: 0; overflow-x: auto; - overflow-y: hidden; + overflow-y: visible; scrollbar-width: none; &::-webkit-scrollbar { @@ -1583,10 +1582,6 @@ .table-scroll-content { min-width: max(100%, var(--contacts-table-min-width)); - width: max-content; - min-height: 100%; - display: flex; - flex-direction: column; } .session-table-sticky { @@ -1599,6 +1594,7 @@ .loading-state, .empty-state { width: 100%; + min-width: max(100%, var(--contacts-table-min-width)); flex: 1; display: flex; flex-direction: column; @@ -1615,6 +1611,7 @@ .load-issue-state { width: 100%; + min-width: max(100%, var(--contacts-table-min-width)); flex: 1; padding: 14px; overflow-y: auto; @@ -1711,6 +1708,7 @@ align-items: center; gap: 12px; 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)); font-size: 12px; @@ -1792,6 +1790,7 @@ .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); @@ -1801,6 +1800,7 @@ .contacts-virtuoso { height: 100%; + width: 100%; &::-webkit-scrollbar { width: 6px; @@ -1915,6 +1915,7 @@ align-items: center; gap: 12px; padding: 12px var(--contacts-inline-padding); + min-width: max(100%, var(--contacts-table-min-width)); height: 72px; box-sizing: border-box; border-radius: 10px; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 37c7674..7a8dc3c 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -6366,6 +6366,7 @@ function ExportPage() { className="contacts-virtuoso" data={filteredContacts} computeItemKey={(_, contact) => contact.username} + fixedItemHeight={76} itemContent={renderContactRow} rangeChanged={handleContactsRangeChanged} atTopStateChange={setIsContactsListAtTop} From ecd64f62bc295bcfe9f5a1e8b9e8021bb335a96f Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 19:16:07 +0800 Subject: [PATCH 04/42] feat(export): show and stop background page tasks --- src/pages/AnalyticsPage.tsx | 56 +++++++++ src/pages/AnnualReportPage.tsx | 41 +++++++ src/pages/AnnualReportWindow.tsx | 34 ++++++ src/pages/ChatPage.tsx | 106 ++++++++++++++++- src/pages/ExportPage.scss | 162 ++++++++++++++++++++++++++ src/pages/ExportPage.tsx | 115 ++++++++++++++++++ src/pages/GroupAnalyticsPage.tsx | 100 ++++++++++++++++ src/pages/SnsPage.tsx | 78 +++++++++++++ src/services/backgroundTaskMonitor.ts | 149 +++++++++++++++++++++++ src/types/backgroundTask.ts | 46 ++++++++ 10 files changed, 883 insertions(+), 4 deletions(-) create mode 100644 src/services/backgroundTaskMonitor.ts create mode 100644 src/types/backgroundTask.ts diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 1557679..977f183 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -4,6 +4,12 @@ import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, import ReactECharts from 'echarts-for-react' import { useAnalyticsStore } from '../stores/analyticsStore' import { useThemeStore } from '../stores/themeStore' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import './AnalyticsPage.scss' import { Avatar } from '../components/Avatar' @@ -48,6 +54,13 @@ function AnalyticsPage() { const loadData = useCallback(async (forceRefresh = false) => { if (isLoaded && !forceRefresh) return + const taskId = registerBackgroundTask({ + sourcePage: 'analytics', + title: forceRefresh ? '刷新分析看板' : '加载分析看板', + detail: '准备读取整体统计数据', + progressText: '整体统计', + cancelable: true + }) setIsLoading(true) setError(null) setProgress(0) @@ -60,27 +73,70 @@ function AnalyticsPage() { try { setLoadingStatus('正在统计消息数据...') + updateBackgroundTask(taskId, { + detail: '正在统计消息数据', + progressText: '整体统计' + }) const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前页面分析流程已结束' + }) + setIsLoading(false) + return + } if (statsResult.success && statsResult.data) { setStatistics(statsResult.data) } else { setError(statsResult.error || '加载统计数据失败') + finishBackgroundTask(taskId, 'failed', { + detail: statsResult.error || '加载统计数据失败' + }) setIsLoading(false) return } setLoadingStatus('正在分析联系人排名...') + updateBackgroundTask(taskId, { + detail: '正在分析联系人排名', + progressText: '联系人排名' + }) const rankingsResult = await window.electronAPI.analytics.getContactRankings(20) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,联系人排名后续步骤未继续' + }) + setIsLoading(false) + return + } if (rankingsResult.success && rankingsResult.data) { setRankings(rankingsResult.data) } setLoadingStatus('正在计算时间分布...') + updateBackgroundTask(taskId, { + detail: '正在计算时间分布', + progressText: '时间分布' + }) const timeResult = await window.electronAPI.analytics.getTimeDistribution() + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,时间分布结果未继续写入' + }) + setIsLoading(false) + return + } if (timeResult.success && timeResult.data) { setTimeDistribution(timeResult.data) } markLoaded() + finishBackgroundTask(taskId, 'completed', { + detail: '分析看板数据加载完成', + progressText: '已完成' + }) } catch (e) { setError(String(e)) + finishBackgroundTask(taskId, 'failed', { + detail: String(e) + }) } finally { setIsLoading(false) if (removeListener) removeListener() 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 344393b..94d82f5 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 背景图案 (用于导出) @@ -164,6 +170,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) @@ -171,25 +184,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/ChatPage.tsx b/src/pages/ChatPage.tsx index a663b67..6256d9b 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) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index d9c66df..6c2b112 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; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 7a8dc3c..956c4f2 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -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') @@ -1911,6 +1936,10 @@ function ExportPage() { return () => window.clearInterval(timer) }, [contactsList.length, isContactsListLoading, contactsLoadIssue]) + useEffect(() => { + return subscribeBackgroundTasks(setBackgroundTasks) + }, []) + useEffect(() => { tasksRef.current = tasks }, [tasks]) @@ -5499,6 +5528,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 '' @@ -5586,6 +5625,21 @@ function ExportPage() { 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) { @@ -6422,6 +6476,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}} +
+
+ +
+ ))} +
+ ) : ( +
+ 当前没有检测到其他页面后台任务 +
+ )} +
+
总消息数
diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 05a616d..4d2c65e 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -5,6 +5,12 @@ import { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' import * as configService from '../services/config' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import './GroupAnalyticsPage.scss' interface GroupChatInfo { @@ -176,15 +182,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 +344,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 +360,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) } diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 0482240..52ca0b7 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, @@ -728,9 +734,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 +767,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 +798,10 @@ export default function SnsPage() { running: false }) contactsCountBatchTimerRef.current = null + finishBackgroundTask(taskId, 'completed', { + detail: '联系人朋友圈条数补算完成', + progressText: `${totalTargets}/${totalTargets}` + }) return } @@ -789,6 +829,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 +847,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 +896,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 +965,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 +1002,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) 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 +} From a8c05fd26c7457d8e6f7015ac7e9f585c2f2c4c0 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 19:31:20 +0800 Subject: [PATCH 05/42] fix(export): add top horizontal scrollbar for session table --- src/pages/ExportPage.scss | 17 +++++++++++++++-- src/pages/ExportPage.tsx | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 6c2b112..632ec42 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1975,12 +1975,11 @@ } } + .table-top-scrollbar, .table-bottom-scrollbar { flex: 0 0 auto; - height: 16px; overflow-x: auto; overflow-y: hidden; - border-top: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary)); scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--text-tertiary) 70%, transparent) transparent; @@ -1999,10 +1998,24 @@ } } + .table-top-scrollbar { + height: 14px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, 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); + } + + .table-top-scrollbar-inner { + height: 1px; + } + .selection-clear-btn { border: 1px solid var(--border-color); border-radius: 8px; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 956c4f2..31fd25f 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1537,10 +1537,11 @@ function ExportPage() { const contactsAvatarCacheRef = useRef>({}) const contactsVirtuosoRef = useRef(null) const sessionTableSectionRef = useRef(null) + const contactsTopScrollbarRef = useRef(null) const contactsHorizontalViewportRef = useRef(null) const contactsHorizontalContentRef = useRef(null) const contactsBottomScrollbarRef = useRef(null) - const contactsScrollSyncSourceRef = useRef<'viewport' | 'bottom' | null>(null) + const contactsScrollSyncSourceRef = useRef<'viewport' | 'top' | 'bottom' | null>(null) const sessionFormatDropdownRef = useRef(null) const detailRequestSeqRef = useRef(0) const sessionsRef = useRef([]) @@ -5665,13 +5666,18 @@ function ExportPage() { row.mutualFriends.statusLabel.startsWith('加载中') )) ), [sessionLoadDetailRows]) - const syncContactsHorizontalScroll = useCallback((source: 'viewport' | 'bottom', scrollLeft: number) => { + const syncContactsHorizontalScroll = useCallback((source: 'viewport' | 'top' | 'bottom', scrollLeft: number) => { if (contactsScrollSyncSourceRef.current && contactsScrollSyncSourceRef.current !== source) return contactsScrollSyncSourceRef.current = source + const topScrollbar = contactsTopScrollbarRef.current const viewport = contactsHorizontalViewportRef.current const bottomScrollbar = contactsBottomScrollbarRef.current + if (source !== 'top' && topScrollbar && Math.abs(topScrollbar.scrollLeft - scrollLeft) > 1) { + topScrollbar.scrollLeft = scrollLeft + } + if (source !== 'viewport' && viewport && Math.abs(viewport.scrollLeft - scrollLeft) > 1) { viewport.scrollLeft = scrollLeft } @@ -5689,6 +5695,9 @@ function ExportPage() { const handleContactsHorizontalViewportScroll = useCallback((event: UIEvent) => { syncContactsHorizontalScroll('viewport', event.currentTarget.scrollLeft) }, [syncContactsHorizontalScroll]) + const handleContactsTopScrollbarScroll = useCallback((event: UIEvent) => { + syncContactsHorizontalScroll('top', event.currentTarget.scrollLeft) + }, [syncContactsHorizontalScroll]) const handleContactsBottomScrollbarScroll = useCallback((event: UIEvent) => { syncContactsHorizontalScroll('bottom', event.currentTarget.scrollLeft) }, [syncContactsHorizontalScroll]) @@ -5714,6 +5723,17 @@ function ExportPage() { viewport.scrollLeft = clampedScrollLeft } + const topScrollbar = contactsTopScrollbarRef.current + if (topScrollbar) { + const nextScrollLeft = Math.min(topScrollbar.scrollLeft, maxScrollLeft) + if (Math.abs(topScrollbar.scrollLeft - nextScrollLeft) > 1) { + topScrollbar.scrollLeft = nextScrollLeft + } + if (Math.abs(nextScrollLeft - clampedScrollLeft) > 1) { + topScrollbar.scrollLeft = clampedScrollLeft + } + } + const bottomScrollbar = contactsBottomScrollbarRef.current if (bottomScrollbar) { const nextScrollLeft = Math.min(bottomScrollbar.scrollLeft, maxScrollLeft) @@ -6317,6 +6337,17 @@ function ExportPage() {
)} + {hasFilteredContacts && hasContactsHorizontalOverflow && ( +
+
+
+ )} + {hasFilteredContacts && (
From b070b4f659559fa11804ef9451e35525536de1e8 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 19:37:27 +0800 Subject: [PATCH 06/42] fix(export): support dragging session table header horizontally --- src/pages/ExportPage.scss | 19 ++++--- src/pages/ExportPage.tsx | 106 +++++++++++++++++++++++++------------- 2 files changed, 80 insertions(+), 45 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 632ec42..e592061 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1878,6 +1878,15 @@ font-weight: 600; letter-spacing: 0.01em; flex-shrink: 0; + + &.is-draggable { + cursor: grab; + } + + &.is-dragging { + cursor: grabbing; + user-select: none; + } } .contacts-list-header-select { @@ -1975,7 +1984,6 @@ } } - .table-top-scrollbar, .table-bottom-scrollbar { flex: 0 0 auto; overflow-x: auto; @@ -1998,11 +2006,6 @@ } } - .table-top-scrollbar { - height: 14px; - border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); - } - .table-bottom-scrollbar-inner { height: 1px; } @@ -2012,10 +2015,6 @@ border-top: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); } - .table-top-scrollbar-inner { - height: 1px; - } - .selection-clear-btn { border: 1px solid var(--border-color); border-radius: 8px; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 31fd25f..38d01a5 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type UIEvent, 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' @@ -1512,6 +1512,7 @@ function ExportPage() { const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [nowTick, setNowTick] = useState(Date.now()) const [isContactsListAtTop, setIsContactsListAtTop] = useState(true) + const [isContactsHeaderDragging, setIsContactsHeaderDragging] = useState(false) const [contactsHorizontalScrollMetrics, setContactsHorizontalScrollMetrics] = useState({ viewportWidth: 0, contentWidth: 0 @@ -1537,11 +1538,16 @@ function ExportPage() { const contactsAvatarCacheRef = useRef>({}) const contactsVirtuosoRef = useRef(null) const sessionTableSectionRef = useRef(null) - const contactsTopScrollbarRef = useRef(null) const contactsHorizontalViewportRef = useRef(null) const contactsHorizontalContentRef = useRef(null) const contactsBottomScrollbarRef = useRef(null) - const contactsScrollSyncSourceRef = useRef<'viewport' | 'top' | 'bottom' | null>(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([]) @@ -5666,18 +5672,13 @@ function ExportPage() { row.mutualFriends.statusLabel.startsWith('加载中') )) ), [sessionLoadDetailRows]) - const syncContactsHorizontalScroll = useCallback((source: 'viewport' | 'top' | 'bottom', scrollLeft: number) => { + const syncContactsHorizontalScroll = useCallback((source: 'viewport' | 'bottom', scrollLeft: number) => { if (contactsScrollSyncSourceRef.current && contactsScrollSyncSourceRef.current !== source) return contactsScrollSyncSourceRef.current = source - const topScrollbar = contactsTopScrollbarRef.current const viewport = contactsHorizontalViewportRef.current const bottomScrollbar = contactsBottomScrollbarRef.current - if (source !== 'top' && topScrollbar && Math.abs(topScrollbar.scrollLeft - scrollLeft) > 1) { - topScrollbar.scrollLeft = scrollLeft - } - if (source !== 'viewport' && viewport && Math.abs(viewport.scrollLeft - scrollLeft) > 1) { viewport.scrollLeft = scrollLeft } @@ -5695,12 +5696,63 @@ function ExportPage() { const handleContactsHorizontalViewportScroll = useCallback((event: UIEvent) => { syncContactsHorizontalScroll('viewport', event.currentTarget.scrollLeft) }, [syncContactsHorizontalScroll]) - const handleContactsTopScrollbarScroll = useCallback((event: UIEvent) => { - syncContactsHorizontalScroll('top', 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 @@ -5723,17 +5775,6 @@ function ExportPage() { viewport.scrollLeft = clampedScrollLeft } - const topScrollbar = contactsTopScrollbarRef.current - if (topScrollbar) { - const nextScrollLeft = Math.min(topScrollbar.scrollLeft, maxScrollLeft) - if (Math.abs(topScrollbar.scrollLeft - nextScrollLeft) > 1) { - topScrollbar.scrollLeft = nextScrollLeft - } - if (Math.abs(nextScrollLeft - clampedScrollLeft) > 1) { - topScrollbar.scrollLeft = clampedScrollLeft - } - } - const bottomScrollbar = contactsBottomScrollbarRef.current if (bottomScrollbar) { const nextScrollLeft = Math.min(bottomScrollbar.scrollLeft, maxScrollLeft) @@ -6337,19 +6378,14 @@ function ExportPage() {
)} - {hasFilteredContacts && hasContactsHorizontalOverflow && ( -
-
-
- )} - {hasFilteredContacts && ( -
+
)} -
+
)} -
+
)} -
-
-
- - {hasRecentExport && {recentExportTime}} -
- -
-
) }, [ - lastExportBySession, - nowTick, openContactSnsTimeline, openSessionDetail, openSessionMutualFriendsDialog, - openSingleExport, - queuedSessionIds, - runningSessionIds, selectedSessions, - sessionDetail?.wxid, sessionContentMetrics, sessionMutualFriendsMetrics, sessionLoadTraceMap, @@ -6050,6 +6189,77 @@ function ExportPage() { snsUserPostCountsStatus, toggleSelectSession ]) + const visibleContactsForActionRail = useMemo(() => { + if (!hasFilteredContacts || contactsVisibleRange.endIndex < contactsVisibleRange.startIndex || contactsVisibleRange.endIndex < 0) return [] + const startIndex = Math.max(0, Math.min(filteredContacts.length - 1, contactsVisibleRange.startIndex)) + const endIndex = Math.max(startIndex, Math.min(filteredContacts.length - 1, contactsVisibleRange.endIndex)) + if (!Number.isFinite(startIndex) || !Number.isFinite(endIndex) || endIndex < startIndex) return [] + return filteredContacts.slice(startIndex, endIndex + 1).map((contact, offset) => { + const index = startIndex + offset + return { + contact, + index, + top: (index * CONTACTS_ROW_HEIGHT) - contactsListScrollTop + } + }) + }, [contactsListScrollTop, contactsVisibleRange.endIndex, contactsVisibleRange.startIndex, filteredContacts, hasFilteredContacts]) + const renderContactActionRailItem = useCallback((contact: ContactInfo, index: number, top: number) => { + const matchedSession = sessionRowByUsername.get(contact.username) + const canExport = Boolean(matchedSession?.hasSession) + const checked = canExport && selectedSessions.has(contact.username) + const isRunning = canExport && runningSessionIds.has(contact.username) + const isQueued = canExport && queuedSessionIds.has(contact.username) + const recentExportTimestamp = lastExportBySession[contact.username] + const hasRecentExport = canExport && Boolean(recentExportTimestamp) + const recentExportTime = hasRecentExport ? formatRecentExportTime(recentExportTimestamp, nowTick) : '' + + return ( +
+
+
+
+ + {hasRecentExport && {recentExportTime}} +
+ +
+
+
+ ) + }, [ + lastExportBySession, + nowTick, + openSessionDetail, + openSingleExport, + queuedSessionIds, + runningSessionIds, + selectedSessions, + sessionDetail?.wxid, + sessionRowByUsername + ]) const handleContactsListWheelCapture = useCallback((event: WheelEvent) => { const deltaY = event.deltaY if (!deltaY) return @@ -6067,9 +6277,19 @@ function ExportPage() { window.scrollBy({ top: deltaY, behavior: 'auto' }) } }, [isContactsListAtTop]) + const handleContactsListScrollCapture = useCallback((event: UIEvent) => { + const target = event.target + if (!(target instanceof HTMLDivElement)) return + const nextScrollTop = Math.max(0, target.scrollTop) + setContactsListScrollTop(prev => ( + Math.abs(prev - nextScrollTop) > 1 ? nextScrollTop : prev + )) + }, []) useEffect(() => { if (hasFilteredContacts) return setIsContactsListAtTop(true) + setContactsListScrollTop(0) + setContactsVisibleRange({ startIndex: 0, endIndex: -1 }) }, [hasFilteredContacts]) const chooseExportFolder = useCallback(async () => { const result = await window.electronAPI.dialog.openFile({ @@ -6481,18 +6701,24 @@ function ExportPage() {
contact.username} - fixedItemHeight={76} + fixedItemHeight={CONTACTS_ROW_HEIGHT} itemContent={renderContactRow} rangeChanged={handleContactsRangeChanged} atTopStateChange={setIsContactsListAtTop} overscan={420} /> +
+ {visibleContactsForActionRail.map(({ contact, index, top }) => ( + renderContactActionRailItem(contact, index, top) + ))} +
)}
@@ -6790,15 +7016,15 @@ function ExportPage() {
- 打开桌面端微信,进入到这个人的朋友圈中,刷ta 的朋友圈,刷的越多这里的数据聚合越多 + 打开桌面端微信进入对方朋友圈,刷得越多这里聚合得越全。已确认身份的共同好友会展示头像、备注、微信号;未确认的旧数据仅保留名字。
setSessionMutualFriendsSearch(event.target.value)} - placeholder="搜索共同好友" - aria-label="搜索共同好友" + placeholder="搜索共同好友(备注 / 微信号 / wxid)" + aria-label="搜索共同好友(备注 / 微信号 / wxid)" />
@@ -6810,20 +7036,48 @@ function ExportPage() { ) : (
{filteredSessionMutualFriendsDialogItems.map((item, index) => ( -
+
{index + 1} - {item.name} - - {getSessionMutualFriendDirectionLabel(item.direction)} - +
+ +
+
+ {item.name} + + {getSessionMutualFriendDirectionLabel(item.direction)} + + {!item.isConfirmed && ( + 身份未确认 + )} +
+
+ {item.isConfirmed ? ( + item.wechatId ? `微信号: ${item.wechatId}` : '微信号: 未设置' + ) : ( + '仅从旧数据中解析到名字' + )} +
+
+ {item.isConfirmed + ? `wxid: ${item.wxid || item.username || ''}` + : '没有可用的 wxid,未做自动匹配'} +
+ + {describeSessionMutualFriendRelation(item, sessionMutualFriendsDialogTarget.displayName)} + +
+
{item.totalCount.toLocaleString('zh-CN')} {formatYmdDateFromSeconds(item.latestTime)} - - {describeSessionMutualFriendRelation(item, sessionMutualFriendsDialogTarget.displayName)} -
))}
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 72aaa57..ef06b47 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -767,7 +767,53 @@ export interface ElectronAPI { } }> likes: Array - comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> }> + comments: Array<{ + id: string + nickname: string + username?: string + content: string + refCommentId: string + refNickname?: string + refUsername?: string + emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> + }> + likesDetail?: Array<{ + nickname: string + username?: string + wxid?: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string + avatarUrl?: string + source: 'xml' | 'legacy' + }> + commentsDetail?: Array<{ + id: string + nickname: string + username?: string + wxid?: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string + avatarUrl?: string + content: string + refCommentId: string + refNickname?: string + refUsername?: string + refWxid?: string + refAlias?: string + refWechatId?: string + refRemark?: string + refNickName?: string + refDisplayName?: string + refAvatarUrl?: string + emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> + source: 'xml' | 'legacy' + }> rawXml?: string }> error?: string diff --git a/src/types/sns.ts b/src/types/sns.ts index 9193385..ccfeba3 100644 --- a/src/types/sns.ts +++ b/src/types/sns.ts @@ -28,12 +28,53 @@ export interface SnsCommentEmoji { export interface SnsComment { id: string nickname: string + username?: string content: string refCommentId: string refNickname?: string + refUsername?: string emojis?: SnsCommentEmoji[] } +export interface SnsLikeDetail { + nickname: string + username?: string + wxid?: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string + avatarUrl?: string + source: 'xml' | 'legacy' +} + +export interface SnsCommentDetail { + id: string + nickname: string + username?: string + wxid?: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string + avatarUrl?: string + content: string + refCommentId: string + refNickname?: string + refUsername?: string + refWxid?: string + refAlias?: string + refWechatId?: string + refRemark?: string + refNickName?: string + refDisplayName?: string + refAvatarUrl?: string + emojis?: SnsCommentEmoji[] + source: 'xml' | 'legacy' +} + export interface SnsPost { id: string tid?: string // 数据库主键(雪花 ID),用于精确删除 @@ -46,6 +87,8 @@ export interface SnsPost { media: SnsMedia[] likes: string[] comments: SnsComment[] + likesDetail?: SnsLikeDetail[] + commentsDetail?: SnsCommentDetail[] rawXml?: string linkTitle?: string linkUrl?: string From 2d4a5fc62fe0daabcf54cfe2c724d1b9f586243f Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 20:15:51 +0800 Subject: [PATCH 12/42] Revert "feat: enrich mutual friend identities in export dialog" This reverts commit f3027da43885a67583099008991dbfc4def3f4d1. --- electron/services/snsService.ts | 37 +-- src/pages/ExportPage.scss | 138 ++------- src/pages/ExportPage.tsx | 498 ++++++++------------------------ src/types/electron.d.ts | 48 +-- src/types/sns.ts | 43 --- 5 files changed, 147 insertions(+), 617 deletions(-) diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 5bf4d43..2bb2908 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -52,7 +52,6 @@ interface SnsContactIdentity { remark?: string nickName?: string displayName: string - avatarUrl?: string } interface ParsedLikeUser { @@ -80,7 +79,6 @@ interface ArkmeLikeDetail { remark?: string nickName?: string displayName: string - avatarUrl?: string source: 'xml' | 'legacy' } @@ -94,7 +92,6 @@ interface ArkmeCommentDetail { remark?: string nickName?: string displayName: string - avatarUrl?: string content: string refCommentId: string refNickname?: string @@ -105,7 +102,6 @@ interface ArkmeCommentDetail { refRemark?: string refNickName?: string refDisplayName?: string - refAvatarUrl?: string emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] source: 'xml' | 'legacy' } @@ -327,7 +323,6 @@ class SnsService { let alias: string | undefined let remark: string | undefined let nickName: string | undefined - let avatarUrl = this.toOptionalString(cached?.avatarUrl) try { const contactResult = await wcdbService.getContact(normalized) @@ -341,17 +336,6 @@ class SnsService { // 联系人补全失败不影响导出 } - if (!avatarUrl) { - try { - const avatarResult = await wcdbService.getAvatarUrls([normalized]) - if (avatarResult.success && avatarResult.map) { - avatarUrl = this.toOptionalString(avatarResult.map[normalized]) - } - } catch { - // 头像补全失败不影响导出 - } - } - const displayName = remark || nickName || alias || cached?.displayName || normalized return { username: normalized, @@ -360,8 +344,7 @@ class SnsService { wechatId: alias, remark, nickName, - displayName, - avatarUrl + displayName } })() identityCache.set(normalized, pending) @@ -429,7 +412,6 @@ class SnsService { remark: identity?.remark, nickName: identity?.nickName, displayName: identity?.displayName || nickname || username || '', - avatarUrl: identity?.avatarUrl, source: likeSource }) } @@ -501,7 +483,6 @@ class SnsService { remark: actor?.remark, nickName: actor?.nickName, displayName: actor?.displayName || nickname || username || '', - avatarUrl: actor?.avatarUrl, content: comment.content || '', refCommentId: comment.refCommentId || '', refNickname: comment.refNickname || refActor?.displayName, @@ -512,7 +493,6 @@ class SnsService { refRemark: refActor?.remark, refNickName: refActor?.nickName, refDisplayName: refActor?.displayName, - refAvatarUrl: refActor?.avatarUrl, emojis: comment.emojis, source: commentSource }) @@ -1041,8 +1021,7 @@ class SnsService { const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) if (!result.success || !result.timeline || result.timeline.length === 0) return result - const identityCache = new Map>() - const enrichedTimeline = await Promise.all(result.timeline.map(async (post: any) => { + const enrichedTimeline = result.timeline.map((post: any) => { const contact = this.contactCache.get(post.username) const isVideoPost = post.type === 15 const videoKey = extractVideoKey(post.rawXml || '') @@ -1082,22 +1061,14 @@ class SnsService { finalComments = this.fixCommentRefs(dllComments) } - const normalizedPost: SnsPost = { - ...post, - comments: finalComments - } - const { likesDetail, commentsDetail } = await this.buildArkmeInteractionDetails(normalizedPost, identityCache) - return { ...post, avatarUrl: contact?.avatarUrl, nickname: post.nickname || contact?.displayName || post.username, media: fixedMedia, - comments: finalComments, - likesDetail, - commentsDetail + comments: finalComments } - })) + }) return { ...result, timeline: enrichedTimeline } } diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index e6f895c..d1c8c0d 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -623,22 +623,18 @@ .session-mutual-friends-row { display: grid; - grid-template-columns: 36px minmax(0, 1fr) 72px 108px; - gap: 12px; + grid-template-columns: 36px minmax(120px, 0.82fr) max-content 56px 96px minmax(0, 1.28fr); + gap: 10px; align-items: center; - padding: 12px; + padding: 8px 12px; border-bottom: 1px solid color-mix(in srgb, var(--border-color) 68%, transparent); font-size: 12px; color: var(--text-secondary); - min-height: 72px; + min-height: 42px; &:last-child { border-bottom: none; } - - &.unconfirmed { - background: color-mix(in srgb, var(--bg-secondary) 60%, transparent); - } } .session-mutual-friends-rank, @@ -652,32 +648,6 @@ text-align: center; } -.session-mutual-friends-user { - min-width: 0; - display: flex; - align-items: center; - gap: 12px; -} - -.session-mutual-friends-user-avatar { - flex-shrink: 0; -} - -.session-mutual-friends-user-main { - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; -} - -.session-mutual-friends-user-head { - min-width: 0; - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - .session-mutual-friends-name { min-width: 0; overflow: hidden; @@ -717,30 +687,6 @@ } } -.session-mutual-friends-identity-badge { - border-radius: 999px; - padding: 3px 8px; - font-size: 10px; - line-height: 1; - color: #92400e; - border: 1px solid color-mix(in srgb, #d97706 34%, var(--border-color)); - background: color-mix(in srgb, #f59e0b 11%, var(--bg-secondary)); - white-space: nowrap; -} - -.session-mutual-friends-identity { - min-width: 0; - color: var(--text-secondary); - line-height: 1.25; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - &.secondary { - color: var(--text-tertiary); - } -} - .session-mutual-friends-desc { min-width: 0; color: var(--text-tertiary); @@ -2034,7 +1980,6 @@ height: var(--contacts-default-list-height); overflow: hidden; padding: 0 0 12px; - position: relative; } .contacts-virtuoso { @@ -2052,43 +1997,6 @@ } } - .contacts-action-rail { - position: absolute; - top: 0; - right: 0; - bottom: 12px; - width: max(var(--contacts-action-col-width), 184px); - z-index: 18; - pointer-events: none; - - &::before { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: -12px; - width: 12px; - pointer-events: none; - background: linear-gradient(to right, transparent, var(--bg-primary)); - } - } - - .contacts-action-rail-row { - position: absolute; - right: 0; - width: 100%; - height: var(--contacts-row-height); - padding: 0 0 4px; - box-sizing: border-box; - display: flex; - justify-content: flex-end; - pointer-events: none; - - &.selected .contacts-action-rail-card { - background: rgba(var(--primary-rgb), 0.08); - } - } - .table-bottom-scrollbar { flex: 0 0 auto; overflow-x: auto; @@ -2183,6 +2091,10 @@ &.selected .contact-item { background: rgba(var(--primary-rgb), 0.08); } + + &.selected .row-action-cell { + background: rgba(var(--primary-rgb), 0.08); + } } .contact-item { @@ -2562,30 +2474,26 @@ } } -.row-action-cell, -.contacts-action-rail-card { +.row-action-cell { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; - width: 100%; - min-width: 0; - height: calc(var(--contacts-row-height) - 4px); - padding: 0 6px 0 0; - box-sizing: border-box; - justify-content: center; + width: var(--contacts-action-col-width); + min-width: var(--contacts-action-col-width); + flex-shrink: 0; + position: sticky; + right: 0; + z-index: 10; background: var(--bg-primary); - pointer-events: auto; - position: relative; - z-index: 1; &::before { content: ''; position: absolute; top: -12px; bottom: -12px; - left: -12px; - width: 12px; + left: -8px; + width: 8px; pointer-events: none; background: linear-gradient(to right, transparent, var(--bg-primary)); } @@ -4253,19 +4161,13 @@ } .session-mutual-friends-row { - grid-template-columns: 28px minmax(0, 1fr) 56px 72px; + grid-template-columns: 30px minmax(88px, 0.9fr) max-content 44px 74px; gap: 8px; font-size: 12px; } - .session-mutual-friends-user { - align-items: flex-start; - gap: 10px; - } - - .session-mutual-friends-user-avatar { - width: 36px !important; - height: 36px !important; + .session-mutual-friends-desc { + display: none; } .session-load-detail-row { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index b5741ce..38d01a5 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -44,12 +44,11 @@ import { subscribeBackgroundTasks } from '../services/backgroundTaskMonitor' import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' -import { Avatar } from '../components/Avatar' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../components/Export/ExportDefaultsSettingsForm' -import type { SnsCommentDetail, SnsLikeDetail, SnsPost } from '../types/sns' +import type { SnsPost } from '../types/sns' import { cloneExportDateRange, createDefaultDateRange, @@ -73,7 +72,6 @@ type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname' type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson' -const CONTACTS_ROW_HEIGHT = 76 interface ExportOptions { format: TextExportFormat @@ -515,12 +513,6 @@ const getAvatarLetter = (name: string): string => { return [...name][0] || '?' } -const toOptionalString = (value: unknown): string | undefined => { - if (typeof value !== 'string') return undefined - const trimmed = value.trim() - return trimmed ? trimmed : undefined -} - const toComparableNameSet = (values: Array): Set => { const set = new Set() for (const value of values) { @@ -610,15 +602,7 @@ type SessionMutualFriendDirection = 'incoming' | 'outgoing' | 'bidirectional' type SessionMutualFriendBehavior = 'likes' | 'comments' | 'both' interface SessionMutualFriendItem { - key: string - identityKey?: string name: string - username?: string - wxid?: string - wechatId?: string - remark?: string - avatarUrl?: string - isConfirmed: boolean incomingLikeCount: number incomingCommentCount: number outgoingLikeCount: number @@ -637,63 +621,6 @@ interface SessionMutualFriendsMetric { computedAt: number } -const getSessionMutualFriendIdentityKey = (username?: string, wechatId?: string): string | undefined => { - const normalizedUsername = toOptionalString(username) - if (normalizedUsername) return `u:${normalizedUsername}` - const normalizedWechatId = toOptionalString(wechatId) - if (normalizedWechatId) return `w:${normalizedWechatId}` - return undefined -} - -const getSessionMutualFriendFallbackKey = (name: string): string => `n:${name || '未知用户'}` - -const resolveSessionMutualFriendName = (params: { - displayName?: string - remark?: string - nickName?: string - wechatId?: string - nickname?: string - username?: string -}): string => { - return ( - toOptionalString(params.displayName) || - toOptionalString(params.remark) || - toOptionalString(params.nickName) || - toOptionalString(params.wechatId) || - toOptionalString(params.nickname) || - toOptionalString(params.username) || - '未知用户' - ) -} - -const applySessionMutualFriendDerivedState = (item: SessionMutualFriendItem): void => { - const incomingTotal = item.incomingLikeCount + item.incomingCommentCount - const outgoingTotal = item.outgoingLikeCount + item.outgoingCommentCount - item.direction = incomingTotal > 0 && outgoingTotal > 0 - ? 'bidirectional' - : incomingTotal > 0 - ? 'incoming' - : 'outgoing' - item.behavior = summarizeMutualFriendBehavior( - item.incomingLikeCount + item.outgoingLikeCount, - item.incomingCommentCount + item.outgoingCommentCount - ) -} - -const mergeSessionMutualFriendItemProfile = ( - target: SessionMutualFriendItem, - profile: Partial> -): void => { - if (profile.identityKey && !target.identityKey) target.identityKey = profile.identityKey - if (profile.username && !target.username) target.username = profile.username - if (profile.wxid && !target.wxid) target.wxid = profile.wxid - if (profile.wechatId && !target.wechatId) target.wechatId = profile.wechatId - if (profile.remark && !target.remark) target.remark = profile.remark - if (profile.avatarUrl && !target.avatarUrl) target.avatarUrl = profile.avatarUrl - if (profile.name && (!target.name || !target.isConfirmed)) target.name = profile.name - if (profile.isConfirmed) target.isConfirmed = true -} - interface SessionSnsRankCacheEntry { likes: SessionSnsRankItem[] comments: SessionSnsRankItem[] @@ -751,121 +678,55 @@ const buildSessionMutualFriendsMetric = ( ): SessionMutualFriendsMetric => { const friendMap = new Map() - const ensureItem = (seed: { - name: string - username?: string - wxid?: string - wechatId?: string - remark?: string - avatarUrl?: string - identityKey?: string - isConfirmed: boolean - }): SessionMutualFriendItem => { - const key = seed.identityKey || getSessionMutualFriendFallbackKey(seed.name) - const existing = friendMap.get(key) - if (existing) { - mergeSessionMutualFriendItemProfile(existing, seed) - return existing - } - - const created: SessionMutualFriendItem = { - key, - identityKey: seed.identityKey, - name: seed.name, - username: seed.username, - wxid: seed.wxid, - wechatId: seed.wechatId, - remark: seed.remark, - avatarUrl: seed.avatarUrl, - isConfirmed: seed.isConfirmed, - incomingLikeCount: 0, - incomingCommentCount: 0, - outgoingLikeCount: 0, - outgoingCommentCount: 0, - totalCount: 0, - latestTime: 0, - direction: 'incoming', - behavior: 'likes' - } - friendMap.set(key, created) - return created - } - for (const post of posts) { const createTime = Number(post?.createTime) || 0 - const likesDetail = Array.isArray(post?.likesDetail) && post.likesDetail.length > 0 - ? post.likesDetail - : (Array.isArray(post?.likes) ? post.likes : []).map((likeNameRaw): SnsLikeDetail => ({ - nickname: String(likeNameRaw || '').trim() || '未知用户', - displayName: String(likeNameRaw || '').trim() || '未知用户', - source: 'legacy' - })) - const commentsDetail = Array.isArray(post?.commentsDetail) && post.commentsDetail.length > 0 - ? post.commentsDetail - : (Array.isArray(post?.comments) ? post.comments : []).map((comment): SnsCommentDetail => ({ - id: String(comment?.id || ''), - nickname: String(comment?.nickname || '').trim() || '未知用户', - displayName: String(comment?.nickname || '').trim() || '未知用户', - content: String(comment?.content || ''), - refCommentId: String(comment?.refCommentId || ''), - refNickname: comment?.refNickname, - refUsername: comment?.refUsername, - emojis: comment?.emojis, - source: 'legacy' - })) + const likes = Array.isArray(post?.likes) ? post.likes : [] + const comments = Array.isArray(post?.comments) ? post.comments : [] - for (const like of likesDetail) { - const username = toOptionalString(like.username || like.wxid) - const wechatId = toOptionalString(like.wechatId || like.alias) - const identityKey = getSessionMutualFriendIdentityKey(username, wechatId) - const existing = ensureItem({ - name: resolveSessionMutualFriendName({ - displayName: like.displayName, - remark: like.remark, - nickName: like.nickName, - wechatId, - nickname: like.nickname, - username - }), - username, - wxid: username, - wechatId, - remark: toOptionalString(like.remark), - avatarUrl: toOptionalString(like.avatarUrl), - identityKey, - isConfirmed: Boolean(identityKey && username) + for (const likeNameRaw of likes) { + const name = String(likeNameRaw || '').trim() || '未知用户' + const existing = friendMap.get(name) + if (existing) { + existing.incomingLikeCount += 1 + existing.totalCount += 1 + existing.behavior = existing.incomingCommentCount > 0 ? 'both' : 'likes' + if (createTime > existing.latestTime) existing.latestTime = createTime + continue + } + friendMap.set(name, { + name, + incomingLikeCount: 1, + incomingCommentCount: 0, + outgoingLikeCount: 0, + outgoingCommentCount: 0, + totalCount: 1, + latestTime: createTime, + direction: 'incoming', + behavior: 'likes' }) - existing.incomingLikeCount += 1 - existing.totalCount += 1 - if (createTime > existing.latestTime) existing.latestTime = createTime - applySessionMutualFriendDerivedState(existing) } - for (const comment of commentsDetail) { - const username = toOptionalString(comment.username || comment.wxid) - const wechatId = toOptionalString(comment.wechatId || comment.alias) - const identityKey = getSessionMutualFriendIdentityKey(username, wechatId) - const existing = ensureItem({ - name: resolveSessionMutualFriendName({ - displayName: comment.displayName, - remark: comment.remark, - nickName: comment.nickName, - wechatId, - nickname: comment.nickname, - username - }), - username, - wxid: username, - wechatId, - remark: toOptionalString(comment.remark), - avatarUrl: toOptionalString(comment.avatarUrl), - identityKey, - isConfirmed: Boolean(identityKey && username) + for (const comment of comments) { + const name = String(comment?.nickname || '').trim() || '未知用户' + const existing = friendMap.get(name) + if (existing) { + existing.incomingCommentCount += 1 + existing.totalCount += 1 + existing.behavior = existing.incomingLikeCount > 0 ? 'both' : 'comments' + if (createTime > existing.latestTime) existing.latestTime = createTime + continue + } + friendMap.set(name, { + name, + incomingLikeCount: 0, + incomingCommentCount: 1, + outgoingLikeCount: 0, + outgoingCommentCount: 0, + totalCount: 1, + latestTime: createTime, + direction: 'incoming', + behavior: 'comments' }) - existing.incomingCommentCount += 1 - existing.totalCount += 1 - if (createTime > existing.latestTime) existing.latestTime = createTime - applySessionMutualFriendDerivedState(existing) } } @@ -1652,8 +1513,6 @@ function ExportPage() { const [nowTick, setNowTick] = useState(Date.now()) const [isContactsListAtTop, setIsContactsListAtTop] = useState(true) const [isContactsHeaderDragging, setIsContactsHeaderDragging] = useState(false) - const [contactsListScrollTop, setContactsListScrollTop] = useState(0) - const [contactsVisibleRange, setContactsVisibleRange] = useState({ startIndex: 0, endIndex: -1 }) const [contactsHorizontalScrollMetrics, setContactsHorizontalScrollMetrics] = useState({ viewportWidth: 0, contentWidth: 0 @@ -2929,27 +2788,21 @@ function ExportPage() { const getSessionMutualFriendProfile = useCallback((sessionId: string): { displayName: string - remark?: string - wechatId?: string - avatarUrl?: string - primaryIdentityKey: string - candidateIdentityKeys: Set + candidateNames: Set } => { const normalizedSessionId = String(sessionId || '').trim() const contact = contactsList.find(item => item.username === normalizedSessionId) const session = sessionsRef.current.find(item => item.username === normalizedSessionId) const displayName = contact?.displayName || contact?.remark || contact?.nickname || session?.displayName || normalizedSessionId - const wechatId = toOptionalString(contact?.alias) - const candidateIdentityKeys = new Set() - candidateIdentityKeys.add(`u:${normalizedSessionId}`) - if (wechatId) candidateIdentityKeys.add(`w:${wechatId}`) return { displayName, - remark: toOptionalString(contact?.remark), - wechatId, - avatarUrl: toOptionalString(contact?.avatarUrl), - primaryIdentityKey: `u:${normalizedSessionId}`, - candidateIdentityKeys + candidateNames: toComparableNameSet([ + displayName, + contact?.displayName, + contact?.remark, + contact?.nickname, + contact?.alias + ]) } }, [contactsList]) @@ -2961,54 +2814,41 @@ function ExportPage() { const directMetric = directMetrics[normalizedTargetSessionId] if (!directMetric) return null - const targetProfile = getSessionMutualFriendProfile(normalizedTargetSessionId) + const { candidateNames } = getSessionMutualFriendProfile(normalizedTargetSessionId) const mergedMap = new Map() for (const item of directMetric.items) { - mergedMap.set(item.key, { ...item }) + mergedMap.set(item.name, { ...item }) } for (const [sourceSessionId, sourceMetric] of Object.entries(directMetrics)) { if (!sourceMetric || sourceSessionId === normalizedTargetSessionId) continue const sourceProfile = getSessionMutualFriendProfile(sourceSessionId) if (!sourceProfile.displayName) continue - const reverseMatches = sourceMetric.items.filter(item => { - if (!item.identityKey) return false - return targetProfile.candidateIdentityKeys.has(item.identityKey) - }) + if (mergedMap.has(sourceProfile.displayName)) continue + + const reverseMatches = sourceMetric.items.filter(item => candidateNames.has(item.name)) if (reverseMatches.length === 0) continue const reverseCount = reverseMatches.reduce((sum, item) => sum + item.totalCount, 0) const reverseLikeCount = reverseMatches.reduce((sum, item) => sum + item.incomingLikeCount, 0) const reverseCommentCount = reverseMatches.reduce((sum, item) => sum + item.incomingCommentCount, 0) const reverseLatestTime = reverseMatches.reduce((latest, item) => Math.max(latest, item.latestTime), 0) - const existing = mergedMap.get(sourceProfile.primaryIdentityKey) + const existing = mergedMap.get(sourceProfile.displayName) if (existing) { - mergeSessionMutualFriendItemProfile(existing, { - identityKey: sourceProfile.primaryIdentityKey, - username: sourceSessionId, - wxid: sourceSessionId, - wechatId: sourceProfile.wechatId, - remark: sourceProfile.remark, - avatarUrl: sourceProfile.avatarUrl, - name: sourceProfile.displayName, - isConfirmed: true - }) existing.outgoingLikeCount += reverseLikeCount existing.outgoingCommentCount += reverseCommentCount existing.totalCount += reverseCount existing.latestTime = Math.max(existing.latestTime, reverseLatestTime) - applySessionMutualFriendDerivedState(existing) + existing.direction = (existing.incomingLikeCount + existing.incomingCommentCount) > 0 + ? 'bidirectional' + : 'outgoing' + existing.behavior = summarizeMutualFriendBehavior( + existing.incomingLikeCount + existing.outgoingLikeCount, + existing.incomingCommentCount + existing.outgoingCommentCount + ) } else { - const created: SessionMutualFriendItem = { - key: sourceProfile.primaryIdentityKey, - identityKey: sourceProfile.primaryIdentityKey, + mergedMap.set(sourceProfile.displayName, { name: sourceProfile.displayName, - username: sourceSessionId, - wxid: sourceSessionId, - wechatId: sourceProfile.wechatId, - remark: sourceProfile.remark, - avatarUrl: sourceProfile.avatarUrl, - isConfirmed: true, incomingLikeCount: 0, incomingCommentCount: 0, outgoingLikeCount: reverseLikeCount, @@ -3017,8 +2857,7 @@ function ExportPage() { latestTime: reverseLatestTime, direction: 'outgoing', behavior: summarizeMutualFriendBehavior(reverseLikeCount, reverseCommentCount) - } - mergedMap.set(created.key, created) + }) } } @@ -3048,7 +2887,7 @@ function ExportPage() { for (const targetSessionId of allSessionIds) { if (targetSessionId === normalizedSessionId) continue const targetProfile = getSessionMutualFriendProfile(targetSessionId) - if (directMetric.items.some(item => item.identityKey && targetProfile.candidateIdentityKeys.has(item.identityKey))) { + if (directMetric.items.some(item => targetProfile.candidateNames.has(item.name))) { impactedSessionIds.add(targetSessionId) } } @@ -4995,11 +4834,6 @@ function ExportPage() { const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex } sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex } - setContactsVisibleRange(prev => ( - prev.startIndex === startIndex && prev.endIndex === endIndex - ? prev - : { startIndex, endIndex } - )) if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) if (visibleTargets.length === 0) return @@ -5203,18 +5037,7 @@ function ExportPage() { const items = sessionMutualFriendsDialogMetric?.items || [] const keyword = sessionMutualFriendsSearch.trim().toLowerCase() if (!keyword) return items - return items.filter((item) => { - const haystack = [ - item.name, - item.remark, - item.wechatId, - item.wxid, - item.username - ] - .map(value => String(value || '').toLowerCase()) - .join(' ') - return haystack.includes(keyword) - }) + return items.filter(item => item.name.toLowerCase().includes(keyword)) }, [sessionMutualFriendsDialogMetric, sessionMutualFriendsSearch]) const applySessionDetailStats = useCallback(( @@ -5795,7 +5618,7 @@ function ExportPage() { const taskCenterAlertCount = taskRunningCount + taskQueuedCount const hasFilteredContacts = filteredContacts.length > 0 const contactsTableMinWidth = useMemo(() => { - const baseWidth = 24 + 34 + 44 + 160 + 120 + (4 * 72) + (7 * 12) + 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 @@ -5991,6 +5814,11 @@ function ExportPage() { const canExport = Boolean(matchedSession?.hasSession) const isSessionBindingPending = !matchedSession && (isLoading || isSessionEnriching) const checked = canExport && selectedSessions.has(contact.username) + const isRunning = canExport && runningSessionIds.has(contact.username) + const isQueued = canExport && queuedSessionIds.has(contact.username) + const recentExportTimestamp = lastExportBySession[contact.username] + const hasRecentExport = canExport && Boolean(recentExportTimestamp) + const recentExportTime = hasRecentExport ? formatRecentExportTime(recentExportTimestamp, nowTick) : '' const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username]) const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) const displayedMessageCount = countedMessages ?? hintedMessages @@ -6167,14 +5995,47 @@ function ExportPage() { )}
)} +
+
+
+ + {hasRecentExport && {recentExportTime}} +
+ +
+
) }, [ + lastExportBySession, + nowTick, openContactSnsTimeline, openSessionDetail, openSessionMutualFriendsDialog, + openSingleExport, + queuedSessionIds, + runningSessionIds, selectedSessions, + sessionDetail?.wxid, sessionContentMetrics, sessionMutualFriendsMetrics, sessionLoadTraceMap, @@ -6189,77 +6050,6 @@ function ExportPage() { snsUserPostCountsStatus, toggleSelectSession ]) - const visibleContactsForActionRail = useMemo(() => { - if (!hasFilteredContacts || contactsVisibleRange.endIndex < contactsVisibleRange.startIndex || contactsVisibleRange.endIndex < 0) return [] - const startIndex = Math.max(0, Math.min(filteredContacts.length - 1, contactsVisibleRange.startIndex)) - const endIndex = Math.max(startIndex, Math.min(filteredContacts.length - 1, contactsVisibleRange.endIndex)) - if (!Number.isFinite(startIndex) || !Number.isFinite(endIndex) || endIndex < startIndex) return [] - return filteredContacts.slice(startIndex, endIndex + 1).map((contact, offset) => { - const index = startIndex + offset - return { - contact, - index, - top: (index * CONTACTS_ROW_HEIGHT) - contactsListScrollTop - } - }) - }, [contactsListScrollTop, contactsVisibleRange.endIndex, contactsVisibleRange.startIndex, filteredContacts, hasFilteredContacts]) - const renderContactActionRailItem = useCallback((contact: ContactInfo, index: number, top: number) => { - const matchedSession = sessionRowByUsername.get(contact.username) - const canExport = Boolean(matchedSession?.hasSession) - const checked = canExport && selectedSessions.has(contact.username) - const isRunning = canExport && runningSessionIds.has(contact.username) - const isQueued = canExport && queuedSessionIds.has(contact.username) - const recentExportTimestamp = lastExportBySession[contact.username] - const hasRecentExport = canExport && Boolean(recentExportTimestamp) - const recentExportTime = hasRecentExport ? formatRecentExportTime(recentExportTimestamp, nowTick) : '' - - return ( -
-
-
-
- - {hasRecentExport && {recentExportTime}} -
- -
-
-
- ) - }, [ - lastExportBySession, - nowTick, - openSessionDetail, - openSingleExport, - queuedSessionIds, - runningSessionIds, - selectedSessions, - sessionDetail?.wxid, - sessionRowByUsername - ]) const handleContactsListWheelCapture = useCallback((event: WheelEvent) => { const deltaY = event.deltaY if (!deltaY) return @@ -6277,19 +6067,9 @@ function ExportPage() { window.scrollBy({ top: deltaY, behavior: 'auto' }) } }, [isContactsListAtTop]) - const handleContactsListScrollCapture = useCallback((event: UIEvent) => { - const target = event.target - if (!(target instanceof HTMLDivElement)) return - const nextScrollTop = Math.max(0, target.scrollTop) - setContactsListScrollTop(prev => ( - Math.abs(prev - nextScrollTop) > 1 ? nextScrollTop : prev - )) - }, []) useEffect(() => { if (hasFilteredContacts) return setIsContactsListAtTop(true) - setContactsListScrollTop(0) - setContactsVisibleRange({ startIndex: 0, endIndex: -1 }) }, [hasFilteredContacts]) const chooseExportFolder = useCallback(async () => { const result = await window.electronAPI.dialog.openFile({ @@ -6701,24 +6481,18 @@ function ExportPage() {
contact.username} - fixedItemHeight={CONTACTS_ROW_HEIGHT} + fixedItemHeight={76} itemContent={renderContactRow} rangeChanged={handleContactsRangeChanged} atTopStateChange={setIsContactsListAtTop} overscan={420} /> -
- {visibleContactsForActionRail.map(({ contact, index, top }) => ( - renderContactActionRailItem(contact, index, top) - ))} -
)}
@@ -7016,15 +6790,15 @@ function ExportPage() {
- 打开桌面端微信进入对方朋友圈,刷得越多这里聚合得越全。已确认身份的共同好友会展示头像、备注、微信号;未确认的旧数据仅保留名字。 + 打开桌面端微信,进入到这个人的朋友圈中,刷ta 的朋友圈,刷的越多这里的数据聚合越多
setSessionMutualFriendsSearch(event.target.value)} - placeholder="搜索共同好友(备注 / 微信号 / wxid)" - aria-label="搜索共同好友(备注 / 微信号 / wxid)" + placeholder="搜索共同好友" + aria-label="搜索共同好友" />
@@ -7036,48 +6810,20 @@ function ExportPage() { ) : (
{filteredSessionMutualFriendsDialogItems.map((item, index) => ( -
+
{index + 1} -
- -
-
- {item.name} - - {getSessionMutualFriendDirectionLabel(item.direction)} - - {!item.isConfirmed && ( - 身份未确认 - )} -
-
- {item.isConfirmed ? ( - item.wechatId ? `微信号: ${item.wechatId}` : '微信号: 未设置' - ) : ( - '仅从旧数据中解析到名字' - )} -
-
- {item.isConfirmed - ? `wxid: ${item.wxid || item.username || ''}` - : '没有可用的 wxid,未做自动匹配'} -
- - {describeSessionMutualFriendRelation(item, sessionMutualFriendsDialogTarget.displayName)} - -
-
+ {item.name} + + {getSessionMutualFriendDirectionLabel(item.direction)} + {item.totalCount.toLocaleString('zh-CN')} {formatYmdDateFromSeconds(item.latestTime)} + + {describeSessionMutualFriendRelation(item, sessionMutualFriendsDialogTarget.displayName)} +
))}
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index ef06b47..72aaa57 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -767,53 +767,7 @@ export interface ElectronAPI { } }> likes: Array - comments: Array<{ - id: string - nickname: string - username?: string - content: string - refCommentId: string - refNickname?: string - refUsername?: string - emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> - }> - likesDetail?: Array<{ - nickname: string - username?: string - wxid?: string - alias?: string - wechatId?: string - remark?: string - nickName?: string - displayName: string - avatarUrl?: string - source: 'xml' | 'legacy' - }> - commentsDetail?: Array<{ - id: string - nickname: string - username?: string - wxid?: string - alias?: string - wechatId?: string - remark?: string - nickName?: string - displayName: string - avatarUrl?: string - content: string - refCommentId: string - refNickname?: string - refUsername?: string - refWxid?: string - refAlias?: string - refWechatId?: string - refRemark?: string - refNickName?: string - refDisplayName?: string - refAvatarUrl?: string - emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> - source: 'xml' | 'legacy' - }> + comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> }> rawXml?: string }> error?: string diff --git a/src/types/sns.ts b/src/types/sns.ts index ccfeba3..9193385 100644 --- a/src/types/sns.ts +++ b/src/types/sns.ts @@ -28,53 +28,12 @@ export interface SnsCommentEmoji { export interface SnsComment { id: string nickname: string - username?: string content: string refCommentId: string refNickname?: string - refUsername?: string emojis?: SnsCommentEmoji[] } -export interface SnsLikeDetail { - nickname: string - username?: string - wxid?: string - alias?: string - wechatId?: string - remark?: string - nickName?: string - displayName: string - avatarUrl?: string - source: 'xml' | 'legacy' -} - -export interface SnsCommentDetail { - id: string - nickname: string - username?: string - wxid?: string - alias?: string - wechatId?: string - remark?: string - nickName?: string - displayName: string - avatarUrl?: string - content: string - refCommentId: string - refNickname?: string - refUsername?: string - refWxid?: string - refAlias?: string - refWechatId?: string - refRemark?: string - refNickName?: string - refDisplayName?: string - refAvatarUrl?: string - emojis?: SnsCommentEmoji[] - source: 'xml' | 'legacy' -} - export interface SnsPost { id: string tid?: string // 数据库主键(雪花 ID),用于精确删除 @@ -87,8 +46,6 @@ export interface SnsPost { media: SnsMedia[] likes: string[] comments: SnsComment[] - likesDetail?: SnsLikeDetail[] - commentsDetail?: SnsCommentDetail[] rawXml?: string linkTitle?: string linkUrl?: string From d4915e1a624c49b9010994a472f155ccc46f763a Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 11:01:54 +0800 Subject: [PATCH 13/42] feat: support batch-select SNS contacts --- src/components/Sns/SnsFilterPanel.tsx | 88 ++++++++++++++----- src/pages/SnsPage.scss | 122 ++++++++++++++++++++++++-- src/pages/SnsPage.tsx | 73 ++++++++++++--- 3 files changed, 243 insertions(+), 40 deletions(-) diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index b7e6a49..654d543 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Search, User, X, Loader2 } from 'lucide-react' +import { Search, User, X, Loader2, CheckSquare, Square, Download } from 'lucide-react' import { Avatar } from '../Avatar' interface Contact { @@ -25,7 +25,12 @@ interface SnsFilterPanelProps { setContactSearch: (val: string) => void loading?: boolean contactsCountProgress?: ContactsCountProgress + selectedContactUsernames: string[] + activeContactUsername?: string onOpenContactTimeline: (contact: Contact) => void + onToggleContactSelected: (contact: Contact) => void + onClearSelectedContacts: () => void + onExportSelectedContacts: () => void } export const SnsFilterPanel: React.FC = ({ @@ -37,12 +42,21 @@ export const SnsFilterPanel: React.FC = ({ setContactSearch, loading, contactsCountProgress, - onOpenContactTimeline + selectedContactUsernames, + activeContactUsername, + onOpenContactTimeline, + onToggleContactSelected, + onClearSelectedContacts, + onExportSelectedContacts }) => { const filteredContacts = contacts.filter(c => (c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) || c.username.toLowerCase().includes(contactSearch.toLowerCase()) ) + const selectedContactLookup = React.useMemo( + () => new Set(selectedContactUsernames), + [selectedContactUsernames] + ) const clearFilters = () => { setSearchKeyword('') @@ -122,35 +136,69 @@ export const SnsFilterPanel: React.FC = ({
)} +
+ 点左侧可多选下载,点右侧可查看单人详情 +
+
{filteredContacts.map(contact => { const isPostCountReady = contact.postCountStatus === 'ready' + const isSelected = selectedContactLookup.has(contact.username) + const isActive = activeContactUsername === contact.username return ( -
onOpenContactTimeline(contact)} - > - -
- {contact.displayName} +
+ +
-
- {isPostCountReady ? ( - {Math.max(0, Math.floor(Number(contact.postCount || 0)))}条 - ) : ( - - - - )} -
-
) })} {filteredContacts.length === 0 && (
{getEmptyStateText()}
)}
+ + {selectedContactUsernames.length > 0 && ( +
+ 已选 {selectedContactUsernames.length} 人 + + +
+ )}
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index d6d24ad..486c23a 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1211,6 +1211,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 +1225,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 +1341,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 52ca0b7..23ad458 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -63,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' @@ -123,6 +124,7 @@ export default function SnsPage() { total: 0, running: false }) + const [selectedContactUsernames, setSelectedContactUsernames] = useState([]) const [currentUserProfile, setCurrentUserProfile] = useState(() => readSidebarUserProfileCache() || { wxid: '', displayName: '' @@ -140,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) @@ -186,6 +189,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]) @@ -376,6 +386,14 @@ 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 myTimelineCount = useMemo(() => { if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') { return normalizePostCount(resolvedCurrentUserContact.postCount) @@ -389,6 +407,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({ @@ -561,6 +583,15 @@ 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 @@ -1040,6 +1071,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) @@ -1264,13 +1312,7 @@ export default function SnsPage() {
+ {selectedContactUsernames.length > 0 && ( +
+ 仅显示 + {selectedFeedContactsSummary} 的动态 + +
+ )} +
{loadingNewer && (
@@ -1391,9 +1448,11 @@ export default function SnsPage() {

未找到相关动态

- {(searchKeyword || jumpTargetDate) && ( + {(searchKeyword || jumpTargetDate || selectedContactUsernames.length > 0) && ( From cbdd5b3a24a14830529e1dd9a79dfe89a8d8f8ca Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 11:48:25 +0800 Subject: [PATCH 15/42] fix: refresh sns feed with latest selected contacts --- src/pages/SnsPage.tsx | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index f8ac599..fb8f009 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -407,6 +407,15 @@ export default function SnsPage() { 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) @@ -624,14 +633,19 @@ export default function SnsPage() { try { const limit = 20 - const selectedUsernames = selectedContactUsernames.length > 0 ? selectedContactUsernames : undefined + 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 @@ -642,7 +656,7 @@ export default function SnsPage() { limit, 0, selectedUsernames, - searchKeyword, + currentSearchKeyword, topTs + 1, undefined ); @@ -683,7 +697,7 @@ export default function SnsPage() { limit, 0, selectedUsernames, - searchKeyword, + currentSearchKeyword, startTs, // default undefined endTs ) @@ -697,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, selectedUsernames, 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); @@ -728,7 +742,7 @@ export default function SnsPage() { void loadPosts({ reset: true }) } } - }, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedContactUsernames]) + }, [persistSnsPageCache]) const stopContactsCountHydration = useCallback((resetProgress = false) => { contactsCountHydrationTokenRef.current += 1 @@ -1406,7 +1420,7 @@ export default function SnsPage() { )}
- {posts.map(post => ( + {visiblePosts.map(post => ( - {loading && posts.length === 0 && ( + {loading && visiblePosts.length === 0 && (
@@ -1433,18 +1447,18 @@ 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 && (

未找到相关动态

From bea824aee905516a47f23fba3cbbdfc589bab4b4 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 12:04:56 +0800 Subject: [PATCH 16/42] feat: unify chat analytics navigation --- src/App.tsx | 18 +++- src/components/ChatAnalysisHeader.scss | 95 +++++++++++++++++++ src/components/ChatAnalysisHeader.tsx | 65 +++++++++++++ src/components/Sidebar.tsx | 18 +--- src/pages/AnalyticsPage.scss | 16 ++++ src/pages/AnalyticsPage.tsx | 23 ++++- src/pages/AnalyticsWelcomePage.scss | 33 ++++++- src/pages/AnalyticsWelcomePage.tsx | 59 ++++++------ src/pages/ChatAnalyticsHubPage.scss | 123 +++++++++++++++++++++++++ src/pages/ChatAnalyticsHubPage.tsx | 59 ++++++++++++ src/pages/ChatPage.tsx | 2 +- src/pages/GroupAnalyticsPage.scss | 4 + src/pages/GroupAnalyticsPage.tsx | 2 + 13 files changed, 464 insertions(+), 53 deletions(-) create mode 100644 src/components/ChatAnalysisHeader.scss create mode 100644 src/components/ChatAnalysisHeader.tsx create mode 100644 src/pages/ChatAnalyticsHubPage.scss create mode 100644 src/pages/ChatAnalyticsHubPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 416d3e1..36f2fc0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Routes, Route, useNavigate, useLocation } from 'react-router-dom' +import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom' import TitleBar from './components/TitleBar' import Sidebar from './components/Sidebar' import RouteGuard from './components/RouteGuard' @@ -8,6 +8,7 @@ import HomePage from './pages/HomePage' import ChatPage from './pages/ChatPage' import AnalyticsPage from './pages/AnalyticsPage' import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage' +import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage' import AnnualReportPage from './pages/AnnualReportPage' import AnnualReportWindow from './pages/AnnualReportWindow' import DualReportPage from './pages/DualReportPage' @@ -37,6 +38,12 @@ import { GlobalSessionMonitor } from './components/GlobalSessionMonitor' import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal' import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal' +function RouteStateRedirect({ to }: { to: string }) { + const location = useLocation() + + return +} + function App() { const navigate = useNavigate() const location = useLocation() @@ -562,9 +569,12 @@ function App() { } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/ChatAnalysisHeader.scss b/src/components/ChatAnalysisHeader.scss new file mode 100644 index 0000000..f5a127a --- /dev/null +++ b/src/components/ChatAnalysisHeader.scss @@ -0,0 +1,95 @@ +.chat-analysis-header { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px 24px 16px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 16px; + flex-shrink: 0; +} + +.chat-analysis-back { + width: fit-content; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: color 0.2s ease; + font-size: 13px; + font-weight: 500; + + &:hover { + color: var(--text-primary); + } +} + +.chat-analysis-breadcrumb { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-tertiary); + font-size: 13px; + + .chat-analysis-breadcrumb-separator { + opacity: 0.6; + } + + .current { + color: var(--text-primary); + font-weight: 600; + } +} + +.chat-analysis-switcher { + display: inline-flex; + align-items: center; + gap: 6px; + width: fit-content; + padding: 4px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 999px; +} + +.chat-analysis-switcher-item { + min-width: 104px; + padding: 8px 16px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s ease; + + &:hover { + color: var(--text-primary); + background: var(--bg-hover); + } + + &.active { + background: var(--primary-light); + color: var(--primary); + } +} + +@media (max-width: 768px) { + .chat-analysis-header { + padding: 16px 18px 14px; + } + + .chat-analysis-switcher { + width: 100%; + } + + .chat-analysis-switcher-item { + flex: 1; + min-width: 0; + } +} diff --git a/src/components/ChatAnalysisHeader.tsx b/src/components/ChatAnalysisHeader.tsx new file mode 100644 index 0000000..e9097a5 --- /dev/null +++ b/src/components/ChatAnalysisHeader.tsx @@ -0,0 +1,65 @@ +import { ChevronLeft } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import './ChatAnalysisHeader.scss' + +export type ChatAnalysisMode = 'private' | 'group' + +interface ChatAnalysisHeaderProps { + currentMode: ChatAnalysisMode +} + +const MODE_CONFIG: Record = { + private: { + label: '私聊分析', + path: '/analytics/private' + }, + group: { + label: '群聊分析', + path: '/analytics/group' + } +} + +function ChatAnalysisHeader({ currentMode }: ChatAnalysisHeaderProps) { + const navigate = useNavigate() + const currentLabel = MODE_CONFIG[currentMode].label + + return ( +
+ + +
+ 聊天分析 + / + {currentLabel} +
+ +
+ {(Object.entries(MODE_CONFIG) as Array<[ChatAnalysisMode, { label: string; path: string }]>).map(([mode, config]) => ( + + ))} +
+
+ ) +} + +export default ChatAnalysisHeader diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 76547fb..af6f4f5 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { NavLink, useLocation, useNavigate } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, Trash2 } from 'lucide-react' +import { Home, MessageSquare, BarChart3, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, Trash2 } from 'lucide-react' import { useAppStore } from '../stores/appStore' import * as configService from '../services/config' import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' @@ -375,24 +375,14 @@ function Sidebar() { 通讯录 - {/* 私聊分析 */} + {/* 聊天分析 */} - 私聊分析 - - - {/* 群聊分析 */} - - - 群聊分析 + 聊天分析 {/* 年度报告 */} diff --git a/src/pages/AnalyticsPage.scss b/src/pages/AnalyticsPage.scss index 73d7ca9..1135123 100644 --- a/src/pages/AnalyticsPage.scss +++ b/src/pages/AnalyticsPage.scss @@ -1,3 +1,19 @@ +.analytics-page-shell { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 100%; + + .loading-container, + .error-container { + flex: 1; + } + + .page-header { + margin-bottom: 16px; + } +} + // 加载和错误状态 .loading-container, .error-container { diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 977f183..5702bb2 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, type ReactNode } from 'react' import { useLocation } from 'react-router-dom' import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, Medal, UserMinus, Search, X } from 'lucide-react' import ReactECharts from 'echarts-for-react' @@ -12,6 +12,7 @@ import { } from '../services/backgroundTaskMonitor' import './AnalyticsPage.scss' import { Avatar } from '../components/Avatar' +import ChatAnalysisHeader from '../components/ChatAnalysisHeader' interface ExcludeCandidate { username: string @@ -416,8 +417,15 @@ function AnalyticsPage() { } } + const renderPageShell = (content: ReactNode) => ( +
+ + {content} +
+ ) + if (isLoading && !isLoaded) { - return ( + return renderPageShell(

{loadingStatus}

@@ -430,7 +438,7 @@ function AnalyticsPage() { } if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) { - return ( + return renderPageShell(

{error}

@@ -446,11 +454,16 @@ function AnalyticsPage() { } if (error && !isLoaded) { - return (

{error}

) + return renderPageShell( +
+

{error}

+ +
+ ) } - return ( + return renderPageShell( <>

私聊分析

diff --git a/src/pages/AnalyticsWelcomePage.scss b/src/pages/AnalyticsWelcomePage.scss index 7a61efb..0e698bc 100644 --- a/src/pages/AnalyticsWelcomePage.scss +++ b/src/pages/AnalyticsWelcomePage.scss @@ -1,13 +1,30 @@ +.analytics-entry-page { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 100%; +} + .analytics-welcome-container { display: flex; flex-direction: column; + flex: 1; align-items: center; justify-content: center; - height: 100%; + min-height: 0; padding: 40px; background: var(--bg-primary); color: var(--text-primary); animation: fadeIn 0.4s ease-out; + overflow-y: auto; + + &.analytics-welcome-container--mode { + border-radius: 20px; + border: 1px solid var(--border-color); + background: + radial-gradient(circle at top, rgba(7, 193, 96, 0.06), transparent 48%), + var(--bg-primary); + } .welcome-content { text-align: center; @@ -106,6 +123,18 @@ } } +@media (max-width: 768px) { + .analytics-welcome-container { + padding: 28px 18px; + + .welcome-content { + .action-cards { + grid-template-columns: 1fr; + } + } + } +} + @keyframes fadeIn { from { opacity: 0; @@ -116,4 +145,4 @@ opacity: 1; transform: translateY(0); } -} \ No newline at end of file +} diff --git a/src/pages/AnalyticsWelcomePage.tsx b/src/pages/AnalyticsWelcomePage.tsx index 38a5f9f..e5344ae 100644 --- a/src/pages/AnalyticsWelcomePage.tsx +++ b/src/pages/AnalyticsWelcomePage.tsx @@ -1,6 +1,7 @@ import { useNavigate } from 'react-router-dom' import { BarChart2, History, RefreshCcw } from 'lucide-react' import { useAnalyticsStore } from '../stores/analyticsStore' +import ChatAnalysisHeader from '../components/ChatAnalysisHeader' import './AnalyticsWelcomePage.scss' function AnalyticsWelcomePage() { @@ -14,11 +15,11 @@ function AnalyticsWelcomePage() { const { lastLoadTime } = useAnalyticsStore() const handleLoadCache = () => { - navigate('/analytics/view') + navigate('/analytics/private/view') } const handleNewAnalysis = () => { - navigate('/analytics/view', { state: { forceRefresh: true } }) + navigate('/analytics/private/view', { state: { forceRefresh: true } }) } const formatLastTime = (ts: number | null) => { @@ -27,33 +28,37 @@ function AnalyticsWelcomePage() { } return ( -
-
-
- -
-

私聊数据分析

-

- WeFlow 可以分析你的聊天记录,生成详细的统计报表。
- 你可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。 -

+
+ -
- +
+
+
+ +
+

私聊数据分析

+

+ WeFlow 可以分析你的好友聊天记录,生成详细的统计报表。
+ 你可以选择加载上次的分析结果,或者重新开始一次新的私聊分析。 +

- +
+ + + +
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 6256d9b..357e1df 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -3323,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/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index b9cd651..867b4c2 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -463,6 +463,10 @@ background: var(--bg-secondary); border-radius: 16px; overflow: hidden; + + .chat-analysis-header { + margin: 16px 16px 0; + } } .resize-handle { diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 4d2c65e..c706822 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -4,6 +4,7 @@ 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, @@ -1189,6 +1190,7 @@ function GroupAnalyticsPage() { {renderGroupList()}
setIsResizing(true)} />
+ {renderDetailPanel()}
{renderMemberModal()} From f1affc7d63af2fe454e50481fc05f229e31c074c Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 12:12:50 +0800 Subject: [PATCH 17/42] refactor: simplify chat analytics header --- src/components/ChatAnalysisHeader.scss | 104 +++++++++++++++---------- src/components/ChatAnalysisHeader.tsx | 91 +++++++++++++++------- 2 files changed, 126 insertions(+), 69 deletions(-) diff --git a/src/components/ChatAnalysisHeader.scss b/src/components/ChatAnalysisHeader.scss index f5a127a..5c9808c 100644 --- a/src/components/ChatAnalysisHeader.scss +++ b/src/components/ChatAnalysisHeader.scss @@ -1,16 +1,16 @@ .chat-analysis-header { + position: relative; display: flex; - flex-direction: column; - gap: 12px; - padding: 20px 24px 16px; - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 16px; + align-items: center; + min-height: 28px; + padding: 4px 0; + background: transparent; + border: none; + border-radius: 0; flex-shrink: 0; } .chat-analysis-back { - width: fit-content; display: inline-flex; align-items: center; gap: 6px; @@ -21,7 +21,7 @@ cursor: pointer; transition: color 0.2s ease; font-size: 13px; - font-weight: 500; + font-weight: 600; &:hover { color: var(--text-primary); @@ -32,64 +32,84 @@ display: flex; align-items: center; gap: 8px; - color: var(--text-tertiary); font-size: 13px; + color: var(--text-secondary); .chat-analysis-breadcrumb-separator { opacity: 0.6; } - - .current { - color: var(--text-primary); - font-weight: 600; - } } -.chat-analysis-switcher { +.chat-analysis-dropdown { + position: relative; +} + +.chat-analysis-current-trigger { display: inline-flex; align-items: center; gap: 6px; - width: fit-content; - padding: 4px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 999px; -} - -.chat-analysis-switcher-item { - min-width: 104px; - padding: 8px 16px; + padding: 0; border: none; - border-radius: 999px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 13px; - font-weight: 500; - transition: all 0.2s ease; + font-weight: 600; + transition: color 0.2s ease; + + .current { + color: var(--text-primary); + } + + svg { + transition: transform 0.2s ease; + } &:hover { color: var(--text-primary); - background: var(--bg-hover); } - &.active { - background: var(--primary-light); + &.open svg { + transform: rotate(180deg); + } +} + +.chat-analysis-menu { + position: absolute; + top: calc(100% + 10px); + right: 0; + min-width: 120px; + padding: 6px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12); + z-index: 20; +} + +.chat-analysis-menu-item { + width: 100%; + display: block; + padding: 9px 12px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + text-align: left; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: background 0.2s ease, color 0.2s ease; + + &:hover { + background: var(--bg-hover); color: var(--primary); } } @media (max-width: 768px) { - .chat-analysis-header { - padding: 16px 18px 14px; - } - - .chat-analysis-switcher { - width: 100%; - } - - .chat-analysis-switcher-item { - flex: 1; - min-width: 0; + .chat-analysis-breadcrumb { + flex-wrap: wrap; + row-gap: 4px; } } diff --git a/src/components/ChatAnalysisHeader.tsx b/src/components/ChatAnalysisHeader.tsx index e9097a5..c6f1344 100644 --- a/src/components/ChatAnalysisHeader.tsx +++ b/src/components/ChatAnalysisHeader.tsx @@ -1,4 +1,5 @@ -import { ChevronLeft } from 'lucide-react' +import { ChevronDown, ChevronLeft } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import './ChatAnalysisHeader.scss' @@ -22,41 +23,77 @@ const MODE_CONFIG: Record = { function ChatAnalysisHeader({ currentMode }: ChatAnalysisHeaderProps) { const navigate = useNavigate() const currentLabel = MODE_CONFIG[currentMode].label + const [menuOpen, setMenuOpen] = useState(false) + const dropdownRef = useRef(null) + const alternateMode = useMemo( + () => (currentMode === 'private' ? 'group' : 'private'), + [currentMode] + ) + + useEffect(() => { + if (!menuOpen) return + + const handleClickOutside = (event: MouseEvent) => { + if (!dropdownRef.current?.contains(event.target as Node)) { + setMenuOpen(false) + } + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setMenuOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleEscape) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleEscape) + } + }, [menuOpen]) return (
- -
- 聊天分析 + / - {currentLabel} -
- -
- {(Object.entries(MODE_CONFIG) as Array<[ChatAnalysisMode, { label: string; path: string }]>).map(([mode, config]) => ( +
- ))} + + {menuOpen && ( +
+ +
+ )} +
) From 6911132c954129c13341dd0c323308efb66b48b3 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 12:17:21 +0800 Subject: [PATCH 18/42] fix: align group analytics header layout --- src/pages/GroupAnalyticsPage.scss | 14 +++++++++----- src/pages/GroupAnalyticsPage.tsx | 14 ++++++++------ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index 867b4c2..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 { @@ -463,10 +471,6 @@ background: var(--bg-secondary); border-radius: 16px; overflow: hidden; - - .chat-analysis-header { - margin: 16px 16px 0; - } } .resize-handle { diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index c706822..ae372ad 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1186,12 +1186,14 @@ function GroupAnalyticsPage() { } return ( -
- {renderGroupList()} -
setIsResizing(true)} /> -
- - {renderDetailPanel()} +
+ +
+ {renderGroupList()} +
setIsResizing(true)} /> +
+ {renderDetailPanel()} +
{renderMemberModal()}
From 74e974177c8621d5dec689934781f1b102eb343f Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 12:24:42 +0800 Subject: [PATCH 19/42] refactor: tighten private analytics header --- src/components/ChatAnalysisHeader.scss | 21 ++++++++++++++++ src/components/ChatAnalysisHeader.tsx | 7 ++++-- src/pages/AnalyticsPage.scss | 22 ----------------- src/pages/AnalyticsPage.tsx | 33 +++++++++++++------------- 4 files changed, 43 insertions(+), 40 deletions(-) diff --git a/src/components/ChatAnalysisHeader.scss b/src/components/ChatAnalysisHeader.scss index 5c9808c..4e5e99f 100644 --- a/src/components/ChatAnalysisHeader.scss +++ b/src/components/ChatAnalysisHeader.scss @@ -2,6 +2,8 @@ position: relative; display: flex; align-items: center; + justify-content: space-between; + gap: 16px; min-height: 28px; padding: 4px 0; background: transparent; @@ -107,9 +109,28 @@ } } +.chat-analysis-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-left: auto; + flex-wrap: wrap; +} + @media (max-width: 768px) { + .chat-analysis-header { + align-items: flex-start; + flex-wrap: wrap; + } + .chat-analysis-breadcrumb { flex-wrap: wrap; row-gap: 4px; } + + .chat-analysis-actions { + width: 100%; + justify-content: flex-start; + } } diff --git a/src/components/ChatAnalysisHeader.tsx b/src/components/ChatAnalysisHeader.tsx index c6f1344..112a15d 100644 --- a/src/components/ChatAnalysisHeader.tsx +++ b/src/components/ChatAnalysisHeader.tsx @@ -1,5 +1,5 @@ import { ChevronDown, ChevronLeft } from 'lucide-react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useNavigate } from 'react-router-dom' import './ChatAnalysisHeader.scss' @@ -7,6 +7,7 @@ export type ChatAnalysisMode = 'private' | 'group' interface ChatAnalysisHeaderProps { currentMode: ChatAnalysisMode + actions?: ReactNode } const MODE_CONFIG: Record = { @@ -20,7 +21,7 @@ const MODE_CONFIG: Record = { } } -function ChatAnalysisHeader({ currentMode }: ChatAnalysisHeaderProps) { +function ChatAnalysisHeader({ currentMode, actions }: ChatAnalysisHeaderProps) { const navigate = useNavigate() const currentLabel = MODE_CONFIG[currentMode].label const [menuOpen, setMenuOpen] = useState(false) @@ -95,6 +96,8 @@ function ChatAnalysisHeader({ currentMode }: ChatAnalysisHeaderProps) { )}
+ + {actions ?
{actions}
: null}
) } diff --git a/src/pages/AnalyticsPage.scss b/src/pages/AnalyticsPage.scss index 1135123..f905f4d 100644 --- a/src/pages/AnalyticsPage.scss +++ b/src/pages/AnalyticsPage.scss @@ -8,10 +8,6 @@ .error-container { flex: 1; } - - .page-header { - margin-bottom: 16px; - } } // 加载和错误状态 @@ -69,24 +65,6 @@ } } -.page-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 16px; - - h1 { - margin: 0; - } - - .header-actions { - display: flex; - align-items: center; - gap: 8px; - } -} - @keyframes spin { from { transform: rotate(0deg); diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 5702bb2..a689089 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -424,6 +424,19 @@ function AnalyticsPage() {
) + const analyticsHeaderActions = ( + <> + + + + ) + if (isLoading && !isLoaded) { return renderPageShell(
@@ -463,21 +476,9 @@ function AnalyticsPage() { } - return renderPageShell( - <> -
-

私聊分析

-
- - -
-
+ return ( +
+
@@ -625,7 +626,7 @@ function AnalyticsPage() {
)} - +
) } From 627aa35f887f9afaad3d4b43cea7af570a9e718b Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 12:38:23 +0800 Subject: [PATCH 20/42] refactor: move sidebar toggle to title bar --- src/App.tsx | 8 ++++++-- src/components/Sidebar.scss | 20 -------------------- src/components/Sidebar.tsx | 16 ++++++---------- src/components/TitleBar.scss | 30 ++++++++++++++++++++++++++++++ src/components/TitleBar.tsx | 22 +++++++++++++++++++--- 5 files changed, 61 insertions(+), 35 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 36f2fc0..79b5253 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -72,6 +72,7 @@ function App() { const isNotificationWindow = location.pathname === '/notification-window' const isExportRoute = location.pathname === '/export' const [themeHydrated, setThemeHydrated] = useState(false) + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) // 锁定状态 // const [isLocked, setIsLocked] = useState(false) // Moved to store @@ -446,7 +447,10 @@ function App() { useHello={lockUseHello} /> )} - + setSidebarCollapsed((prev) => !prev)} + /> {/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */} @@ -557,7 +561,7 @@ function App() { />
- +
diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index bdead0a..f4805a2 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -244,26 +244,6 @@ gap: 4px; } -.collapse-btn { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - padding: 8px; - border: none; - background: transparent; - color: var(--text-tertiary); - cursor: pointer; - border-radius: 9999px; - transition: all 0.2s ease; - margin-top: 4px; - - &:hover { - background: var(--bg-tertiary); - color: var(--text-primary); - } -} - .sidebar-clear-dialog-overlay { position: fixed; inset: 0; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index af6f4f5..2f68ba6 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { NavLink, useLocation, useNavigate } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, Trash2 } from 'lucide-react' +import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, Trash2 } from 'lucide-react' import { useAppStore } from '../stores/appStore' import * as configService from '../services/config' import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' @@ -62,10 +62,13 @@ const normalizeAccountId = (value?: string | null): string => { return suffixMatch ? suffixMatch[1] : trimmed } -function Sidebar() { +interface SidebarProps { + collapsed: boolean +} + +function Sidebar({ collapsed }: SidebarProps) { const location = useLocation() const navigate = useNavigate() - const [collapsed, setCollapsed] = useState(false) const [authEnabled, setAuthEnabled] = useState(false) const [activeExportTaskCount, setActiveExportTaskCount] = useState(0) const [userProfile, setUserProfile] = useState({ @@ -482,13 +485,6 @@ function Sidebar() { 设置 -
{showClearAccountDialog && ( diff --git a/src/components/TitleBar.scss b/src/components/TitleBar.scss index 9c18972..139083c 100644 --- a/src/components/TitleBar.scss +++ b/src/components/TitleBar.scss @@ -4,10 +4,13 @@ display: flex; align-items: center; padding-left: 16px; + padding-right: 16px; border-bottom: 1px solid var(--border-color); -webkit-app-region: drag; flex-shrink: 0; gap: 8px; + position: relative; + z-index: 2101; } // 繁花如梦:标题栏毛玻璃 @@ -16,6 +19,12 @@ -webkit-backdrop-filter: blur(20px); } +.title-brand { + display: inline-flex; + align-items: center; + gap: 8px; +} + .title-logo { width: 20px; height: 20px; @@ -27,3 +36,24 @@ font-weight: 500; color: var(--text-secondary); } + +.title-sidebar-toggle { + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-tertiary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease; + -webkit-app-region: no-drag; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } +} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 570e6e9..7b1b4e0 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -1,14 +1,30 @@ +import { PanelLeftClose, PanelLeftOpen } from 'lucide-react' import './TitleBar.scss' interface TitleBarProps { title?: string + sidebarCollapsed?: boolean + onToggleSidebar?: () => void } -function TitleBar({ title }: TitleBarProps = {}) { +function TitleBar({ title, sidebarCollapsed = false, onToggleSidebar }: TitleBarProps = {}) { return (
- WeFlow - {title || 'WeFlow'} +
+ WeFlow + {title || 'WeFlow'} + {onToggleSidebar ? ( + + ) : null} +
) } From 5b2e48baddd1325d78d251a274d14f98b57cecbb Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 13:24:47 +0800 Subject: [PATCH 21/42] refactor: streamline sidebar account menu --- src/components/Sidebar.scss | 41 ++++++++++++++------ src/components/Sidebar.tsx | 75 ++++++++++++++++++++----------------- 2 files changed, 70 insertions(+), 46 deletions(-) diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index f4805a2..89a791c 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -45,29 +45,48 @@ margin: 0 12px 10px; } -.sidebar-user-clear-trigger { +.sidebar-user-menu { position: absolute; left: 0; right: 0; bottom: calc(100% + 8px); z-index: 12; - border: 1px solid rgba(255, 59, 48, 0.28); - border-radius: 10px; + border: 1px solid var(--border-color); + border-radius: 12px; background: var(--bg-secondary); - color: #d93025; - padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12); +} + +.sidebar-user-menu-item { + width: 100%; + border: none; + border-radius: 10px; + background: transparent; + color: var(--text-primary); + padding: 9px 10px; display: flex; align-items: center; gap: 8px; - font-size: 12px; - font-weight: 600; + font-size: 13px; + font-weight: 500; cursor: pointer; - box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12); - transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; + text-align: left; + transition: background 0.2s ease, color 0.2s ease; &:hover { - background: rgba(255, 59, 48, 0.08); - border-color: rgba(255, 59, 48, 0.46); + background: var(--bg-tertiary); + } + + &.danger { + color: #d93025; + + &:hover { + background: rgba(255, 59, 48, 0.08); + } } } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 2f68ba6..fbd0694 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -282,6 +282,11 @@ function Sidebar({ collapsed }: SidebarProps) { setShowClearAccountDialog(true) } + const openSettingsFromAccountMenu = () => { + setIsAccountMenuOpen(false) + navigate('/settings') + } + const handleConfirmClearAccountData = async () => { if (!canConfirmClear || isClearingAccountData) return setIsClearingAccountData(true) @@ -420,16 +425,43 @@ function Sidebar({ collapsed }: SidebarProps) {
+ +
{isAccountMenuOpen && ( - +
+ + +
)}
- - - - - - - - 设置 - -
{showClearAccountDialog && ( From 37796c98c97e4c8391e5488ae378657f70c55e41 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 13:32:19 +0800 Subject: [PATCH 22/42] feat: show settings as modal dialog --- src/App.tsx | 50 +++++++++++-- src/components/Sidebar.tsx | 13 +++- src/pages/SettingsPage.scss | 120 ++++++++++++++++++++++++++---- src/pages/SettingsPage.tsx | 142 ++++++++++++++++++++++-------------- 4 files changed, 248 insertions(+), 77 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 79b5253..f50a7b2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react' -import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom' +import { useEffect, useRef, useState } from 'react' +import { Routes, Route, Navigate, useNavigate, useLocation, type Location } from 'react-router-dom' import TitleBar from './components/TitleBar' import Sidebar from './components/Sidebar' import RouteGuard from './components/RouteGuard' @@ -47,6 +47,13 @@ function RouteStateRedirect({ to }: { to: string }) { function App() { const navigate = useNavigate() const location = useLocation() + const settingsBackgroundRef = useRef({ + pathname: '/home', + search: '', + hash: '', + state: null, + key: 'settings-fallback' + } as Location) const { setDbConnected, @@ -70,7 +77,12 @@ function App() { const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') const isStandaloneChatWindow = location.pathname === '/chat-window' const isNotificationWindow = location.pathname === '/notification-window' - const isExportRoute = location.pathname === '/export' + const isSettingsRoute = location.pathname === '/settings' + const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null + const routeLocation = isSettingsRoute + ? settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current + : location + const isExportRoute = routeLocation.pathname === '/export' const [themeHydrated, setThemeHydrated] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false) @@ -89,6 +101,12 @@ function App() { // 数据收集同意状态 const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false) + useEffect(() => { + if (location.pathname !== '/settings') { + settingsBackgroundRef.current = location + } + }, [location]) + useEffect(() => { const root = document.documentElement const body = document.body @@ -437,6 +455,25 @@ function App() { } // 主窗口 - 完整布局 + const handleCloseSettings = () => { + const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current + if (backgroundLocation.pathname === '/settings') { + navigate('/home', { replace: true }) + return + } + navigate( + { + pathname: backgroundLocation.pathname, + search: backgroundLocation.search, + hash: backgroundLocation.hash + }, + { + replace: true, + state: backgroundLocation.state + } + ) + } + return (
- + } /> } /> } /> @@ -584,7 +621,6 @@ function App() { } /> } /> - } />
+ + {isSettingsRoute && ( + + )}
) } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index fbd0694..e215448 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -284,7 +284,11 @@ function Sidebar({ collapsed }: SidebarProps) { const openSettingsFromAccountMenu = () => { setIsAccountMenuOpen(false) - navigate('/settings') + navigate('/settings', { + state: { + backgroundLocation: location + } + }) } const handleConfirmClearAccountData = async () => { @@ -432,7 +436,12 @@ function Sidebar({ collapsed }: SidebarProps) { setLocked(true) return } - navigate('/settings', { state: { initialTab: 'security' } }) + navigate('/settings', { + state: { + initialTab: 'security', + backgroundLocation: location + } + }) }} title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined} > diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 8dc2525..40b8f73 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: 18px; flex-shrink: 0; h1 { @@ -22,29 +43,76 @@ } } +.settings-title-block { + display: flex; + flex-direction: column; + gap: 6px; + + p { + margin: 0; + font-size: 13px; + color: var(--text-tertiary); + line-height: 1.6; + } +} + .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-direction: column; + gap: 6px; + padding: 12px; + width: 220px; flex-shrink: 0; - width: fit-content; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 20px; + overflow-y: auto; } .tab-btn { display: flex; align-items: center; gap: 6px; - padding: 10px 18px; + width: 100%; + justify-content: flex-start; + padding: 11px 14px; border: none; - border-radius: 8px; + border-radius: 12px; font-size: 14px; font-weight: 500; cursor: pointer; @@ -67,6 +135,7 @@ .settings-body { flex: 1; overflow-y: auto; + min-width: 0; padding-right: 8px; &::-webkit-scrollbar { @@ -85,8 +154,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 +1003,7 @@ padding: 10px 24px; border-radius: 9999px; font-size: 14px; - z-index: 100; + z-index: 2200; animation: slideDown 0.3s ease; &.success { @@ -946,6 +1017,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; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 8afa61b..d0ae9b2 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) @@ -2049,66 +2064,81 @@ 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()} -
- ))} -
-
- + {/* 多账号选择对话框 */} + {showWxidSelect && wxidOptions.length > 1 && ( +
setShowWxidSelect(false)}> +
e.stopPropagation()}> +
+

检测到多个微信账号

+

请选择要使用的账号

+
+
+ {wxidOptions.map((opt) => ( +
handleSelectWxid(opt.wxid)} + > + {opt.wxid} + 最后修改 {new Date(opt.modifiedTime).toLocaleString()} +
+ ))} +
+
+ +
-
- )} + )} -
-

设置

-
- +
+
+

设置

+

在这里集中调整 WeFlow 的功能、外观与数据行为。

+
+
+ + {onClose && ( + + )} +
+
+ +
+
+ {tabs.map(tab => ( + + ))} +
+ +
+ {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 => ( - - ))} -
- -
- {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()} -
-
) } From 38d899fa94e0a73fdf259eec698df4c4042c1041 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 13:38:29 +0800 Subject: [PATCH 23/42] style: tighten settings and account menu --- src/components/Sidebar.scss | 2 +- src/pages/SettingsPage.scss | 10 +--------- src/pages/SettingsPage.tsx | 1 - 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index 89a791c..1774e75 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -53,7 +53,7 @@ z-index: 12; border: 1px solid var(--border-color); border-radius: 12px; - background: var(--bg-secondary); + background: var(--bg-secondary-solid, var(--bg-primary)); display: flex; flex-direction: column; gap: 4px; diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 40b8f73..567e056 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -32,7 +32,7 @@ align-items: flex-start; justify-content: space-between; gap: 20px; - margin-bottom: 18px; + margin-bottom: 14px; flex-shrink: 0; h1 { @@ -46,14 +46,6 @@ .settings-title-block { display: flex; flex-direction: column; - gap: 6px; - - p { - margin: 0; - font-size: 13px; - color: var(--text-tertiary); - line-height: 1.6; - } } .settings-actions { diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index d0ae9b2..78e1997 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -2098,7 +2098,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {

设置

-

在这里集中调整 WeFlow 的功能、外观与数据行为。

) + const resolvedWhisperModelPath = whisperModelDir || whisperModelStatus?.modelPath || '' + const renderModelsTab = () => (
@@ -1439,42 +1441,52 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
-
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) + + )} +
+
+
+
-
-
-
-
- )} -
+ )} +
+ )}
From 96ac655d92ffb995d7601b2a11c4d68ed6c89442 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 13:55:26 +0800 Subject: [PATCH 26/42] fix: preserve text export format selection --- src/pages/ExportPage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 38d01a5..4287c27 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -3887,11 +3887,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, From e209bd68d40dc72e02d8564e18deed1ac9f12bc1 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 14:06:03 +0800 Subject: [PATCH 27/42] fix: export link metadata for arkme json --- electron/services/exportService.ts | 47 ++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 5507bd6..20864a5 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1800,6 +1800,26 @@ class ExportService { else if (appMsgKind === 'quote') meta.appMsgType = '57' if (appMsgKind) meta.appMsgKind = appMsgKind + const appMsgDesc = this.extractXmlValue(normalized, 'des') || this.extractXmlValue(normalized, 'desc') + const appMsgAppName = this.extractXmlValue(normalized, 'appname') + const appMsgSourceName = + this.extractXmlValue(normalized, 'sourcename') || + this.extractXmlValue(normalized, 'sourcedisplayname') + const appMsgSourceUsername = this.extractXmlValue(normalized, 'sourceusername') + const appMsgThumbUrl = + this.extractXmlValue(normalized, 'thumburl') || + this.extractXmlValue(normalized, 'cdnthumburl') || + this.extractXmlValue(normalized, 'cover') || + this.extractXmlValue(normalized, 'coverurl') || + this.extractXmlValue(normalized, 'thumbUrl') || + this.extractXmlValue(normalized, 'coverUrl') + + if (appMsgDesc) meta.appMsgDesc = appMsgDesc + if (appMsgAppName) meta.appMsgAppName = appMsgAppName + if (appMsgSourceName) meta.appMsgSourceName = appMsgSourceName + if (appMsgSourceUsername) meta.appMsgSourceUsername = appMsgSourceUsername + if (appMsgThumbUrl) meta.appMsgThumbUrl = appMsgThumbUrl + if (appMsgKind === 'quote') { const quoteInfo = this.parseQuoteMessage(normalized) if (quoteInfo.content) meta.quotedContent = quoteInfo.content @@ -1807,6 +1827,18 @@ class ExportService { if (quoteInfo.type) meta.quotedType = quoteInfo.type } + if (appMsgKind === 'link') { + const linkCard = this.extractHtmlLinkCard(normalized, localType) + const linkUrl = linkCard?.url || this.normalizeHtmlLinkUrl( + this.extractXmlValue(normalized, 'shareurl') || + this.extractXmlValue(normalized, 'shorturl') || + this.extractXmlValue(normalized, 'dataurl') + ) + if (linkCard?.title) meta.linkTitle = linkCard.title + if (linkUrl) meta.linkUrl = linkUrl + if (appMsgThumbUrl) meta.linkThumb = appMsgThumbUrl + } + if (isMusic) { const musicTitle = this.extractXmlValue(normalized, 'songname') || @@ -3906,9 +3938,10 @@ class ExportService { const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType) if (appMsgMeta) { - if (options.format === 'arkme-json') { - Object.assign(msgObj, appMsgMeta) - } else if (options.format === 'json' && appMsgMeta.appMsgKind === 'quote') { + if ( + options.format === 'arkme-json' || + (options.format === 'json' && (appMsgMeta.appMsgKind === 'quote' || appMsgMeta.appMsgKind === 'link')) + ) { Object.assign(msgObj, appMsgMeta) } } @@ -4100,9 +4133,17 @@ class ExportService { if (message.locationLabel) compactMessage.locationLabel = message.locationLabel if (message.appMsgType) compactMessage.appMsgType = message.appMsgType if (message.appMsgKind) compactMessage.appMsgKind = message.appMsgKind + if (message.appMsgDesc) compactMessage.appMsgDesc = message.appMsgDesc + if (message.appMsgAppName) compactMessage.appMsgAppName = message.appMsgAppName + if (message.appMsgSourceName) compactMessage.appMsgSourceName = message.appMsgSourceName + if (message.appMsgSourceUsername) compactMessage.appMsgSourceUsername = message.appMsgSourceUsername + if (message.appMsgThumbUrl) compactMessage.appMsgThumbUrl = message.appMsgThumbUrl if (message.quotedContent) compactMessage.quotedContent = message.quotedContent if (message.quotedSender) compactMessage.quotedSender = message.quotedSender if (message.quotedType) compactMessage.quotedType = message.quotedType + if (message.linkTitle) compactMessage.linkTitle = message.linkTitle + if (message.linkUrl) compactMessage.linkUrl = message.linkUrl + if (message.linkThumb) compactMessage.linkThumb = message.linkThumb if (message.finderTitle) compactMessage.finderTitle = message.finderTitle if (message.finderDesc) compactMessage.finderDesc = message.finderDesc if (message.finderUsername) compactMessage.finderUsername = message.finderUsername From c899fa72b8544267930c5b142ca49d0d5b8baad4 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 14:11:05 +0800 Subject: [PATCH 28/42] fix: keep export tabs on one line --- src/pages/ExportPage.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index d1c8c0d..989c961 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1622,9 +1622,11 @@ .table-tabs { display: flex; gap: 8px; - flex-wrap: wrap; + flex-wrap: nowrap; + align-items: center; .tab-btn { + flex: 0 0 auto; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-secondary); From 6ad1e6c3f3cebecefa42769de198c6697adaf464 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 14:13:51 +0800 Subject: [PATCH 29/42] style: tighten export tab spacing --- src/pages/ExportPage.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 989c961..3a7053f 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1630,7 +1630,7 @@ border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-secondary); - padding: 7px 12px; + padding: 7px 8px; border-radius: 999px; cursor: pointer; font-size: 13px; From 122ad73c2e1cdbaba1c8e9327d06d79c2f13fc39 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 14:15:40 +0800 Subject: [PATCH 30/42] style: remove outer export table frame --- src/pages/ExportPage.scss | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 3a7053f..0595164 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1563,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; } From ee4d1f5689f7dc9ed4b72262d05dd51025f981f0 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 14:18:07 +0800 Subject: [PATCH 31/42] style: tighten export tab content layout --- src/pages/ExportPage.scss | 10 ++++++++-- src/pages/ExportPage.tsx | 15 ++++++++++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 0595164..83a40da 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1625,14 +1625,20 @@ border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-secondary); - padding: 7px 8px; + padding: 7px 6px; border-radius: 999px; cursor: pointer; font-size: 13px; white-space: nowrap; display: inline-flex; align-items: center; - gap: 4px; + + .tab-btn-content { + display: inline-flex; + align-items: center; + gap: 4px; + line-height: 1; + } &.active { border-color: var(--primary); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 4287c27..ae7460b 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -6338,13 +6338,22 @@ function ExportPage() {
From f53de9fe0b251d888e496c383252eb242af95912 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 14:27:24 +0800 Subject: [PATCH 32/42] fix(export): tighten session export tab width --- src/pages/ExportPage.scss | 3 +++ src/pages/SettingsPage.scss | 48 ++++++++++++++++++------------------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 83a40da..45b31cc 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1622,6 +1622,8 @@ .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); @@ -1632,6 +1634,7 @@ white-space: nowrap; display: inline-flex; align-items: center; + justify-content: center; .tab-btn-content { display: inline-flex; diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 72d4b64..fa710b6 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -94,33 +94,33 @@ border: 1px solid var(--border-color); border-radius: 20px; overflow-y: auto; -} -.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); + .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); - &:hover { - color: var(--text-primary); - background: var(--bg-secondary); - } + &:hover { + color: var(--text-primary); + background: var(--bg-secondary); + } - &.active { - background: var(--card-bg); - color: var(--primary); - box-shadow: var(--shadow-sm); + &.active { + background: var(--card-bg); + color: var(--primary); + box-shadow: var(--shadow-sm); + } } } From 24c47c3aa3483c1a86129ae70609628a36eb5f4d Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 14:33:51 +0800 Subject: [PATCH 33/42] fix(export): refine session export table layout --- src/pages/ExportPage.scss | 26 +++++++----- src/pages/ExportPage.tsx | 86 +++++++++++++++++++-------------------- 2 files changed, 59 insertions(+), 53 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 45b31cc..00730ba 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1736,6 +1736,7 @@ flex: 1; display: flex; flex-direction: column; + background: var(--bg-secondary); } .table-wrap { @@ -1744,6 +1745,7 @@ overflow-x: auto; overflow-y: visible; scrollbar-width: none; + background: var(--bg-secondary); &::-webkit-scrollbar { display: none; @@ -1758,7 +1760,7 @@ position: sticky; top: 0; z-index: 20; - background: var(--card-bg); + background: var(--bg-secondary); } .loading-state, @@ -1771,6 +1773,7 @@ align-items: center; justify-content: center; gap: 12px; + background: var(--bg-secondary); color: var(--text-tertiary); font-size: 14px; @@ -1785,11 +1788,12 @@ 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); @@ -1863,7 +1867,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; @@ -1880,7 +1884,7 @@ 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: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary)); font-size: 12px; color: var(--text-tertiary); font-weight: 600; @@ -1963,7 +1967,7 @@ position: sticky; right: 0; z-index: 8; - background: var(--bg-primary); + background: var(--bg-secondary); white-space: nowrap; &::before { @@ -1974,7 +1978,7 @@ left: -8px; width: 8px; pointer-events: none; - background: linear-gradient(to right, transparent, var(--bg-primary)); + background: linear-gradient(to right, transparent, var(--bg-secondary)); } } @@ -1986,6 +1990,7 @@ height: var(--contacts-default-list-height); overflow: hidden; padding: 0 0 12px; + background: var(--bg-secondary); } .contacts-virtuoso { @@ -2007,7 +2012,7 @@ flex: 0 0 auto; overflow-x: auto; overflow-y: hidden; - background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary)); + 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; @@ -2357,11 +2362,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; @@ -2491,7 +2497,7 @@ position: sticky; right: 0; z-index: 10; - background: var(--bg-primary); + background: var(--bg-secondary); &::before { content: ''; @@ -2501,7 +2507,7 @@ left: -8px; width: 8px; pointer-events: none; - background: linear-gradient(to right, transparent, var(--bg-primary)); + background: linear-gradient(to right, transparent, var(--bg-secondary)); } .row-action-main { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index ae7460b..fae2112 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -6328,6 +6328,49 @@ function ExportPage() {
+
+
+ + + +
+ +
+
+ + setSearchKeyword(event.target.value)} + placeholder={`搜索${activeTabLabel}联系人...`} + /> + {searchKeyword && ( + + )} +
+ +
+
+
-
-
- - - -
- -
-
- - setSearchKeyword(event.target.value)} - placeholder={`搜索${activeTabLabel}联系人...`} - /> - {searchKeyword && ( - - )} -
- -
-
- {contactsList.length > 0 && isContactsListLoading && (
From 0f3ecdc4eeb2e911abb006b2f85321ae93f16e47 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 14:39:23 +0800 Subject: [PATCH 34/42] fix(export): hide duplicate session table scrollbar --- src/pages/ExportPage.scss | 8 ++ src/pages/ExportPage.tsx | 254 +++++++++++++++++++------------------- 2 files changed, 136 insertions(+), 126 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 00730ba..596c2f8 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1717,6 +1717,7 @@ } .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)); @@ -1740,12 +1741,19 @@ } .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; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index fae2112..2a47938 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -6371,137 +6371,139 @@ function ExportPage() {
-
-
-
- {contactsList.length > 0 && isContactsListLoading && ( -
- - 联系人列表同步中… -
- )} +
+
+
+
+ {contactsList.length > 0 && isContactsListLoading && ( +
+ + 联系人列表同步中… +
+ )} - {hasFilteredContacts && ( -
- - - - - {contactsHeaderMainLabel} - - 总消息数 - 表情包 - 语音 - 图片 - 视频 - {shouldShowSnsColumn && ( - 朋友圈 - )} - {shouldShowMutualFriendsColumn && ( - 共同好友 - )} - - {selectedCount > 0 && ( - <> - - - + {hasFilteredContacts && ( +
+ + + + + {contactsHeaderMainLabel} + + 总消息数 + 表情包 + 语音 + 图片 + 视频 + {shouldShowSnsColumn && ( + 朋友圈 )} - + {shouldShowMutualFriendsColumn && ( + 共同好友 + )} + + {selectedCount > 0 && ( + <> + + + + )} + +
+ )} +
+ + {contactsList.length === 0 && contactsLoadIssue ? ( +
+
+
+ + {contactsLoadIssue.title} +
+

{contactsLoadIssue.message}

+

{contactsLoadIssue.reason}

+
    +
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • +
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • +
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • +
+
+ + + +
+ {showContactsDiagnostics && ( +
{contactsDiagnosticsText}
+ )} +
+
+ ) : isContactsListLoading && contactsList.length === 0 ? ( +
+ + 联系人加载中... +
+ ) : !hasFilteredContacts ? ( +
+ 暂无联系人 +
+ ) : ( +
+ contact.username} + fixedItemHeight={76} + itemContent={renderContactRow} + rangeChanged={handleContactsRangeChanged} + atTopStateChange={setIsContactsListAtTop} + overscan={420} + />
)}
- - {contactsList.length === 0 && contactsLoadIssue ? ( -
-
-
- - {contactsLoadIssue.title} -
-

{contactsLoadIssue.message}

-

{contactsLoadIssue.reason}

-
    -
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • -
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • -
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • -
-
- - - -
- {showContactsDiagnostics && ( -
{contactsDiagnosticsText}
- )} -
-
- ) : isContactsListLoading && contactsList.length === 0 ? ( -
- - 联系人加载中... -
- ) : !hasFilteredContacts ? ( -
- 暂无联系人 -
- ) : ( -
- contact.username} - fixedItemHeight={76} - itemContent={renderContactRow} - rangeChanged={handleContactsRangeChanged} - atTopStateChange={setIsContactsListAtTop} - overscan={420} - /> -
- )}
From dfac3c57cca3d272186aaed757d613dbcde14d14 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 14:43:14 +0800 Subject: [PATCH 35/42] fix(export): remove virtuoso horizontal scrollbar --- src/pages/ExportPage.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 596c2f8..186a5e6 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2004,6 +2004,8 @@ .contacts-virtuoso { height: 100%; width: 100%; + overflow-x: hidden !important; + overflow-y: auto !important; &::-webkit-scrollbar { width: 6px; From da054de708356f08d21050bc3fae837b50ce83bf Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 14:47:14 +0800 Subject: [PATCH 36/42] fix(export): clean session row action background --- src/pages/ExportPage.scss | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 186a5e6..47aaef9 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2112,10 +2112,6 @@ &.selected .contact-item { background: rgba(var(--primary-rgb), 0.08); } - - &.selected .row-action-cell { - background: rgba(var(--primary-rgb), 0.08); - } } .contact-item { @@ -2507,18 +2503,7 @@ position: sticky; right: 0; z-index: 10; - background: var(--bg-secondary); - - &::before { - content: ''; - position: absolute; - top: -12px; - bottom: -12px; - left: -8px; - width: 8px; - pointer-events: none; - background: linear-gradient(to right, transparent, var(--bg-secondary)); - } + background: transparent; .row-action-main { display: inline-flex; From 092450e4f829e7518640dda2b299c6566aced43c Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 14:48:34 +0800 Subject: [PATCH 37/42] chore(export): shorten session export label --- src/pages/ExportPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 2a47938..7ec9d3a 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -6008,7 +6008,7 @@ function ExportPage() { }) }} > - {!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '单会话导出'} + {!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '导出'} {hasRecentExport && {recentExportTime}}
From fe1c8862e6233049d80fef228a28cdec25a128b6 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 14:55:34 +0800 Subject: [PATCH 38/42] feat(export): pin session table edge columns --- src/pages/ExportPage.scss | 90 ++++++++++++++++++++++++++++++++++----- src/pages/ExportPage.tsx | 72 ++++++++++++++++--------------- 2 files changed, 118 insertions(+), 44 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 47aaef9..e4eb6bb 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1722,9 +1722,12 @@ --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(44px + 12px + var(--contacts-name-text-width)); + --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; @@ -1886,13 +1889,14 @@ } .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-secondary) 90%, var(--bg-primary)); + background: var(--contacts-header-bg); font-size: 12px; color: var(--text-tertiary); font-weight: 600; @@ -1909,6 +1913,30 @@ } } + .contacts-list-header-left { + position: sticky; + left: 0; + z-index: 12; + 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); + background: var(--contacts-header-bg); + + &::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + right: calc(-1 * var(--contacts-column-gap)); + width: var(--contacts-column-gap); + background: var(--contacts-header-bg); + pointer-events: none; + } + } + .contacts-list-header-select { width: var(--contacts-select-col-width); min-width: var(--contacts-select-col-width); @@ -1974,8 +2002,8 @@ flex-shrink: 0; position: sticky; right: 0; - z-index: 8; - background: var(--bg-secondary); + z-index: 13; + background: var(--contacts-header-bg); white-space: nowrap; &::before { @@ -1986,7 +2014,7 @@ left: -8px; width: 8px; pointer-events: none; - background: linear-gradient(to right, transparent, var(--bg-secondary)); + background: linear-gradient(to right, transparent, var(--contacts-header-bg)); } } @@ -2110,14 +2138,16 @@ padding-bottom: 4px; &.selected .contact-item { - background: rgba(var(--primary-rgb), 0.08); + --contacts-row-bg: rgba(var(--primary-rgb), 0.08); + background: var(--contacts-row-bg); } } .contact-item { + --contacts-row-bg: var(--bg-secondary); display: flex; align-items: center; - gap: 12px; + gap: var(--contacts-column-gap); padding: 12px 6px 12px var(--contacts-inline-padding); min-width: max(100%, var(--contacts-table-min-width)); height: 72px; @@ -2125,9 +2155,36 @@ border-radius: 10px; transition: all 0.2s; cursor: default; + background: var(--contacts-row-bg); &:hover { - background: var(--bg-hover); + --contacts-row-bg: var(--bg-hover); + background: var(--contacts-row-bg); + } + } + + .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); + + &::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + right: calc(-1 * var(--contacts-column-gap)); + width: var(--contacts-column-gap); + background: var(--contacts-row-bg); + pointer-events: none; } } @@ -2496,6 +2553,8 @@ 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); @@ -2503,7 +2562,18 @@ position: sticky; right: 0; z-index: 10; - background: transparent; + background: var(--contacts-row-bg); + + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -8px; + width: 8px; + pointer-events: none; + background: linear-gradient(to right, transparent, var(--contacts-row-bg)); + } .row-action-main { display: inline-flex; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 7ec9d3a..31c7ab8 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -5876,27 +5876,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}
+
@@ -6394,19 +6396,21 @@ function ExportPage() { onPointerUp={handleContactsHeaderPointerUp} onPointerCancel={handleContactsHeaderPointerCancel} > - - - - - {contactsHeaderMainLabel} + + + + + + {contactsHeaderMainLabel} + 总消息数 表情包 From 0d9fcc731aaeb5bed0874e5d536422c111539d2f Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 14:58:52 +0800 Subject: [PATCH 39/42] fix(export): restore sticky session rows --- src/pages/ExportPage.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index e4eb6bb..fa701f6 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2032,7 +2032,7 @@ .contacts-virtuoso { height: 100%; width: 100%; - overflow-x: hidden !important; + overflow-x: clip !important; overflow-y: auto !important; &::-webkit-scrollbar { From 4489a0f702e6da85737607f73499c76e107c5db8 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 15:31:15 +0800 Subject: [PATCH 40/42] fix(export): align session sticky columns --- src/pages/ExportPage.scss | 54 +++++++++++++-------------------------- src/pages/ExportPage.tsx | 7 +++++ 2 files changed, 25 insertions(+), 36 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index fa701f6..9bd1ddd 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2024,26 +2024,29 @@ 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; background: var(--bg-secondary); - } - - .contacts-virtuoso { - height: 100%; - width: 100%; - overflow-x: clip !important; - overflow-y: auto !important; &::-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 { @@ -2156,6 +2159,7 @@ transition: all 0.2s; cursor: default; background: var(--contacts-row-bg); + isolation: isolate; &:hover { --contacts-row-bg: var(--bg-hover); @@ -2174,18 +2178,7 @@ 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); - - &::after { - content: ''; - position: absolute; - top: 0; - bottom: 0; - right: calc(-1 * var(--contacts-column-gap)); - width: var(--contacts-column-gap); - background: var(--contacts-row-bg); - pointer-events: none; - } + background: inherit; } .row-select-cell { @@ -2562,18 +2555,7 @@ position: sticky; right: 0; z-index: 10; - background: var(--contacts-row-bg); - - &::before { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: -8px; - width: 8px; - pointer-events: none; - background: linear-gradient(to right, transparent, var(--contacts-row-bg)); - } + background: inherit; .row-action-main { display: inline-flex; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 31c7ab8..cd5183f 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1513,6 +1513,7 @@ function ExportPage() { 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 @@ -1600,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 @@ -6492,11 +6497,13 @@ function ExportPage() { ) : (
contact.username} fixedItemHeight={76} From 29529271fbaee4fdcca895188b57f44253aad14d Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 15:58:28 +0800 Subject: [PATCH 41/42] fix(export): clean session row hover state --- src/pages/ExportPage.scss | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 9bd1ddd..46c0fb5 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2141,8 +2141,12 @@ padding-bottom: 4px; &.selected .contact-item { - --contacts-row-bg: 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 .contact-item:hover { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 60%, transparent); } } @@ -2159,11 +2163,11 @@ transition: all 0.2s; cursor: default; background: var(--contacts-row-bg); - isolation: isolate; + box-shadow: inset 0 0 0 1px transparent; &:hover { - --contacts-row-bg: var(--bg-hover); background: var(--contacts-row-bg); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-tertiary) 24%, transparent); } } @@ -2178,7 +2182,7 @@ width: var(--contacts-left-sticky-width); min-width: var(--contacts-left-sticky-width); max-width: var(--contacts-left-sticky-width); - background: inherit; + background: var(--contacts-row-bg); } .row-select-cell { @@ -2555,7 +2559,7 @@ position: sticky; right: 0; z-index: 10; - background: inherit; + background: var(--contacts-row-bg); .row-action-main { display: inline-flex; From 67fd53a5033a664bcc9c74150afa930450bb16ac Mon Sep 17 00:00:00 2001 From: aits2026 Date: Tue, 10 Mar 2026 16:17:22 +0800 Subject: [PATCH 42/42] fix(export): unlock session header left columns --- src/pages/ExportPage.scss | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 46c0fb5..ab340f4 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1914,27 +1914,12 @@ } .contacts-list-header-left { - position: sticky; - left: 0; - z-index: 12; 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); - background: var(--contacts-header-bg); - - &::after { - content: ''; - position: absolute; - top: 0; - bottom: 0; - right: calc(-1 * var(--contacts-column-gap)); - width: var(--contacts-column-gap); - background: var(--contacts-header-bg); - pointer-events: none; - } } .contacts-list-header-select {