mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
@@ -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
|
||||||
|
|||||||
74
src/App.tsx
74
src/App.tsx
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
136
src/components/ChatAnalysisHeader.scss
Normal file
136
src/components/ChatAnalysisHeader.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/components/ChatAnalysisHeader.tsx
Normal file
105
src/components/ChatAnalysisHeader.tsx
Normal 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
|
||||||
41
src/components/ErrorBoundary.tsx
Normal file
41
src/components/ErrorBoundary.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
123
src/pages/ChatAnalyticsHubPage.scss
Normal file
123
src/pages/ChatAnalyticsHubPage.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/pages/ChatAnalyticsHubPage.tsx
Normal file
59
src/pages/ChatAnalyticsHubPage.tsx
Normal 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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -63,10 +122,12 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
149
src/services/backgroundTaskMonitor.ts
Normal file
149
src/services/backgroundTaskMonitor.ts
Normal 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)
|
||||||
|
}
|
||||||
46
src/types/backgroundTask.ts
Normal file
46
src/types/backgroundTask.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user