From 459f23bbd6351629f963c3b57e65abf57598bc43 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Thu, 5 Mar 2026 10:57:15 +0800 Subject: [PATCH] feat(sidebar): add account data clear action and detail feedback --- electron/main.ts | 189 +++++++++++++++++++++++++++++++++++- electron/preload.ts | 2 + src/components/Sidebar.scss | 138 +++++++++++++++++++++++++- src/components/Sidebar.tsx | 173 ++++++++++++++++++++++++++++++--- src/types/electron.d.ts | 6 ++ 5 files changed, 493 insertions(+), 15 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 7401603..0581e17 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -3,7 +3,7 @@ import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron' import { Worker } from 'worker_threads' import { join, dirname } from 'path' import { autoUpdater } from 'electron-updater' -import { readFile, writeFile, mkdir } from 'fs/promises' +import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises' import { existsSync } from 'fs' import { ConfigService } from './services/config' import { dbPathService } from './services/dbPathService' @@ -772,6 +772,65 @@ function showMainWindow() { } } +const normalizeAccountId = (value: string): string => { + const trimmed = String(value || '').trim() + if (!trimmed) return '' + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + return match?.[1] || trimmed + } + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed +} + +const buildAccountNameMatcher = (wxidCandidates: string[]) => { + const loweredCandidates = wxidCandidates + .map((item) => String(item || '').trim().toLowerCase()) + .filter(Boolean) + return (name: string): boolean => { + const loweredName = String(name || '').trim().toLowerCase() + if (!loweredName) return false + return loweredCandidates.some((candidate) => ( + loweredName === candidate || + loweredName.startsWith(`${candidate}_`) || + loweredName.includes(candidate) + )) + } +} + +const removePathIfExists = async ( + targetPath: string, + removedPaths: string[], + warnings: string[] +): Promise => { + if (!targetPath || !existsSync(targetPath)) return + try { + await rm(targetPath, { recursive: true, force: true }) + removedPaths.push(targetPath) + } catch (error) { + warnings.push(`${targetPath}: ${String(error)}`) + } +} + +const removeMatchedEntriesInDir = async ( + rootDir: string, + shouldRemove: (name: string) => boolean, + removedPaths: string[], + warnings: string[] +): Promise => { + if (!rootDir || !existsSync(rootDir)) return + try { + const entries = await readdir(rootDir, { withFileTypes: true }) + for (const entry of entries) { + if (!shouldRemove(entry.name)) continue + const targetPath = join(rootDir, entry.name) + await removePathIfExists(targetPath, removedPaths, warnings) + } + } catch (error) { + warnings.push(`${rootDir}: ${String(error)}`) + } +} + // 注册 IPC 处理器 function registerIpcHandlers() { registerNotificationHandlers() @@ -1190,6 +1249,134 @@ function registerIpcHandlers() { return true }) + ipcMain.handle('chat:clearCurrentAccountData', async (_, options?: { clearCache?: boolean; clearExports?: boolean }) => { + const cfg = configService + if (!cfg) return { success: false, error: '配置服务未初始化' } + + const clearCache = options?.clearCache === true + const clearExports = options?.clearExports === true + if (!clearCache && !clearExports) { + return { success: false, error: '请至少选择一项清理范围' } + } + + const rawWxid = String(cfg.get('myWxid') || '').trim() + if (!rawWxid) { + return { success: false, error: '当前账号未登录或未识别,无法清理' } + } + const normalizedWxid = normalizeAccountId(rawWxid) + const wxidCandidates = Array.from(new Set([rawWxid, normalizedWxid].filter(Boolean))) + const isMatchedAccountName = buildAccountNameMatcher(wxidCandidates) + const removedPaths: string[] = [] + const warnings: string[] = [] + + try { + wcdbService.close() + chatService.close() + } catch (error) { + warnings.push(`关闭数据库连接失败: ${String(error)}`) + } + + if (clearCache) { + const [analyticsResult, imageResult] = await Promise.all([ + analyticsService.clearCache(), + imageDecryptService.clearCache() + ]) + const chatResult = chatService.clearCaches() + const cleanupResults = [analyticsResult, imageResult, chatResult] + for (const result of cleanupResults) { + if (!result.success && result.error) warnings.push(result.error) + } + + const configuredCachePath = String(cfg.get('cachePath') || '').trim() + const documentsWeFlowDir = join(app.getPath('documents'), 'WeFlow') + const userDataCacheDir = join(app.getPath('userData'), 'cache') + const cacheRootCandidates = [ + configuredCachePath, + join(documentsWeFlowDir, 'Images'), + join(documentsWeFlowDir, 'Voices'), + join(documentsWeFlowDir, 'Emojis'), + userDataCacheDir + ].filter(Boolean) + + for (const wxid of wxidCandidates) { + if (configuredCachePath) { + await removePathIfExists(join(configuredCachePath, wxid), removedPaths, warnings) + await removePathIfExists(join(configuredCachePath, 'Images', wxid), removedPaths, warnings) + await removePathIfExists(join(configuredCachePath, 'Voices', wxid), removedPaths, warnings) + await removePathIfExists(join(configuredCachePath, 'Emojis', wxid), removedPaths, warnings) + } + await removePathIfExists(join(documentsWeFlowDir, 'Images', wxid), removedPaths, warnings) + await removePathIfExists(join(documentsWeFlowDir, 'Voices', wxid), removedPaths, warnings) + await removePathIfExists(join(documentsWeFlowDir, 'Emojis', wxid), removedPaths, warnings) + await removePathIfExists(join(userDataCacheDir, wxid), removedPaths, warnings) + } + + for (const cacheRoot of cacheRootCandidates) { + await removeMatchedEntriesInDir(cacheRoot, isMatchedAccountName, removedPaths, warnings) + } + } + + if (clearExports) { + const configuredExportPath = String(cfg.get('exportPath') || '').trim() + const documentsWeFlowDir = join(app.getPath('documents'), 'WeFlow') + const exportRootCandidates = [ + configuredExportPath, + join(documentsWeFlowDir, 'exports'), + join(documentsWeFlowDir, 'Exports') + ].filter(Boolean) + + for (const exportRoot of exportRootCandidates) { + await removeMatchedEntriesInDir(exportRoot, isMatchedAccountName, removedPaths, warnings) + } + + const resetConfigKeys = [ + 'exportSessionRecordMap', + 'exportLastSessionRunMap', + 'exportLastContentRunMap', + 'exportSessionMessageCountCacheMap', + 'exportSessionContentMetricCacheMap', + 'exportSnsStatsCacheMap', + 'snsPageCacheMap', + 'contactsListCacheMap', + 'contactsAvatarCacheMap', + 'lastSession' + ] + for (const key of resetConfigKeys) { + const defaultValue = key === 'lastSession' ? '' : {} + cfg.set(key as any, defaultValue as any) + } + } + + try { + const wxidConfigsRaw = cfg.get('wxidConfigs') as Record | undefined + if (wxidConfigsRaw && typeof wxidConfigsRaw === 'object') { + const nextConfigs: Record = { ...wxidConfigsRaw } + for (const key of Object.keys(nextConfigs)) { + if (isMatchedAccountName(key) || normalizeAccountId(key) === normalizedWxid) { + delete nextConfigs[key] + } + } + cfg.set('wxidConfigs' as any, nextConfigs as any) + } + cfg.set('myWxid' as any, '') + cfg.set('decryptKey' as any, '') + cfg.set('imageXorKey' as any, 0) + cfg.set('imageAesKey' as any, '') + cfg.set('dbPath' as any, '') + cfg.set('lastOpenedDb' as any, '') + cfg.set('onboardingDone' as any, false) + cfg.set('lastSession' as any, '') + } catch (error) { + warnings.push(`清理账号配置失败: ${String(error)}`) + } + + return { + success: true, + removedPaths, + warning: warnings.length > 0 ? warnings.join('; ') : undefined + } + }) + ipcMain.handle('chat:getSessionDetail', async (_, sessionId: string) => { return chatService.getSessionDetail(sessionId) }) diff --git a/electron/preload.ts b/electron/preload.ts index 068e488..8ac25a6 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -166,6 +166,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'), downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5), getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId), + clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) => + ipcRenderer.invoke('chat:clearCurrentAccountData', options), close: () => ipcRenderer.invoke('chat:close'), getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId), diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index 3d9b9ec..81d1ec2 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -10,8 +10,11 @@ &.collapsed { width: 64px; - .sidebar-user-card { + .sidebar-user-card-wrap { margin: 0 8px 8px; + } + + .sidebar-user-card { padding: 8px 0; justify-content: center; @@ -37,8 +40,39 @@ } } -.sidebar-user-card { +.sidebar-user-card-wrap { + position: relative; margin: 0 12px 10px; +} + +.sidebar-user-clear-trigger { + position: absolute; + left: 0; + right: 0; + bottom: calc(100% + 8px); + z-index: 12; + border: 1px solid rgba(255, 59, 48, 0.28); + border-radius: 10px; + background: var(--bg-secondary); + color: #d93025; + padding: 8px 10px; + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12); + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; + + &:hover { + background: rgba(255, 59, 48, 0.08); + border-color: rgba(255, 59, 48, 0.46); + } +} + +.sidebar-user-card { + width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 12px; @@ -47,6 +81,18 @@ align-items: center; gap: 10px; min-height: 56px; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; + + &:hover { + border-color: rgba(99, 102, 241, 0.32); + background: var(--bg-tertiary); + } + + &.menu-open { + border-color: rgba(99, 102, 241, 0.44); + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.12); + } .user-avatar { width: 36px; @@ -74,6 +120,7 @@ .user-meta { min-width: 0; + flex: 1; } .user-name { @@ -93,6 +140,17 @@ overflow: hidden; text-overflow: ellipsis; } + + .user-menu-caret { + color: var(--text-tertiary); + display: inline-flex; + transition: transform 0.2s ease, color 0.2s ease; + + &.open { + transform: rotate(180deg); + color: var(--text-secondary); + } + } } .nav-menu { @@ -206,6 +264,82 @@ } } +.sidebar-clear-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.3); + display: flex; + align-items: center; + justify-content: center; + z-index: 1100; + padding: 20px; +} + +.sidebar-clear-dialog { + width: min(460px, 100%); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 16px; + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24); + padding: 18px 18px 16px; + + h3 { + margin: 0; + font-size: 16px; + color: var(--text-primary); + } + + p { + margin: 10px 0 0; + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); + } +} + +.sidebar-clear-options { + margin-top: 14px; + display: flex; + gap: 14px; + flex-wrap: wrap; + + label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-primary); + } +} + +.sidebar-clear-actions { + margin-top: 18px; + display: flex; + justify-content: flex-end; + gap: 10px; + + button { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 8px 14px; + font-size: 13px; + cursor: pointer; + background: var(--bg-secondary); + color: var(--text-primary); + } + + button:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + .danger { + border-color: #ef4444; + background: #ef4444; + color: #fff; + } +} + // 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色 [data-theme="blossom-dream"] .sidebar { background: rgba(255, 255, 255, 0.6); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 2753108..009d235 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } 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 { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, ChevronUp, Trash2 } from 'lucide-react' import { useAppStore } from '../stores/appStore' import * as configService from '../services/config' import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' @@ -69,12 +69,30 @@ function Sidebar() { wxid: '', displayName: '未识别用户' }) + const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) + const [showClearAccountDialog, setShowClearAccountDialog] = useState(false) + const [shouldClearCacheData, setShouldClearCacheData] = useState(false) + const [shouldClearExportData, setShouldClearExportData] = useState(false) + const [isClearingAccountData, setIsClearingAccountData] = useState(false) + const accountCardWrapRef = useRef(null) const setLocked = useAppStore(state => state.setLocked) useEffect(() => { window.electronAPI.auth.verifyEnabled().then(setAuthEnabled) }, []) + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (!isAccountMenuOpen) return + const target = event.target as Node | null + if (accountCardWrapRef.current && target && !accountCardWrapRef.current.contains(target)) { + setIsAccountMenuOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isAccountMenuOpen]) + useEffect(() => { const unsubscribe = onExportSessionStatus((payload) => { const countFromPayload = typeof payload?.activeTaskCount === 'number' @@ -235,6 +253,68 @@ function Sidebar() { return location.pathname === path || location.pathname.startsWith(`${path}/`) } const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}` + const canConfirmClear = shouldClearCacheData || shouldClearExportData + + const resetClearDialogState = () => { + setShouldClearCacheData(false) + setShouldClearExportData(false) + setShowClearAccountDialog(false) + } + + const openClearAccountDialog = () => { + setIsAccountMenuOpen(false) + setShouldClearCacheData(false) + setShouldClearExportData(false) + setShowClearAccountDialog(true) + } + + const handleConfirmClearAccountData = async () => { + if (!canConfirmClear || isClearingAccountData) return + setIsClearingAccountData(true) + try { + const result = await window.electronAPI.chat.clearCurrentAccountData({ + clearCache: shouldClearCacheData, + clearExports: shouldClearExportData + }) + if (!result.success) { + window.alert(result.error || '清理失败,请稍后重试。') + return + } + window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY) + setUserProfile({ wxid: '', displayName: '未识别用户' }) + window.dispatchEvent(new Event('wxid-changed')) + + const removedPaths = Array.isArray(result.removedPaths) ? result.removedPaths : [] + const selectedScopes = [ + shouldClearCacheData ? '缓存数据' : '', + shouldClearExportData ? '导出数据' : '' + ].filter(Boolean) + const detailLines: string[] = [ + `清理范围:${selectedScopes.join('、') || '未选择'}`, + `已清理项目:${removedPaths.length} 项` + ] + if (removedPaths.length > 0) { + detailLines.push('', '清理明细(最多显示 8 项):') + for (const [index, path] of removedPaths.slice(0, 8).entries()) { + detailLines.push(`${index + 1}. ${path}`) + } + if (removedPaths.length > 8) { + detailLines.push(`... 其余 ${removedPaths.length - 8} 项已省略`) + } + } + if (result.warning) { + detailLines.push('', `注意:${result.warning}`) + } + window.alert(`账号数据清理完成。\n\n${detailLines.join('\n')}\n\n为保障数据安全,WeFlow 已清除该账号本地缓存/导出相关数据。若需再次获取数据,请手动登录微信客户端并重新在 WeFlow 完成配置。`) + resetClearDialogState() + window.location.reload() + } catch (error) { + console.error('清理账号数据失败:', error) + window.alert('清理失败,请稍后重试。') + } finally { + setIsClearingAccountData(false) + } + } return ( ) } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 713e21c..440244f 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -191,6 +191,12 @@ export interface ElectronAPI { messages?: Message[] error?: string }> + clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) => Promise<{ + success: boolean + removedPaths?: string[] + warning?: string + error?: string + }> getContact: (username: string) => Promise getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null> updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>