Merge pull request #406 from hicccc77/dev

Dev
This commit is contained in:
cc
2026-03-10 22:21:10 +08:00
committed by GitHub
31 changed files with 2993 additions and 606 deletions

View File

@@ -1800,6 +1800,26 @@ class ExportService {
else if (appMsgKind === 'quote') meta.appMsgType = '57' else if (appMsgKind === 'quote') meta.appMsgType = '57'
if (appMsgKind) meta.appMsgKind = appMsgKind 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') { if (appMsgKind === 'quote') {
const quoteInfo = this.parseQuoteMessage(normalized) const quoteInfo = this.parseQuoteMessage(normalized)
if (quoteInfo.content) meta.quotedContent = quoteInfo.content if (quoteInfo.content) meta.quotedContent = quoteInfo.content
@@ -1807,6 +1827,18 @@ class ExportService {
if (quoteInfo.type) meta.quotedType = quoteInfo.type 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) { if (isMusic) {
const musicTitle = const musicTitle =
this.extractXmlValue(normalized, 'songname') || this.extractXmlValue(normalized, 'songname') ||
@@ -3906,9 +3938,10 @@ class ExportService {
const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType) const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType)
if (appMsgMeta) { if (appMsgMeta) {
if (options.format === 'arkme-json') { if (
Object.assign(msgObj, appMsgMeta) options.format === 'arkme-json' ||
} else if (options.format === 'json' && appMsgMeta.appMsgKind === 'quote') { (options.format === 'json' && (appMsgMeta.appMsgKind === 'quote' || appMsgMeta.appMsgKind === 'link'))
) {
Object.assign(msgObj, appMsgMeta) Object.assign(msgObj, appMsgMeta)
} }
} }
@@ -4100,9 +4133,17 @@ class ExportService {
if (message.locationLabel) compactMessage.locationLabel = message.locationLabel if (message.locationLabel) compactMessage.locationLabel = message.locationLabel
if (message.appMsgType) compactMessage.appMsgType = message.appMsgType if (message.appMsgType) compactMessage.appMsgType = message.appMsgType
if (message.appMsgKind) compactMessage.appMsgKind = message.appMsgKind 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.quotedContent) compactMessage.quotedContent = message.quotedContent
if (message.quotedSender) compactMessage.quotedSender = message.quotedSender if (message.quotedSender) compactMessage.quotedSender = message.quotedSender
if (message.quotedType) compactMessage.quotedType = message.quotedType 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.finderTitle) compactMessage.finderTitle = message.finderTitle
if (message.finderDesc) compactMessage.finderDesc = message.finderDesc if (message.finderDesc) compactMessage.finderDesc = message.finderDesc
if (message.finderUsername) compactMessage.finderUsername = message.finderUsername if (message.finderUsername) compactMessage.finderUsername = message.finderUsername

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom' import { Routes, Route, Navigate, useNavigate, useLocation, type Location } from 'react-router-dom'
import TitleBar from './components/TitleBar' import TitleBar from './components/TitleBar'
import Sidebar from './components/Sidebar' import Sidebar from './components/Sidebar'
import RouteGuard from './components/RouteGuard' import RouteGuard from './components/RouteGuard'
@@ -8,6 +8,7 @@ import HomePage from './pages/HomePage'
import ChatPage from './pages/ChatPage' import ChatPage from './pages/ChatPage'
import AnalyticsPage from './pages/AnalyticsPage' import AnalyticsPage from './pages/AnalyticsPage'
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage' import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage'
import AnnualReportPage from './pages/AnnualReportPage' import AnnualReportPage from './pages/AnnualReportPage'
import AnnualReportWindow from './pages/AnnualReportWindow' import AnnualReportWindow from './pages/AnnualReportWindow'
import DualReportPage from './pages/DualReportPage' import DualReportPage from './pages/DualReportPage'
@@ -37,9 +38,22 @@ import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal' import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal' import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
function RouteStateRedirect({ to }: { to: string }) {
const location = useLocation()
return <Navigate to={to} replace state={location.state} />
}
function App() { function App() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const settingsBackgroundRef = useRef<Location>({
pathname: '/home',
search: '',
hash: '',
state: null,
key: 'settings-fallback'
} as Location)
const { const {
setDbConnected, setDbConnected,
@@ -63,8 +77,14 @@ function App() {
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
const isStandaloneChatWindow = location.pathname === '/chat-window' const isStandaloneChatWindow = location.pathname === '/chat-window'
const isNotificationWindow = location.pathname === '/notification-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 [themeHydrated, setThemeHydrated] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
// 锁定状态 // 锁定状态
// const [isLocked, setIsLocked] = useState(false) // Moved to store // const [isLocked, setIsLocked] = useState(false) // Moved to store
@@ -81,6 +101,12 @@ function App() {
// 数据收集同意状态 // 数据收集同意状态
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false) const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
useEffect(() => {
if (location.pathname !== '/settings') {
settingsBackgroundRef.current = location
}
}, [location])
useEffect(() => { useEffect(() => {
const root = document.documentElement const root = document.documentElement
const body = document.body 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 ( return (
<div className="app-container"> <div className="app-container">
<div className="window-drag-region" aria-hidden="true" /> <div className="window-drag-region" aria-hidden="true" />
@@ -439,7 +484,10 @@ function App() {
useHello={lockUseHello} useHello={lockUseHello}
/> />
)} )}
<TitleBar /> <TitleBar
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed((prev) => !prev)}
/>
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */} {/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
<UpdateProgressCapsule /> <UpdateProgressCapsule />
@@ -550,27 +598,29 @@ function App() {
/> />
<div className="main-layout"> <div className="main-layout">
<Sidebar /> <Sidebar collapsed={sidebarCollapsed} />
<main className="content"> <main className="content">
<RouteGuard> <RouteGuard>
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}> <div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
<ExportPage /> <ExportPage />
</div> </div>
<Routes> <Routes location={routeLocation}>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} /> <Route path="/home" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} /> <Route path="/chat" element={<ChatPage />} />
<Route path="/analytics" element={<AnalyticsWelcomePage />} /> <Route path="/analytics" element={<ChatAnalyticsHubPage />} />
<Route path="/analytics/view" element={<AnalyticsPage />} /> <Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
<Route path="/group-analytics" element={<GroupAnalyticsPage />} /> <Route path="/analytics/private/view" element={<AnalyticsPage />} />
<Route path="/analytics/group" element={<GroupAnalyticsPage />} />
<Route path="/analytics/view" element={<RouteStateRedirect to="/analytics/private/view" />} />
<Route path="/group-analytics" element={<RouteStateRedirect to="/analytics/group" />} />
<Route path="/annual-report" element={<AnnualReportPage />} /> <Route path="/annual-report" element={<AnnualReportPage />} />
<Route path="/annual-report/view" element={<AnnualReportWindow />} /> <Route path="/annual-report/view" element={<AnnualReportWindow />} />
<Route path="/dual-report" element={<DualReportPage />} /> <Route path="/dual-report" element={<DualReportPage />} />
<Route path="/dual-report/view" element={<DualReportWindow />} /> <Route path="/dual-report/view" element={<DualReportWindow />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} /> <Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
<Route path="/sns" element={<SnsPage />} /> <Route path="/sns" element={<SnsPage />} />
<Route path="/contacts" element={<ContactsPage />} /> <Route path="/contacts" element={<ContactsPage />} />
@@ -579,6 +629,10 @@ function App() {
</RouteGuard> </RouteGuard>
</main> </main>
</div> </div>
{isSettingsRoute && (
<SettingsPage onClose={handleCloseSettings} />
)}
</div> </div>
) )
} }

View File

@@ -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;
}
}

View File

@@ -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<ChatAnalysisMode, { label: string; path: string }> = {
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<HTMLDivElement | null>(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 (
<div className="chat-analysis-header">
<div className="chat-analysis-breadcrumb">
<button
type="button"
className="chat-analysis-back"
onClick={() => navigate('/analytics')}
>
<ChevronLeft size={16} />
<span></span>
</button>
<span className="chat-analysis-breadcrumb-separator">/</span>
<div className="chat-analysis-dropdown" ref={dropdownRef}>
<button
type="button"
className={`chat-analysis-current-trigger ${menuOpen ? 'open' : ''}`}
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((prev) => !prev)}
>
<span className="current">{currentLabel}</span>
<ChevronDown size={14} />
</button>
{menuOpen && (
<div className="chat-analysis-menu" role="menu" aria-label="切换聊天分析类型">
<button
type="button"
role="menuitem"
className="chat-analysis-menu-item"
onClick={() => {
setMenuOpen(false)
navigate(MODE_CONFIG[alternateMode].path)
}}
>
{MODE_CONFIG[alternateMode].label}
</button>
</div>
)}
</div>
</div>
{actions ? <div className="chat-analysis-actions">{actions}</div> : null}
</div>
)
}
export default ChatAnalysisHeader

View File

@@ -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<Props, State> {
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 || (
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
<p></p>
<p style={{ fontSize: '12px', marginTop: '8px' }}>
{this.state.error?.message || '未知错误'}
</p>
</div>
)
}
return this.props.children
}
}

View File

@@ -43,31 +43,52 @@
.sidebar-user-card-wrap { .sidebar-user-card-wrap {
position: relative; position: relative;
margin: 0 12px 10px; margin: 0 12px 10px;
--sidebar-user-menu-width: 172px;
} }
.sidebar-user-clear-trigger { .sidebar-user-menu {
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: auto;
bottom: calc(100% + 8px); bottom: calc(100% + 8px);
width: max(100%, var(--sidebar-user-menu-width));
z-index: 12; 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; border-radius: 10px;
background: var(--bg-secondary); background: transparent;
color: #d93025; color: var(--text-primary);
padding: 8px 10px; padding: 9px 10px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-size: 12px; font-size: 13px;
font-weight: 600; font-weight: 500;
cursor: pointer; cursor: pointer;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12); text-align: left;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: var(--bg-tertiary);
}
&.danger {
color: #d93025;
&:hover { &:hover {
background: rgba(255, 59, 48, 0.08); background: rgba(255, 59, 48, 0.08);
border-color: rgba(255, 59, 48, 0.46); }
} }
} }
@@ -244,26 +265,6 @@
gap: 4px; 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 { .sidebar-clear-dialog-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom' 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 { useAppStore } from '../stores/appStore'
import * as configService from '../services/config' import * as configService from '../services/config'
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
@@ -62,10 +62,13 @@ const normalizeAccountId = (value?: string | null): string => {
return suffixMatch ? suffixMatch[1] : trimmed return suffixMatch ? suffixMatch[1] : trimmed
} }
function Sidebar() { interface SidebarProps {
collapsed: boolean
}
function Sidebar({ collapsed }: SidebarProps) {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const [collapsed, setCollapsed] = useState(false)
const [authEnabled, setAuthEnabled] = useState(false) const [authEnabled, setAuthEnabled] = useState(false)
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0) const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({ const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
@@ -279,6 +282,15 @@ function Sidebar() {
setShowClearAccountDialog(true) setShowClearAccountDialog(true)
} }
const openSettingsFromAccountMenu = () => {
setIsAccountMenuOpen(false)
navigate('/settings', {
state: {
backgroundLocation: location
}
})
}
const handleConfirmClearAccountData = async () => { const handleConfirmClearAccountData = async () => {
if (!canConfirmClear || isClearingAccountData) return if (!canConfirmClear || isClearingAccountData) return
setIsClearingAccountData(true) setIsClearingAccountData(true)
@@ -375,24 +387,14 @@ function Sidebar() {
<span className="nav-label"></span> <span className="nav-label"></span>
</NavLink> </NavLink>
{/* 聊分析 */} {/* 聊分析 */}
<NavLink <NavLink
to="/analytics" to="/analytics"
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`} className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
title={collapsed ? '聊分析' : undefined} title={collapsed ? '聊分析' : undefined}
> >
<span className="nav-icon"><BarChart3 size={20} /></span> <span className="nav-icon"><BarChart3 size={20} /></span>
<span className="nav-label"></span> <span className="nav-label"></span>
</NavLink>
{/* 群聊分析 */}
<NavLink
to="/group-analytics"
className={`nav-item ${isActive('/group-analytics') ? 'active' : ''}`}
title={collapsed ? '群聊分析' : undefined}
>
<span className="nav-icon"><Users size={20} /></span>
<span className="nav-label"></span>
</NavLink> </NavLink>
{/* 年度报告 */} {/* 年度报告 */}
@@ -427,16 +429,48 @@ function Sidebar() {
</nav> </nav>
<div className="sidebar-footer"> <div className="sidebar-footer">
<button
className="nav-item"
onClick={() => {
if (authEnabled) {
setLocked(true)
return
}
navigate('/settings', {
state: {
initialTab: 'security',
backgroundLocation: location
}
})
}}
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
>
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
</button>
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}> <div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
{isAccountMenuOpen && ( {isAccountMenuOpen && (
<div className="sidebar-user-menu" role="menu" aria-label="账号菜单">
<button <button
className="sidebar-user-clear-trigger" className="sidebar-user-menu-item"
onClick={openSettingsFromAccountMenu}
type="button"
role="menuitem"
>
<Settings size={14} />
<span></span>
</button>
<button
className="sidebar-user-menu-item danger"
onClick={openClearAccountDialog} onClick={openClearAccountDialog}
type="button" type="button"
role="menuitem"
> >
<Trash2 size={14} /> <Trash2 size={14} />
<span></span> <span></span>
</button> </button>
</div>
)} )}
<div <div
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`} className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
@@ -465,40 +499,6 @@ function Sidebar() {
)} )}
</div> </div>
</div> </div>
<button
className="nav-item"
onClick={() => {
if (authEnabled) {
setLocked(true)
return
}
navigate('/settings', { state: { initialTab: 'security' } })
}}
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
>
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
</button>
<NavLink
to="/settings"
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
title={collapsed ? '设置' : undefined}
>
<span className="nav-icon">
<Settings size={20} />
</span>
<span className="nav-label"></span>
</NavLink>
<button
className="collapse-btn"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? '展开菜单' : '收起菜单'}
>
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div> </div>
{showClearAccountDialog && ( {showClearAccountDialog && (

View File

@@ -1,5 +1,5 @@
import React from 'react' 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' import { Avatar } from '../Avatar'
interface Contact { interface Contact {
@@ -25,7 +25,12 @@ interface SnsFilterPanelProps {
setContactSearch: (val: string) => void setContactSearch: (val: string) => void
loading?: boolean loading?: boolean
contactsCountProgress?: ContactsCountProgress contactsCountProgress?: ContactsCountProgress
selectedContactUsernames: string[]
activeContactUsername?: string
onOpenContactTimeline: (contact: Contact) => void onOpenContactTimeline: (contact: Contact) => void
onToggleContactSelected: (contact: Contact) => void
onClearSelectedContacts: () => void
onExportSelectedContacts: () => void
} }
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
@@ -37,12 +42,21 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
setContactSearch, setContactSearch,
loading, loading,
contactsCountProgress, contactsCountProgress,
onOpenContactTimeline selectedContactUsernames,
activeContactUsername,
onOpenContactTimeline,
onToggleContactSelected,
onClearSelectedContacts,
onExportSelectedContacts
}) => { }) => {
const filteredContacts = contacts.filter(c => const filteredContacts = contacts.filter(c =>
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) || (c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
c.username.toLowerCase().includes(contactSearch.toLowerCase()) c.username.toLowerCase().includes(contactSearch.toLowerCase())
) )
const selectedContactLookup = React.useMemo(
() => new Set(selectedContactUsernames),
[selectedContactUsernames]
)
const clearFilters = () => { const clearFilters = () => {
setSearchKeyword('') setSearchKeyword('')
@@ -122,14 +136,34 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
</div> </div>
)} )}
<div className="contact-interaction-hint">
</div>
<div className="contact-list-scroll"> <div className="contact-list-scroll">
{filteredContacts.map(contact => { {filteredContacts.map(contact => {
const isPostCountReady = contact.postCountStatus === 'ready' const isPostCountReady = contact.postCountStatus === 'ready'
const isSelected = selectedContactLookup.has(contact.username)
const isActive = activeContactUsername === contact.username
return ( return (
<div <div
key={contact.username} key={contact.username}
className="contact-row" className={`contact-row${isSelected ? ' is-selected' : ''}${isActive ? ' is-active' : ''}`}
>
<button
type="button"
className={`contact-select-btn${isSelected ? ' checked' : ''}`}
onClick={() => onToggleContactSelected(contact)}
title={isSelected ? `取消选择 ${contact.displayName}` : `选择 ${contact.displayName}`}
aria-pressed={isSelected}
>
{isSelected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
<button
type="button"
className="contact-main-btn"
onClick={() => onOpenContactTimeline(contact)} onClick={() => onOpenContactTimeline(contact)}
title={`查看 ${contact.displayName} 的朋友圈`}
> >
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" /> <Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
<div className="contact-meta"> <div className="contact-meta">
@@ -144,6 +178,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
</span> </span>
)} )}
</div> </div>
</button>
</div> </div>
) )
})} })}
@@ -151,6 +186,19 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
<div className="empty-state">{getEmptyStateText()}</div> <div className="empty-state">{getEmptyStateText()}</div>
)} )}
</div> </div>
{selectedContactUsernames.length > 0 && (
<div className="contact-batch-bar">
<span className="contact-batch-summary"> {selectedContactUsernames.length} </span>
<button type="button" className="contact-batch-btn" onClick={onClearSelectedContacts}>
</button>
<button type="button" className="contact-batch-btn primary" onClick={onExportSelectedContacts}>
<Download size={14} />
<span></span>
</button>
</div>
)}
</div> </div>
</div> </div>
</aside> </aside>

