feat(export): add sns stats card and conversation tab updates

This commit is contained in:
tisonhuang
2026-03-01 15:20:08 +08:00
parent e686bb6247
commit 596baad296
9 changed files with 491 additions and 188 deletions

View File

@@ -1020,6 +1020,10 @@ function registerIpcHandlers() {
return snsService.getSnsUsernames() return snsService.getSnsUsernames()
}) })
ipcMain.handle('sns:getExportStats', async () => {
return snsService.getExportStats()
})
ipcMain.handle('sns:debugResource', async (_, url: string) => { ipcMain.handle('sns:debugResource', async (_, url: string) => {
return snsService.debugResource(url) return snsService.debugResource(url)
}) })

View File

@@ -288,6 +288,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload), downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),

View File

@@ -235,6 +235,13 @@ class SnsService {
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string) this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
} }
private parseCountValue(row: any): number {
if (!row || typeof row !== 'object') return 0
const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0]
const num = Number(raw)
return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0
}
private parseLikesFromXml(xml: string): string[] { private parseLikesFromXml(xml: string): string[] {
if (!xml) return [] if (!xml) return []
const likes: string[] = [] const likes: string[] = []
@@ -359,6 +366,39 @@ class SnsService {
return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) } return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) }
} }
async getExportStats(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> {
try {
let totalPosts = 0
const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine')
if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) {
totalPosts = this.parseCountValue(postCountResult.rows[0])
}
let totalFriends = 0
const friendCountPrimary = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
)
if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) {
totalFriends = this.parseCountValue(friendCountPrimary.rows[0])
} else {
const friendCountFallback = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
)
if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) {
totalFriends = this.parseCountValue(friendCountFallback.rows[0])
}
}
return { success: true, data: { totalPosts, totalFriends } }
} catch (e) {
return { success: false, error: String(e) }
}
}
// 安装朋友圈删除拦截 // 安装朋友圈删除拦截
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
return wcdbService.installSnsBlockDeleteTrigger() return wcdbService.installSnsBlockDeleteTrigger()

View File

