From 596baad2969d00f185ce18308b53393acf3f8888 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 15:20:08 +0800 Subject: [PATCH] feat(export): add sns stats card and conversation tab updates --- electron/main.ts | 4 + electron/preload.ts | 1 + electron/services/snsService.ts | 40 ++++ src/components/Sidebar.scss | 70 +++++- src/components/Sidebar.tsx | 63 +++++ src/pages/ExportPage.scss | 74 +----- src/pages/ExportPage.tsx | 412 ++++++++++++++++++++++---------- src/services/config.ts | 14 ++ src/types/electron.d.ts | 1 + 9 files changed, 491 insertions(+), 188 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index af89f08..0a47a22 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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) }) diff --git a/electron/preload.ts b/electron/preload.ts index 99aceff..49c3126 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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), diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 835850f..b9f43c2 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -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() diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index d2a1b7f..70781e7 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -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; @@ -130,4 +198,4 @@ background: rgba(209, 158, 187, 0.15); color: #D19EBB; border: 1px solid rgba(209, 158, 187, 0.2); -} \ No newline at end of file +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 0085b6d..2effba3 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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({ + 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() {
+
+
+ {userProfile.avatarUrl ? : {getAvatarLetter(userProfile.displayName)}} +
+
+
{userProfile.displayName}
+
{userProfile.wxid || 'wxid 未识别'}
+
+
+ {authEnabled && ( @@ -1109,16 +1251,25 @@ function ExportPage() {
{card.label}
-
- 总会话数 - {card.total} -
-
- 已导出会话数 - {card.exported} -
+ {card.stats.map((stat) => ( +
+ {stat.label} + {stat.value.toLocaleString()} +
+ ))}
- + ) })} @@ -1147,7 +1298,9 @@ function ExportPage() { />
- {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}` : ''}
@@ -1168,9 +1321,18 @@ function ExportPage() {
- - - + + + +
@@ -1210,13 +1372,13 @@ function ExportPage() { {isLoading ? ( - +
加载中...
) : visibleSessions.length === 0 ? ( - +
暂无会话
@@ -1239,8 +1401,8 @@ function ExportPage() {

导出范围

- {exportDialog.scope === 'single' ? '单会话' : exportDialog.scope === 'multi' ? '多会话' : `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']})`} - 共 {exportDialog.sessionIds.length} 个会话 + {scopeLabel} + {scopeCountLabel}
{exportDialog.sessionNames.slice(0, 20).map(name => ( @@ -1253,7 +1415,7 @@ function ExportPage() {

对话文本导出格式选择

- {formatOptions.map(option => ( + {formatCandidateOptions.map(option => ( -
diff --git a/src/services/config.ts b/src/services/config.ts index bb96231..7927939 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -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): P await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map) } +export async function getExportLastSnsPostCount(): Promise { + 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 { + 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 { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index ff6a293..dfa82e3 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -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 }>