View File

@@ -4,10 +4,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding-left: 16px; padding-left: 16px;
padding-right: 16px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
-webkit-app-region: drag; -webkit-app-region: drag;
flex-shrink: 0; flex-shrink: 0;
gap: 8px; gap: 8px;
position: relative;
z-index: 2101;
} }
// 繁花如梦:标题栏毛玻璃 // 繁花如梦:标题栏毛玻璃
@@ -16,6 +19,12 @@
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
} }
.title-brand {
display: inline-flex;
align-items: center;
gap: 8px;
}
.title-logo { .title-logo {
width: 20px; width: 20px;
height: 20px; height: 20px;
@@ -27,3 +36,24 @@
font-weight: 500; font-weight: 500;
color: var(--text-secondary); 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);
}
}

View File

@@ -1,14 +1,30 @@
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'
import './TitleBar.scss' import './TitleBar.scss'
interface TitleBarProps { interface TitleBarProps {
title?: string title?: string
sidebarCollapsed?: boolean
onToggleSidebar?: () => void
} }
function TitleBar({ title }: TitleBarProps = {}) { function TitleBar({ title, sidebarCollapsed = false, onToggleSidebar }: TitleBarProps = {}) {
return ( return (
<div className="title-bar"> <div className="title-bar">
<div className="title-brand">
<img src="./logo.png" alt="WeFlow" className="title-logo" /> <img src="./logo.png" alt="WeFlow" className="title-logo" />
<span className="titles">{title || 'WeFlow'}</span> <span className="titles">{title || 'WeFlow'}</span>
{onToggleSidebar ? (
<button
type="button"
className="title-sidebar-toggle"
onClick={onToggleSidebar}
title={sidebarCollapsed ? '展开菜单' : '收起菜单'}
aria-label={sidebarCollapsed ? '展开菜单' : '收起菜单'}
>
{sidebarCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
</button>
) : null}
</div>
</div> </div>
) )
} }

View File

@@ -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, .loading-container,
.error-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 { @keyframes spin {
from { from {
transform: rotate(0deg); transform: rotate(0deg);

View File

@@ -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 { useLocation } from 'react-router-dom'
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, Medal, UserMinus, Search, X } from 'lucide-react' import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, Medal, UserMinus, Search, X } from 'lucide-react'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
import { useAnalyticsStore } from '../stores/analyticsStore' import { useAnalyticsStore } from '../stores/analyticsStore'
import { useThemeStore } from '../stores/themeStore' import { useThemeStore } from '../stores/themeStore'
import {
finishBackgroundTask,
isBackgroundTaskCancelRequested,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import './AnalyticsPage.scss' import './AnalyticsPage.scss'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
interface ExcludeCandidate { interface ExcludeCandidate {
username: string username: string
@@ -48,6 +55,13 @@ function AnalyticsPage() {
const loadData = useCallback(async (forceRefresh = false) => { const loadData = useCallback(async (forceRefresh = false) => {
if (isLoaded && !forceRefresh) return if (isLoaded && !forceRefresh) return
const taskId = registerBackgroundTask({
sourcePage: 'analytics',
title: forceRefresh ? '刷新分析看板' : '加载分析看板',
detail: '准备读取整体统计数据',
progressText: '整体统计',
cancelable: true
})
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
setProgress(0) setProgress(0)
@@ -60,27 +74,70 @@ function AnalyticsPage() {
try { try {
setLoadingStatus('正在统计消息数据...') setLoadingStatus('正在统计消息数据...')
updateBackgroundTask(taskId, {
detail: '正在统计消息数据',
progressText: '整体统计'
})
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh) const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,当前页面分析流程已结束'
})
setIsLoading(false)
return
}
if (statsResult.success && statsResult.data) { if (statsResult.success && statsResult.data) {
setStatistics(statsResult.data) setStatistics(statsResult.data)
} else { } else {
setError(statsResult.error || '加载统计数据失败') setError(statsResult.error || '加载统计数据失败')
finishBackgroundTask(taskId, 'failed', {
detail: statsResult.error || '加载统计数据失败'
})
setIsLoading(false) setIsLoading(false)
return return
} }
setLoadingStatus('正在分析联系人排名...') setLoadingStatus('正在分析联系人排名...')
updateBackgroundTask(taskId, {
detail: '正在分析联系人排名',
progressText: '联系人排名'
})
const rankingsResult = await window.electronAPI.analytics.getContactRankings(20) const rankingsResult = await window.electronAPI.analytics.getContactRankings(20)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,联系人排名后续步骤未继续'
})
setIsLoading(false)
return
}
if (rankingsResult.success && rankingsResult.data) { if (rankingsResult.success && rankingsResult.data) {
setRankings(rankingsResult.data) setRankings(rankingsResult.data)
} }
setLoadingStatus('正在计算时间分布...') setLoadingStatus('正在计算时间分布...')
updateBackgroundTask(taskId, {
detail: '正在计算时间分布',
progressText: '时间分布'
})
const timeResult = await window.electronAPI.analytics.getTimeDistribution() const timeResult = await window.electronAPI.analytics.getTimeDistribution()
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,时间分布结果未继续写入'
})
setIsLoading(false)
return
}
if (timeResult.success && timeResult.data) { if (timeResult.success && timeResult.data) {
setTimeDistribution(timeResult.data) setTimeDistribution(timeResult.data)
} }
markLoaded() markLoaded()
finishBackgroundTask(taskId, 'completed', {
detail: '分析看板数据加载完成',
progressText: '已完成'
})
} catch (e) { } catch (e) {
setError(String(e)) setError(String(e))
finishBackgroundTask(taskId, 'failed', {
detail: String(e)
})
} finally { } finally {
setIsLoading(false) setIsLoading(false)
if (removeListener) removeListener() if (removeListener) removeListener()
@@ -360,8 +417,28 @@ function AnalyticsPage() {
} }
} }
const renderPageShell = (content: ReactNode) => (
<div className="analytics-page-shell">
<ChatAnalysisHeader currentMode="private" />
{content}
</div>
)
const analyticsHeaderActions = (
<>
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
{isLoading ? '刷新中...' : '刷新'}
</button>
<button className="btn btn-secondary" onClick={openExcludeDialog}>
<UserMinus size={16} />
{excludedUsernames.size > 0 ? ` (${excludedUsernames.size})` : ''}
</button>
</>
)
if (isLoading && !isLoaded) { if (isLoading && !isLoaded) {
return ( return renderPageShell(
<div className="loading-container"> <div className="loading-container">
<Loader2 size={48} className="spin" /> <Loader2 size={48} className="spin" />
<p className="loading-status">{loadingStatus}</p> <p className="loading-status">{loadingStatus}</p>
@@ -374,7 +451,7 @@ function AnalyticsPage() {
} }
if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) { if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) {
return ( return renderPageShell(
<div className="error-container"> <div className="error-container">
<p>{error}</p> <p>{error}</p>
<div className="error-actions"> <div className="error-actions">
@@ -390,25 +467,18 @@ function AnalyticsPage() {
} }
if (error && !isLoaded) { if (error && !isLoaded) {
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}></button></div>) return renderPageShell(
<div className="error-container">
<p>{error}</p>
<button className="btn btn-primary" onClick={() => loadData(true)}></button>
</div>
)
} }
return ( return (
<> <div className="analytics-page-shell">
<div className="page-header"> <ChatAnalysisHeader currentMode="private" actions={analyticsHeaderActions} />
<h1></h1>
<div className="header-actions">
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
{isLoading ? '刷新中...' : '刷新'}
</button>
<button className="btn btn-secondary" onClick={openExcludeDialog}>
<UserMinus size={16} />
{excludedUsernames.size > 0 ? ` (${excludedUsernames.size})` : ''}
</button>
</div>
</div>
<div className="page-scroll"> <div className="page-scroll">
<section className="page-section"> <section className="page-section">
<div className="stats-overview"> <div className="stats-overview">
@@ -556,7 +626,7 @@ function AnalyticsPage() {
</div> </div>
</div> </div>
)} )}
</> </div>
) )
} }

View File

