mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat(export): add sns stats card and conversation tab updates
This commit is contained in:
@@ -1020,6 +1020,10 @@ function registerIpcHandlers() {
|
||||
return snsService.getSnsUsernames()
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getExportStats', async () => {
|
||||
return snsService.getExportStats()
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:debugResource', async (_, url: string) => {
|
||||
return snsService.debugResource(url)
|
||||
})
|
||||
|
||||
@@ -288,6 +288,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
||||
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
|
||||
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
|
||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
|
||||
|
||||
@@ -235,6 +235,13 @@ class SnsService {
|
||||
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[] {
|
||||
if (!xml) return []
|
||||
const likes: string[] = []
|
||||
@@ -359,6 +366,39 @@ class SnsService {
|
||||
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 }> {
|
||||
return wcdbService.installSnsBlockDeleteTrigger()
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
&.collapsed {
|
||||
width: 64px;
|
||||
|
||||
.sidebar-user-card {
|
||||
margin: 0 8px 8px;
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
|
||||
.user-meta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu,
|
||||
.sidebar-footer {
|
||||
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 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -2,19 +2,69 @@ import { useState, useEffect } from 'react'
|
||||
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 { useAppStore } from '../stores/appStore'
|
||||
import * as configService from '../services/config'
|
||||
|
||||
import './Sidebar.scss'
|
||||
|
||||
interface SidebarUserProfile {
|
||||
wxid: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const location = useLocation()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [authEnabled, setAuthEnabled] = useState(false)
|
||||
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
|
||||
wxid: '',
|
||||
displayName: '未识别用户'
|
||||
})
|
||||
const setLocked = useAppStore(state => state.setLocked)
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||
}
|
||||
@@ -106,6 +156,19 @@ function Sidebar() {
|
||||
</nav>
|
||||
|
||||
<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 && (
|
||||
<button
|
||||
className="nav-item"
|
||||
|
||||
@@ -14,67 +14,10 @@
|
||||
}
|
||||
|
||||
.export-top-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 380px) 1fr;
|
||||
gap: 14px;
|
||||
display: block;
|
||||
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 {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -120,6 +63,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.layout-trigger {
|
||||
@@ -142,14 +86,16 @@
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-md);
|
||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.28);
|
||||
padding: 6px;
|
||||
z-index: 20;
|
||||
z-index: 3000;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
opacity: 1;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.layout-option {
|
||||
@@ -188,7 +134,7 @@
|
||||
|
||||
.content-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(150px, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -397,6 +343,7 @@
|
||||
.table-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.tab-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -406,6 +353,9 @@
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import {
|
||||
Aperture,
|
||||
CheckSquare,
|
||||
Download,
|
||||
ExternalLink,
|
||||
@@ -20,10 +21,11 @@ import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../
|
||||
import * as configService from '../services/config'
|
||||
import './ExportPage.scss'
|
||||
|
||||
type ConversationTab = 'private' | 'group' | 'official'
|
||||
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
||||
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 ContentCardType = ContentType | 'sns'
|
||||
|
||||
type SessionLayout = 'shared' | 'per-session'
|
||||
|
||||
@@ -80,10 +82,16 @@ interface TaskProgress {
|
||||
interface ExportTaskPayload {
|
||||
sessionIds: string[]
|
||||
outputDir: string
|
||||
options: ElectronExportOptions
|
||||
options?: ElectronExportOptions
|
||||
scope: TaskScope
|
||||
contentType?: ContentType
|
||||
sessionNames: string[]
|
||||
snsOptions?: {
|
||||
format: 'json' | 'html'
|
||||
exportMedia?: boolean
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface ExportTask {
|
||||
@@ -107,12 +115,6 @@ interface ExportDialogState {
|
||||
title: string
|
||||
}
|
||||
|
||||
interface CurrentUserProfile {
|
||||
wxid: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||
const contentTypeLabels: Record<ContentType, string> = {
|
||||
text: '聊天文本',
|
||||
@@ -212,6 +214,7 @@ const parseDateInput = (value: string, endOfDay: boolean): Date => {
|
||||
const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => {
|
||||
if (session.username.endsWith('@chatroom')) return 'group'
|
||||
if (contact?.type === 'official') return 'official'
|
||||
if (contact?.type === 'former_friend') return 'former_friend'
|
||||
return 'private'
|
||||
}
|
||||
|
||||
@@ -242,8 +245,6 @@ function ExportPage() {
|
||||
const [activeTab, setActiveTab] = useState<ConversationTab>('private')
|
||||
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
||||
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUserProfile>({ wxid: '', displayName: '未识别用户' })
|
||||
|
||||
const [exportFolder, setExportFolder] = useState('')
|
||||
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A')
|
||||
const [showWriteLayoutSelect, setShowWriteLayoutSelect] = useState(false)
|
||||
@@ -279,6 +280,11 @@ function ExportPage() {
|
||||
const [tasks, setTasks] = useState<ExportTask[]>([])
|
||||
const [lastExportBySession, setLastExportBySession] = 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 progressUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
@@ -308,32 +314,9 @@ function ExportPage() {
|
||||
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 () => {
|
||||
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.getExportDefaultFormat(),
|
||||
configService.getExportDefaultMedia(),
|
||||
@@ -343,7 +326,8 @@ function ExportPage() {
|
||||
configService.getExportDefaultConcurrency(),
|
||||
configService.getExportWriteLayout(),
|
||||
configService.getExportLastSessionRunMap(),
|
||||
configService.getExportLastContentRunMap()
|
||||
configService.getExportLastContentRunMap(),
|
||||
configService.getExportLastSnsPostCount()
|
||||
])
|
||||
|
||||
if (savedPath) {
|
||||
@@ -356,6 +340,7 @@ function ExportPage() {
|
||||
setWriteLayout(savedWriteLayout)
|
||||
setLastExportBySession(savedSessionMap)
|
||||
setLastExportByContent(savedContentMap)
|
||||
setLastSnsExportPostCount(savedSnsPostCount)
|
||||
|
||||
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
|
||||
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 () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
@@ -418,10 +417,10 @@ function ExportPage() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadCurrentUser()
|
||||
loadBaseConfig()
|
||||
loadSessions()
|
||||
}, [loadCurrentUser, loadBaseConfig, loadSessions])
|
||||
loadSnsStats()
|
||||
}, [loadBaseConfig, loadSessions, loadSnsStats])
|
||||
|
||||
useEffect(() => {
|
||||
preselectAppliedRef.current = false
|
||||
@@ -541,6 +540,14 @@ function ExportPage() {
|
||||
const openExportDialog = (payload: Omit<ExportDialogState, 'open'>) => {
|
||||
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.contentType === 'text') {
|
||||
setOptions(prev => ({ ...prev, exportMedia: false }))
|
||||
@@ -613,6 +620,25 @@ function ExportPage() {
|
||||
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) => {
|
||||
setLastExportBySession(prev => {
|
||||
const next = { ...prev }
|
||||
@@ -663,6 +689,21 @@ function ExportPage() {
|
||||
updateTask(next.id, task => ({ ...task, status: 'running', startedAt: Date.now() }))
|
||||
|
||||
progressUnsubscribeRef.current?.()
|
||||
if (next.payload.scope === 'sns') {
|
||||
progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => {
|
||||
updateTask(next.id, task => ({
|
||||
...task,
|
||||
progress: {
|
||||
current: payload.current || 0,
|
||||
total: payload.total || 0,
|
||||
currentName: '',
|
||||
phaseLabel: payload.status || '',
|
||||
phaseProgress: payload.total > 0 ? payload.current : 0,
|
||||
phaseTotal: payload.total || 0
|
||||
}
|
||||
}))
|
||||
})
|
||||
} else {
|
||||
progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => {
|
||||
updateTask(next.id, task => ({
|
||||
...task,
|
||||
@@ -676,8 +717,53 @@ function ExportPage() {
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -714,6 +800,7 @@ function ExportPage() {
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
updateTask(next.id, task => ({
|
||||
...task,
|
||||
@@ -727,7 +814,7 @@ function ExportPage() {
|
||||
runningTaskIdRef.current = null
|
||||
void runNextTask()
|
||||
}
|
||||
}, [updateTask, markSessionExported, markContentExported])
|
||||
}, [updateTask, markSessionExported, markContentExported, loadSnsStats, lastSnsExportPostCount])
|
||||
|
||||
useEffect(() => {
|
||||
void runNextTask()
|
||||
@@ -741,14 +828,22 @@ function ExportPage() {
|
||||
}, [])
|
||||
|
||||
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 =
|
||||
exportDialog.scope === 'single'
|
||||
? `${exportDialog.sessionNames[0] || '会话'} 导出`
|
||||
: exportDialog.scope === 'multi'
|
||||
? `批量导出(${exportDialog.sessionIds.length} 个会话)`
|
||||
: exportDialog.scope === 'sns'
|
||||
? '朋友圈批量导出'
|
||||
: `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出`
|
||||
|
||||
const task: ExportTask = {
|
||||
@@ -762,7 +857,8 @@ function ExportPage() {
|
||||
outputDir: exportFolder,
|
||||
options: exportOptions,
|
||||
scope: exportDialog.scope,
|
||||
contentType: exportDialog.contentType
|
||||
contentType: exportDialog.contentType,
|
||||
snsOptions
|
||||
},
|
||||
progress: createEmptyProgress()
|
||||
}
|
||||
@@ -819,6 +915,15 @@ function ExportPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const openSnsExport = () => {
|
||||
openExportDialog({
|
||||
scope: 'sns',
|
||||
sessionIds: [],
|
||||
sessionNames: ['全部朋友圈动态'],
|
||||
title: '朋友圈批量导出'
|
||||
})
|
||||
}
|
||||
|
||||
const runningSessionIds = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const task of tasks) {
|
||||
@@ -841,11 +946,25 @@ function ExportPage() {
|
||||
return set
|
||||
}, [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 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: 'voice' as ContentType, icon: Mic },
|
||||
{ type: 'image' as ContentType, icon: ImageIcon },
|
||||
@@ -862,15 +981,31 @@ function ExportPage() {
|
||||
return {
|
||||
...item,
|
||||
label: contentTypeLabels[item.type],
|
||||
total,
|
||||
exported
|
||||
stats: [
|
||||
{ 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(() => {
|
||||
if (activeTab === 'private') return '私聊'
|
||||
if (activeTab === 'group') return '群聊'
|
||||
if (activeTab === 'former_friend') return '曾经的好友'
|
||||
return '公众号'
|
||||
}, [activeTab])
|
||||
|
||||
@@ -913,7 +1048,7 @@ function ExportPage() {
|
||||
}
|
||||
|
||||
const renderTableHeader = () => {
|
||||
if (activeTab === 'private') {
|
||||
if (activeTab === 'private' || activeTab === 'former_friend') {
|
||||
return (
|
||||
<tr>
|
||||
<th className="sticky-col">选择</th>
|
||||
@@ -991,7 +1126,7 @@ function ExportPage() {
|
||||
<td>{valueOrDash(metrics.videoMessages)}</td>
|
||||
<td>{valueOrDash(metrics.emojiMessages)}</td>
|
||||
|
||||
{activeTab === 'private' && (
|
||||
{(activeTab === 'private' || activeTab === 'former_friend') && (
|
||||
<>
|
||||
<td>{valueOrDash(metrics.privateMutualGroups)}</td>
|
||||
<td>{timestampOrDash(metrics.firstTimestamp)}</td>
|
||||
@@ -1032,20 +1167,27 @@ function ExportPage() {
|
||||
}, [visibleSessions, selectedSessions])
|
||||
|
||||
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 (
|
||||
<div className="export-board-page">
|
||||
<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="path-control">
|
||||
<span className="control-label">导出位置</span>
|
||||
@@ -1074,7 +1216,7 @@ function ExportPage() {
|
||||
</div>
|
||||
|
||||
<div className="write-layout-control">
|
||||
<span className="control-label">写入格式</span>
|
||||
<span className="control-label">写入目录方式</span>
|
||||
<button className="layout-trigger" onClick={() => setShowWriteLayoutSelect(prev => !prev)}>
|
||||
{writeLayoutLabel}
|
||||
</button>
|
||||
@@ -1109,16 +1251,25 @@ function ExportPage() {
|
||||
<div className="card-title"><Icon size={16} /> {card.label}</div>
|
||||
</div>
|
||||
<div className="card-stats">
|
||||
<div className="stat-item">
|
||||
<span>总会话数</span>
|
||||
<strong>{card.total}</strong>
|
||||
{card.stats.map((stat) => (
|
||||
<div key={stat.label} className="stat-item">
|
||||
<span>{stat.label}</span>
|
||||
<strong>{stat.value.toLocaleString()}</strong>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span>已导出会话数</span>
|
||||
<strong>{card.exported}</strong>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
@@ -1147,7 +1298,9 @@ function ExportPage() {
|
||||
/>
|
||||
</div>
|
||||
<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}` : ''}
|
||||
</div>
|
||||
</>
|
||||
@@ -1168,9 +1321,18 @@ function ExportPage() {
|
||||
<div className="session-table-section">
|
||||
<div className="table-toolbar">
|
||||
<div className="table-tabs" role="tablist" aria-label="会话类型">
|
||||
<button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}>私聊</button>
|
||||
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}>群聊</button>
|
||||
<button className={`tab-btn ${activeTab === 'official' ? 'active' : ''}`} onClick={() => setActiveTab('official')}>公众号</button>
|
||||
<button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}>
|
||||
私聊({tabCounts.private})
|
||||
</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 className="toolbar-actions">
|
||||
@@ -1210,13 +1372,13 @@ function ExportPage() {
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={activeTab === 'group' ? 14 : activeTab === 'private' ? 11 : 10}>
|
||||
<td colSpan={tableColSpan}>
|
||||
<div className="table-state"><Loader2 size={16} className="spin" />加载中...</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : visibleSessions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={activeTab === 'group' ? 14 : activeTab === 'private' ? 11 : 10}>
|
||||
<td colSpan={tableColSpan}>
|
||||
<div className="table-state">暂无会话</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1239,8 +1401,8 @@ function ExportPage() {
|
||||
<div className="dialog-section">
|
||||
<h4>导出范围</h4>
|
||||
<div className="scope-tag-row">
|
||||
<span className="scope-tag">{exportDialog.scope === 'single' ? '单会话' : exportDialog.scope === 'multi' ? '多会话' : `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']})`}</span>
|
||||
<span className="scope-count">共 {exportDialog.sessionIds.length} 个会话</span>
|
||||
<span className="scope-tag">{scopeLabel}</span>
|
||||
<span className="scope-count">{scopeCountLabel}</span>
|
||||
</div>
|
||||
<div className="scope-list">
|
||||
{exportDialog.sessionNames.slice(0, 20).map(name => (
|
||||
@@ -1253,7 +1415,7 @@ function ExportPage() {
|
||||
<div className="dialog-section">
|
||||
<h4>对话文本导出格式选择</h4>
|
||||
<div className="format-grid">
|
||||
{formatOptions.map(option => (
|
||||
{formatCandidateOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={`format-card ${options.format === option.value ? 'active' : ''}`}
|
||||
@@ -1363,7 +1525,7 @@ function ExportPage() {
|
||||
|
||||
<div className="dialog-actions">
|
||||
<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} /> 创建导出任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ export const CONFIG_KEYS = {
|
||||
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
|
||||
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
|
||||
EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap',
|
||||
EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount',
|
||||
|
||||
// 安全
|
||||
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)
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -539,6 +539,7 @@ export interface ElectronAPI {
|
||||
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
||||
selectExportDir: () => Promise<{ canceled: boolean; filePath?: 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 }>
|
||||
uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>
|
||||
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>
|
||||
|
||||
Reference in New Issue
Block a user