feat(sidebar): add account data clear action and detail feedback

This commit is contained in:
tisonhuang
2026-03-05 10:57:15 +08:00
parent 360754737f
commit 459f23bbd6
5 changed files with 493 additions and 15 deletions

View File

@@ -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<void> => {
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<void> => {
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<string, any> | undefined
if (wxidConfigsRaw && typeof wxidConfigsRaw === 'object') {
const nextConfigs: Record<string, any> = { ...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)
})

View File

@@ -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),

View File

@@ -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);

View File

@@ -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<HTMLDivElement | null>(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 (
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
@@ -331,16 +411,42 @@ 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 className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
{isAccountMenuOpen && (
<button
className="sidebar-user-clear-trigger"
onClick={openClearAccountDialog}
type="button"
>
<Trash2 size={14} />
<span></span>
</button>
)}
<div
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
title={collapsed ? `${userProfile.displayName}${userProfile.wxid ? `\n${userProfile.wxid}` : ''}` : undefined}
onClick={() => setIsAccountMenuOpen(prev => !prev)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setIsAccountMenuOpen(prev => !prev)
}
}}
>
<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>
{!collapsed && (
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
<ChevronUp size={14} />
</span>
)}
</div>
</div>
@@ -374,6 +480,49 @@ function Sidebar() {
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
{showClearAccountDialog && (
<div className="sidebar-clear-dialog-overlay" onClick={() => !isClearingAccountData && resetClearDialogState()}>
<div className="sidebar-clear-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<h3></h3>
<p>
weflow
weflow
</p>
<div className="sidebar-clear-options">
<label>
<input
type="checkbox"
checked={shouldClearCacheData}
onChange={(event) => setShouldClearCacheData(event.target.checked)}
disabled={isClearingAccountData}
/>
</label>
<label>
<input
type="checkbox"
checked={shouldClearExportData}
onChange={(event) => setShouldClearExportData(event.target.checked)}
disabled={isClearingAccountData}
/>
</label>
</div>
<div className="sidebar-clear-actions">
<button type="button" onClick={resetClearDialogState} disabled={isClearingAccountData}></button>
<button
type="button"
className="danger"
disabled={!canConfirmClear || isClearingAccountData}
onClick={handleConfirmClearAccountData}
>
{isClearingAccountData ? '清除中...' : '确认清除'}
</button>
</div>
</div>
</div>
)}
</aside>
)
}

View File

@@ -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<Contact | null>
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>