@@ -1,13 +1,30 @@
.analytics-entry-page {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
}
.analytics-welcome-container { .analytics-welcome-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; min-height: 0;
padding: 40px; padding: 40px;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
animation: fadeIn 0.4s ease-out; 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 { .welcome-content {
text-align: center; 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 { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;

View File

@@ -1,6 +1,7 @@
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { BarChart2, History, RefreshCcw } from 'lucide-react' import { BarChart2, History, RefreshCcw } from 'lucide-react'
import { useAnalyticsStore } from '../stores/analyticsStore' import { useAnalyticsStore } from '../stores/analyticsStore'
import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
import './AnalyticsWelcomePage.scss' import './AnalyticsWelcomePage.scss'
function AnalyticsWelcomePage() { function AnalyticsWelcomePage() {
@@ -14,11 +15,11 @@ function AnalyticsWelcomePage() {
const { lastLoadTime } = useAnalyticsStore() const { lastLoadTime } = useAnalyticsStore()
const handleLoadCache = () => { const handleLoadCache = () => {
navigate('/analytics/view') navigate('/analytics/private/view')
} }
const handleNewAnalysis = () => { const handleNewAnalysis = () => {
navigate('/analytics/view', { state: { forceRefresh: true } }) navigate('/analytics/private/view', { state: { forceRefresh: true } })
} }
const formatLastTime = (ts: number | null) => { const formatLastTime = (ts: number | null) => {
@@ -27,15 +28,18 @@ function AnalyticsWelcomePage() {
} }
return ( return (
<div className="analytics-welcome-container"> <div className="analytics-entry-page">
<ChatAnalysisHeader currentMode="private" />
<div className="analytics-welcome-container analytics-welcome-container--mode">
<div className="welcome-content"> <div className="welcome-content">
<div className="icon-wrapper"> <div className="icon-wrapper">
<BarChart2 size={40} /> <BarChart2 size={40} />
</div> </div>
<h1></h1> <h1></h1>
<p> <p>
WeFlow <br /> WeFlow <br />
</p> </p>
<div className="action-cards"> <div className="action-cards">
@@ -57,6 +61,7 @@ function AnalyticsWelcomePage() {
</div> </div>
</div> </div>
</div> </div>
</div>
) )
} }

View File

@@ -1,6 +1,12 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react' import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
import {
finishBackgroundTask,
isBackgroundTaskCancelRequested,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import './AnnualReportPage.scss' import './AnnualReportPage.scss'
type YearOption = number | 'all' type YearOption = number | 'all'
@@ -49,8 +55,17 @@ function AnnualReportPage() {
useEffect(() => { useEffect(() => {
let disposed = false let disposed = false
let taskId = '' let taskId = ''
let uiTaskId = ''
const applyLoadPayload = (payload: YearsLoadPayload) => { 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.strategy) setLoadStrategy(payload.strategy)
if (payload.phase) setLoadPhase(payload.phase) if (payload.phase) setLoadPhase(payload.phase)
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText) if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
@@ -91,6 +106,14 @@ function AnnualReportPage() {
setIsLoadingMoreYears(false) setIsLoadingMoreYears(false)
setHasYearsLoadFinished(true) setHasYearsLoadFinished(true)
setLoadPhase('done') setLoadPhase('done')
if (uiTaskId) {
finishBackgroundTask(uiTaskId, payload.canceled ? 'canceled' : 'completed', {
detail: payload.canceled
? '年度报告年份加载已停止'
: `年度报告年份加载完成,共 ${years.length} 个年份`,
progressText: payload.canceled ? '已停止' : `${years.length} 个年份`
})
}
} else { } else {
setIsLoadingMoreYears(true) setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false) setHasYearsLoadFinished(false)
@@ -105,6 +128,18 @@ function AnnualReportPage() {
}) })
const startLoad = async () => { const startLoad = async () => {
uiTaskId = registerBackgroundTask({
sourcePage: 'annualReport',
title: '年度报告年份加载',
detail: '准备使用原生快速模式加载年份',
progressText: '初始化',
cancelable: true,
onCancel: async () => {
if (taskId) {
await window.electronAPI.annualReport.cancelAvailableYearsLoad(taskId)
}
}
})
setIsLoading(true) setIsLoading(true)
setIsLoadingMoreYears(true) setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false) setHasYearsLoadFinished(false)
@@ -120,6 +155,9 @@ function AnnualReportPage() {
try { try {
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad() const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
if (!startResult.success || !startResult.taskId) { if (!startResult.success || !startResult.taskId) {
finishBackgroundTask(uiTaskId, 'failed', {
detail: startResult.error || '加载年度数据失败'
})
setLoadError(startResult.error || '加载年度数据失败') setLoadError(startResult.error || '加载年度数据失败')
setIsLoading(false) setIsLoading(false)
setIsLoadingMoreYears(false) setIsLoadingMoreYears(false)
@@ -131,6 +169,9 @@ function AnnualReportPage() {
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
finishBackgroundTask(uiTaskId, 'failed', {
detail: String(e)
})
setLoadError(String(e)) setLoadError(String(e))
setIsLoading(false) setIsLoading(false)
setIsLoadingMoreYears(false) setIsLoadingMoreYears(false)

View File

@@ -2,6 +2,12 @@ import { useState, useEffect, useRef } from 'react'
import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react' import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react'
import html2canvas from 'html2canvas' import html2canvas from 'html2canvas'
import { useThemeStore } from '../stores/themeStore' import { useThemeStore } from '../stores/themeStore'
import {
finishBackgroundTask,
isBackgroundTaskCancelRequested,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import './AnnualReportWindow.scss' import './AnnualReportWindow.scss'
// SVG 背景图案 (用于导出) // SVG 背景图案 (用于导出)
@@ -127,12 +133,6 @@ function AnnualReportWindow() {
const { currentTheme, themeMode } = useThemeStore() const { currentTheme, themeMode } = useThemeStore()
// 应用主题到独立窗口
useEffect(() => {
document.documentElement.setAttribute('data-theme', currentTheme)
document.documentElement.setAttribute('data-mode', themeMode)
}, [currentTheme, themeMode])
// Section refs // Section refs
const sectionRefs = { const sectionRefs = {
cover: useRef<HTMLElement>(null), cover: useRef<HTMLElement>(null),
@@ -164,6 +164,13 @@ function AnnualReportWindow() {
}, []) }, [])
const generateReport = async (year: number) => { const generateReport = async (year: number) => {
const taskId = registerBackgroundTask({
sourcePage: 'annualReport',
title: '年度报告生成',
detail: `正在生成 ${formatYearLabel(year)} 年度报告`,
progressText: '初始化',
cancelable: true
})
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
setLoadingProgress(0) setLoadingProgress(0)
@@ -171,25 +178,46 @@ function AnnualReportWindow() {
const removeProgressListener = window.electronAPI.annualReport.onProgress?.((payload: { status: string; progress: number }) => { const removeProgressListener = window.electronAPI.annualReport.onProgress?.((payload: { status: string; progress: number }) => {
setLoadingProgress(payload.progress) setLoadingProgress(payload.progress)
setLoadingStage(payload.status) setLoadingStage(payload.status)
updateBackgroundTask(taskId, {
detail: payload.status || '正在生成年度报告',
progressText: `${Math.max(0, Math.round(payload.progress || 0))}%`
})
}) })
try { try {
const result = await window.electronAPI.annualReport.generateReport(year) const result = await window.electronAPI.annualReport.generateReport(year)
removeProgressListener?.() removeProgressListener?.()
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,当前报告结果未继续写入页面'
})
setIsLoading(false)
return
}
setLoadingProgress(100) setLoadingProgress(100)
setLoadingStage('完成') setLoadingStage('完成')
if (result.success && result.data) { if (result.success && result.data) {
finishBackgroundTask(taskId, 'completed', {
detail: '年度报告生成完成',
progressText: '100%'
})
setTimeout(() => { setTimeout(() => {
setReportData(result.data!) setReportData(result.data!)
setIsLoading(false) setIsLoading(false)
}, 300) }, 300)
} else { } else {
finishBackgroundTask(taskId, 'failed', {
detail: result.error || '生成年度报告失败'
})
setError(result.error || '生成报告失败') setError(result.error || '生成报告失败')
setIsLoading(false) setIsLoading(false)
} }
} catch (e) { } catch (e) {
removeProgressListener?.() removeProgressListener?.()
finishBackgroundTask(taskId, 'failed', {
detail: String(e)
})
setError(String(e)) setError(String(e))
setIsLoading(false) setIsLoading(false)
} }

View File

@@ -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;
}
}

View File

@@ -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 (
<div className="chat-analytics-hub-page">
<div className="chat-analytics-hub-content">
<div className="chat-analytics-hub-badge">
<BarChart3 size={16} />
<span></span>
</div>
<h1></h1>
<p className="chat-analytics-hub-desc">
</p>
<div className="chat-analytics-hub-grid">
<button
type="button"
className="chat-analytics-entry-card"
onClick={() => navigate('/analytics/private')}
>
<div className="entry-card-icon">
<MessageSquare size={24} />
</div>
<div className="entry-card-header">
<h2></h2>
<ArrowRight size={18} />
</div>
<p></p>
<span className="entry-card-cta"></span>
</button>
<button
type="button"
className="chat-analytics-entry-card"
onClick={() => navigate('/analytics/group')}
>
<div className="entry-card-icon group">
<Users size={24} />
</div>
<div className="entry-card-header">
<h2></h2>
<ArrowRight size={18} />
</div>
<p></p>
<span className="entry-card-cta"></span>
</button>
</div>
</div>
</div>
)
}
export default ChatAnalyticsHubPage

View File

@@ -33,6 +33,16 @@
gap: 12px; gap: 12px;
align-items: flex-start; 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 { .avatar {
width: 40px; width: 40px;
height: 40px; height: 40px;

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { useParams, useLocation } from 'react-router-dom' import { useParams, useLocation } from 'react-router-dom'
import { ChatRecordItem } from '../types/models' import { ChatRecordItem } from '../types/models'
import TitleBar from '../components/TitleBar' import TitleBar from '../components/TitleBar'
import { ErrorBoundary } from '../components/ErrorBoundary'
import './ChatHistoryPage.scss' import './ChatHistoryPage.scss'
export default function ChatHistoryPage() { export default function ChatHistoryPage() {
@@ -166,7 +167,9 @@ export default function ChatHistoryPage() {
<div className="status-msg empty"></div> <div className="status-msg empty"></div>
) : ( ) : (
recordList.map((item, i) => ( recordList.map((item, i) => (
<HistoryItem key={i} item={item} /> <ErrorBoundary key={i} fallback={<div className="history-item error-item"></div>}>
<HistoryItem item={item} />
</ErrorBoundary>
)) ))
)} )}
</div> </div>
@@ -175,6 +178,8 @@ export default function ChatHistoryPage() {
} }
function HistoryItem({ item }: { item: ChatRecordItem }) { function HistoryItem({ item }: { item: ChatRecordItem }) {
const [imageError, setImageError] = useState(false)
// sourcetime 在合并转发里有两种格式: // sourcetime 在合并转发里有两种格式:
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46" // 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
let time = '' let time = ''
@@ -197,19 +202,16 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
if (src) { if (src) {
return ( return (
<div className="media-content"> <div className="media-content">
{imageError ? (
<div className="media-tip"></div>
) : (
<img <img
src={src} src={src}
alt="图片" alt="图片"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={(e) => { onError={() => setImageError(true)}
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)
}}
/> />
)}
</div> </div>
) )
} }

View File

@@ -14,6 +14,12 @@ import JumpToDatePopover from '../components/JumpToDatePopover'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline' import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
import * as configService from '../services/config' import * as configService from '../services/config'
import {
finishBackgroundTask,
isBackgroundTaskCancelRequested,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import { import {
emitOpenSingleExport, emitOpenSingleExport,
onExportSessionStatus, onExportSessionStatus,
@@ -1067,6 +1073,13 @@ function ChatPage(props: ChatPageProps) {
const loadSessionDetail = useCallback(async (sessionId: string) => { const loadSessionDetail = useCallback(async (sessionId: string) => {
const normalizedSessionId = String(sessionId || '').trim() const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return if (!normalizedSessionId) return
const taskId = registerBackgroundTask({
sourcePage: 'chat',
title: '聊天页会话详情统计',
detail: `准备读取 ${sessionMapRef.current.get(normalizedSessionId)?.displayName || normalizedSessionId} 的详情`,
progressText: '基础信息',
cancelable: true
})
const requestSeq = ++detailRequestSeqRef.current const requestSeq = ++detailRequestSeqRef.current
const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId) const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId)
@@ -1130,8 +1143,23 @@ function ChatPage(props: ChatPageProps) {
} }
try { try {
updateBackgroundTask(taskId, {
detail: '正在读取会话基础详情',
progressText: '基础信息'
})
const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId) 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) { if (result.success && result.detail) {
setSessionDetail((prev) => ({ setSessionDetail((prev) => ({
wxid: normalizedSessionId, wxid: normalizedSessionId,
@@ -1170,6 +1198,10 @@ function ChatPage(props: ChatPageProps) {
} }
try { try {
updateBackgroundTask(taskId, {
detail: '正在读取补充信息与导出统计',
progressText: '补充统计'
})
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
window.electronAPI.chat.getExportSessionStats( 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) { if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) {
const detail = extraResultSettled.value.detail const detail = extraResultSettled.value.detail
@@ -1214,8 +1257,15 @@ function ChatPage(props: ChatPageProps) {
}) })
} }
} }
finishBackgroundTask(taskId, 'completed', {
detail: '聊天页会话详情统计完成',
progressText: '已完成'
})
} catch (e) { } catch (e) {
console.error('加载会话详情补充统计失败:', e) console.error('加载会话详情补充统计失败:', e)
finishBackgroundTask(taskId, 'failed', {
detail: String(e)
})
} finally { } finally {
if (requestSeq === detailRequestSeqRef.current) { if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingDetailExtra(false) setIsLoadingDetailExtra(false)
@@ -1228,13 +1278,31 @@ function ChatPage(props: ChatPageProps) {
if (!normalizedSessionId || isLoadingRelationStats) return if (!normalizedSessionId || isLoadingRelationStats) return
const requestSeq = detailRequestSeqRef.current const requestSeq = detailRequestSeqRef.current
const taskId = registerBackgroundTask({
sourcePage: 'chat',
title: '聊天页关系统计补算',
detail: `正在补算 ${normalizedSessionId} 的共同好友与关联数据`,
progressText: '关系统计',
cancelable: true
})
setIsLoadingRelationStats(true) setIsLoadingRelationStats(true)
try { try {
const relationResult = await window.electronAPI.chat.getExportSessionStats( const relationResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId], [normalizedSessionId],
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } { 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 const metric = relationResult.success && relationResult.data
? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined ? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined
@@ -1254,11 +1322,26 @@ function ChatPage(props: ChatPageProps) {
setIsRefreshingDetailStats(true) setIsRefreshingDetailStats(true)
void (async () => { void (async () => {
try { try {
updateBackgroundTask(taskId, {
detail: '正在刷新关系统计结果',
progressText: '关系统计刷新'
})
const freshResult = await window.electronAPI.chat.getExportSessionStats( const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId], [normalizedSessionId],
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } { 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) { if (freshResult.success && freshResult.data) {
const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
@@ -1266,17 +1349,32 @@ function ChatPage(props: ChatPageProps) {
applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true) applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true)
} }
} }
finishBackgroundTask(taskId, 'completed', {
detail: '聊天页关系统计补算完成',
progressText: '已完成'
})
} catch (error) { } catch (error) {
console.error('刷新会话关系统计失败:', error) console.error('刷新会话关系统计失败:', error)
finishBackgroundTask(taskId, 'failed', {
detail: String(error)
})
} finally { } finally {
if (requestSeq === detailRequestSeqRef.current) { if (requestSeq === detailRequestSeqRef.current) {
setIsRefreshingDetailStats(false) setIsRefreshingDetailStats(false)
} }
} }
})() })()
} else {
finishBackgroundTask(taskId, 'completed', {
detail: '聊天页关系统计补算完成',
progressText: '已完成'
})
} }
} catch (error) { } catch (error) {
console.error('加载会话关系统计失败:', error) console.error('加载会话关系统计失败:', error)
finishBackgroundTask(taskId, 'failed', {
detail: String(error)
})
} finally { } finally {
if (requestSeq === detailRequestSeqRef.current) { if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingRelationStats(false) setIsLoadingRelationStats(false)
@@ -3225,7 +3323,7 @@ function ChatPage(props: ChatPageProps) {
const handleGroupAnalytics = useCallback(() => { const handleGroupAnalytics = useCallback(() => {
if (!currentSessionId || !isGroupChatSession(currentSessionId)) return if (!currentSessionId || !isGroupChatSession(currentSessionId)) return
navigate('/group-analytics', { navigate('/analytics/group', {
state: { state: {
preselectGroupIds: [currentSessionId] preselectGroupIds: [currentSessionId]
} }

View File

@@ -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 { .session-load-detail-table {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -744,6 +906,7 @@
width: 100%; width: 100%;
border: none; border: none;
background: transparent; background: transparent;
color: var(--text-primary);
text-align: left; text-align: left;
padding: 8px 10px; padding: 8px 10px;
border-radius: 8px; border-radius: 8px;
@@ -765,6 +928,7 @@
.layout-option-label { .layout-option-label {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: inherit;
} }
.layout-option-desc { .layout-option-desc {
@@ -1399,15 +1563,10 @@
} }
.session-table-section { .session-table-section {
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--card-bg);
padding: 12px;
flex: 0 0 auto; flex: 0 0 auto;
min-height: 420px; min-height: 420px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px;
overflow: visible; overflow: visible;
} }
@@ -1458,20 +1617,31 @@
.table-tabs { .table-tabs {
display: flex; display: flex;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: nowrap;
align-items: center;
.tab-btn { .tab-btn {
flex: 0 0 auto;
width: auto;
max-width: max-content;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-secondary); color: var(--text-secondary);
padding: 7px 12px; padding: 7px 6px;
border-radius: 999px; border-radius: 999px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
white-space: nowrap; white-space: nowrap;
display: inline-flex;
align-items: center;
justify-content: center;
.tab-btn-content {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
line-height: 1;
}
&.active { &.active {
border-color: var(--primary); border-color: var(--primary);
@@ -1547,14 +1717,21 @@
} }
.table-wrap { .table-wrap {
--contacts-native-scrollbar-compensation: 18px;
--contacts-row-height: 76px; --contacts-row-height: 76px;
--contacts-default-visible-rows: 10; --contacts-default-visible-rows: 10;
--contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows)); --contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows));
--contacts-select-col-width: 34px; --contacts-select-col-width: 34px;
--contacts-avatar-col-width: 44px;
--contacts-inline-padding: 12px; --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-message-col-width: 120px;
--contacts-media-col-width: 72px; --contacts-media-col-width: 72px;
--contacts-action-col-width: 140px; --contacts-action-col-width: 140px;
--contacts-table-min-width: 1200px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 10px; border-radius: 10px;
@@ -1563,24 +1740,51 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--bg-secondary);
} }
.table-wrap { .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 { .session-table-sticky {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 20; z-index: 20;
background: var(--card-bg); background: var(--bg-secondary);
} }
.loading-state, .loading-state,
.empty-state { .empty-state {
width: 100%;
min-width: max(100%, var(--contacts-table-min-width));
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 12px; gap: 12px;
background: var(--bg-secondary);
color: var(--text-tertiary); color: var(--text-tertiary);
font-size: 14px; font-size: 14px;
@@ -1590,14 +1794,17 @@
} }
.load-issue-state { .load-issue-state {
width: 100%;
min-width: max(100%, var(--contacts-table-min-width));
flex: 1; flex: 1;
padding: 14px; padding: 14px;
overflow-y: auto; overflow-y: auto;
background: var(--bg-secondary);
} }
.issue-card { .issue-card {
border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color)); 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; border-radius: 12px;
padding: 14px; padding: 14px;
color: var(--text-primary); color: var(--text-primary);
@@ -1671,7 +1878,7 @@
.issue-diagnostics { .issue-diagnostics {
margin-top: 12px; margin-top: 12px;
border-radius: 8px; 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); border: 1px dashed var(--border-color);
padding: 10px; padding: 10px;
font-size: 12px; font-size: 12px;
@@ -1682,17 +1889,37 @@
} }
.contacts-list-header { .contacts-list-header {
--contacts-header-bg: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary));
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: var(--contacts-column-gap);
padding: 10px var(--contacts-inline-padding) 8px; 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); 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; font-size: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
font-weight: 600; font-weight: 600;
letter-spacing: 0.01em; letter-spacing: 0.01em;
flex-shrink: 0; 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 { .contacts-list-header-select {
@@ -1705,8 +1932,10 @@
} }
.contacts-list-header-main { .contacts-list-header-main {
flex: 1; flex: 0 0 var(--contacts-main-col-width);
min-width: 0; width: var(--contacts-main-col-width);
min-width: var(--contacts-main-col-width);
max-width: var(--contacts-main-col-width);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -1721,21 +1950,30 @@
.contacts-list-header-count { .contacts-list-header-count {
width: var(--contacts-message-col-width); width: var(--contacts-message-col-width);
min-width: var(--contacts-message-col-width);
display: flex;
align-items: center;
justify-content: center;
text-align: center; text-align: center;
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
box-sizing: border-box;
} }
.contacts-list-header-media { .contacts-list-header-media {
width: var(--contacts-media-col-width); width: var(--contacts-media-col-width);
min-width: var(--contacts-media-col-width); min-width: var(--contacts-media-col-width);
display: flex;
align-items: center;
justify-content: center;
text-align: center; text-align: center;
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
box-sizing: border-box;
} }
.contacts-list-header-actions { .contacts-list-header-actions {
@@ -1749,8 +1987,8 @@
flex-shrink: 0; flex-shrink: 0;
position: sticky; position: sticky;
right: 0; right: 0;
z-index: 8; z-index: 13;
background: var(--bg-primary); background: var(--contacts-header-bg);
white-space: nowrap; white-space: nowrap;
&::before { &::before {
@@ -1761,30 +1999,70 @@
left: -8px; left: -8px;
width: 8px; width: 8px;
pointer-events: none; pointer-events: none;
background: linear-gradient(to right, transparent, var(--bg-primary)); background: linear-gradient(to right, transparent, var(--contacts-header-bg));
} }
} }
.contacts-list { .contacts-list {
width: 100%;
min-width: max(100%, var(--contacts-table-min-width));
flex: 1; flex: 1;
min-height: var(--contacts-default-list-height); min-height: var(--contacts-default-list-height);
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; padding: 0 0 12px;
} background: var(--bg-secondary);
.contacts-virtuoso {
height: 100%;
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 6px;
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-track {
background: var(--text-tertiary); background: transparent;
border-radius: 3px;
opacity: 0.3;
} }
&::-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 { .selection-clear-btn {
@@ -1848,30 +2126,50 @@
padding-bottom: 4px; padding-bottom: 4px;
&.selected .contact-item { &.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 { &.selected .contact-item:hover {
background: rgba(var(--primary-rgb), 0.08); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 60%, transparent);
} }
} }
.contact-item { .contact-item {
--contacts-row-bg: var(--bg-secondary);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: var(--contacts-column-gap);
padding: 12px var(--contacts-inline-padding); padding: 12px 6px 12px var(--contacts-inline-padding);
min-width: max(100%, var(--contacts-table-min-width));
height: 72px; height: 72px;
box-sizing: border-box; box-sizing: border-box;
border-radius: 10px; border-radius: 10px;
transition: all 0.2s; transition: all 0.2s;
cursor: default; cursor: default;
background: var(--contacts-row-bg);
box-shadow: inset 0 0 0 1px transparent;
&:hover { &: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 { .row-select-cell {
width: var(--contacts-select-col-width); width: var(--contacts-select-col-width);
min-width: var(--contacts-select-col-width); min-width: var(--contacts-select-col-width);
@@ -1905,8 +2203,10 @@
} }
.contact-info { .contact-info {
flex: 1; flex: 0 0 var(--contacts-name-text-width);
min-width: 0; width: var(--contacts-name-text-width);
min-width: var(--contacts-name-text-width);
max-width: var(--contacts-name-text-width);
} }
.contact-name { .contact-name {
@@ -1949,6 +2249,7 @@
gap: 4px; gap: 4px;
flex-shrink: 0; flex-shrink: 0;
text-align: center; text-align: center;
box-sizing: border-box;
} }
.row-media-metric { .row-media-metric {
@@ -1959,6 +2260,7 @@
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
text-align: center; text-align: center;
box-sizing: border-box;
} }
.row-media-metric-value { .row-media-metric-value {
@@ -1982,6 +2284,7 @@
background: transparent; background: transparent;
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100%;
min-height: 14px; min-height: 14px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -2104,11 +2407,12 @@
min-width: 1300px; min-width: 1300px;
border-collapse: separate; border-collapse: separate;
border-spacing: 0; border-spacing: 0;
background: var(--bg-secondary);
thead th { thead th {
position: sticky; position: sticky;
top: 0; 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; z-index: 4;
font-size: 12px; font-size: 12px;
text-align: left; text-align: left;
@@ -2231,24 +2535,16 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
justify-content: center;
align-self: stretch;
gap: 4px; gap: 4px;
width: var(--contacts-action-col-width); width: var(--contacts-action-col-width);
min-width: var(--contacts-action-col-width);
flex-shrink: 0; flex-shrink: 0;
position: sticky; position: sticky;
right: 0; right: 0;
z-index: 6; z-index: 10;
background: var(--bg-primary); background: var(--contacts-row-bg);
&::before {
content: '';
position: absolute;
top: -12px;
bottom: -12px;
left: -8px;
width: 8px;
pointer-events: none;
background: linear-gradient(to right, transparent, var(--bg-primary));
}
.row-action-main { .row-action-main {
display: inline-flex; display: inline-flex;
@@ -3929,6 +4225,8 @@
.table-wrap { .table-wrap {
--contacts-inline-padding: 10px; --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-message-col-width: 104px;
--contacts-media-col-width: 62px; --contacts-media-col-width: 62px;
--contacts-action-col-width: 140px; --contacts-action-col-width: 140px;

View File

@@ -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 { useLocation } from 'react-router-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
@@ -30,6 +30,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import type { ChatSession as AppChatSession, ContactInfo } from '../types/models' import type { ChatSession as AppChatSession, ContactInfo } from '../types/models'
import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron' import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron'
import type { BackgroundTaskRecord } from '../types/backgroundTask'
import * as configService from '../services/config' import * as configService from '../services/config'
import { import {
emitExportSessionStatus, emitExportSessionStatus,
@@ -37,6 +38,11 @@ import {
onExportSessionStatusRequest, onExportSessionStatusRequest,
onOpenSingleExport onOpenSingleExport
} from '../services/exportBridge' } from '../services/exportBridge'
import {
requestCancelBackgroundTask,
requestCancelBackgroundTasks,
subscribeBackgroundTasks
} from '../services/backgroundTaskMonitor'
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import { SnsPostItem } from '../components/Sns/SnsPostItem' import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
@@ -176,6 +182,24 @@ const contentTypeLabels: Record<ContentType, string> = {
emoji: '表情包' emoji: '表情包'
} }
const backgroundTaskSourceLabels: Record<string, string> = {
export: '导出页',
chat: '聊天页',
analytics: '分析页',
sns: '朋友圈页',
groupAnalytics: '群分析页',
annualReport: '年度报告',
other: '其他页面'
}
const backgroundTaskStatusLabels: Record<BackgroundTaskRecord['status'], string> = {
running: '运行中',
cancel_requested: '停止中',
completed: '已完成',
failed: '失败',
canceled: '已停止'
}
const conversationTabLabels: Record<ConversationTab, string> = { const conversationTabLabels: Record<ConversationTab, string> = {
private: '私聊', private: '私聊',
group: '群聊', group: '群聊',
@@ -1422,6 +1446,7 @@ function ExportPage() {
const [sessionMutualFriendsMetrics, setSessionMutualFriendsMetrics] = useState<Record<string, SessionMutualFriendsMetric>>({}) const [sessionMutualFriendsMetrics, setSessionMutualFriendsMetrics] = useState<Record<string, SessionMutualFriendsMetric>>({})
const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState<SessionSnsTimelineTarget | null>(null) const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState<SessionSnsTimelineTarget | null>(null)
const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('') const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('')
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskRecord[]>([])
const [exportFolder, setExportFolder] = useState('') const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B') const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
@@ -1487,6 +1512,12 @@ function ExportPage() {
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
const [nowTick, setNowTick] = useState(Date.now()) const [nowTick, setNowTick] = useState(Date.now())
const [isContactsListAtTop, setIsContactsListAtTop] = useState(true) const [isContactsListAtTop, setIsContactsListAtTop] = useState(true)
const [isContactsHeaderDragging, setIsContactsHeaderDragging] = useState(false)
const [contactsListScrollParent, setContactsListScrollParent] = useState<HTMLDivElement | null>(null)
const [contactsHorizontalScrollMetrics, setContactsHorizontalScrollMetrics] = useState({
viewportWidth: 0,
contentWidth: 0
})
const tabCounts = useContactTypeCountsStore(state => state.tabCounts) const tabCounts = useContactTypeCountsStore(state => state.tabCounts)
const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading) const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading)
const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady) const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady)
@@ -1508,6 +1539,16 @@ function ExportPage() {
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({}) const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
const contactsVirtuosoRef = useRef<VirtuosoHandle | null>(null) const contactsVirtuosoRef = useRef<VirtuosoHandle | null>(null)
const sessionTableSectionRef = useRef<HTMLDivElement | null>(null) const sessionTableSectionRef = useRef<HTMLDivElement | null>(null)
const contactsHorizontalViewportRef = useRef<HTMLDivElement | null>(null)
const contactsHorizontalContentRef = useRef<HTMLDivElement | null>(null)
const contactsBottomScrollbarRef = useRef<HTMLDivElement | null>(null)
const contactsScrollSyncSourceRef = useRef<'viewport' | 'bottom' | null>(null)
const contactsHeaderDragStateRef = useRef({
pointerId: -1,
startClientX: 0,
startScrollLeft: 0,
didDrag: false
})
const sessionFormatDropdownRef = useRef<HTMLDivElement | null>(null) const sessionFormatDropdownRef = useRef<HTMLDivElement | null>(null)
const detailRequestSeqRef = useRef(0) const detailRequestSeqRef = useRef(0)
const sessionsRef = useRef<SessionRow[]>([]) const sessionsRef = useRef<SessionRow[]>([])
@@ -1560,6 +1601,10 @@ function ExportPage() {
endIndex: -1 endIndex: -1
}) })
const handleContactsListScrollParentRef = useCallback((node: HTMLDivElement | null) => {
setContactsListScrollParent(prev => (prev === node ? prev : node))
}, [])
const ensureExportCacheScope = useCallback(async (): Promise<string> => { const ensureExportCacheScope = useCallback(async (): Promise<string> => {
if (exportCacheScopeReadyRef.current) { if (exportCacheScopeReadyRef.current) {
return exportCacheScopeRef.current return exportCacheScopeRef.current
@@ -1903,6 +1948,10 @@ function ExportPage() {
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [contactsList.length, isContactsListLoading, contactsLoadIssue]) }, [contactsList.length, isContactsListLoading, contactsLoadIssue])
useEffect(() => {
return subscribeBackgroundTasks(setBackgroundTasks)
}, [])
useEffect(() => { useEffect(() => {
tasksRef.current = tasks tasksRef.current = tasks
}, [tasks]) }, [tasks])
@@ -3843,11 +3892,9 @@ function ExportPage() {
if (scope === 'content' && contentType) { if (scope === 'content' && contentType) {
if (contentType === 'text') { 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)) const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? options.exportConcurrency))
return { return {
...base, ...base,
format: fastTextFormat,
contentType, contentType,
exportConcurrency: textExportConcurrency, exportConcurrency: textExportConcurrency,
exportAvatars: base.exportAvatars, exportAvatars: base.exportAvatars,
@@ -5491,6 +5538,16 @@ function ExportPage() {
alert('复制失败,请手动复制诊断信息') alert('复制失败,请手动复制诊断信息')
} }
}, [contactsDiagnosticsText]) }, [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(() => { const sessionContactsUpdatedAtLabel = useMemo(() => {
if (!sessionContactsUpdatedAt) return '' if (!sessionContactsUpdatedAt) return ''
@@ -5563,6 +5620,36 @@ function ExportPage() {
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
const taskCenterAlertCount = taskRunningCount + taskQueuedCount const taskCenterAlertCount = taskRunningCount + taskQueuedCount
const hasFilteredContacts = filteredContacts.length > 0 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<CSSProperties>(() => ({
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(() => { const sessionLoadDetailUpdatedAt = useMemo(() => {
let latest = 0 let latest = 0
for (const row of sessionLoadDetailRows) { for (const row of sessionLoadDetailRows) {
@@ -5588,6 +5675,136 @@ function ExportPage() {
row.mutualFriends.statusLabel.startsWith('加载中') row.mutualFriends.statusLabel.startsWith('加载中')
)) ))
), [sessionLoadDetailRows]) ), [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<HTMLDivElement>) => {
syncContactsHorizontalScroll('viewport', event.currentTarget.scrollLeft)
}, [syncContactsHorizontalScroll])
const handleContactsBottomScrollbarScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (contactsHeaderDragStateRef.current.pointerId !== event.pointerId) return
resetContactsHeaderDrag(event.currentTarget)
}, [resetContactsHeaderDrag])
const handleContactsHeaderPointerCancel = useCallback((event: PointerEvent<HTMLDivElement>) => {
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(() => { const closeTaskCenter = useCallback(() => {
setIsTaskCenterOpen(false) setIsTaskCenterOpen(false)
setExpandedPerfTaskId(null) setExpandedPerfTaskId(null)
@@ -5664,6 +5881,7 @@ function ExportPage() {
return ( return (
<div className={`contact-row ${checked ? 'selected' : ''}`}> <div className={`contact-row ${checked ? 'selected' : ''}`}>
<div className="contact-item"> <div className="contact-item">
<div className="row-left-sticky">
<div className="row-select-cell"> <div className="row-select-cell">
<button <button
className={`select-icon-btn ${checked ? 'checked' : ''}`} className={`select-icon-btn ${checked ? 'checked' : ''}`}
@@ -5686,6 +5904,7 @@ function ExportPage() {
<div className="contact-name">{contact.displayName}</div> <div className="contact-name">{contact.displayName}</div>
<div className="contact-remark">{contact.alias || contact.username}</div> <div className="contact-remark">{contact.alias || contact.username}</div>
</div> </div>
</div>
<div className="row-message-count"> <div className="row-message-count">
<div className="row-message-stats"> <div className="row-message-stats">
<strong className={`row-message-count-value ${messageCountState.state === 'value' ? '' : 'muted'}`}> <strong className={`row-message-count-value ${messageCountState.state === 'value' ? '' : 'muted'}`}>
@@ -5796,7 +6015,7 @@ function ExportPage() {
}) })
}} }}
> >
{!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '单会话导出'} {!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '导出'}
</button> </button>
{hasRecentExport && <span className="row-export-time">{recentExportTime}</span>} {hasRecentExport && <span className="row-export-time">{recentExportTime}</span>}
</div> </div>
@@ -6115,18 +6334,26 @@ function ExportPage() {
</div> </div>
<div className="session-table-section" ref={sessionTableSectionRef}> <div className="session-table-section" ref={sessionTableSectionRef}>
<div className="session-table-layout"> <div className="session-table-layout">
<div className="table-wrap"> <div className="table-wrap" style={contactsTableStyle}>
<div className="session-table-sticky">
<div className="table-toolbar"> <div className="table-toolbar">
<div className="table-tabs" role="tablist" aria-label="会话类型"> <div className="table-tabs" role="tablist" aria-label="会话类型">
<button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}> <button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}>
{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.private} <span className="tab-btn-content">
<span></span>
<span>{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.private}</span>
</span>
</button> </button>
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}> <button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}>
{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.group} <span className="tab-btn-content">
<span></span>
<span>{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.group}</span>
</span>
</button> </button>
<button className={`tab-btn ${activeTab === 'former_friend' ? 'active' : ''}`} onClick={() => setActiveTab('former_friend')}> <button className={`tab-btn ${activeTab === 'former_friend' ? 'active' : ''}`} onClick={() => setActiveTab('former_friend')}>
{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.former_friend} <span className="tab-btn-content">
<span></span>
<span>{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.former_friend}</span>
</span>
</button> </button>
</div> </div>
@@ -6151,6 +6378,14 @@ function ExportPage() {
</div> </div>
</div> </div>
<div className="table-scroll-shell">
<div
ref={contactsHorizontalViewportRef}
className="table-scroll-viewport"
onScroll={handleContactsHorizontalViewportScroll}
>
<div ref={contactsHorizontalContentRef} className="table-scroll-content">
<div className="session-table-sticky">
{contactsList.length > 0 && isContactsListLoading && ( {contactsList.length > 0 && isContactsListLoading && (
<div className="table-stage-hint"> <div className="table-stage-hint">
<Loader2 size={14} className="spin" /> <Loader2 size={14} className="spin" />
@@ -6159,7 +6394,14 @@ function ExportPage() {
)} )}
{hasFilteredContacts && ( {hasFilteredContacts && (
<div className="contacts-list-header"> <div
className={`contacts-list-header ${hasContactsHorizontalOverflow ? 'is-draggable' : ''} ${isContactsHeaderDragging ? 'is-dragging' : ''}`}
onPointerDown={handleContactsHeaderPointerDown}
onPointerMove={handleContactsHeaderPointerMove}
onPointerUp={handleContactsHeaderPointerUp}
onPointerCancel={handleContactsHeaderPointerCancel}
>
<span className="contacts-list-header-left">
<span className="contacts-list-header-select"> <span className="contacts-list-header-select">
<button <button
className={`select-icon-btn ${isAllVisibleSelected ? 'checked' : ''}`} className={`select-icon-btn ${isAllVisibleSelected ? 'checked' : ''}`}
@@ -6174,6 +6416,7 @@ function ExportPage() {
<span className="contacts-list-header-main"> <span className="contacts-list-header-main">
<span className="contacts-list-header-main-label">{contactsHeaderMainLabel}</span> <span className="contacts-list-header-main-label">{contactsHeaderMainLabel}</span>
</span> </span>
</span>
<span className="contacts-list-header-count"></span> <span className="contacts-list-header-count"></span>
<span className="contacts-list-header-media"></span> <span className="contacts-list-header-media"></span>
<span className="contacts-list-header-media"></span> <span className="contacts-list-header-media"></span>
@@ -6254,13 +6497,16 @@ function ExportPage() {
) : ( ) : (
<div <div
className="contacts-list" className="contacts-list"
ref={handleContactsListScrollParentRef}
onWheelCapture={handleContactsListWheelCapture} onWheelCapture={handleContactsListWheelCapture}
> >
<Virtuoso <Virtuoso
ref={contactsVirtuosoRef} ref={contactsVirtuosoRef}
className="contacts-virtuoso" className="contacts-virtuoso"
customScrollParent={contactsListScrollParent ?? undefined}
data={filteredContacts} data={filteredContacts}
computeItemKey={(_, contact) => contact.username} computeItemKey={(_, contact) => contact.username}
fixedItemHeight={76}
itemContent={renderContactRow} itemContent={renderContactRow}
rangeChanged={handleContactsRangeChanged} rangeChanged={handleContactsRangeChanged}
atTopStateChange={setIsContactsListAtTop} atTopStateChange={setIsContactsListAtTop}
@@ -6269,6 +6515,20 @@ function ExportPage() {
</div> </div>
)} )}
</div> </div>
</div>
</div>
{hasFilteredContacts && hasContactsHorizontalOverflow && (
<div
ref={contactsBottomScrollbarRef}
className="table-bottom-scrollbar"
onScroll={handleContactsBottomScrollbarScroll}
aria-label="会话列表横向滚动条"
>
<div className="table-bottom-scrollbar-inner" style={contactsBottomScrollbarInnerStyle} />
</div>
)}
</div>
{showSessionLoadDetailModal && ( {showSessionLoadDetailModal && (
<div <div
@@ -6303,6 +6563,67 @@ function ExportPage() {
</div> </div>
<div className="session-load-detail-body"> <div className="session-load-detail-body">
<section className="session-load-detail-block">
<h5></h5>
<div className="session-load-detail-summary">
<div className="session-load-detail-summary-text">
<strong>{runningNonExportTaskCount}</strong>
<span></span>
{nonExportBackgroundTasksUpdatedAt > 0 && (
<em> {new Date(nonExportBackgroundTasksUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false })}</em>
)}
</div>
<button
type="button"
className="session-load-detail-stop-btn"
onClick={handleCancelAllNonExportTasks}
disabled={cancelableNonExportTaskCount === 0}
>
</button>
</div>
<p className="session-load-detail-note">
</p>
{nonExportBackgroundTasks.length > 0 ? (
<div className="session-load-detail-task-list">
{nonExportBackgroundTasks.map((task) => (
<div key={task.id} className={`session-load-detail-task-item status-${task.status}`}>
<div className="session-load-detail-task-main">
<div className="session-load-detail-task-title-row">
<span className="session-load-detail-task-source">
{backgroundTaskSourceLabels[task.sourcePage] || backgroundTaskSourceLabels.other}
</span>
<strong>{task.title}</strong>
<span className={`session-load-detail-task-status status-${task.status}`}>
{backgroundTaskStatusLabels[task.status]}
</span>
</div>
<p>{task.detail || '暂无详细说明'}</p>
<div className="session-load-detail-task-meta">
<span>{formatLoadDetailTime(task.startedAt)}</span>
<span>{formatLoadDetailTime(task.updatedAt)}</span>
{task.progressText && <span>{task.progressText}</span>}
</div>
</div>
<button
type="button"
className="session-load-detail-task-stop-btn"
onClick={() => handleCancelBackgroundTask(task.id)}
disabled={!task.cancelable || (task.status !== 'running' && task.status !== 'cancel_requested')}
>
</button>
</div>
))}
</div>
) : (
<div className="session-load-detail-empty">
</div>
)}
</section>
<section className="session-load-detail-block"> <section className="session-load-detail-block">
<h5></h5> <h5></h5>
<div className="session-load-detail-table"> <div className="session-load-detail-table">

View File

@@ -1,6 +1,14 @@
.group-analytics-shell {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
}
.group-analytics-page { .group-analytics-page {
display: flex; display: flex;
height: 100%; flex: 1;
min-height: 0;
gap: 16px; gap: 16px;
&.standalone { &.standalone {

View File

@@ -4,7 +4,14 @@ import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, C
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker' import DateRangePicker from '../components/DateRangePicker'
import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
import * as configService from '../services/config' import * as configService from '../services/config'
import {
finishBackgroundTask,
isBackgroundTaskCancelRequested,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import './GroupAnalyticsPage.scss' import './GroupAnalyticsPage.scss'
interface GroupChatInfo { interface GroupChatInfo {
@@ -176,15 +183,39 @@ function GroupAnalyticsPage() {
}, []) }, [])
const loadGroups = useCallback(async () => { const loadGroups = useCallback(async () => {
const taskId = registerBackgroundTask({
sourcePage: 'groupAnalytics',
title: '群列表加载',
detail: '正在读取群聊列表',
progressText: '群聊列表',
cancelable: true
})
setIsLoading(true) setIsLoading(true)
try { try {
const result = await window.electronAPI.groupAnalytics.getGroupChats() const result = await window.electronAPI.groupAnalytics.getGroupChats()
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,群聊列表结果未继续写入'
})
return
}
if (result.success && result.data) { if (result.success && result.data) {
setGroups(result.data) setGroups(result.data)
setFilteredGroups(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) { } catch (e) {
console.error(e) console.error(e)
finishBackgroundTask(taskId, 'failed', {
detail: String(e)
})
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -314,6 +345,13 @@ function GroupAnalyticsPage() {
const loadFunctionData = async (func: AnalysisFunction) => { const loadFunctionData = async (func: AnalysisFunction) => {
if (!selectedGroup) return if (!selectedGroup) return
const taskId = registerBackgroundTask({
sourcePage: 'groupAnalytics',
title: `群分析:${func}`,
detail: `正在读取 ${selectedGroup.displayName || selectedGroup.username} 的分析数据`,
progressText: func,
cancelable: true
})
setFunctionLoading(true) setFunctionLoading(true)
// 计算时间戳 // 计算时间戳
@@ -323,33 +361,96 @@ function GroupAnalyticsPage() {
try { try {
switch (func) { switch (func) {
case 'members': { case 'members': {
updateBackgroundTask(taskId, {
detail: '正在读取群成员列表',
progressText: '成员列表'
})
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) 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) 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 break
} }
case 'memberExport': { case 'memberExport': {
updateBackgroundTask(taskId, {
detail: '正在读取导出成员列表',
progressText: '成员导出'
})
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) 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) 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 break
} }
case 'ranking': { case 'ranking': {
updateBackgroundTask(taskId, {
detail: '正在计算群消息排行',
progressText: '消息排行'
})
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime) 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) 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 break
} }
case 'activeHours': { case 'activeHours': {
updateBackgroundTask(taskId, {
detail: '正在计算群活跃时段',
progressText: '活跃时段'
})
const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime) 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) 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 break
} }
case 'mediaStats': { case 'mediaStats': {
updateBackgroundTask(taskId, {
detail: '正在统计群消息类型',
progressText: '消息类型'
})
const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime) 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) 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 break
} }
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
finishBackgroundTask(taskId, 'failed', {
detail: String(e)
})
} finally { } finally {
setFunctionLoading(false) setFunctionLoading(false)
} }
@@ -1085,12 +1186,15 @@ function GroupAnalyticsPage() {
} }
return ( return (
<div className="group-analytics-shell">
<ChatAnalysisHeader currentMode="group" />
<div className={`group-analytics-page ${isResizing ? 'resizing' : ''}`} ref={containerRef}> <div className={`group-analytics-page ${isResizing ? 'resizing' : ''}`} ref={containerRef}>
{renderGroupList()} {renderGroupList()}
<div className="resize-handle" onMouseDown={() => setIsResizing(true)} /> <div className="resize-handle" onMouseDown={() => setIsResizing(true)} />
<div className="detail-area"> <div className="detail-area">
{renderDetailPanel()} {renderDetailPanel()}
</div> </div>
</div>
{renderMemberModal()} {renderMemberModal()}
</div> </div>
) )

View File

@@ -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 { .settings-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; width: min(1160px, calc(100vw - 96px));
margin: -24px; height: min(820px, calc(100vh - 120px));
max-height: 100%;
padding: 24px; 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; overflow: hidden;
} }
.settings-header { .settings-header {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
margin-bottom: 20px; gap: 20px;
margin-bottom: 14px;
flex-shrink: 0; flex-shrink: 0;
h1 { h1 {
@@ -22,29 +43,67 @@
} }
} }
.settings-title-block {
display: flex;
flex-direction: column;
}
.settings-actions { .settings-actions {
display: flex; display: flex;
align-items: center;
gap: 12px; 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 { .settings-tabs {
display: flex; display: flex;
gap: 4px; flex-direction: column;
padding: 4px; gap: 6px;
background: var(--bg-tertiary); padding: 12px;
border-radius: 12px; width: 220px;
margin-bottom: 20px;
flex-shrink: 0; flex-shrink: 0;
width: fit-content; background: var(--bg-secondary);
} border: 1px solid var(--border-color);
border-radius: 20px;
overflow-y: auto;
.tab-btn { .tab-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 10px 18px; width: 100%;
justify-content: flex-start;
padding: 11px 14px;
border: none; border: none;
border-radius: 8px; border-radius: 12px;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -62,11 +121,13 @@
color: var(--primary); color: var(--primary);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
}
} }
.settings-body { .settings-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
min-width: 0;
padding-right: 8px; padding-right: 8px;
&::-webkit-scrollbar { &::-webkit-scrollbar {
@@ -85,8 +146,10 @@
.tab-content { .tab-content {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 16px; border: 1px solid var(--border-color);
border-radius: 20px;
padding: 24px; padding: 24px;
min-height: 100%;
.section-desc { .section-desc {
font-size: 13px; font-size: 13px;
@@ -932,7 +995,7 @@
padding: 10px 24px; padding: 10px 24px;
border-radius: 9999px; border-radius: 9999px;
font-size: 14px; font-size: 14px;
z-index: 100; z-index: 2200;
animation: slideDown 0.3s ease; animation: slideDown 0.3s ease;
&.success { &.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 { @keyframes slideDown {
from { from {
opacity: 0; opacity: 0;
@@ -1784,54 +1868,106 @@
.model-status-card { .model-status-card {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; align-items: stretch;
gap: 16px; gap: 14px;
} }
.model-info { .model-info {
flex: 1; display: flex;
flex-direction: column;
gap: 10px;
min-width: 0; min-width: 0;
.model-name-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.model-name { .model-name {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); 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; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; align-items: flex-start;
gap: 10px;
.status-indicator { .status-indicator {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
width: fit-content;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 600;
&.success { &.success {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.2);
color: #10b981; color: #10b981;
} }
&.warning { &.warning {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.2);
color: #f59e0b; 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 { .path-text {
font-size: 12px; font-size: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
font-family: monospace; font-family: monospace;
word-break: break-all; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
} }
} }
.model-actions { .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 { .btn-download {
display: inline-flex; display: inline-flex;
@@ -1866,16 +2002,18 @@
.download-status { .download-status {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 8px;
width: 280px; width: 100%;
max-width: 420px;
.status-header, .status-header,
.progress-info { .progress-info {
// specific layout class
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; // Align vertically align-items: center;
width: 100%; width: 100%;
gap: 12px;
flex-wrap: wrap;
} }
.percent { .percent {
@@ -1889,6 +2027,7 @@
.details { .details {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 6px; gap: 6px;
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
@@ -1963,10 +2102,12 @@
.path-selector { .path-selector {
display: flex; display: flex;
gap: 8px; gap: 8px;
flex-wrap: wrap;
input { input {
margin-bottom: 0 !important; margin-bottom: 0 !important;
flex: 1; flex: 1;
min-width: 220px;
font-family: monospace; font-family: monospace;
font-size: 12px; font-size: 12px;
} }

View File

@@ -10,7 +10,7 @@ import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor, RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic, 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' } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import './SettingsPage.scss' import './SettingsPage.scss'
@@ -36,7 +36,11 @@ interface WxidOption {
modifiedTime: number modifiedTime: number
} }
function SettingsPage() { interface SettingsPageProps {
onClose?: () => void
}
function SettingsPage({ onClose }: SettingsPageProps = {}) {
const location = useLocation() const location = useLocation()
const { const {
isDbConnected, isDbConnected,
@@ -195,6 +199,17 @@ function SettingsPage() {
setActiveTab(initialTab) setActiveTab(initialTab)
}, [location.state]) }, [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(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message) setDbKeyStatus(payload.message)
@@ -1410,6 +1425,8 @@ function SettingsPage() {
</div> </div>
</div> </div>
) )
const resolvedWhisperModelPath = whisperModelDir || whisperModelStatus?.modelPath || ''
const renderModelsTab = () => ( const renderModelsTab = () => (
<div className="tab-content"> <div className="tab-content">
<div className="form-group"> <div className="form-group">
@@ -1424,16 +1441,25 @@ function SettingsPage() {
<div className="setting-control vertical has-border"> <div className="setting-control vertical has-border">
<div className="model-status-card"> <div className="model-status-card">
<div className="model-info"> <div className="model-info">
<div className="model-name">SenseVoiceSmall (245 MB)</div> <div className="model-name-row">
<div className="model-path"> <div className="model-name">SenseVoiceSmall</div>
<span className="model-size">245 MB</span>
</div>
<div className="model-meta">
{whisperModelStatus?.exists ? ( {whisperModelStatus?.exists ? (
<span className="status-indicator success"><Check size={14} /> </span> <span className="status-indicator success"><Check size={14} /> </span>
) : ( ) : (
<span className="status-indicator warning"></span> <span className="status-indicator warning"></span>
)} )}
{whisperModelDir && <div className="path-text" title={whisperModelDir}>{whisperModelDir}</div>} {resolvedWhisperModelPath && (
<div className="model-path-block">
<span className="path-label"></span>
<div className="path-text" title={resolvedWhisperModelPath}>{resolvedWhisperModelPath}</div>
</div>
)}
</div> </div>
</div> </div>
{(!whisperModelStatus?.exists || isWhisperDownloading) && (
<div className="model-actions"> <div className="model-actions">
{!whisperModelStatus?.exists && !isWhisperDownloading && ( {!whisperModelStatus?.exists && !isWhisperDownloading && (
<button <button
@@ -1460,6 +1486,7 @@ function SettingsPage() {
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
<div className="sub-setting"> <div className="sub-setting">
@@ -2049,7 +2076,8 @@ function SettingsPage() {
) )
return ( return (
<div className="settings-page"> <div className="settings-modal-overlay" onClick={() => onClose?.()}>
<div className="settings-page" onClick={(event) => event.stopPropagation()}>
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>} {message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
{/* 多账号选择对话框 */} {/* 多账号选择对话框 */}
@@ -2080,17 +2108,29 @@ function SettingsPage() {
)} )}
<div className="settings-header"> <div className="settings-header">
<div className="settings-title-block">
<h1></h1> <h1></h1>
</div>
<div className="settings-actions"> <div className="settings-actions">
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}> <button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'} <Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
</button> </button>
{onClose && (
<button type="button" className="settings-close-btn" onClick={onClose} aria-label="关闭设置">
<X size={18} />
</button>
)}
</div> </div>
</div> </div>
<div className="settings-tabs"> <div className="settings-layout">
<div className="settings-tabs" role="tablist" aria-label="设置项">
{tabs.map(tab => ( {tabs.map(tab => (
<button key={tab.id} className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`} onClick={() => setActiveTab(tab.id)}> <button
key={tab.id}
className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
<tab.icon size={16} /> <tab.icon size={16} />
<span>{tab.label}</span> <span>{tab.label}</span>
</button> </button>
@@ -2108,7 +2148,8 @@ function SettingsPage() {
{activeTab === 'security' && renderSecurityTab()} {activeTab === 'security' && renderSecurityTab()}
{activeTab === 'about' && renderAboutTab()} {activeTab === 'about' && renderAboutTab()}
</div> </div>
</div>
</div>
</div> </div>
) )
} }

View File

@@ -1,7 +1,7 @@
/* Global Variables */ /* Global Variables */
:root { :root {
--sns-max-width: 800px; --sns-max-width: 800px;
--sns-panel-width: 320px; --sns-panel-width: 380px;
--sns-bg-color: var(--bg-primary); --sns-bg-color: var(--bg-primary);
--sns-card-bg: var(--bg-secondary); --sns-card-bg: var(--bg-secondary);
--sns-border-radius-lg: 16px; --sns-border-radius-lg: 16px;
@@ -263,6 +263,48 @@
padding-top: 16px; 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 { .posts-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1211,6 +1253,13 @@
font-variant-numeric: tabular-nums; 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 { .contact-list-scroll {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -1218,23 +1267,75 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
/* Remove gap to allow borders to merge */
.contact-row { .contact-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
padding: 10px;
border-radius: var(--sns-border-radius-md);
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
border: 1px solid transparent;
margin-bottom: 4px; margin-bottom: 4px;
border-radius: var(--sns-border-radius-md);
transition: transform 0.2s ease;
&:hover {
transform: translateX(2px);
}
&.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 { &:hover {
background: var(--hover-bg); background: var(--hover-bg);
transform: translateX(2px); }
z-index: 10;
} }
.contact-meta { .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 { .empty-state {
text-align: center; text-align: center;
color: var(--text-tertiary); color: var(--text-tertiary);

View File

@@ -9,6 +9,12 @@ import type { ContactSnsTimelineTarget } from '../components/Sns/contactSnsTimel
import JumpToDatePopover from '../components/JumpToDatePopover' import JumpToDatePopover from '../components/JumpToDatePopover'
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
import * as configService from '../services/config' import * as configService from '../services/config'
import {
finishBackgroundTask,
isBackgroundTaskCancelRequested,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import { import {
createExportDateRangeSelectionFromPreset, createExportDateRangeSelectionFromPreset,
getExportDateRangeLabel, getExportDateRangeLabel,
@@ -57,6 +63,7 @@ interface SnsOverviewStats {
} }
type OverviewStatsStatus = 'loading' | 'ready' | 'error' type OverviewStatsStatus = 'loading' | 'ready' | 'error'
type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] }
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
@@ -117,6 +124,7 @@ export default function SnsPage() {
total: 0, total: 0,
running: false running: false
}) })
const [selectedContactUsernames, setSelectedContactUsernames] = useState<string[]>([])
const [currentUserProfile, setCurrentUserProfile] = useState<SidebarUserProfile>(() => readSidebarUserProfileCache() || { const [currentUserProfile, setCurrentUserProfile] = useState<SidebarUserProfile>(() => readSidebarUserProfileCache() || {
wxid: '', wxid: '',
displayName: '' displayName: ''
@@ -134,6 +142,7 @@ export default function SnsPage() {
// 导出相关状态 // 导出相关状态
const [showExportDialog, setShowExportDialog] = useState(false) const [showExportDialog, setShowExportDialog] = useState(false)
const [exportScope, setExportScope] = useState<SnsExportScope>({ kind: 'all' })
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html') const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html')
const [exportFolder, setExportFolder] = useState('') const [exportFolder, setExportFolder] = useState('')
const [exportImages, setExportImages] = useState(false) const [exportImages, setExportImages] = useState(false)
@@ -164,9 +173,11 @@ export default function SnsPage() {
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus) const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
const searchKeywordRef = useRef(searchKeyword) const searchKeywordRef = useRef(searchKeyword)
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate) const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
const selectedContactUsernamesRef = useRef<string[]>(selectedContactUsernames)
const cacheScopeKeyRef = useRef('') const cacheScopeKeyRef = useRef('')
const snsUserPostCountsCacheScopeKeyRef = useRef('') const snsUserPostCountsCacheScopeKeyRef = useRef('')
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const pendingResetFeedRef = useRef(false)
const contactsLoadTokenRef = useRef(0) const contactsLoadTokenRef = useRef(0)
const contactsCountHydrationTokenRef = useRef(0) const contactsCountHydrationTokenRef = useRef(0)
const contactsCountBatchTimerRef = useRef<number | null>(null) const contactsCountBatchTimerRef = useRef<number | null>(null)
@@ -180,6 +191,13 @@ export default function SnsPage() {
useEffect(() => { useEffect(() => {
contactsRef.current = contacts contactsRef.current = contacts
}, [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(() => { useEffect(() => {
overviewStatsRef.current = overviewStats overviewStatsRef.current = overviewStats
}, [overviewStats]) }, [overviewStats])
@@ -192,6 +210,9 @@ export default function SnsPage() {
useEffect(() => { useEffect(() => {
jumpTargetDateRef.current = jumpTargetDate jumpTargetDateRef.current = jumpTargetDate
}, [jumpTargetDate]) }, [jumpTargetDate])
useEffect(() => {
selectedContactUsernamesRef.current = selectedContactUsernames
}, [selectedContactUsernames])
useEffect(() => { useEffect(() => {
if (!showJumpPopover) { if (!showJumpPopover) {
setJumpPopoverDate(jumpTargetDate || new Date()) setJumpPopoverDate(jumpTargetDate || new Date())
@@ -370,6 +391,31 @@ export default function SnsPage() {
return contacts.find((contact) => contact.username === normalizedTargetUsername) || null return contacts.find((contact) => contact.username === normalizedTargetUsername) || null
}, [authorTimelineTarget, contacts]) }, [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(() => { const myTimelineCount = useMemo(() => {
if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') { if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') {
return normalizePostCount(resolvedCurrentUserContact.postCount) return normalizePostCount(resolvedCurrentUserContact.postCount)
@@ -383,6 +429,10 @@ export default function SnsPage() {
: overviewStatsStatus === 'loading' || contactsLoading : overviewStatsStatus === 'loading' || contactsLoading
) )
const canStartExport = Boolean(exportFolder) && !isExporting && (
exportScope.kind === 'all' || exportScope.usernames.length > 0
)
const openCurrentUserTimeline = useCallback(() => { const openCurrentUserTimeline = useCallback(() => {
if (!resolvedCurrentUserContact) return if (!resolvedCurrentUserContact) return
setAuthorTimelineTarget({ setAuthorTimelineTarget({
@@ -393,7 +443,11 @@ export default function SnsPage() {
}, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact]) }, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact])
const isDefaultViewNow = useCallback(() => { const isDefaultViewNow = useCallback(() => {
return !searchKeywordRef.current.trim() && !jumpTargetDateRef.current return (
!searchKeywordRef.current.trim() &&
!jumpTargetDateRef.current &&
selectedContactUsernamesRef.current.length === 0
)
}, []) }, [])
const ensureSnsCacheScopeKey = useCallback(async () => { const ensureSnsCacheScopeKey = useCallback(async () => {
@@ -555,9 +609,23 @@ export default function SnsPage() {
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDateRangeSelection), [exportDateRangeSelection]) 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 loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
const { reset = false, direction = 'older' } = options const { reset = false, direction = 'older' } = options
if (loadingRef.current) return if (loadingRef.current) {
if (reset) {
pendingResetFeedRef.current = true
}
return
}
loadingRef.current = true loadingRef.current = true
if (direction === 'newer') setLoadingNewer(true) if (direction === 'newer') setLoadingNewer(true)
@@ -565,13 +633,19 @@ export default function SnsPage() {
try { try {
const limit = 20 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 startTs: number | undefined = undefined
let endTs: number | undefined = undefined let endTs: number | undefined = undefined
if (reset) { if (reset) {
// If jumping to date, set endTs to end of that day // If jumping to date, set endTs to end of that day
if (jumpTargetDate) { if (currentJumpTargetDate) {
endTs = Math.floor(jumpTargetDate.getTime() / 1000) + 86399 endTs = Math.floor(currentJumpTargetDate.getTime() / 1000) + 86399
} }
} else if (direction === 'newer') { } else if (direction === 'newer') {
const currentPosts = postsRef.current const currentPosts = postsRef.current
@@ -581,8 +655,8 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.getTimeline( const result = await window.electronAPI.sns.getTimeline(
limit, limit,
0, 0,
undefined, selectedUsernames,
searchKeyword, currentSearchKeyword,
topTs + 1, topTs + 1,
undefined undefined
); );
@@ -622,8 +696,8 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.getTimeline( const result = await window.electronAPI.sns.getTimeline(
limit, limit,
0, 0,
undefined, selectedUsernames,
searchKeyword, currentSearchKeyword,
startTs, // default undefined startTs, // default undefined
endTs endTs
) )
@@ -637,7 +711,7 @@ export default function SnsPage() {
// Check for newer items above topTs // Check for newer items above topTs
const topTs = result.timeline[0]?.createTime || 0; const topTs = result.timeline[0]?.createTime || 0;
if (topTs > 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)); setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0));
} else { } else {
setHasNewer(false); setHasNewer(false);
@@ -663,8 +737,12 @@ export default function SnsPage() {
setLoading(false) setLoading(false)
setLoadingNewer(false) setLoadingNewer(false)
loadingRef.current = false loadingRef.current = false
if (pendingResetFeedRef.current) {
pendingResetFeedRef.current = false
void loadPosts({ reset: true })
} }
}, [jumpTargetDate, persistSnsPageCache, searchKeyword]) }
}, [persistSnsPageCache])
const stopContactsCountHydration = useCallback((resetProgress = false) => { const stopContactsCountHydration = useCallback((resetProgress = false) => {
contactsCountHydrationTokenRef.current += 1 contactsCountHydrationTokenRef.current += 1
@@ -728,9 +806,23 @@ export default function SnsPage() {
}) })
if (pendingTargets.length === 0) return if (pendingTargets.length === 0) return
const taskId = registerBackgroundTask({
sourcePage: 'sns',
title: '朋友圈联系人计数补算',
detail: `正在补算 ${pendingTargets.length} 个联系人朋友圈条数`,
progressText: `${preResolved}/${totalTargets}`,
cancelable: true
})
let normalizedCounts: Record<string, number> = {} let normalizedCounts: Record<string, number> = {}
try { try {
const result = await window.electronAPI.sns.getUserPostCounts() const result = await window.electronAPI.sns.getUserPostCounts()
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,当前计数查询结束后不再继续分批写入'
})
return
}
if (runToken !== contactsCountHydrationTokenRef.current) return if (runToken !== contactsCountHydrationTokenRef.current) return
if (result.success && result.counts) { if (result.success && result.counts) {
normalizedCounts = Object.fromEntries( normalizedCounts = Object.fromEntries(
@@ -747,12 +839,28 @@ export default function SnsPage() {
} }
} catch (error) { } catch (error) {
console.error('Failed to load contact post counts:', error) console.error('Failed to load contact post counts:', error)
finishBackgroundTask(taskId, 'failed', {
detail: String(error)
})
return
} }
let resolved = preResolved let resolved = preResolved
let cursor = 0 let cursor = 0
const applyBatch = () => { const applyBatch = () => {
if (runToken !== contactsCountHydrationTokenRef.current) return 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) const batch = pendingTargets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE)
if (batch.length === 0) { if (batch.length === 0) {
@@ -762,6 +870,10 @@ export default function SnsPage() {
running: false running: false
}) })
contactsCountBatchTimerRef.current = null contactsCountBatchTimerRef.current = null
finishBackgroundTask(taskId, 'completed', {
detail: '联系人朋友圈条数补算完成',
progressText: `${totalTargets}/${totalTargets}`
})
return return
} }
@@ -789,6 +901,10 @@ export default function SnsPage() {
total: totalTargets, total: totalTargets,
running: resolved < totalTargets running: resolved < totalTargets
}) })
updateBackgroundTask(taskId, {
detail: `已完成 ${resolved}/${totalTargets} 个联系人朋友圈条数补算`,
progressText: `${resolved}/${totalTargets}`
})
if (cursor < totalTargets) { if (cursor < totalTargets) {
contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS) contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS)
@@ -803,6 +919,13 @@ export default function SnsPage() {
// Load Contacts先按最近会话显示联系人再异步统计朋友圈条数并增量排序 // Load Contacts先按最近会话显示联系人再异步统计朋友圈条数并增量排序
const loadContacts = useCallback(async () => { const loadContacts = useCallback(async () => {
const requestToken = ++contactsLoadTokenRef.current const requestToken = ++contactsLoadTokenRef.current
const taskId = registerBackgroundTask({
sourcePage: 'sns',
title: '朋友圈联系人列表加载',
detail: '准备读取联系人缓存与最近会话',
progressText: '初始化',
cancelable: true
})
stopContactsCountHydration(true) stopContactsCountHydration(true)
setContactsLoading(true) setContactsLoading(true)
try { try {
@@ -845,10 +968,20 @@ export default function SnsPage() {
}) })
} }
updateBackgroundTask(taskId, {
detail: '正在读取联系人与最近会话数据',
progressText: '联系人快照'
})
const [contactsResult, sessionsResult] = await Promise.all([ const [contactsResult, sessionsResult] = await Promise.all([
window.electronAPI.chat.getContacts(), window.electronAPI.chat.getContacts(),
window.electronAPI.chat.getSessions() window.electronAPI.chat.getSessions()
]) ])
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,当前联系人查询结束后未继续补齐'
})
return
}
const contactMap = new Map<string, Contact>() const contactMap = new Map<string, Contact>()
const sessionTimestampMap = new Map<string, number>() const sessionTimestampMap = new Map<string, number>()
@@ -904,7 +1037,17 @@ export default function SnsPage() {
// 用 enrichSessionsContactInfo 统一补充头像和显示名 // 用 enrichSessionsContactInfo 统一补充头像和显示名
if (allUsernames.length > 0) { if (allUsernames.length > 0) {
updateBackgroundTask(taskId, {
detail: '正在补齐联系人显示名与头像',
progressText: '联系人补齐'
})
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames) const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,联系人补齐未继续写入'
})
return
}
if (enriched.success && enriched.contacts) { if (enriched.success && enriched.contacts) {
contactsList = contactsList.map((contact) => { contactsList = contactsList.map((contact) => {
const extra = enriched.contacts?.[contact.username] 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) { } catch (error) {
if (requestToken !== contactsLoadTokenRef.current) return if (requestToken !== contactsLoadTokenRef.current) return
console.error('Failed to load contacts:', error) console.error('Failed to load contacts:', error)
stopContactsCountHydration(true) stopContactsCountHydration(true)
finishBackgroundTask(taskId, 'failed', {
detail: String(error)
})
} finally { } finally {
if (requestToken === contactsLoadTokenRef.current) { if (requestToken === contactsLoadTokenRef.current) {
setContactsLoading(false) 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) => { const handlePostDelete = useCallback((postId: string, username: string) => {
setPosts(prev => { setPosts(prev => {
const next = prev.filter(p => p.id !== postId) const next = prev.filter(p => p.id !== postId)
@@ -1029,6 +1196,7 @@ export default function SnsPage() {
stopContactsCountHydration(true) stopContactsCountHydration(true)
setContacts([]) setContacts([])
setPosts([]); setHasMore(true); setHasNewer(false); setPosts([]); setHasMore(true); setHasNewer(false);
setSelectedContactUsernames([])
setSearchKeyword(''); setJumpTargetDate(undefined); setSearchKeyword(''); setJumpTargetDate(undefined);
void hydrateSnsPageCache() void hydrateSnsPageCache()
loadContacts(); loadContacts();
@@ -1046,6 +1214,21 @@ export default function SnsPage() {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [searchKeyword, jumpTargetDate, loadPosts]) }, [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<HTMLDivElement>) => { const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) {
@@ -1186,13 +1369,7 @@ export default function SnsPage() {
<Shield size={20} /> <Shield size={20} />
</button> </button>
<button <button
onClick={() => { onClick={() => openExportDialog({ kind: 'all' })}
setExportResult(null)
setExportProgress(null)
setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all'))
setIsExportDateRangeDialogOpen(false)
setShowExportDialog(true)
}}
className="icon-btn export-btn" className="icon-btn export-btn"
title="导出朋友圈" title="导出朋友圈"
> >
@@ -1214,6 +1391,20 @@ export default function SnsPage() {
</div> </div>
</div> </div>
{selectedContactUsernames.length > 0 && (
<div className="feed-contact-filter-bar">
<span className="feed-contact-filter-label"></span>
<span className="feed-contact-filter-summary">{selectedFeedContactsSummary} </span>
<button
type="button"
className="feed-contact-filter-clear"
onClick={clearSelectedContacts}
>
</button>
</div>
)}
<div className="sns-posts-scroll" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}> <div className="sns-posts-scroll" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
{loadingNewer && ( {loadingNewer && (
<div className="status-indicator loading-newer"> <div className="status-indicator loading-newer">
@@ -1229,7 +1420,7 @@ export default function SnsPage() {
)} )}
<div className="posts-list"> <div className="posts-list">
{posts.map(post => ( {visiblePosts.map(post => (
<SnsPostItem <SnsPostItem
key={post.id} key={post.id}
post={{ ...post, isProtected: triggerInstalled === true }} post={{ ...post, isProtected: triggerInstalled === true }}
@@ -1247,7 +1438,7 @@ export default function SnsPage() {
))} ))}
</div> </div>
{loading && posts.length === 0 && ( {loading && visiblePosts.length === 0 && (
<div className="initial-loading"> <div className="initial-loading">
<div className="loading-pulse"> <div className="loading-pulse">
<div className="pulse-circle"></div> <div className="pulse-circle"></div>
@@ -1256,24 +1447,26 @@ export default function SnsPage() {
</div> </div>
)} )}
{loading && posts.length > 0 && ( {loading && visiblePosts.length > 0 && (
<div className="status-indicator loading-more"> <div className="status-indicator loading-more">
<RefreshCw size={16} className="spinning" /> <RefreshCw size={16} className="spinning" />
<span>...</span> <span>...</span>
</div> </div>
)} )}
{!hasMore && posts.length > 0 && ( {!hasMore && visiblePosts.length > 0 && (
<div className="status-indicator no-more"></div> <div className="status-indicator no-more"></div>
)} )}
{!loading && posts.length === 0 && ( {!loading && visiblePosts.length === 0 && (
<div className="no-results"> <div className="no-results">
<div className="no-results-icon"><Search size={48} /></div> <div className="no-results-icon"><Search size={48} /></div>
<p></p> <p></p>
{(searchKeyword || jumpTargetDate) && ( {(searchKeyword || jumpTargetDate || selectedContactUsernames.length > 0) && (
<button onClick={() => { <button onClick={() => {
setSearchKeyword(''); setJumpTargetDate(undefined); setSearchKeyword('')
setJumpTargetDate(undefined)
clearSelectedContacts()
}} className="reset-inline"> }} className="reset-inline">
</button> </button>
@@ -1299,7 +1492,12 @@ export default function SnsPage() {
setContactSearch={setContactSearch} setContactSearch={setContactSearch}
loading={contactsLoading} loading={contactsLoading}
contactsCountProgress={contactsCountProgress} contactsCountProgress={contactsCountProgress}
selectedContactUsernames={selectedContactUsernames}
activeContactUsername={authorTimelineTarget?.username}
onOpenContactTimeline={openContactTimeline} onOpenContactTimeline={openContactTimeline}
onToggleContactSelected={toggleContactSelected}
onClearSelectedContacts={clearSelectedContacts}
onExportSelectedContacts={openSelectedContactsExport}
/> />
{/* Dialogs and Overlays */} {/* Dialogs and Overlays */}
@@ -1444,9 +1642,12 @@ export default function SnsPage() {
<div className="export-dialog-body"> <div className="export-dialog-body">
{/* 筛选条件提示 */} {/* 筛选条件提示 */}
{searchKeyword && ( {(searchKeyword || exportScope.kind === 'selected') && (
<div className="export-filter-info"> <div className="export-filter-info">
<span className="filter-badge"></span> <span className="filter-badge"></span>
{exportScope.kind === 'selected' && (
<span className="filter-tag">: {exportSelectedContactsSummary}</span>
)}
{searchKeyword && <span className="filter-tag">: "{searchKeyword}"</span>} {searchKeyword && <span className="filter-tag">: "{searchKeyword}"</span>}
</div> </div>
)} )}
@@ -1572,7 +1773,7 @@ export default function SnsPage() {
{/* 同步提示 */} {/* 同步提示 */}
<div className="export-sync-hint"> <div className="export-sync-hint">
<Info size={14} /> <Info size={14} />
<span></span> <span>{exportScope.kind === 'selected' ? '将同步主页面的关键词搜索,并仅导出所选联系人' : '将同步主页面的关键词搜索'}</span>
</div> </div>
{/* 进度条 */} {/* 进度条 */}
@@ -1599,7 +1800,7 @@ export default function SnsPage() {
</button> </button>
<button <button
className="export-start-btn" className="export-start-btn"
disabled={!exportFolder || isExporting} disabled={!canStartExport}
onClick={async () => { onClick={async () => {
setIsExporting(true) setIsExporting(true)
setExportProgress({ current: 0, total: 0, status: '准备导出...' }) setExportProgress({ current: 0, total: 0, status: '准备导出...' })
@@ -1614,6 +1815,7 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.exportTimeline({ const result = await window.electronAPI.sns.exportTimeline({
outputDir: exportFolder, outputDir: exportFolder,
format: exportFormat, format: exportFormat,
usernames: exportScope.kind === 'selected' ? exportScope.usernames : undefined,
keyword: searchKeyword || undefined, keyword: searchKeyword || undefined,
exportImages, exportImages,
exportLivePhotos, exportLivePhotos,

View File

@@ -0,0 +1,149 @@
import type {
BackgroundTaskInput,
BackgroundTaskRecord,
BackgroundTaskStatus,
BackgroundTaskUpdate
} from '../types/backgroundTask'
type BackgroundTaskListener = (tasks: BackgroundTaskRecord[]) => void
const tasks = new Map<string, BackgroundTaskRecord>()
const cancelHandlers = new Map<string, () => void | Promise<void>>()
const listeners = new Set<BackgroundTaskListener>()
let taskSequence = 0
const ACTIVE_STATUSES = new Set<BackgroundTaskStatus>(['running', 'cancel_requested'])
const MAX_SETTLED_TASKS = 24
const buildTaskId = (): string => {
taskSequence += 1
return `bg-task-${Date.now()}-${taskSequence}`
}
const notifyListeners = () => {
const snapshot = getBackgroundTaskSnapshot()
for (const listener of listeners) {
listener(snapshot)
}
}
const pruneSettledTasks = () => {
const settledTasks = [...tasks.values()]
.filter(task => !ACTIVE_STATUSES.has(task.status))
.sort((a, b) => (b.finishedAt || b.updatedAt) - (a.finishedAt || a.updatedAt))
for (const staleTask of settledTasks.slice(MAX_SETTLED_TASKS)) {
tasks.delete(staleTask.id)
}
}
export const getBackgroundTaskSnapshot = (): BackgroundTaskRecord[] => (
[...tasks.values()].sort((a, b) => {
const aActive = ACTIVE_STATUSES.has(a.status) ? 1 : 0
const bActive = ACTIVE_STATUSES.has(b.status) ? 1 : 0
if (aActive !== bActive) return bActive - aActive
return b.updatedAt - a.updatedAt
})
)
export const subscribeBackgroundTasks = (listener: BackgroundTaskListener): (() => void) => {
listeners.add(listener)
listener(getBackgroundTaskSnapshot())
return () => {
listeners.delete(listener)
}
}
export const registerBackgroundTask = (input: BackgroundTaskInput): string => {
const now = Date.now()
const taskId = buildTaskId()
tasks.set(taskId, {
id: taskId,
sourcePage: input.sourcePage,
title: input.title,
detail: input.detail,
progressText: input.progressText,
cancelable: input.cancelable !== false,
cancelRequested: false,
status: 'running',
startedAt: now,
updatedAt: now
})
if (input.onCancel) {
cancelHandlers.set(taskId, input.onCancel)
}
pruneSettledTasks()
notifyListeners()
return taskId
}
export const updateBackgroundTask = (taskId: string, patch: BackgroundTaskUpdate): void => {
const existing = tasks.get(taskId)
if (!existing) return
const nextStatus = patch.status || existing.status
const nextUpdatedAt = Date.now()
tasks.set(taskId, {
...existing,
...patch,
status: nextStatus,
updatedAt: nextUpdatedAt,
finishedAt: ACTIVE_STATUSES.has(nextStatus) ? undefined : (existing.finishedAt || nextUpdatedAt)
})
pruneSettledTasks()
notifyListeners()
}
export const finishBackgroundTask = (
taskId: string,
status: Extract<BackgroundTaskStatus, 'completed' | 'failed' | 'canceled'>,
patch?: Omit<BackgroundTaskUpdate, 'status'>
): void => {
const existing = tasks.get(taskId)
if (!existing) return
const now = Date.now()
tasks.set(taskId, {
...existing,
...patch,
status,
updatedAt: now,
finishedAt: now,
cancelRequested: status === 'canceled' ? true : existing.cancelRequested
})
cancelHandlers.delete(taskId)
pruneSettledTasks()
notifyListeners()
}
export const requestCancelBackgroundTask = (taskId: string): boolean => {
const existing = tasks.get(taskId)
if (!existing || !existing.cancelable || !ACTIVE_STATUSES.has(existing.status)) return false
tasks.set(taskId, {
...existing,
status: 'cancel_requested',
cancelRequested: true,
detail: existing.detail || '停止请求已发出,当前查询完成后会结束后续加载',
updatedAt: Date.now()
})
const cancelHandler = cancelHandlers.get(taskId)
if (cancelHandler) {
void Promise.resolve(cancelHandler()).catch(() => {})
}
notifyListeners()
return true
}
export const requestCancelBackgroundTasks = (predicate: (task: BackgroundTaskRecord) => boolean): number => {
let canceledCount = 0
for (const task of tasks.values()) {
if (!predicate(task)) continue
if (requestCancelBackgroundTask(task.id)) {
canceledCount += 1
}
}
return canceledCount
}
export const isBackgroundTaskCancelRequested = (taskId: string): boolean => {
const task = tasks.get(taskId)
return Boolean(task?.cancelRequested)
}

View File

@@ -0,0 +1,46 @@
export type BackgroundTaskSourcePage =
| 'export'
| 'chat'
| 'analytics'
| 'sns'
| 'groupAnalytics'
| 'annualReport'
| 'other'
export type BackgroundTaskStatus =
| 'running'
| 'cancel_requested'
| 'completed'
| 'failed'
| 'canceled'
export interface BackgroundTaskRecord {
id: string
sourcePage: BackgroundTaskSourcePage
title: string
detail?: string
progressText?: string
cancelable: boolean
cancelRequested: boolean
status: BackgroundTaskStatus
startedAt: number
updatedAt: number
finishedAt?: number
}
export interface BackgroundTaskInput {
sourcePage: BackgroundTaskSourcePage
title: string
detail?: string
progressText?: string
cancelable?: boolean
onCancel?: () => void | Promise<void>
}
export interface BackgroundTaskUpdate {
title?: string
detail?: string
progressText?: string
status?: BackgroundTaskStatus
cancelable?: boolean
}