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 diff --git a/src/App.tsx b/src/App.tsx index 416d3e1..f50a7b2 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 { 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' @@ -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,9 +38,22 @@ 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() + const settingsBackgroundRef = useRef({ + pathname: '/home', + search: '', + hash: '', + state: null, + key: 'settings-fallback' + } as Location) const { setDbConnected, @@ -63,8 +77,14 @@ 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) // 锁定状态 // const [isLocked, setIsLocked] = useState(false) // Moved to store @@ -81,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 @@ -429,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 (
) } diff --git a/src/components/ChatAnalysisHeader.scss b/src/components/ChatAnalysisHeader.scss new file mode 100644 index 0000000..4e5e99f --- /dev/null +++ b/src/components/ChatAnalysisHeader.scss @@ -0,0 +1,136 @@ +.chat-analysis-header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 28px; + padding: 4px 0; + background: transparent; + border: none; + border-radius: 0; + flex-shrink: 0; +} + +.chat-analysis-back { + 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: 600; + + &:hover { + color: var(--text-primary); + } +} + +.chat-analysis-breadcrumb { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-secondary); + + .chat-analysis-breadcrumb-separator { + opacity: 0.6; + } +} + +.chat-analysis-dropdown { + position: relative; +} + +.chat-analysis-current-trigger { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: color 0.2s ease; + + .current { + color: var(--text-primary); + } + + svg { + transition: transform 0.2s ease; + } + + &:hover { + color: var(--text-primary); + } + + &.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); + } +} + +.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 new file mode 100644 index 0000000..112a15d --- /dev/null +++ b/src/components/ChatAnalysisHeader.tsx @@ -0,0 +1,105 @@ +import { ChevronDown, ChevronLeft } from 'lucide-react' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' +import './ChatAnalysisHeader.scss' + +export type ChatAnalysisMode = 'private' | 'group' + +interface ChatAnalysisHeaderProps { + currentMode: ChatAnalysisMode + actions?: ReactNode +} + +const MODE_CONFIG: Record = { + private: { + label: '私聊分析', + path: '/analytics/private' + }, + group: { + label: '群聊分析', + path: '/analytics/group' + } +} + +function ChatAnalysisHeader({ currentMode, actions }: 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 ( +
+
+ + / +
+ + + {menuOpen && ( +
+ +
+ )} +
+
+ + {actions ?
{actions}
: null} +
+ ) +} + +export default ChatAnalysisHeader diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..6d02c86 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,41 @@ +import { Component, ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error?: Error +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: any) { + console.error('ErrorBoundary caught:', error, errorInfo) + } + + render() { + if (this.state.hasError) { + return this.props.fallback || ( +
+

消息渲染出错

+

+ {this.state.error?.message || '未知错误'} +

+
+ ) + } + + return this.props.children + } +} diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index bdead0a..810815b 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -43,31 +43,52 @@ .sidebar-user-card-wrap { position: relative; margin: 0 12px 10px; + --sidebar-user-menu-width: 172px; } -.sidebar-user-clear-trigger { +.sidebar-user-menu { position: absolute; left: 0; - right: 0; + right: auto; bottom: calc(100% + 8px); + width: max(100%, var(--sidebar-user-menu-width)); z-index: 12; - border: 1px solid rgba(255, 59, 48, 0.28); + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-secondary-solid, var(--bg-primary)); + 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: var(--bg-secondary); - color: #d93025; - padding: 8px 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); + } } } @@ -244,26 +265,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 76547fb..e215448 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, 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({ @@ -279,6 +282,15 @@ function Sidebar() { setShowClearAccountDialog(true) } + const openSettingsFromAccountMenu = () => { + setIsAccountMenuOpen(false) + navigate('/settings', { + state: { + backgroundLocation: location + } + }) + } + const handleConfirmClearAccountData = async () => { if (!canConfirmClear || isClearingAccountData) return setIsClearingAccountData(true) @@ -375,24 +387,14 @@ function Sidebar() { 通讯录 - {/* 私聊分析 */} + {/* 聊天分析 */} - 私聊分析 - - - {/* 群聊分析 */} - - - 群聊分析 + 聊天分析 {/* 年度报告 */} @@ -427,16 +429,48 @@ function Sidebar() {
+ +
{isAccountMenuOpen && ( - +
+ + +
)}
- - - - - - - - 设置 - - -
{showClearAccountDialog && ( 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/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} +
) } diff --git a/src/pages/AnalyticsPage.scss b/src/pages/AnalyticsPage.scss index 73d7ca9..f905f4d 100644 --- a/src/pages/AnalyticsPage.scss +++ b/src/pages/AnalyticsPage.scss @@ -1,3 +1,15 @@ +.analytics-page-shell { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 100%; + + .loading-container, + .error-container { + flex: 1; + } +} + // 加载和错误状态 .loading-container, .error-container { @@ -53,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 1557679..a689089 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -1,11 +1,18 @@ -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' 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' +import ChatAnalysisHeader from '../components/ChatAnalysisHeader' interface ExcludeCandidate { username: string @@ -48,6 +55,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 +74,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() @@ -360,8 +417,28 @@ function AnalyticsPage() { } } + const renderPageShell = (content: ReactNode) => ( +
+ + {content} +
+ ) + + const analyticsHeaderActions = ( + <> + + + + ) + if (isLoading && !isLoaded) { - return ( + return renderPageShell(

{loadingStatus}

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

{error}

@@ -390,25 +467,18 @@ function AnalyticsPage() { } if (error && !isLoaded) { - return (

{error}

) + return renderPageShell( +
+

{error}

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

私聊分析

-
- - -
-
+
+
@@ -556,7 +626,7 @@ function AnalyticsPage() {
)} - +
) } 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/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..ab5fd0d 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -2,6 +2,12 @@ import { useState, useEffect, useRef } from 'react' import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react' import html2canvas from 'html2canvas' import { useThemeStore } from '../stores/themeStore' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import './AnnualReportWindow.scss' // SVG 背景图案 (用于导出) @@ -127,12 +133,6 @@ function AnnualReportWindow() { const { currentTheme, themeMode } = useThemeStore() - // 应用主题到独立窗口 - useEffect(() => { - document.documentElement.setAttribute('data-theme', currentTheme) - document.documentElement.setAttribute('data-mode', themeMode) - }, [currentTheme, themeMode]) - // Section refs const sectionRefs = { cover: useRef(null), @@ -164,6 +164,13 @@ function AnnualReportWindow() { }, []) const generateReport = async (year: number) => { + const taskId = registerBackgroundTask({ + sourcePage: 'annualReport', + title: '年度报告生成', + detail: `正在生成 ${formatYearLabel(year)} 年度报告`, + progressText: '初始化', + cancelable: true + }) setIsLoading(true) setError(null) setLoadingProgress(0) @@ -171,25 +178,46 @@ function AnnualReportWindow() { const removeProgressListener = window.electronAPI.annualReport.onProgress?.((payload: { status: string; progress: number }) => { setLoadingProgress(payload.progress) setLoadingStage(payload.status) + updateBackgroundTask(taskId, { + detail: payload.status || '正在生成年度报告', + progressText: `${Math.max(0, Math.round(payload.progress || 0))}%` + }) }) try { const result = await window.electronAPI.annualReport.generateReport(year) removeProgressListener?.() + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前报告结果未继续写入页面' + }) + setIsLoading(false) + return + } setLoadingProgress(100) setLoadingStage('完成') if (result.success && result.data) { + finishBackgroundTask(taskId, 'completed', { + detail: '年度报告生成完成', + progressText: '100%' + }) setTimeout(() => { setReportData(result.data!) setIsLoading(false) }, 300) } else { + finishBackgroundTask(taskId, 'failed', { + detail: result.error || '生成年度报告失败' + }) setError(result.error || '生成报告失败') setIsLoading(false) } } catch (e) { removeProgressListener?.() + finishBackgroundTask(taskId, 'failed', { + detail: String(e) + }) setError(String(e)) setIsLoading(false) } diff --git a/src/pages/ChatAnalyticsHubPage.scss b/src/pages/ChatAnalyticsHubPage.scss new file mode 100644 index 0000000..4d970cd --- /dev/null +++ b/src/pages/ChatAnalyticsHubPage.scss @@ -0,0 +1,123 @@ +.chat-analytics-hub-page { + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 40px 24px; +} + +.chat-analytics-hub-content { + width: min(860px, 100%); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.chat-analytics-hub-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 999px; + background: var(--primary-light); + color: var(--primary); + font-size: 13px; + font-weight: 600; +} + +.chat-analytics-hub-content h1 { + margin: 20px 0 12px; + font-size: 32px; + line-height: 1.2; + color: var(--text-primary); +} + +.chat-analytics-hub-desc { + max-width: 620px; + margin: 0 0 32px; + color: var(--text-secondary); + font-size: 15px; + line-height: 1.7; +} + +.chat-analytics-hub-grid { + width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} + +.chat-analytics-entry-card { + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: left; + gap: 14px; + min-height: 260px; + padding: 28px; + border: 1px solid var(--border-color); + border-radius: 20px; + background: + linear-gradient(180deg, rgba(7, 193, 96, 0.08) 0%, rgba(7, 193, 96, 0.02) 100%), + var(--card-bg); + color: var(--text-primary); + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; + + &:hover { + transform: translateY(-4px); + border-color: rgba(7, 193, 96, 0.35); + box-shadow: 0 20px 36px rgba(7, 193, 96, 0.12); + } + + .entry-card-icon { + width: 52px; + height: 52px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(7, 193, 96, 0.12); + color: #07c160; + + &.group { + background: rgba(24, 119, 242, 0.12); + color: #1877f2; + } + } + + .entry-card-header { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + h2 { + margin: 0; + font-size: 24px; + line-height: 1.2; + } + + p { + margin: 0; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.7; + } + + .entry-card-cta { + margin-top: auto; + color: var(--primary); + font-size: 13px; + font-weight: 600; + } +} + +@media (max-width: 900px) { + .chat-analytics-hub-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/pages/ChatAnalyticsHubPage.tsx b/src/pages/ChatAnalyticsHubPage.tsx new file mode 100644 index 0000000..6c4456e --- /dev/null +++ b/src/pages/ChatAnalyticsHubPage.tsx @@ -0,0 +1,59 @@ +import { ArrowRight, BarChart3, MessageSquare, Users } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import './ChatAnalyticsHubPage.scss' + +function ChatAnalyticsHubPage() { + const navigate = useNavigate() + + return ( +
+
+
+ + 聊天分析 +
+ +

选择你要进入的分析视角

+

+ 私聊分析更适合看好友聊天统计和趋势,群聊分析则用于查看群成员、发言排行和活跃时段。 +

+ +
+ + + +
+
+
+ ) +} + +export default ChatAnalyticsHubPage diff --git a/src/pages/ChatHistoryPage.scss b/src/pages/ChatHistoryPage.scss index 74c2af6..c108394 100644 --- a/src/pages/ChatHistoryPage.scss +++ b/src/pages/ChatHistoryPage.scss @@ -33,6 +33,16 @@ gap: 12px; align-items: flex-start; + &.error-item { + padding: 12px; + background: var(--bg-secondary); + border-radius: 8px; + color: var(--text-tertiary); + font-size: 13px; + text-align: center; + justify-content: center; + } + .avatar { width: 40px; height: 40px; diff --git a/src/pages/ChatHistoryPage.tsx b/src/pages/ChatHistoryPage.tsx index 45404e8..18c4f56 100644 --- a/src/pages/ChatHistoryPage.tsx +++ b/src/pages/ChatHistoryPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { useParams, useLocation } from 'react-router-dom' import { ChatRecordItem } from '../types/models' import TitleBar from '../components/TitleBar' +import { ErrorBoundary } from '../components/ErrorBoundary' import './ChatHistoryPage.scss' export default function ChatHistoryPage() { @@ -166,7 +167,9 @@ export default function ChatHistoryPage() {
暂无可显示的聊天记录
) : ( recordList.map((item, i) => ( - + 消息解析失败
}> + + )) )}
@@ -175,6 +178,8 @@ export default function ChatHistoryPage() { } function HistoryItem({ item }: { item: ChatRecordItem }) { + const [imageError, setImageError] = useState(false) + // sourcetime 在合并转发里有两种格式: // 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46" let time = '' @@ -197,19 +202,16 @@ function HistoryItem({ item }: { item: ChatRecordItem }) { if (src) { return (
- 图片 { - const target = e.target as HTMLImageElement - target.style.display = 'none' - const placeholder = document.createElement('div') - placeholder.className = 'media-tip' - placeholder.textContent = '图片无法加载' - target.parentElement?.appendChild(placeholder) - }} - /> + {imageError ? ( +
图片无法加载
+ ) : ( + 图片 setImageError(true)} + /> + )}
) } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index a663b67..357e1df 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -14,6 +14,12 @@ import JumpToDatePopover from '../components/JumpToDatePopover' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline' import * as configService from '../services/config' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import { emitOpenSingleExport, onExportSessionStatus, @@ -1067,6 +1073,13 @@ function ChatPage(props: ChatPageProps) { const loadSessionDetail = useCallback(async (sessionId: string) => { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId) return + const taskId = registerBackgroundTask({ + sourcePage: 'chat', + title: '聊天页会话详情统计', + detail: `准备读取 ${sessionMapRef.current.get(normalizedSessionId)?.displayName || normalizedSessionId} 的详情`, + progressText: '基础信息', + cancelable: true + }) const requestSeq = ++detailRequestSeqRef.current const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId) @@ -1130,8 +1143,23 @@ function ChatPage(props: ChatPageProps) { } try { + updateBackgroundTask(taskId, { + detail: '正在读取会话基础详情', + progressText: '基础信息' + }) const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId) - if (requestSeq !== detailRequestSeqRef.current) return + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前基础查询结束后未继续补充统计' + }) + return + } + if (requestSeq !== detailRequestSeqRef.current) { + finishBackgroundTask(taskId, 'canceled', { + detail: '会话已切换,旧详情任务已停止' + }) + return + } if (result.success && result.detail) { setSessionDetail((prev) => ({ wxid: normalizedSessionId, @@ -1170,6 +1198,10 @@ function ChatPage(props: ChatPageProps) { } try { + updateBackgroundTask(taskId, { + detail: '正在读取补充信息与导出统计', + progressText: '补充统计' + }) const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), window.electronAPI.chat.getExportSessionStats( @@ -1178,7 +1210,18 @@ function ChatPage(props: ChatPageProps) { ) ]) - if (requestSeq !== detailRequestSeqRef.current) return + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,补充统计结果未继续写入' + }) + return + } + if (requestSeq !== detailRequestSeqRef.current) { + finishBackgroundTask(taskId, 'canceled', { + detail: '会话已切换,旧补充统计任务已停止' + }) + return + } if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) { const detail = extraResultSettled.value.detail @@ -1214,8 +1257,15 @@ function ChatPage(props: ChatPageProps) { }) } } + finishBackgroundTask(taskId, 'completed', { + detail: '聊天页会话详情统计完成', + progressText: '已完成' + }) } catch (e) { console.error('加载会话详情补充统计失败:', e) + finishBackgroundTask(taskId, 'failed', { + detail: String(e) + }) } finally { if (requestSeq === detailRequestSeqRef.current) { setIsLoadingDetailExtra(false) @@ -1228,13 +1278,31 @@ function ChatPage(props: ChatPageProps) { if (!normalizedSessionId || isLoadingRelationStats) return const requestSeq = detailRequestSeqRef.current + const taskId = registerBackgroundTask({ + sourcePage: 'chat', + title: '聊天页关系统计补算', + detail: `正在补算 ${normalizedSessionId} 的共同好友与关联数据`, + progressText: '关系统计', + cancelable: true + }) setIsLoadingRelationStats(true) try { const relationResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } ) - if (requestSeq !== detailRequestSeqRef.current) return + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前关系统计查询结束后未继续刷新' + }) + return + } + if (requestSeq !== detailRequestSeqRef.current) { + finishBackgroundTask(taskId, 'canceled', { + detail: '会话已切换,旧关系统计任务已停止' + }) + return + } const metric = relationResult.success && relationResult.data ? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined @@ -1254,11 +1322,26 @@ function ChatPage(props: ChatPageProps) { setIsRefreshingDetailStats(true) void (async () => { try { + updateBackgroundTask(taskId, { + detail: '正在刷新关系统计结果', + progressText: '关系统计刷新' + }) const freshResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } ) - if (requestSeq !== detailRequestSeqRef.current) return + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,刷新结果未继续写入' + }) + return + } + if (requestSeq !== detailRequestSeqRef.current) { + finishBackgroundTask(taskId, 'canceled', { + detail: '会话已切换,旧关系统计刷新任务已停止' + }) + return + } if (freshResult.success && freshResult.data) { const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined @@ -1266,17 +1349,32 @@ function ChatPage(props: ChatPageProps) { applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true) } } + finishBackgroundTask(taskId, 'completed', { + detail: '聊天页关系统计补算完成', + progressText: '已完成' + }) } catch (error) { console.error('刷新会话关系统计失败:', error) + finishBackgroundTask(taskId, 'failed', { + detail: String(error) + }) } finally { if (requestSeq === detailRequestSeqRef.current) { setIsRefreshingDetailStats(false) } } })() + } else { + finishBackgroundTask(taskId, 'completed', { + detail: '聊天页关系统计补算完成', + progressText: '已完成' + }) } } catch (error) { console.error('加载会话关系统计失败:', error) + finishBackgroundTask(taskId, 'failed', { + detail: String(error) + }) } finally { if (requestSeq === detailRequestSeqRef.current) { setIsLoadingRelationStats(false) @@ -3225,7 +3323,7 @@ function ChatPage(props: ChatPageProps) { const handleGroupAnalytics = useCallback(() => { if (!currentSessionId || !isGroupChatSession(currentSessionId)) return - navigate('/group-analytics', { + navigate('/analytics/group', { state: { preselectGroupIds: [currentSessionId] } diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index f0ca4c9..ab340f4 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -254,6 +254,168 @@ } } +.session-load-detail-summary { + padding: 12px 12px 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.session-load-detail-summary-text { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); + + strong { + font-size: 18px; + color: var(--text-primary); + } + + em { + font-style: normal; + color: var(--text-tertiary); + } +} + +.session-load-detail-note { + margin: 8px 12px 0; + font-size: 12px; + line-height: 1.6; + color: var(--text-tertiary); +} + +.session-load-detail-stop-btn, +.session-load-detail-task-stop-btn { + border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color)); + border-radius: 8px; + background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--bg-secondary)); + color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary)); + cursor: pointer; + white-space: nowrap; + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.session-load-detail-stop-btn { + padding: 8px 12px; + font-size: 12px; +} + +.session-load-detail-task-list { + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.session-load-detail-task-item { + border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary)); + padding: 10px 12px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + + &.status-cancel_requested { + border-color: color-mix(in srgb, var(--warning, #f59e0b) 36%, var(--border-color)); + } + + &.status-failed { + border-color: color-mix(in srgb, var(--danger, #ef4444) 36%, var(--border-color)); + } +} + +.session-load-detail-task-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + + p { + margin: 0; + font-size: 12px; + line-height: 1.55; + color: var(--text-secondary); + } +} + +.session-load-detail-task-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); + + strong { + font-size: 13px; + color: var(--text-primary); + } +} + +.session-load-detail-task-source { + padding: 2px 8px; + border-radius: 999px; + background: var(--bg-secondary); + color: var(--text-secondary); +} + +.session-load-detail-task-status { + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--bg-secondary) 80%, transparent); + color: var(--text-secondary); + + &.status-running { + color: var(--primary); + background: rgba(var(--primary-rgb), 0.1); + } + + &.status-cancel_requested { + color: #b45309; + background: rgba(245, 158, 11, 0.14); + } + + &.status-completed { + color: #166534; + background: rgba(34, 197, 94, 0.14); + } + + &.status-failed { + color: #b91c1c; + background: rgba(239, 68, 68, 0.14); + } +} + +.session-load-detail-task-meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 11px; + color: var(--text-tertiary); +} + +.session-load-detail-task-stop-btn { + padding: 7px 10px; + font-size: 12px; + flex-shrink: 0; +} + +.session-load-detail-empty { + padding: 12px; + font-size: 12px; + color: var(--text-tertiary); +} + .session-load-detail-table { display: flex; flex-direction: column; @@ -744,6 +906,7 @@ width: 100%; border: none; background: transparent; + color: var(--text-primary); text-align: left; padding: 8px 10px; border-radius: 8px; @@ -765,6 +928,7 @@ .layout-option-label { font-size: 13px; font-weight: 600; + color: inherit; } .layout-option-desc { @@ -1399,15 +1563,10 @@ } .session-table-section { - border: 1px solid var(--border-color); - border-radius: 12px; - background: var(--card-bg); - padding: 12px; flex: 0 0 auto; min-height: 420px; display: flex; flex-direction: column; - gap: 10px; overflow: visible; } @@ -1458,20 +1617,31 @@ .table-tabs { display: flex; gap: 8px; - flex-wrap: wrap; + flex-wrap: nowrap; + align-items: center; .tab-btn { + flex: 0 0 auto; + width: auto; + max-width: max-content; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-secondary); - padding: 7px 12px; + padding: 7px 6px; border-radius: 999px; cursor: pointer; font-size: 13px; white-space: nowrap; display: inline-flex; align-items: center; - gap: 4px; + justify-content: center; + + .tab-btn-content { + display: inline-flex; + align-items: center; + gap: 4px; + line-height: 1; + } &.active { border-color: var(--primary); @@ -1547,14 +1717,21 @@ } .table-wrap { + --contacts-native-scrollbar-compensation: 18px; --contacts-row-height: 76px; --contacts-default-visible-rows: 10; --contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows)); --contacts-select-col-width: 34px; + --contacts-avatar-col-width: 44px; --contacts-inline-padding: 12px; + --contacts-column-gap: 12px; + --contacts-name-text-width: 10em; + --contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width)); + --contacts-left-sticky-width: calc(var(--contacts-select-col-width) + var(--contacts-main-col-width) + var(--contacts-column-gap)); --contacts-message-col-width: 120px; --contacts-media-col-width: 72px; --contacts-action-col-width: 140px; + --contacts-table-min-width: 1200px; overflow: hidden; border: 1px solid var(--border-color); border-radius: 10px; @@ -1563,24 +1740,51 @@ flex: 1; display: flex; flex-direction: column; + background: var(--bg-secondary); } .table-wrap { + .table-scroll-shell { + overflow: hidden; + } + + .table-scroll-viewport { + min-height: 0; + overflow-x: auto; + overflow-y: visible; + scrollbar-width: none; + -ms-overflow-style: none; + background: var(--bg-secondary); + padding-bottom: var(--contacts-native-scrollbar-compensation); + margin-bottom: calc(-1 * var(--contacts-native-scrollbar-compensation)); + + &::-webkit-scrollbar { + display: none; + } + } + + .table-scroll-content { + min-width: max(100%, var(--contacts-table-min-width)); + } + .session-table-sticky { position: sticky; top: 0; z-index: 20; - background: var(--card-bg); + background: var(--bg-secondary); } .loading-state, .empty-state { + width: 100%; + min-width: max(100%, var(--contacts-table-min-width)); flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; + background: var(--bg-secondary); color: var(--text-tertiary); font-size: 14px; @@ -1590,14 +1794,17 @@ } .load-issue-state { + width: 100%; + min-width: max(100%, var(--contacts-table-min-width)); flex: 1; padding: 14px; overflow-y: auto; + background: var(--bg-secondary); } .issue-card { border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color)); - background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg)); + background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--bg-secondary)); border-radius: 12px; padding: 14px; color: var(--text-primary); @@ -1671,7 +1878,7 @@ .issue-diagnostics { margin-top: 12px; border-radius: 8px; - background: var(--bg-primary); + background: color-mix(in srgb, var(--bg-secondary) 92%, var(--bg-primary)); border: 1px dashed var(--border-color); padding: 10px; font-size: 12px; @@ -1682,17 +1889,37 @@ } .contacts-list-header { + --contacts-header-bg: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary)); display: flex; align-items: center; - gap: 12px; + gap: var(--contacts-column-gap); padding: 10px var(--contacts-inline-padding) 8px; + min-width: max(100%, var(--contacts-table-min-width)); border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent); - background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary)); + background: var(--contacts-header-bg); font-size: 12px; color: var(--text-tertiary); font-weight: 600; letter-spacing: 0.01em; flex-shrink: 0; + + &.is-draggable { + cursor: grab; + } + + &.is-dragging { + cursor: grabbing; + user-select: none; + } + } + + .contacts-list-header-left { + display: flex; + align-items: center; + gap: var(--contacts-column-gap); + width: var(--contacts-left-sticky-width); + min-width: var(--contacts-left-sticky-width); + max-width: var(--contacts-left-sticky-width); } .contacts-list-header-select { @@ -1705,8 +1932,10 @@ } .contacts-list-header-main { - flex: 1; - min-width: 0; + flex: 0 0 var(--contacts-main-col-width); + width: var(--contacts-main-col-width); + min-width: var(--contacts-main-col-width); + max-width: var(--contacts-main-col-width); display: flex; align-items: center; gap: 8px; @@ -1721,21 +1950,30 @@ .contacts-list-header-count { width: var(--contacts-message-col-width); + min-width: var(--contacts-message-col-width); + display: flex; + align-items: center; + justify-content: center; text-align: center; flex-shrink: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + box-sizing: border-box; } .contacts-list-header-media { width: var(--contacts-media-col-width); min-width: var(--contacts-media-col-width); + display: flex; + align-items: center; + justify-content: center; text-align: center; flex-shrink: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + box-sizing: border-box; } .contacts-list-header-actions { @@ -1749,8 +1987,8 @@ flex-shrink: 0; position: sticky; right: 0; - z-index: 8; - background: var(--bg-primary); + z-index: 13; + background: var(--contacts-header-bg); white-space: nowrap; &::before { @@ -1761,30 +1999,70 @@ left: -8px; width: 8px; pointer-events: none; - background: linear-gradient(to right, transparent, var(--bg-primary)); + background: linear-gradient(to right, transparent, var(--contacts-header-bg)); } } .contacts-list { + width: 100%; + min-width: max(100%, var(--contacts-table-min-width)); flex: 1; min-height: var(--contacts-default-list-height); height: var(--contacts-default-list-height); - overflow: hidden; + position: relative; + overflow-x: clip; + overflow-y: auto; + overscroll-behavior: contain; padding: 0 0 12px; - } - - .contacts-virtuoso { - height: 100%; + background: var(--bg-secondary); &::-webkit-scrollbar { width: 6px; } - &::-webkit-scrollbar-thumb { - background: var(--text-tertiary); - border-radius: 3px; - opacity: 0.3; + &::-webkit-scrollbar-track { + background: transparent; } + + &::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--text-tertiary) 72%, transparent); + border-radius: 999px; + } + } + + .contacts-virtuoso { + width: 100%; + } + + .table-bottom-scrollbar { + flex: 0 0 auto; + overflow-x: auto; + overflow-y: hidden; + background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary)); + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--text-tertiary) 70%, transparent) transparent; + + &::-webkit-scrollbar { + height: 10px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--text-tertiary) 70%, transparent); + } + } + + .table-bottom-scrollbar-inner { + height: 1px; + } + + .table-bottom-scrollbar { + height: 16px; + border-top: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); } .selection-clear-btn { @@ -1848,30 +2126,50 @@ padding-bottom: 4px; &.selected .contact-item { - background: rgba(var(--primary-rgb), 0.08); + background: var(--contacts-row-bg); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 52%, transparent); } - &.selected .row-action-cell { - background: rgba(var(--primary-rgb), 0.08); + &.selected .contact-item:hover { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 60%, transparent); } } .contact-item { + --contacts-row-bg: var(--bg-secondary); display: flex; align-items: center; - gap: 12px; - padding: 12px var(--contacts-inline-padding); + gap: var(--contacts-column-gap); + padding: 12px 6px 12px var(--contacts-inline-padding); + min-width: max(100%, var(--contacts-table-min-width)); height: 72px; box-sizing: border-box; border-radius: 10px; transition: all 0.2s; cursor: default; + background: var(--contacts-row-bg); + box-shadow: inset 0 0 0 1px transparent; &:hover { - background: var(--bg-hover); + background: var(--contacts-row-bg); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-tertiary) 24%, transparent); } } + .row-left-sticky { + position: sticky; + left: 0; + z-index: 11; + display: flex; + align-items: center; + align-self: stretch; + gap: var(--contacts-column-gap); + width: var(--contacts-left-sticky-width); + min-width: var(--contacts-left-sticky-width); + max-width: var(--contacts-left-sticky-width); + background: var(--contacts-row-bg); + } + .row-select-cell { width: var(--contacts-select-col-width); min-width: var(--contacts-select-col-width); @@ -1905,8 +2203,10 @@ } .contact-info { - flex: 1; - min-width: 0; + flex: 0 0 var(--contacts-name-text-width); + width: var(--contacts-name-text-width); + min-width: var(--contacts-name-text-width); + max-width: var(--contacts-name-text-width); } .contact-name { @@ -1949,6 +2249,7 @@ gap: 4px; flex-shrink: 0; text-align: center; + box-sizing: border-box; } .row-media-metric { @@ -1959,6 +2260,7 @@ align-items: center; flex-shrink: 0; text-align: center; + box-sizing: border-box; } .row-media-metric-value { @@ -1982,6 +2284,7 @@ background: transparent; margin: 0; padding: 0; + width: 100%; min-height: 14px; display: inline-flex; align-items: center; @@ -2104,11 +2407,12 @@ min-width: 1300px; border-collapse: separate; border-spacing: 0; + background: var(--bg-secondary); thead th { position: sticky; top: 0; - background: color-mix(in srgb, var(--bg-primary) 75%, var(--bg-secondary)); + background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary)); z-index: 4; font-size: 12px; text-align: left; @@ -2231,24 +2535,16 @@ display: flex; flex-direction: column; align-items: flex-end; + justify-content: center; + align-self: stretch; gap: 4px; width: var(--contacts-action-col-width); + min-width: var(--contacts-action-col-width); flex-shrink: 0; position: sticky; right: 0; - z-index: 6; - background: var(--bg-primary); - - &::before { - content: ''; - position: absolute; - top: -12px; - bottom: -12px; - left: -8px; - width: 8px; - pointer-events: none; - background: linear-gradient(to right, transparent, var(--bg-primary)); - } + z-index: 10; + background: var(--contacts-row-bg); .row-action-main { display: inline-flex; @@ -3929,6 +4225,8 @@ .table-wrap { --contacts-inline-padding: 10px; + --contacts-name-text-width: 10em; + --contacts-main-col-width: calc(44px + 10px + var(--contacts-name-text-width)); --contacts-message-col-width: 104px; --contacts-media-col-width: 62px; --contacts-action-col-width: 140px; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 085af11..cd5183f 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type WheelEvent } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type UIEvent, type WheelEvent } from 'react' import { useLocation } from 'react-router-dom' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { createPortal } from 'react-dom' @@ -30,6 +30,7 @@ import { } from 'lucide-react' import type { ChatSession as AppChatSession, ContactInfo } from '../types/models' import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron' +import type { BackgroundTaskRecord } from '../types/backgroundTask' import * as configService from '../services/config' import { emitExportSessionStatus, @@ -37,6 +38,11 @@ import { onExportSessionStatusRequest, onOpenSingleExport } from '../services/exportBridge' +import { + requestCancelBackgroundTask, + requestCancelBackgroundTasks, + subscribeBackgroundTasks +} from '../services/backgroundTaskMonitor' import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' @@ -176,6 +182,24 @@ const contentTypeLabels: Record = { emoji: '表情包' } +const backgroundTaskSourceLabels: Record = { + export: '导出页', + chat: '聊天页', + analytics: '分析页', + sns: '朋友圈页', + groupAnalytics: '群分析页', + annualReport: '年度报告', + other: '其他页面' +} + +const backgroundTaskStatusLabels: Record = { + running: '运行中', + cancel_requested: '停止中', + completed: '已完成', + failed: '失败', + canceled: '已停止' +} + const conversationTabLabels: Record = { private: '私聊', group: '群聊', @@ -1422,6 +1446,7 @@ function ExportPage() { const [sessionMutualFriendsMetrics, setSessionMutualFriendsMetrics] = useState>({}) const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState(null) const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('') + const [backgroundTasks, setBackgroundTasks] = useState([]) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('B') @@ -1487,6 +1512,12 @@ function ExportPage() { const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [nowTick, setNowTick] = useState(Date.now()) const [isContactsListAtTop, setIsContactsListAtTop] = useState(true) + const [isContactsHeaderDragging, setIsContactsHeaderDragging] = useState(false) + const [contactsListScrollParent, setContactsListScrollParent] = useState(null) + const [contactsHorizontalScrollMetrics, setContactsHorizontalScrollMetrics] = useState({ + viewportWidth: 0, + contentWidth: 0 + }) const tabCounts = useContactTypeCountsStore(state => state.tabCounts) const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading) const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady) @@ -1508,6 +1539,16 @@ function ExportPage() { const contactsAvatarCacheRef = useRef>({}) const contactsVirtuosoRef = useRef(null) const sessionTableSectionRef = useRef(null) + const contactsHorizontalViewportRef = useRef(null) + const contactsHorizontalContentRef = useRef(null) + const contactsBottomScrollbarRef = useRef(null) + const contactsScrollSyncSourceRef = useRef<'viewport' | 'bottom' | null>(null) + const contactsHeaderDragStateRef = useRef({ + pointerId: -1, + startClientX: 0, + startScrollLeft: 0, + didDrag: false + }) const sessionFormatDropdownRef = useRef(null) const detailRequestSeqRef = useRef(0) const sessionsRef = useRef([]) @@ -1560,6 +1601,10 @@ function ExportPage() { endIndex: -1 }) + const handleContactsListScrollParentRef = useCallback((node: HTMLDivElement | null) => { + setContactsListScrollParent(prev => (prev === node ? prev : node)) + }, []) + const ensureExportCacheScope = useCallback(async (): Promise => { if (exportCacheScopeReadyRef.current) { return exportCacheScopeRef.current @@ -1903,6 +1948,10 @@ function ExportPage() { return () => window.clearInterval(timer) }, [contactsList.length, isContactsListLoading, contactsLoadIssue]) + useEffect(() => { + return subscribeBackgroundTasks(setBackgroundTasks) + }, []) + useEffect(() => { tasksRef.current = tasks }, [tasks]) @@ -3843,11 +3892,9 @@ function ExportPage() { if (scope === 'content' && contentType) { if (contentType === 'text') { - const fastTextFormat: TextExportFormat = options.format === 'excel' ? 'arkme-json' : options.format const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? options.exportConcurrency)) return { ...base, - format: fastTextFormat, contentType, exportConcurrency: textExportConcurrency, exportAvatars: base.exportAvatars, @@ -5491,6 +5538,16 @@ function ExportPage() { alert('复制失败,请手动复制诊断信息') } }, [contactsDiagnosticsText]) + const handleCancelBackgroundTask = useCallback((taskId: string) => { + requestCancelBackgroundTask(taskId) + }, []) + const handleCancelAllNonExportTasks = useCallback(() => { + requestCancelBackgroundTasks(task => ( + task.sourcePage !== 'export' && + task.cancelable && + (task.status === 'running' || task.status === 'cancel_requested') + )) + }, []) const sessionContactsUpdatedAtLabel = useMemo(() => { if (!sessionContactsUpdatedAt) return '' @@ -5563,6 +5620,36 @@ function ExportPage() { const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const taskCenterAlertCount = taskRunningCount + taskQueuedCount const hasFilteredContacts = filteredContacts.length > 0 + const contactsTableMinWidth = useMemo(() => { + const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + 140 + (8 * 12) + const snsWidth = shouldShowSnsColumn ? 72 + 12 : 0 + const mutualFriendsWidth = shouldShowMutualFriendsColumn ? 72 + 12 : 0 + return baseWidth + snsWidth + mutualFriendsWidth + }, [shouldShowMutualFriendsColumn, shouldShowSnsColumn]) + const contactsTableStyle = useMemo(() => ( + { + ['--contacts-table-min-width' as const]: `${contactsTableMinWidth}px` + } as CSSProperties + ), [contactsTableMinWidth]) + const hasContactsHorizontalOverflow = contactsHorizontalScrollMetrics.contentWidth - contactsHorizontalScrollMetrics.viewportWidth > 1 + const contactsBottomScrollbarInnerStyle = useMemo(() => ({ + width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px` + }), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth]) + const nonExportBackgroundTasks = useMemo(() => ( + backgroundTasks.filter(task => task.sourcePage !== 'export') + ), [backgroundTasks]) + const runningNonExportTaskCount = useMemo(() => ( + nonExportBackgroundTasks.filter(task => task.status === 'running' || task.status === 'cancel_requested').length + ), [nonExportBackgroundTasks]) + const cancelableNonExportTaskCount = useMemo(() => ( + nonExportBackgroundTasks.filter(task => ( + task.cancelable && + (task.status === 'running' || task.status === 'cancel_requested') + )).length + ), [nonExportBackgroundTasks]) + const nonExportBackgroundTasksUpdatedAt = useMemo(() => ( + nonExportBackgroundTasks.reduce((latest, task) => Math.max(latest, task.updatedAt || 0), 0) + ), [nonExportBackgroundTasks]) const sessionLoadDetailUpdatedAt = useMemo(() => { let latest = 0 for (const row of sessionLoadDetailRows) { @@ -5588,6 +5675,136 @@ function ExportPage() { row.mutualFriends.statusLabel.startsWith('加载中') )) ), [sessionLoadDetailRows]) + const syncContactsHorizontalScroll = useCallback((source: 'viewport' | 'bottom', scrollLeft: number) => { + if (contactsScrollSyncSourceRef.current && contactsScrollSyncSourceRef.current !== source) return + + contactsScrollSyncSourceRef.current = source + const viewport = contactsHorizontalViewportRef.current + const bottomScrollbar = contactsBottomScrollbarRef.current + + if (source !== 'viewport' && viewport && Math.abs(viewport.scrollLeft - scrollLeft) > 1) { + viewport.scrollLeft = scrollLeft + } + + if (source !== 'bottom' && bottomScrollbar && Math.abs(bottomScrollbar.scrollLeft - scrollLeft) > 1) { + bottomScrollbar.scrollLeft = scrollLeft + } + + window.requestAnimationFrame(() => { + if (contactsScrollSyncSourceRef.current === source) { + contactsScrollSyncSourceRef.current = null + } + }) + }, []) + const handleContactsHorizontalViewportScroll = useCallback((event: UIEvent) => { + syncContactsHorizontalScroll('viewport', event.currentTarget.scrollLeft) + }, [syncContactsHorizontalScroll]) + const handleContactsBottomScrollbarScroll = useCallback((event: UIEvent) => { + syncContactsHorizontalScroll('bottom', event.currentTarget.scrollLeft) + }, [syncContactsHorizontalScroll]) + const resetContactsHeaderDrag = useCallback((currentTarget?: HTMLDivElement | null) => { + const dragState = contactsHeaderDragStateRef.current + if (currentTarget && dragState.pointerId >= 0 && currentTarget.hasPointerCapture(dragState.pointerId)) { + currentTarget.releasePointerCapture(dragState.pointerId) + } + dragState.pointerId = -1 + dragState.startClientX = 0 + dragState.startScrollLeft = 0 + dragState.didDrag = false + setIsContactsHeaderDragging(false) + }, []) + const handleContactsHeaderPointerDown = useCallback((event: PointerEvent) => { + if (!hasContactsHorizontalOverflow || event.pointerType === 'touch') return + if (event.button !== 0) return + if (event.target instanceof Element && event.target.closest('button, a, input, textarea, select, label, [role="button"]')) { + return + } + + contactsHeaderDragStateRef.current = { + pointerId: event.pointerId, + startClientX: event.clientX, + startScrollLeft: contactsHorizontalViewportRef.current?.scrollLeft ?? 0, + didDrag: false + } + event.currentTarget.setPointerCapture(event.pointerId) + setIsContactsHeaderDragging(true) + }, [hasContactsHorizontalOverflow]) + const handleContactsHeaderPointerMove = useCallback((event: PointerEvent) => { + const dragState = contactsHeaderDragStateRef.current + if (dragState.pointerId !== event.pointerId) return + + const viewport = contactsHorizontalViewportRef.current + const content = contactsHorizontalContentRef.current + if (!viewport || !content) return + + const deltaX = event.clientX - dragState.startClientX + if (!dragState.didDrag && Math.abs(deltaX) < 4) return + + dragState.didDrag = true + const maxScrollLeft = Math.max(0, content.scrollWidth - viewport.clientWidth) + const nextScrollLeft = Math.max(0, Math.min(dragState.startScrollLeft - deltaX, maxScrollLeft)) + + viewport.scrollLeft = nextScrollLeft + syncContactsHorizontalScroll('viewport', nextScrollLeft) + event.preventDefault() + }, [syncContactsHorizontalScroll]) + const handleContactsHeaderPointerUp = useCallback((event: PointerEvent) => { + if (contactsHeaderDragStateRef.current.pointerId !== event.pointerId) return + resetContactsHeaderDrag(event.currentTarget) + }, [resetContactsHeaderDrag]) + const handleContactsHeaderPointerCancel = useCallback((event: PointerEvent) => { + if (contactsHeaderDragStateRef.current.pointerId !== event.pointerId) return + resetContactsHeaderDrag(event.currentTarget) + }, [resetContactsHeaderDrag]) + useEffect(() => { + const viewport = contactsHorizontalViewportRef.current + const content = contactsHorizontalContentRef.current + if (!viewport || !content) return + + const syncMetrics = () => { + const viewportWidth = Math.round(viewport.clientWidth) + const contentWidth = Math.round(content.scrollWidth) + + setContactsHorizontalScrollMetrics((prev) => ( + prev.viewportWidth === viewportWidth && prev.contentWidth === contentWidth + ? prev + : { viewportWidth, contentWidth } + )) + + const maxScrollLeft = Math.max(0, contentWidth - viewportWidth) + const clampedScrollLeft = Math.min(viewport.scrollLeft, maxScrollLeft) + + if (Math.abs(viewport.scrollLeft - clampedScrollLeft) > 1) { + viewport.scrollLeft = clampedScrollLeft + } + + const bottomScrollbar = contactsBottomScrollbarRef.current + if (bottomScrollbar) { + const nextScrollLeft = Math.min(bottomScrollbar.scrollLeft, maxScrollLeft) + if (Math.abs(bottomScrollbar.scrollLeft - nextScrollLeft) > 1) { + bottomScrollbar.scrollLeft = nextScrollLeft + } + if (Math.abs(nextScrollLeft - clampedScrollLeft) > 1) { + bottomScrollbar.scrollLeft = clampedScrollLeft + } + } + } + + syncMetrics() + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', syncMetrics) + return () => window.removeEventListener('resize', syncMetrics) + } + + const resizeObserver = new ResizeObserver(syncMetrics) + resizeObserver.observe(viewport) + resizeObserver.observe(content) + + return () => { + resizeObserver.disconnect() + } + }, []) const closeTaskCenter = useCallback(() => { setIsTaskCenterOpen(false) setExpandedPerfTaskId(null) @@ -5664,27 +5881,29 @@ function ExportPage() { return (
-
- -
-
- {contact.avatarUrl ? ( - - ) : ( - {getAvatarLetter(contact.displayName)} - )} -
-
-
{contact.displayName}
-
{contact.alias || contact.username}
+
+
+ +
+
+ {contact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(contact.displayName)} + )} +
+
+
{contact.displayName}
+
{contact.alias || contact.username}
+
@@ -5796,7 +6015,7 @@ function ExportPage() { }) }} > - {!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '单会话导出'} + {!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '导出'} {hasRecentExport && {recentExportTime}}
@@ -6115,157 +6334,198 @@ function ExportPage() {
-
-
-
-
- - - -
- -
-
- - setSearchKeyword(event.target.value)} - placeholder={`搜索${activeTabLabel}联系人...`} - /> - {searchKeyword && ( - - )} -
- -
+
+
+
+ + +
- {contactsList.length > 0 && isContactsListLoading && ( -
- - 联系人列表同步中… -
- )} - - {hasFilteredContacts && ( -
- - - - - {contactsHeaderMainLabel} - - 总消息数 - 表情包 - 语音 - 图片 - 视频 - {shouldShowSnsColumn && ( - 朋友圈 )} - {shouldShowMutualFriendsColumn && ( - 共同好友 - )} - - {selectedCount > 0 && ( - <> - - - - )} -
- )} + +
- {contactsList.length === 0 && contactsLoadIssue ? ( -
-
-
- - {contactsLoadIssue.title} +
+
+
+
+ {contactsList.length > 0 && isContactsListLoading && ( +
+ + 联系人列表同步中… +
+ )} + + {hasFilteredContacts && ( +
+ + + + + + {contactsHeaderMainLabel} + + + 总消息数 + 表情包 + 语音 + 图片 + 视频 + {shouldShowSnsColumn && ( + 朋友圈 + )} + {shouldShowMutualFriendsColumn && ( + 共同好友 + )} + + {selectedCount > 0 && ( + <> + + + + )} + +
+ )}
-

{contactsLoadIssue.message}

-

{contactsLoadIssue.reason}

-
    -
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • -
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • -
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • -
-
- - - -
- {showContactsDiagnostics && ( -
{contactsDiagnosticsText}
+ + {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} + /> +
)}
- ) : isContactsListLoading && contactsList.length === 0 ? ( -
- - 联系人加载中... -
- ) : !hasFilteredContacts ? ( -
- 暂无联系人 -
- ) : ( +
+ + {hasFilteredContacts && hasContactsHorizontalOverflow && (
- contact.username} - itemContent={renderContactRow} - rangeChanged={handleContactsRangeChanged} - atTopStateChange={setIsContactsListAtTop} - overscan={420} - /> +
)}
@@ -6303,6 +6563,67 @@ function ExportPage() {
+
+
其他页面后台任务
+
+
+ {runningNonExportTaskCount} + 个任务正在占用后台读取资源 + {nonExportBackgroundTasksUpdatedAt > 0 && ( + 最近更新时间 {new Date(nonExportBackgroundTasksUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false })} + )} +
+ +
+

+ 停止请求会阻止其他页面继续发起后续统计或补算;当前已经发出的单次查询,会在返回后结束。 +

+ {nonExportBackgroundTasks.length > 0 ? ( +
+ {nonExportBackgroundTasks.map((task) => ( +
+
+
+ + {backgroundTaskSourceLabels[task.sourcePage] || backgroundTaskSourceLabels.other} + + {task.title} + + {backgroundTaskStatusLabels[task.status]} + +
+

{task.detail || '暂无详细说明'}

+
+ 开始:{formatLoadDetailTime(task.startedAt)} + 更新:{formatLoadDetailTime(task.updatedAt)} + {task.progressText && 进度:{task.progressText}} +
+
+ +
+ ))} +
+ ) : ( +
+ 当前没有检测到其他页面后台任务 +
+ )} +
+
总消息数
diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index b9cd651..d7f5184 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -1,6 +1,14 @@ +.group-analytics-shell { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 100%; +} + .group-analytics-page { display: flex; - height: 100%; + flex: 1; + min-height: 0; gap: 16px; &.standalone { diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 05a616d..ae372ad 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -4,7 +4,14 @@ import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, C import { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' +import ChatAnalysisHeader from '../components/ChatAnalysisHeader' import * as configService from '../services/config' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import './GroupAnalyticsPage.scss' interface GroupChatInfo { @@ -176,15 +183,39 @@ function GroupAnalyticsPage() { }, []) const loadGroups = useCallback(async () => { + const taskId = registerBackgroundTask({ + sourcePage: 'groupAnalytics', + title: '群列表加载', + detail: '正在读取群聊列表', + progressText: '群聊列表', + cancelable: true + }) setIsLoading(true) try { const result = await window.electronAPI.groupAnalytics.getGroupChats() + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,群聊列表结果未继续写入' + }) + return + } if (result.success && result.data) { setGroups(result.data) setFilteredGroups(result.data) + finishBackgroundTask(taskId, 'completed', { + detail: `群聊列表加载完成,共 ${result.data.length} 个群`, + progressText: `${result.data.length} 个群` + }) + } else { + finishBackgroundTask(taskId, 'failed', { + detail: result.error || '加载群聊列表失败' + }) } } catch (e) { console.error(e) + finishBackgroundTask(taskId, 'failed', { + detail: String(e) + }) } finally { setIsLoading(false) } @@ -314,6 +345,13 @@ function GroupAnalyticsPage() { const loadFunctionData = async (func: AnalysisFunction) => { if (!selectedGroup) return + const taskId = registerBackgroundTask({ + sourcePage: 'groupAnalytics', + title: `群分析:${func}`, + detail: `正在读取 ${selectedGroup.displayName || selectedGroup.username} 的分析数据`, + progressText: func, + cancelable: true + }) setFunctionLoading(true) // 计算时间戳 @@ -323,33 +361,96 @@ function GroupAnalyticsPage() { try { switch (func) { case 'members': { + updateBackgroundTask(taskId, { + detail: '正在读取群成员列表', + progressText: '成员列表' + }) const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群成员列表未继续写入' }) + return + } if (result.success && result.data) setMembers(result.data) + finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { + detail: result.success ? `群成员列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取群成员列表失败'), + progressText: result.success ? `${result.data?.length || 0} 人` : '失败' + }) break } case 'memberExport': { + updateBackgroundTask(taskId, { + detail: '正在读取导出成员列表', + progressText: '成员导出' + }) const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员导出列表未继续写入' }) + return + } if (result.success && result.data) setMembers(result.data) + finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { + detail: result.success ? `成员导出列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取成员导出列表失败'), + progressText: result.success ? `${result.data?.length || 0} 人` : '失败' + }) break } case 'ranking': { + updateBackgroundTask(taskId, { + detail: '正在计算群消息排行', + progressText: '消息排行' + }) const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息排行未继续写入' }) + return + } if (result.success && result.data) setRankings(result.data) + finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { + detail: result.success ? `群消息排行加载完成,共 ${result.data?.length || 0} 条` : (result.error || '读取群消息排行失败'), + progressText: result.success ? `${result.data?.length || 0} 条` : '失败' + }) break } case 'activeHours': { + updateBackgroundTask(taskId, { + detail: '正在计算群活跃时段', + progressText: '活跃时段' + }) const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群活跃时段未继续写入' }) + return + } if (result.success && result.data) setActiveHours(result.data.hourlyDistribution) + finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { + detail: result.success ? '群活跃时段加载完成' : (result.error || '读取群活跃时段失败'), + progressText: result.success ? '24 小时分布' : '失败' + }) break } case 'mediaStats': { + updateBackgroundTask(taskId, { + detail: '正在统计群消息类型', + progressText: '消息类型' + }) const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息类型统计未继续写入' }) + return + } if (result.success && result.data) setMediaStats(result.data) + finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { + detail: result.success ? `群消息类型统计完成,共 ${result.data?.total || 0} 条` : (result.error || '读取群消息类型统计失败'), + progressText: result.success ? `${result.data?.total || 0} 条` : '失败' + }) break } } } catch (e) { console.error(e) + finishBackgroundTask(taskId, 'failed', { + detail: String(e) + }) } finally { setFunctionLoading(false) } @@ -1085,11 +1186,14 @@ function GroupAnalyticsPage() { } return ( -
- {renderGroupList()} -
setIsResizing(true)} /> -
- {renderDetailPanel()} +
+ +
+ {renderGroupList()} +
setIsResizing(true)} /> +
+ {renderDetailPanel()} +
{renderMemberModal()}
diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 8dc2525..fa710b6 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -1,17 +1,38 @@ +.settings-modal-overlay { + position: fixed; + top: 41px; + left: 0; + right: 0; + bottom: 0; + z-index: 2050; + display: flex; + align-items: center; + justify-content: center; + padding: 28px 32px; + background: rgba(15, 23, 42, 0.28); + backdrop-filter: blur(10px); +} + .settings-page { display: flex; flex-direction: column; - height: 100%; - margin: -24px; + width: min(1160px, calc(100vw - 96px)); + height: min(820px, calc(100vh - 120px)); + max-height: 100%; padding: 24px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 24px; + box-shadow: 0 28px 80px rgba(15, 23, 42, 0.22); overflow: hidden; } .settings-header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; - margin-bottom: 20px; + gap: 20px; + margin-bottom: 14px; flex-shrink: 0; h1 { @@ -22,51 +43,91 @@ } } +.settings-title-block { + display: flex; + flex-direction: column; +} + .settings-actions { display: flex; + align-items: center; gap: 12px; } +.settings-close-btn { + width: 36px; + height: 36px; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); + color: var(--text-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + border-color: rgba(139, 115, 85, 0.28); + } +} + +.settings-layout { + flex: 1; + min-height: 0; + display: flex; + gap: 20px; + overflow: hidden; +} + .settings-tabs { display: flex; - gap: 4px; - padding: 4px; - background: var(--bg-tertiary); - border-radius: 12px; - margin-bottom: 20px; - flex-shrink: 0; - width: fit-content; -} - -.tab-btn { - display: flex; - align-items: center; + flex-direction: column; gap: 6px; - padding: 10px 18px; - border: none; - border-radius: 8px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - background: transparent; - color: var(--text-secondary); + padding: 12px; + width: 220px; + flex-shrink: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 20px; + overflow-y: auto; - &:hover { - color: var(--text-primary); - background: var(--bg-secondary); - } + .tab-btn { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + justify-content: flex-start; + padding: 11px 14px; + border: none; + border-radius: 12px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + background: transparent; + color: var(--text-secondary); - &.active { - background: var(--card-bg); - color: var(--primary); - box-shadow: var(--shadow-sm); + &:hover { + color: var(--text-primary); + background: var(--bg-secondary); + } + + &.active { + background: var(--card-bg); + color: var(--primary); + box-shadow: var(--shadow-sm); + } } } .settings-body { flex: 1; overflow-y: auto; + min-width: 0; padding-right: 8px; &::-webkit-scrollbar { @@ -85,8 +146,10 @@ .tab-content { background: var(--bg-secondary); - border-radius: 16px; + border: 1px solid var(--border-color); + border-radius: 20px; padding: 24px; + min-height: 100%; .section-desc { font-size: 13px; @@ -932,7 +995,7 @@ padding: 10px 24px; border-radius: 9999px; font-size: 14px; - z-index: 100; + z-index: 2200; animation: slideDown 0.3s ease; &.success { @@ -946,6 +1009,27 @@ } } +@media (max-width: 960px) { + .settings-modal-overlay { + padding: 20px; + } + + .settings-page { + width: min(100%, calc(100vw - 40px)); + height: min(100%, calc(100vh - 82px)); + padding: 20px; + } + + .settings-layout { + flex-direction: column; + } + + .settings-tabs { + width: 100%; + max-height: 220px; + } +} + @keyframes slideDown { from { opacity: 0; @@ -1784,54 +1868,106 @@ .model-status-card { display: flex; - justify-content: space-between; - align-items: center; - gap: 16px; + flex-direction: column; + align-items: stretch; + gap: 14px; } .model-info { - flex: 1; + display: flex; + flex-direction: column; + gap: 10px; min-width: 0; + .model-name-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + .model-name { font-size: 15px; font-weight: 600; color: var(--text-primary); - margin-bottom: 6px; } - .model-path { + .model-size { + display: inline-flex; + align-items: center; + padding: 3px 9px; + border-radius: 999px; + background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + } + + .model-meta { display: flex; flex-direction: column; - gap: 4px; + align-items: flex-start; + gap: 10px; .status-indicator { display: inline-flex; align-items: center; gap: 4px; + width: fit-content; + padding: 4px 10px; + border-radius: 999px; font-size: 12px; - font-weight: 500; + font-weight: 600; &.success { + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.2); color: #10b981; } &.warning { + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.2); color: #f59e0b; } } + .model-path-block { + width: 100%; + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-secondary); + } + + .path-label { + font-size: 11px; + font-weight: 600; + color: var(--text-tertiary); + letter-spacing: 0.02em; + } + .path-text { font-size: 12px; color: var(--text-tertiary); font-family: monospace; - word-break: break-all; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } } } .model-actions { - flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-start; + padding-top: 14px; + border-top: 1px solid var(--border-color); .btn-download { display: inline-flex; @@ -1866,16 +2002,18 @@ .download-status { display: flex; flex-direction: column; - gap: 6px; - width: 280px; + gap: 8px; + width: 100%; + max-width: 420px; .status-header, .progress-info { - // specific layout class display: flex; justify-content: space-between; - align-items: center; // Align vertically + align-items: center; width: 100%; + gap: 12px; + flex-wrap: wrap; } .percent { @@ -1889,6 +2027,7 @@ .details { display: flex; align-items: center; + flex-wrap: wrap; gap: 6px; font-size: 12px; color: var(--text-secondary); @@ -1963,10 +2102,12 @@ .path-selector { display: flex; gap: 8px; + flex-wrap: wrap; input { margin-bottom: 0 !important; flex: 1; + min-width: 220px; font-family: monospace; font-size: 12px; } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 8afa61b..9c5e664 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -10,7 +10,7 @@ import { Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor, Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic, - ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2 + ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2, X } from 'lucide-react' import { Avatar } from '../components/Avatar' import './SettingsPage.scss' @@ -36,7 +36,11 @@ interface WxidOption { modifiedTime: number } -function SettingsPage() { +interface SettingsPageProps { + onClose?: () => void +} + +function SettingsPage({ onClose }: SettingsPageProps = {}) { const location = useLocation() const { isDbConnected, @@ -195,6 +199,17 @@ function SettingsPage() { setActiveTab(initialTab) }, [location.state]) + useEffect(() => { + if (!onClose) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + useEffect(() => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => { setDbKeyStatus(payload.message) @@ -1410,6 +1425,8 @@ function SettingsPage() {
) + const resolvedWhisperModelPath = whisperModelDir || whisperModelStatus?.modelPath || '' + const renderModelsTab = () => (
@@ -1424,42 +1441,52 @@ function SettingsPage() {
-
SenseVoiceSmall (245 MB)
-
+
+
SenseVoiceSmall
+ 245 MB +
+
{whisperModelStatus?.exists ? ( 已安装 ) : ( 未安装 )} - {whisperModelDir &&
{whisperModelDir}
} + {resolvedWhisperModelPath && ( +
+ 模型目录 +
{resolvedWhisperModelPath}
+
+ )}
-
- {!whisperModelStatus?.exists && !isWhisperDownloading && ( - - )} - {isWhisperDownloading && ( -
-
- {Math.round(whisperDownloadProgress)}% - {whisperProgressData.total > 0 && ( - - {formatBytes(whisperProgressData.downloaded)} / {formatBytes(whisperProgressData.total)} - ({formatBytes(whisperProgressData.speed)}/s) - - )} + {(!whisperModelStatus?.exists || isWhisperDownloading) && ( +
+ {!whisperModelStatus?.exists && !isWhisperDownloading && ( + + )} + {isWhisperDownloading && ( +
+
+ {Math.round(whisperDownloadProgress)}% + {whisperProgressData.total > 0 && ( + + {formatBytes(whisperProgressData.downloaded)} / {formatBytes(whisperProgressData.total)} + ({formatBytes(whisperProgressData.speed)}/s) + + )} +
+
+
+
-
-
-
-
- )} -
+ )} +
+ )}
@@ -2049,66 +2076,80 @@ function SettingsPage() { ) return ( -
- {message &&
{message.text}
} +
onClose?.()}> +
event.stopPropagation()}> + {message &&
{message.text}
} - {/* 多账号选择对话框 */} - {showWxidSelect && wxidOptions.length > 1 && ( -
setShowWxidSelect(false)}> -
e.stopPropagation()}> -
-

检测到多个微信账号

-

请选择要使用的账号

-
-
- {wxidOptions.map((opt) => ( -
handleSelectWxid(opt.wxid)} - > - {opt.wxid} - 最后修改 {new Date(opt.modifiedTime).toLocaleString()} -
- ))} -
-
- + {/* 多账号选择对话框 */} + {showWxidSelect && wxidOptions.length > 1 && ( +
setShowWxidSelect(false)}> +
e.stopPropagation()}> +
+

检测到多个微信账号

+

请选择要使用的账号

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

设置

-
- +
+
+

设置

+
+
+ + {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()} -
-
) } diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index d6d24ad..45fcfbc 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1,7 +1,7 @@ /* Global Variables */ :root { --sns-max-width: 800px; - --sns-panel-width: 320px; + --sns-panel-width: 380px; --sns-bg-color: var(--bg-primary); --sns-card-bg: var(--bg-secondary); --sns-border-radius-lg: 16px; @@ -263,6 +263,48 @@ padding-top: 16px; } +.feed-contact-filter-bar { + margin: 10px 4px 0; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--primary) 28%, var(--border-color)); + border-radius: 12px; + background: rgba(var(--primary-rgb), 0.08); + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + + .feed-contact-filter-label { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + } + + .feed-contact-filter-summary { + font-size: 13px; + color: var(--text-primary); + font-weight: 600; + min-width: 0; + } + + .feed-contact-filter-clear { + margin-left: auto; + border: none; + background: transparent; + color: var(--primary); + font-size: 12px; + font-weight: 600; + cursor: pointer; + padding: 0; + white-space: nowrap; + + &:hover { + text-decoration: underline; + text-underline-offset: 2px; + } + } +} + .posts-list { display: flex; flex-direction: column; @@ -1211,6 +1253,13 @@ font-variant-numeric: tabular-nums; } + .contact-interaction-hint { + padding: 10px 16px 0; + font-size: 11px; + line-height: 1.5; + color: var(--text-tertiary); + } + .contact-list-scroll { flex: 1; overflow-y: auto; @@ -1218,23 +1267,75 @@ display: flex; flex-direction: column; gap: 0; - /* Remove gap to allow borders to merge */ .contact-row { display: flex; align-items: center; - gap: 12px; - padding: 10px; - border-radius: var(--sns-border-radius-md); - cursor: pointer; - transition: background 0.2s ease, transform 0.2s ease; - border: 1px solid transparent; + gap: 8px; margin-bottom: 4px; + border-radius: var(--sns-border-radius-md); + transition: transform 0.2s ease; &:hover { - background: var(--hover-bg); transform: translateX(2px); - z-index: 10; + } + + &.is-selected .contact-main-btn { + background: rgba(var(--primary-rgb), 0.06); + border-color: color-mix(in srgb, var(--primary) 20%, var(--border-color)); + } + + &.is-active .contact-main-btn { + background: rgba(var(--primary-rgb), 0.12); + border-color: color-mix(in srgb, var(--primary) 48%, var(--border-color)); + box-shadow: inset 0 0 0 1px rgba(var(--primary-rgb), 0.18); + } + + &.is-active .contact-name { + color: var(--text-primary); + } + + .contact-select-btn { + width: 32px; + height: 32px; + flex-shrink: 0; + border: none; + background: transparent; + border-radius: 8px; + color: var(--text-tertiary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease; + + &:hover { + background: rgba(var(--primary-rgb), 0.1); + color: var(--primary); + } + + &.checked { + color: var(--primary); + } + } + + .contact-main-btn { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: var(--sns-border-radius-md); + border: 1px solid transparent; + background: transparent; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease; + text-align: left; + + &:hover { + background: var(--hover-bg); + } } .contact-meta { @@ -1282,6 +1383,51 @@ } } + .contact-batch-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px 14px; + border-top: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary)); + } + + .contact-batch-summary { + flex: 1; + min-width: 0; + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + } + + .contact-batch-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + border-radius: var(--sns-border-radius-md); + height: 32px; + padding: 0 10px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + font-size: 12px; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease; + + &:hover { + border-color: var(--text-tertiary); + background: var(--hover-bg); + color: var(--text-primary); + } + + &.primary { + border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color)); + background: rgba(var(--primary-rgb), 0.12); + color: var(--primary); + } + } + .empty-state { text-align: center; color: var(--text-tertiary); diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 0482240..fb8f009 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -9,6 +9,12 @@ import type { ContactSnsTimelineTarget } from '../components/Sns/contactSnsTimel import JumpToDatePopover from '../components/JumpToDatePopover' import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' import * as configService from '../services/config' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import { createExportDateRangeSelectionFromPreset, getExportDateRangeLabel, @@ -57,6 +63,7 @@ interface SnsOverviewStats { } type OverviewStatsStatus = 'loading' | 'ready' | 'error' +type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] } const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' @@ -117,6 +124,7 @@ export default function SnsPage() { total: 0, running: false }) + const [selectedContactUsernames, setSelectedContactUsernames] = useState([]) const [currentUserProfile, setCurrentUserProfile] = useState(() => readSidebarUserProfileCache() || { wxid: '', displayName: '' @@ -134,6 +142,7 @@ export default function SnsPage() { // 导出相关状态 const [showExportDialog, setShowExportDialog] = useState(false) + const [exportScope, setExportScope] = useState({ kind: 'all' }) const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html') const [exportFolder, setExportFolder] = useState('') const [exportImages, setExportImages] = useState(false) @@ -164,9 +173,11 @@ export default function SnsPage() { const overviewStatsStatusRef = useRef(overviewStatsStatus) const searchKeywordRef = useRef(searchKeyword) const jumpTargetDateRef = useRef(jumpTargetDate) + const selectedContactUsernamesRef = useRef(selectedContactUsernames) const cacheScopeKeyRef = useRef('') const snsUserPostCountsCacheScopeKeyRef = useRef('') const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) + const pendingResetFeedRef = useRef(false) const contactsLoadTokenRef = useRef(0) const contactsCountHydrationTokenRef = useRef(0) const contactsCountBatchTimerRef = useRef(null) @@ -180,6 +191,13 @@ export default function SnsPage() { useEffect(() => { contactsRef.current = contacts }, [contacts]) + useEffect(() => { + const contactLookup = new Set(contacts.map((contact) => contact.username)) + setSelectedContactUsernames((prev) => { + const next = prev.filter((username) => contactLookup.has(username)) + return next.length === prev.length ? prev : next + }) + }, [contacts]) useEffect(() => { overviewStatsRef.current = overviewStats }, [overviewStats]) @@ -192,6 +210,9 @@ export default function SnsPage() { useEffect(() => { jumpTargetDateRef.current = jumpTargetDate }, [jumpTargetDate]) + useEffect(() => { + selectedContactUsernamesRef.current = selectedContactUsernames + }, [selectedContactUsernames]) useEffect(() => { if (!showJumpPopover) { setJumpPopoverDate(jumpTargetDate || new Date()) @@ -370,6 +391,31 @@ export default function SnsPage() { return contacts.find((contact) => contact.username === normalizedTargetUsername) || null }, [authorTimelineTarget, contacts]) + const exportSelectedContactsSummary = useMemo(() => { + if (exportScope.kind !== 'selected' || exportScope.usernames.length === 0) return '' + const contactMap = new Map(contacts.map((contact) => [contact.username, contact])) + const names = exportScope.usernames.map((username) => contactMap.get(username)?.displayName || username) + if (names.length <= 2) return names.join('、') + return `${names.slice(0, 2).join('、')} 等 ${names.length} 位联系人` + }, [contacts, exportScope]) + + const selectedFeedContactsSummary = useMemo(() => { + if (selectedContactUsernames.length === 0) return '' + const contactMap = new Map(contacts.map((contact) => [contact.username, contact])) + const names = selectedContactUsernames.map((username) => contactMap.get(username)?.displayName || username) + if (names.length <= 2) return names.join('、') + return `${names.slice(0, 2).join('、')} 等 ${names.length} 人` + }, [contacts, selectedContactUsernames]) + + const selectedContactUsernameSet = useMemo(() => ( + new Set(selectedContactUsernames.map((username) => normalizeAccountId(username))) + ), [selectedContactUsernames]) + + const visiblePosts = useMemo(() => { + if (selectedContactUsernameSet.size === 0) return posts + return posts.filter((post) => selectedContactUsernameSet.has(normalizeAccountId(post.username))) + }, [posts, selectedContactUsernameSet]) + const myTimelineCount = useMemo(() => { if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') { return normalizePostCount(resolvedCurrentUserContact.postCount) @@ -383,6 +429,10 @@ export default function SnsPage() { : overviewStatsStatus === 'loading' || contactsLoading ) + const canStartExport = Boolean(exportFolder) && !isExporting && ( + exportScope.kind === 'all' || exportScope.usernames.length > 0 + ) + const openCurrentUserTimeline = useCallback(() => { if (!resolvedCurrentUserContact) return setAuthorTimelineTarget({ @@ -393,7 +443,11 @@ export default function SnsPage() { }, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact]) const isDefaultViewNow = useCallback(() => { - return !searchKeywordRef.current.trim() && !jumpTargetDateRef.current + return ( + !searchKeywordRef.current.trim() && + !jumpTargetDateRef.current && + selectedContactUsernamesRef.current.length === 0 + ) }, []) const ensureSnsCacheScopeKey = useCallback(async () => { @@ -555,9 +609,23 @@ export default function SnsPage() { const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDateRangeSelection), [exportDateRangeSelection]) + const openExportDialog = useCallback((scope: SnsExportScope) => { + setExportScope(scope) + setExportResult(null) + setExportProgress(null) + setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all')) + setIsExportDateRangeDialogOpen(false) + setShowExportDialog(true) + }, []) + const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => { const { reset = false, direction = 'older' } = options - if (loadingRef.current) return + if (loadingRef.current) { + if (reset) { + pendingResetFeedRef.current = true + } + return + } loadingRef.current = true if (direction === 'newer') setLoadingNewer(true) @@ -565,13 +633,19 @@ export default function SnsPage() { try { const limit = 20 + const currentSearchKeyword = searchKeywordRef.current + const currentJumpTargetDate = jumpTargetDateRef.current + const currentSelectedContactUsernames = selectedContactUsernamesRef.current + const selectedUsernames = currentSelectedContactUsernames.length > 0 + ? [...currentSelectedContactUsernames] + : undefined let startTs: number | undefined = undefined let endTs: number | undefined = undefined if (reset) { // If jumping to date, set endTs to end of that day - if (jumpTargetDate) { - endTs = Math.floor(jumpTargetDate.getTime() / 1000) + 86399 + if (currentJumpTargetDate) { + endTs = Math.floor(currentJumpTargetDate.getTime() / 1000) + 86399 } } else if (direction === 'newer') { const currentPosts = postsRef.current @@ -581,8 +655,8 @@ export default function SnsPage() { const result = await window.electronAPI.sns.getTimeline( limit, 0, - undefined, - searchKeyword, + selectedUsernames, + currentSearchKeyword, topTs + 1, undefined ); @@ -622,8 +696,8 @@ export default function SnsPage() { const result = await window.electronAPI.sns.getTimeline( limit, 0, - undefined, - searchKeyword, + selectedUsernames, + currentSearchKeyword, startTs, // default undefined endTs ) @@ -637,7 +711,7 @@ export default function SnsPage() { // Check for newer items above topTs const topTs = result.timeline[0]?.createTime || 0; if (topTs > 0) { - const checkResult = await window.electronAPI.sns.getTimeline(1, 0, undefined, searchKeyword, topTs + 1, undefined); + const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, currentSearchKeyword, topTs + 1, undefined); setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0)); } else { setHasNewer(false); @@ -663,8 +737,12 @@ export default function SnsPage() { setLoading(false) setLoadingNewer(false) loadingRef.current = false + if (pendingResetFeedRef.current) { + pendingResetFeedRef.current = false + void loadPosts({ reset: true }) + } } - }, [jumpTargetDate, persistSnsPageCache, searchKeyword]) + }, [persistSnsPageCache]) const stopContactsCountHydration = useCallback((resetProgress = false) => { contactsCountHydrationTokenRef.current += 1 @@ -728,9 +806,23 @@ export default function SnsPage() { }) if (pendingTargets.length === 0) return + const taskId = registerBackgroundTask({ + sourcePage: 'sns', + title: '朋友圈联系人计数补算', + detail: `正在补算 ${pendingTargets.length} 个联系人朋友圈条数`, + progressText: `${preResolved}/${totalTargets}`, + cancelable: true + }) + let normalizedCounts: Record = {} try { const result = await window.electronAPI.sns.getUserPostCounts() + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前计数查询结束后不再继续分批写入' + }) + return + } if (runToken !== contactsCountHydrationTokenRef.current) return if (result.success && result.counts) { normalizedCounts = Object.fromEntries( @@ -747,12 +839,28 @@ export default function SnsPage() { } } catch (error) { console.error('Failed to load contact post counts:', error) + finishBackgroundTask(taskId, 'failed', { + detail: String(error) + }) + return } let resolved = preResolved let cursor = 0 const applyBatch = () => { if (runToken !== contactsCountHydrationTokenRef.current) return + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: `已停止后续加载,已完成 ${resolved}/${totalTargets}` + }) + contactsCountBatchTimerRef.current = null + setContactsCountProgress({ + resolved, + total: totalTargets, + running: false + }) + return + } const batch = pendingTargets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE) if (batch.length === 0) { @@ -762,6 +870,10 @@ export default function SnsPage() { running: false }) contactsCountBatchTimerRef.current = null + finishBackgroundTask(taskId, 'completed', { + detail: '联系人朋友圈条数补算完成', + progressText: `${totalTargets}/${totalTargets}` + }) return } @@ -789,6 +901,10 @@ export default function SnsPage() { total: totalTargets, running: resolved < totalTargets }) + updateBackgroundTask(taskId, { + detail: `已完成 ${resolved}/${totalTargets} 个联系人朋友圈条数补算`, + progressText: `${resolved}/${totalTargets}` + }) if (cursor < totalTargets) { contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS) @@ -803,6 +919,13 @@ export default function SnsPage() { // Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序) const loadContacts = useCallback(async () => { const requestToken = ++contactsLoadTokenRef.current + const taskId = registerBackgroundTask({ + sourcePage: 'sns', + title: '朋友圈联系人列表加载', + detail: '准备读取联系人缓存与最近会话', + progressText: '初始化', + cancelable: true + }) stopContactsCountHydration(true) setContactsLoading(true) try { @@ -845,10 +968,20 @@ export default function SnsPage() { }) } + updateBackgroundTask(taskId, { + detail: '正在读取联系人与最近会话数据', + progressText: '联系人快照' + }) const [contactsResult, sessionsResult] = await Promise.all([ window.electronAPI.chat.getContacts(), window.electronAPI.chat.getSessions() ]) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前联系人查询结束后未继续补齐' + }) + return + } const contactMap = new Map() const sessionTimestampMap = new Map() @@ -904,7 +1037,17 @@ export default function SnsPage() { // 用 enrichSessionsContactInfo 统一补充头像和显示名 if (allUsernames.length > 0) { + updateBackgroundTask(taskId, { + detail: '正在补齐联系人显示名与头像', + progressText: '联系人补齐' + }) const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,联系人补齐未继续写入' + }) + return + } if (enriched.success && enriched.contacts) { contactsList = contactsList.map((contact) => { const extra = enriched.contacts?.[contact.username] @@ -931,10 +1074,17 @@ export default function SnsPage() { }) } } + finishBackgroundTask(taskId, 'completed', { + detail: `朋友圈联系人列表加载完成,共 ${contactsList.length} 人`, + progressText: `${contactsList.length} 人` + }) } catch (error) { if (requestToken !== contactsLoadTokenRef.current) return console.error('Failed to load contacts:', error) stopContactsCountHydration(true) + finishBackgroundTask(taskId, 'failed', { + detail: String(error) + }) } finally { if (requestToken === contactsLoadTokenRef.current) { setContactsLoading(false) @@ -962,6 +1112,23 @@ export default function SnsPage() { }) }, []) + const toggleContactSelected = useCallback((contact: Contact) => { + setSelectedContactUsernames((prev) => ( + prev.includes(contact.username) + ? prev.filter((username) => username !== contact.username) + : [...prev, contact.username] + )) + }, []) + + const clearSelectedContacts = useCallback(() => { + setSelectedContactUsernames([]) + }, []) + + const openSelectedContactsExport = useCallback(() => { + if (selectedContactUsernames.length === 0) return + openExportDialog({ kind: 'selected', usernames: [...selectedContactUsernames] }) + }, [openExportDialog, selectedContactUsernames]) + const handlePostDelete = useCallback((postId: string, username: string) => { setPosts(prev => { const next = prev.filter(p => p.id !== postId) @@ -1029,6 +1196,7 @@ export default function SnsPage() { stopContactsCountHydration(true) setContacts([]) setPosts([]); setHasMore(true); setHasNewer(false); + setSelectedContactUsernames([]) setSearchKeyword(''); setJumpTargetDate(undefined); void hydrateSnsPageCache() loadContacts(); @@ -1046,6 +1214,21 @@ export default function SnsPage() { return () => clearTimeout(timer) }, [searchKeyword, jumpTargetDate, loadPosts]) + const selectedContactUsernamesKey = useMemo( + () => selectedContactUsernames.join('||'), + [selectedContactUsernames] + ) + + const hasInitializedSelectedFeedFilterRef = useRef(false) + + useEffect(() => { + if (!hasInitializedSelectedFeedFilterRef.current) { + hasInitializedSelectedFeedFilterRef.current = true + return + } + loadPosts({ reset: true }) + }, [loadPosts, selectedContactUsernamesKey]) + const handleScroll = (e: React.UIEvent) => { const { scrollTop, clientHeight, scrollHeight } = e.currentTarget if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { @@ -1186,13 +1369,7 @@ export default function SnsPage() {
+ {selectedContactUsernames.length > 0 && ( +
+ 仅显示 + {selectedFeedContactsSummary} 的动态 + +
+ )} +
{loadingNewer && (
@@ -1229,7 +1420,7 @@ export default function SnsPage() { )}
- {posts.map(post => ( + {visiblePosts.map(post => ( - {loading && posts.length === 0 && ( + {loading && visiblePosts.length === 0 && (
@@ -1256,24 +1447,26 @@ export default function SnsPage() {
)} - {loading && posts.length > 0 && ( + {loading && visiblePosts.length > 0 && (
正在加载更多...
)} - {!hasMore && posts.length > 0 && ( + {!hasMore && visiblePosts.length > 0 && (
或许过往已无可溯洄,但好在还有可以与你相遇的明天
)} - {!loading && posts.length === 0 && ( + {!loading && visiblePosts.length === 0 && (

未找到相关动态

- {(searchKeyword || jumpTargetDate) && ( + {(searchKeyword || jumpTargetDate || selectedContactUsernames.length > 0) && ( @@ -1299,7 +1492,12 @@ export default function SnsPage() { setContactSearch={setContactSearch} loading={contactsLoading} contactsCountProgress={contactsCountProgress} + selectedContactUsernames={selectedContactUsernames} + activeContactUsername={authorTimelineTarget?.username} onOpenContactTimeline={openContactTimeline} + onToggleContactSelected={toggleContactSelected} + onClearSelectedContacts={clearSelectedContacts} + onExportSelectedContacts={openSelectedContactsExport} /> {/* Dialogs and Overlays */} @@ -1444,9 +1642,12 @@ export default function SnsPage() {
{/* 筛选条件提示 */} - {searchKeyword && ( + {(searchKeyword || exportScope.kind === 'selected') && (
- 筛选导出 + 导出范围 + {exportScope.kind === 'selected' && ( + 联系人: {exportSelectedContactsSummary} + )} {searchKeyword && 关键词: "{searchKeyword}"}
)} @@ -1572,7 +1773,7 @@ export default function SnsPage() { {/* 同步提示 */}
- 将同步主页面的关键词搜索 + {exportScope.kind === 'selected' ? '将同步主页面的关键词搜索,并仅导出所选联系人' : '将同步主页面的关键词搜索'}
{/* 进度条 */} @@ -1599,7 +1800,7 @@ export default function SnsPage() {