@@ -10,6 +10,16 @@
&.collapsed { &.collapsed {
width: 64px; width: 64px;
.sidebar-user-card {
margin: 0 8px 8px;
padding: 8px 0;
justify-content: center;
.user-meta {
display: none;
}
}
.nav-menu, .nav-menu,
.sidebar-footer { .sidebar-footer {
padding: 0 8px; padding: 0 8px;
@@ -27,6 +37,64 @@
} }
} }
.sidebar-user-card {
margin: 0 12px 10px;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
.user-avatar {
width: 36px;
height: 36px;
border-radius: 10px;
overflow: hidden;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: #fff;
font-size: 14px;
font-weight: 600;
}
}
.user-meta {
min-width: 0;
}
.user-name {
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-wxid {
margin-top: 2px;
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.nav-menu { .nav-menu {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -130,4 +198,4 @@
background: rgba(209, 158, 187, 0.15); background: rgba(209, 158, 187, 0.15);
color: #D19EBB; color: #D19EBB;
border: 1px solid rgba(209, 158, 187, 0.2); border: 1px solid rgba(209, 158, 187, 0.2);
} }

View File

@@ -2,19 +2,69 @@ import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom' import { NavLink, useLocation } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react' import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import * as configService from '../services/config'
import './Sidebar.scss' import './Sidebar.scss'
interface SidebarUserProfile {
wxid: string
displayName: string
avatarUrl?: string
}
function Sidebar() { function Sidebar() {
const location = useLocation() const location = useLocation()
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [authEnabled, setAuthEnabled] = useState(false) const [authEnabled, setAuthEnabled] = useState(false)
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
wxid: '',
displayName: '未识别用户'
})
const setLocked = useAppStore(state => state.setLocked) const setLocked = useAppStore(state => state.setLocked)
useEffect(() => { useEffect(() => {
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled) window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
}, []) }, [])
useEffect(() => {
const loadCurrentUser = async () => {
try {
const wxid = await configService.getMyWxid()
let displayName = wxid || '未识别用户'
if (wxid) {
const myContact = await window.electronAPI.chat.getContact(wxid)
const bestName = [myContact?.remark, myContact?.nickName, myContact?.alias, wxid].find(Boolean)
if (bestName) displayName = bestName
}
let avatarUrl: string | undefined
const avatarResult = await window.electronAPI.chat.getMyAvatarUrl()
if (avatarResult.success && avatarResult.avatarUrl) {
avatarUrl = avatarResult.avatarUrl
}
setUserProfile({
wxid: wxid || '',
displayName,
avatarUrl
})
} catch (error) {
console.error('加载侧边栏用户信息失败:', error)
}
}
void loadCurrentUser()
const onWxidChanged = () => { void loadCurrentUser() }
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
}, [])
const getAvatarLetter = (name: string): string => {
if (!name) return '?'
return [...name][0] || '?'
}
const isActive = (path: string) => { const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`) return location.pathname === path || location.pathname.startsWith(`${path}/`)
} }
@@ -106,6 +156,19 @@ function Sidebar() {
</nav> </nav>
<div className="sidebar-footer"> <div className="sidebar-footer">
<div
className="sidebar-user-card"
title={collapsed ? `${userProfile.displayName}${userProfile.wxid ? `\n${userProfile.wxid}` : ''}` : undefined}
>
<div className="user-avatar">
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
</div>
<div className="user-meta">
<div className="user-name">{userProfile.displayName}</div>
<div className="user-wxid">{userProfile.wxid || 'wxid 未识别'}</div>
</div>
</div>
{authEnabled && ( {authEnabled && (
<button <button
className="nav-item" className="nav-item"

View File

@@ -14,67 +14,10 @@
} }
.export-top-panel { .export-top-panel {
display: grid; display: block;
grid-template-columns: minmax(260px, 380px) 1fr;
gap: 14px;
flex-shrink: 0; flex-shrink: 0;
} }
.current-user-box {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 14px;
display: flex;
align-items: center;
gap: 12px;
.avatar-wrap {
width: 48px;
height: 48px;
border-radius: 12px;
overflow: hidden;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: #fff;
font-size: 18px;
font-weight: 600;
}
}
.user-meta {
min-width: 0;
}
.user-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-wxid {
margin-top: 2px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.global-export-controls { .global-export-controls {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -120,6 +63,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
z-index: 40;
} }
.layout-trigger { .layout-trigger {
@@ -142,14 +86,16 @@
top: calc(100% + 6px); top: calc(100% + 6px);
left: 0; left: 0;
right: 0; right: 0;
background: var(--card-bg); background: var(--bg-primary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
box-shadow: var(--shadow-md); box-shadow: 0 16px 36px rgba(0, 0, 0, 0.28);
padding: 6px; padding: 6px;
z-index: 20; z-index: 3000;
max-height: 260px; max-height: 260px;
overflow-y: auto; overflow-y: auto;
opacity: 1;
backdrop-filter: none;
} }
.layout-option { .layout-option {
@@ -188,7 +134,7 @@
.content-card-grid { .content-card-grid {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(150px, 1fr)); grid-template-columns: repeat(6, minmax(150px, 1fr));
gap: 10px; gap: 10px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -397,6 +343,7 @@
.table-tabs { .table-tabs {
display: flex; display: flex;
gap: 8px; gap: 8px;
flex-wrap: wrap;
.tab-btn { .tab-btn {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -406,6 +353,9 @@
border-radius: 999px; border-radius: 999px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
white-space: nowrap;
display: inline-flex;
align-items: center;
&.active { &.active {
border-color: var(--primary); border-color: var(--primary);

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { import {
Aperture,
CheckSquare, CheckSquare,
Download, Download,
ExternalLink, ExternalLink,
@@ -20,10 +21,11 @@ import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../
import * as configService from '../services/config' import * as configService from '../services/config'
import './ExportPage.scss' import './ExportPage.scss'
type ConversationTab = 'private' | 'group' | 'official' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
type TaskStatus = 'queued' | 'running' | 'success' | 'error' type TaskStatus = 'queued' | 'running' | 'success' | 'error'
type TaskScope = 'single' | 'multi' | 'content' type TaskScope = 'single' | 'multi' | 'content' | 'sns'
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji'
type ContentCardType = ContentType | 'sns'
type SessionLayout = 'shared' | 'per-session' type SessionLayout = 'shared' | 'per-session'
@@ -80,10 +82,16 @@ interface TaskProgress {
interface ExportTaskPayload { interface ExportTaskPayload {
sessionIds: string[] sessionIds: string[]
outputDir: string outputDir: string
options: ElectronExportOptions options?: ElectronExportOptions
scope: TaskScope scope: TaskScope
contentType?: ContentType contentType?: ContentType
sessionNames: string[] sessionNames: string[]
snsOptions?: {
format: 'json' | 'html'
exportMedia?: boolean
startTime?: number
endTime?: number
}
} }
interface ExportTask { interface ExportTask {
@@ -107,12 +115,6 @@ interface ExportDialogState {
title: string title: string
} }
interface CurrentUserProfile {
wxid: string
displayName: string
avatarUrl?: string
}
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const contentTypeLabels: Record<ContentType, string> = { const contentTypeLabels: Record<ContentType, string> = {
text: '聊天文本', text: '聊天文本',
@@ -212,6 +214,7 @@ const parseDateInput = (value: string, endOfDay: boolean): Date => {
const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => { const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => {
if (session.username.endsWith('@chatroom')) return 'group' if (session.username.endsWith('@chatroom')) return 'group'
if (contact?.type === 'official') return 'official' if (contact?.type === 'official') return 'official'
if (contact?.type === 'former_friend') return 'former_friend'
return 'private' return 'private'
} }
@@ -242,8 +245,6 @@ function ExportPage() {
const [activeTab, setActiveTab] = useState<ConversationTab>('private') const [activeTab, setActiveTab] = useState<ConversationTab>('private')
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set()) const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
const [currentUser, setCurrentUser] = useState<CurrentUserProfile>({ wxid: '', displayName: '未识别用户' })
const [exportFolder, setExportFolder] = useState('') const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A') const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A')
const [showWriteLayoutSelect, setShowWriteLayoutSelect] = useState(false) const [showWriteLayoutSelect, setShowWriteLayoutSelect] = useState(false)
@@ -279,6 +280,11 @@ function ExportPage() {
const [tasks, setTasks] = useState<ExportTask[]>([]) const [tasks, setTasks] = useState<ExportTask[]>([])
const [lastExportBySession, setLastExportBySession] = useState<Record<string, number>>({}) const [lastExportBySession, setLastExportBySession] = useState<Record<string, number>>({})
const [lastExportByContent, setLastExportByContent] = useState<Record<string, number>>({}) const [lastExportByContent, setLastExportByContent] = useState<Record<string, number>>({})
const [lastSnsExportPostCount, setLastSnsExportPostCount] = useState(0)
const [snsStats, setSnsStats] = useState<{ totalPosts: number; totalFriends: number }>({
totalPosts: 0,
totalFriends: 0
})
const [nowTick, setNowTick] = useState(Date.now()) const [nowTick, setNowTick] = useState(Date.now())
const progressUnsubscribeRef = useRef<(() => void) | null>(null) const progressUnsubscribeRef = useRef<(() => void) | null>(null)
@@ -308,32 +314,9 @@ function ExportPage() {
return () => clearInterval(timer) return () => clearInterval(timer)
}, []) }, [])
const loadCurrentUser = useCallback(async () => {
try {
const wxid = await configService.getMyWxid()
let displayName = wxid || '未识别用户'
let avatarUrl: string | undefined
if (wxid) {
const myContact = await window.electronAPI.chat.getContact(wxid)
const bestName = [myContact?.remark, myContact?.nickName, myContact?.alias, wxid].find(Boolean)
if (bestName) displayName = bestName
}
const avatarResult = await window.electronAPI.chat.getMyAvatarUrl()
if (avatarResult.success && avatarResult.avatarUrl) {
avatarUrl = avatarResult.avatarUrl
}
setCurrentUser({ wxid: wxid || '', displayName, avatarUrl })
} catch (error) {
console.error('加载当前用户信息失败:', error)
}
}, [])
const loadBaseConfig = useCallback(async () => { const loadBaseConfig = useCallback(async () => {
try { try {
const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap] = await Promise.all([ const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount] = await Promise.all([
configService.getExportPath(), configService.getExportPath(),
configService.getExportDefaultFormat(), configService.getExportDefaultFormat(),
configService.getExportDefaultMedia(), configService.getExportDefaultMedia(),
@@ -343,7 +326,8 @@ function ExportPage() {
configService.getExportDefaultConcurrency(), configService.getExportDefaultConcurrency(),
configService.getExportWriteLayout(), configService.getExportWriteLayout(),
configService.getExportLastSessionRunMap(), configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap() configService.getExportLastContentRunMap(),
configService.getExportLastSnsPostCount()
]) ])
if (savedPath) { if (savedPath) {
@@ -356,6 +340,7 @@ function ExportPage() {
setWriteLayout(savedWriteLayout) setWriteLayout(savedWriteLayout)
setLastExportBySession(savedSessionMap) setLastExportBySession(savedSessionMap)
setLastExportByContent(savedContentMap) setLastExportByContent(savedContentMap)
setLastSnsExportPostCount(savedSnsPostCount)
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
setOptions(prev => ({ setOptions(prev => ({
@@ -372,6 +357,20 @@ function ExportPage() {
} }
}, []) }, [])
const loadSnsStats = useCallback(async () => {
try {
const result = await window.electronAPI.sns.getExportStats()
if (result.success && result.data) {
setSnsStats({
totalPosts: result.data.totalPosts || 0,
totalFriends: result.data.totalFriends || 0
})
}
} catch (error) {
console.error('加载朋友圈导出统计失败:', error)
}
}, [])
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
try { try {
@@ -418,10 +417,10 @@ function ExportPage() {
}, []) }, [])
useEffect(() => { useEffect(() => {
loadCurrentUser()
loadBaseConfig() loadBaseConfig()
loadSessions() loadSessions()
}, [loadCurrentUser, loadBaseConfig, loadSessions]) loadSnsStats()
}, [loadBaseConfig, loadSessions, loadSnsStats])
useEffect(() => { useEffect(() => {
preselectAppliedRef.current = false preselectAppliedRef.current = false
@@ -541,6 +540,14 @@ function ExportPage() {
const openExportDialog = (payload: Omit<ExportDialogState, 'open'>) => { const openExportDialog = (payload: Omit<ExportDialogState, 'open'>) => {
setExportDialog({ open: true, ...payload }) setExportDialog({ open: true, ...payload })
if (payload.scope === 'sns') {
setOptions(prev => ({
...prev,
format: prev.format === 'json' || prev.format === 'html' ? prev.format : 'html'
}))
return
}
if (payload.scope === 'content' && payload.contentType) { if (payload.scope === 'content' && payload.contentType) {
if (payload.contentType === 'text') { if (payload.contentType === 'text') {
setOptions(prev => ({ ...prev, exportMedia: false })) setOptions(prev => ({ ...prev, exportMedia: false }))
@@ -613,6 +620,25 @@ function ExportPage() {
return base return base
} }
const buildSnsExportOptions = () => {
const format: 'json' | 'html' = options.format === 'json' ? 'json' : 'html'
const dateRange = options.useAllTime
? null
: options.dateRange
? {
startTime: Math.floor(options.dateRange.start.getTime() / 1000),
endTime: Math.floor(options.dateRange.end.getTime() / 1000)
}
: null
return {
format,
exportMedia: options.exportMedia,
startTime: dateRange?.startTime,
endTime: dateRange?.endTime
}
}
const markSessionExported = useCallback((sessionIds: string[], timestamp: number) => { const markSessionExported = useCallback((sessionIds: string[], timestamp: number) => {
setLastExportBySession(prev => { setLastExportBySession(prev => {
const next = { ...prev } const next = { ...prev }
@@ -663,56 +689,117 @@ function ExportPage() {
updateTask(next.id, task => ({ ...task, status: 'running', startedAt: Date.now() })) updateTask(next.id, task => ({ ...task, status: 'running', startedAt: Date.now() }))
progressUnsubscribeRef.current?.() progressUnsubscribeRef.current?.()
progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => { if (next.payload.scope === 'sns') {
updateTask(next.id, task => ({ progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => {
...task,
progress: {
current: payload.current,
total: payload.total,
currentName: payload.currentSession,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0
}
}))
})
try {
const result = await window.electronAPI.export.exportSessions(
next.payload.sessionIds,
next.payload.outputDir,
next.payload.options
)
if (!result.success) {
updateTask(next.id, task => ({ updateTask(next.id, task => ({
...task, ...task,
status: 'error',
finishedAt: Date.now(),
error: result.error || '导出失败'
}))
} else {
const doneAt = Date.now()
const contentTypes = next.payload.contentType
? [next.payload.contentType]
: inferContentTypesFromOptions(next.payload.options)
markSessionExported(next.payload.sessionIds, doneAt)
markContentExported(next.payload.sessionIds, contentTypes, doneAt)
updateTask(next.id, task => ({
...task,
status: 'success',
finishedAt: doneAt,
progress: { progress: {
...task.progress, current: payload.current || 0,
current: task.progress.total || next.payload.sessionIds.length, total: payload.total || 0,
total: task.progress.total || next.payload.sessionIds.length, currentName: '',
phaseLabel: '完成', phaseLabel: payload.status || '',
phaseProgress: 1, phaseProgress: payload.total > 0 ? payload.current : 0,
phaseTotal: 1 phaseTotal: payload.total || 0
} }
})) }))
})
} else {
progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => {
updateTask(next.id, task => ({
...task,
progress: {
current: payload.current,
total: payload.total,
currentName: payload.currentSession,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0
}
}))
})
}
try {
if (next.payload.scope === 'sns') {
const snsOptions = next.payload.snsOptions || { format: 'html' as const, exportMedia: false }
const result = await window.electronAPI.sns.exportTimeline({
outputDir: next.payload.outputDir,
format: snsOptions.format,
exportMedia: snsOptions.exportMedia,
startTime: snsOptions.startTime,
endTime: snsOptions.endTime
})
if (!result.success) {
updateTask(next.id, task => ({
...task,
status: 'error',
finishedAt: Date.now(),
error: result.error || '朋友圈导出失败'
}))
} else {
const doneAt = Date.now()
const exportedPosts = Math.max(0, result.postCount || 0)
const mergedExportedCount = Math.max(lastSnsExportPostCount, exportedPosts)
setLastSnsExportPostCount(mergedExportedCount)
await configService.setExportLastSnsPostCount(mergedExportedCount)
await loadSnsStats()
updateTask(next.id, task => ({
...task,
status: 'success',
finishedAt: doneAt,
progress: {
...task.progress,
current: exportedPosts,
total: exportedPosts,
phaseLabel: '完成',
phaseProgress: 1,
phaseTotal: 1
}
}))
}
} else {
if (!next.payload.options) {
throw new Error('导出参数缺失')
}
const result = await window.electronAPI.export.exportSessions(
next.payload.sessionIds,
next.payload.outputDir,
next.payload.options
)
if (!result.success) {
updateTask(next.id, task => ({
...task,
status: 'error',
finishedAt: Date.now(),
error: result.error || '导出失败'
}))
} else {
const doneAt = Date.now()
const contentTypes = next.payload.contentType
? [next.payload.contentType]
: inferContentTypesFromOptions(next.payload.options)
markSessionExported(next.payload.sessionIds, doneAt)
markContentExported(next.payload.sessionIds, contentTypes, doneAt)
updateTask(next.id, task => ({
...task,
status: 'success',
finishedAt: doneAt,
progress: {
...task.progress,
current: task.progress.total || next.payload.sessionIds.length,
total: task.progress.total || next.payload.sessionIds.length,
phaseLabel: '完成',
phaseProgress: 1,
phaseTotal: 1
}
}))
}
} }
} catch (error) { } catch (error) {
updateTask(next.id, task => ({ updateTask(next.id, task => ({
@@ -727,7 +814,7 @@ function ExportPage() {
runningTaskIdRef.current = null runningTaskIdRef.current = null
void runNextTask() void runNextTask()
} }
}, [updateTask, markSessionExported, markContentExported]) }, [updateTask, markSessionExported, markContentExported, loadSnsStats, lastSnsExportPostCount])
useEffect(() => { useEffect(() => {
void runNextTask() void runNextTask()
@@ -741,15 +828,23 @@ function ExportPage() {
}, []) }, [])
const createTask = async () => { const createTask = async () => {
if (!exportDialog.open || exportDialog.sessionIds.length === 0 || !exportFolder) return if (!exportDialog.open || !exportFolder) return
if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return
const exportOptions = buildExportOptions(exportDialog.scope, exportDialog.contentType) const exportOptions = exportDialog.scope === 'sns'
? undefined
: buildExportOptions(exportDialog.scope, exportDialog.contentType)
const snsOptions = exportDialog.scope === 'sns'
? buildSnsExportOptions()
: undefined
const title = const title =
exportDialog.scope === 'single' exportDialog.scope === 'single'
? `${exportDialog.sessionNames[0] || '会话'} 导出` ? `${exportDialog.sessionNames[0] || '会话'} 导出`
: exportDialog.scope === 'multi' : exportDialog.scope === 'multi'
? `批量导出(${exportDialog.sessionIds.length} 个会话)` ? `批量导出(${exportDialog.sessionIds.length} 个会话)`
: `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出` : exportDialog.scope === 'sns'
? '朋友圈批量导出'
: `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出`
const task: ExportTask = { const task: ExportTask = {
id: createTaskId(), id: createTaskId(),
@@ -762,7 +857,8 @@ function ExportPage() {
outputDir: exportFolder, outputDir: exportFolder,
options: exportOptions, options: exportOptions,
scope: exportDialog.scope, scope: exportDialog.scope,
contentType: exportDialog.contentType contentType: exportDialog.contentType,
snsOptions
}, },
progress: createEmptyProgress() progress: createEmptyProgress()
} }
@@ -819,6 +915,15 @@ function ExportPage() {
}) })
} }
const openSnsExport = () => {
openExportDialog({
scope: 'sns',
sessionIds: [],
sessionNames: ['全部朋友圈动态'],
title: '朋友圈批量导出'
})
}
const runningSessionIds = useMemo(() => { const runningSessionIds = useMemo(() => {
const set = new Set<string>() const set = new Set<string>()
for (const task of tasks) { for (const task of tasks) {
@@ -841,11 +946,25 @@ function ExportPage() {
return set return set
}, [tasks]) }, [tasks])
const tabCounts = useMemo(() => {
const counts: Record<ConversationTab, number> = {
private: 0,
group: 0,
official: 0,
former_friend: 0
}
for (const session of sessions) {
counts[session.kind] += 1
}
return counts
}, [sessions])
const contentCards = useMemo(() => { const contentCards = useMemo(() => {
const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group') const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group')
const total = scopeSessions.length const totalSessions = scopeSessions.length
const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts)
return [ const sessionCards = [
{ type: 'text' as ContentType, icon: MessageSquareText }, { type: 'text' as ContentType, icon: MessageSquareText },
{ type: 'voice' as ContentType, icon: Mic }, { type: 'voice' as ContentType, icon: Mic },
{ type: 'image' as ContentType, icon: ImageIcon }, { type: 'image' as ContentType, icon: ImageIcon },
@@ -862,15 +981,31 @@ function ExportPage() {
return { return {
...item, ...item,
label: contentTypeLabels[item.type], label: contentTypeLabels[item.type],
total, stats: [
exported { label: '总会话数', value: totalSessions },
{ label: '已导出会话数', value: exported }
]
} }
}) })
}, [sessions, lastExportByContent])
const snsCard = {
type: 'sns' as ContentCardType,
icon: Aperture,
label: '朋友圈',
stats: [
{ label: '朋友圈条数', value: snsStats.totalPosts },
{ label: '好友数', value: snsStats.totalFriends },
{ label: '已导出朋友圈条数', value: snsExportedCount }
]
}
return [...sessionCards, snsCard]
}, [sessions, lastExportByContent, snsStats, lastSnsExportPostCount])
const activeTabLabel = useMemo(() => { const activeTabLabel = useMemo(() => {
if (activeTab === 'private') return '私聊' if (activeTab === 'private') return '私聊'
if (activeTab === 'group') return '群聊' if (activeTab === 'group') return '群聊'
if (activeTab === 'former_friend') return '曾经的好友'
return '公众号' return '公众号'
}, [activeTab]) }, [activeTab])
@@ -913,7 +1048,7 @@ function ExportPage() {
} }
const renderTableHeader = () => { const renderTableHeader = () => {
if (activeTab === 'private') { if (activeTab === 'private' || activeTab === 'former_friend') {
return ( return (
<tr> <tr>
<th className="sticky-col"></th> <th className="sticky-col"></th>
@@ -991,7 +1126,7 @@ function ExportPage() {
<td>{valueOrDash(metrics.videoMessages)}</td> <td>{valueOrDash(metrics.videoMessages)}</td>
<td>{valueOrDash(metrics.emojiMessages)}</td> <td>{valueOrDash(metrics.emojiMessages)}</td>
{activeTab === 'private' && ( {(activeTab === 'private' || activeTab === 'former_friend') && (
<> <>
<td>{valueOrDash(metrics.privateMutualGroups)}</td> <td>{valueOrDash(metrics.privateMutualGroups)}</td>
<td>{timestampOrDash(metrics.firstTimestamp)}</td> <td>{timestampOrDash(metrics.firstTimestamp)}</td>
@@ -1032,20 +1167,27 @@ function ExportPage() {
}, [visibleSessions, selectedSessions]) }, [visibleSessions, selectedSessions])
const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A类型分目录' const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A类型分目录'
const tableColSpan = activeTab === 'group' ? 14 : (activeTab === 'private' || activeTab === 'former_friend' ? 11 : 10)
const canCreateTask = exportDialog.scope === 'sns'
? Boolean(exportFolder)
: Boolean(exportFolder) && exportDialog.sessionIds.length > 0
const scopeLabel = exportDialog.scope === 'single'
? '单会话'
: exportDialog.scope === 'multi'
? '多会话'
: exportDialog.scope === 'sns'
? '朋友圈批量'
: `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']}`
const scopeCountLabel = exportDialog.scope === 'sns'
? `${snsStats.totalPosts} 条朋友圈动态`
: `${exportDialog.sessionIds.length} 个会话`
const formatCandidateOptions = exportDialog.scope === 'sns'
? formatOptions.filter(option => option.value === 'html' || option.value === 'json')
: formatOptions
return ( return (
<div className="export-board-page"> <div className="export-board-page">
<div className="export-top-panel"> <div className="export-top-panel">
<div className="current-user-box">
<div className="avatar-wrap">
{currentUser.avatarUrl ? <img src={currentUser.avatarUrl} alt="" /> : <span>{getAvatarLetter(currentUser.displayName)}</span>}
</div>
<div className="user-meta">
<div className="user-name">{currentUser.displayName}</div>
<div className="user-wxid">{currentUser.wxid || 'wxid 未识别'}</div>
</div>
</div>
<div className="global-export-controls"> <div className="global-export-controls">
<div className="path-control"> <div className="path-control">
<span className="control-label"></span> <span className="control-label"></span>
@@ -1074,7 +1216,7 @@ function ExportPage() {
</div> </div>
<div className="write-layout-control"> <div className="write-layout-control">
<span className="control-label"></span> <span className="control-label"></span>
<button className="layout-trigger" onClick={() => setShowWriteLayoutSelect(prev => !prev)}> <button className="layout-trigger" onClick={() => setShowWriteLayoutSelect(prev => !prev)}>
{writeLayoutLabel} {writeLayoutLabel}
</button> </button>
@@ -1109,16 +1251,25 @@ function ExportPage() {
<div className="card-title"><Icon size={16} /> {card.label}</div> <div className="card-title"><Icon size={16} /> {card.label}</div>
</div> </div>
<div className="card-stats"> <div className="card-stats">
<div className="stat-item"> {card.stats.map((stat) => (
<span></span> <div key={stat.label} className="stat-item">
<strong>{card.total}</strong> <span>{stat.label}</span>
</div> <strong>{stat.value.toLocaleString()}</strong>
<div className="stat-item"> </div>
<span></span> ))}
<strong>{card.exported}</strong>
</div>
</div> </div>
<button className="card-export-btn" onClick={() => openContentExport(card.type)}></button> <button
className="card-export-btn"
onClick={() => {
if (card.type === 'sns') {
openSnsExport()
return
}
openContentExport(card.type)
}}
>
</button>
</div> </div>
) )
})} })}
@@ -1147,7 +1298,9 @@ function ExportPage() {
/> />
</div> </div>
<div className="task-progress-text"> <div className="task-progress-text">
{task.progress.current} / {task.progress.total || task.payload.sessionIds.length} {task.progress.total > 0
? `${task.progress.current} / ${task.progress.total}`
: '处理中'}
{task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''} {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''}
</div> </div>
</> </>
@@ -1168,9 +1321,18 @@ function ExportPage() {
<div className="session-table-section"> <div className="session-table-section">
<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> <button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}>
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}></button> {tabCounts.private}
<button className={`tab-btn ${activeTab === 'official' ? 'active' : ''}`} onClick={() => setActiveTab('official')}></button> </button>
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}>
{tabCounts.group}
</button>
<button className={`tab-btn ${activeTab === 'official' ? 'active' : ''}`} onClick={() => setActiveTab('official')}>
{tabCounts.official}
</button>
<button className={`tab-btn ${activeTab === 'former_friend' ? 'active' : ''}`} onClick={() => setActiveTab('former_friend')}>
{tabCounts.former_friend}
</button>
</div> </div>
<div className="toolbar-actions"> <div className="toolbar-actions">
@@ -1210,13 +1372,13 @@ function ExportPage() {
<tbody> <tbody>
{isLoading ? ( {isLoading ? (
<tr> <tr>
<td colSpan={activeTab === 'group' ? 14 : activeTab === 'private' ? 11 : 10}> <td colSpan={tableColSpan}>
<div className="table-state"><Loader2 size={16} className="spin" />...</div> <div className="table-state"><Loader2 size={16} className="spin" />...</div>
</td> </td>
</tr> </tr>
) : visibleSessions.length === 0 ? ( ) : visibleSessions.length === 0 ? (
<tr> <tr>
<td colSpan={activeTab === 'group' ? 14 : activeTab === 'private' ? 11 : 10}> <td colSpan={tableColSpan}>
<div className="table-state"></div> <div className="table-state"></div>
</td> </td>
</tr> </tr>
@@ -1239,8 +1401,8 @@ function ExportPage() {
<div className="dialog-section"> <div className="dialog-section">
<h4></h4> <h4></h4>
<div className="scope-tag-row"> <div className="scope-tag-row">
<span className="scope-tag">{exportDialog.scope === 'single' ? '单会话' : exportDialog.scope === 'multi' ? '多会话' : `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']}`}</span> <span className="scope-tag">{scopeLabel}</span>
<span className="scope-count"> {exportDialog.sessionIds.length} </span> <span className="scope-count">{scopeCountLabel}</span>
</div> </div>
<div className="scope-list"> <div className="scope-list">
{exportDialog.sessionNames.slice(0, 20).map(name => ( {exportDialog.sessionNames.slice(0, 20).map(name => (
@@ -1253,7 +1415,7 @@ function ExportPage() {
<div className="dialog-section"> <div className="dialog-section">
<h4></h4> <h4></h4>
<div className="format-grid"> <div className="format-grid">
{formatOptions.map(option => ( {formatCandidateOptions.map(option => (
<button <button
key={option.value} key={option.value}
className={`format-card ${options.format === option.value ? 'active' : ''}`} className={`format-card ${options.format === option.value ? 'active' : ''}`}
@@ -1363,7 +1525,7 @@ function ExportPage() {
<div className="dialog-actions"> <div className="dialog-actions">
<button className="secondary-btn" onClick={closeExportDialog}></button> <button className="secondary-btn" onClick={closeExportDialog}></button>
<button className="primary-btn" onClick={() => void createTask()} disabled={!exportFolder || exportDialog.sessionIds.length === 0}> <button className="primary-btn" onClick={() => void createTask()} disabled={!canCreateTask}>
<Download size={14} /> <Download size={14} />
</button> </button>
</div> </div>

View File

@@ -35,6 +35,7 @@ export const CONFIG_KEYS = {
EXPORT_WRITE_LAYOUT: 'exportWriteLayout', EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap', EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap',
EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount',
// 安全 // 安全
AUTH_ENABLED: 'authEnabled', AUTH_ENABLED: 'authEnabled',
@@ -435,6 +436,19 @@ export async function setExportLastContentRunMap(map: Record<string, number>): P
await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map) await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map)
} }
export async function getExportLastSnsPostCount(): Promise<number> {
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT)
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
return Math.floor(value)
}
return 0
}
export async function setExportLastSnsPostCount(count: number): Promise<void> {
const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0
await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized)
}
// === 安全相关 === // === 安全相关 ===
export async function getAuthEnabled(): Promise<boolean> { export async function getAuthEnabled(): Promise<boolean> {

View File

@@ -539,6 +539,7 @@ export interface ElectronAPI {
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }>
installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }>
uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }> uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }> checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>