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()
|
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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
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
|
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 }>
|
||||||
|
|||||||
Reference in New Issue
Block a user