mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
页面交互与动画优化
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -63,6 +63,7 @@ chatlab-format.md
|
||||
*.bak
|
||||
AGENTS.md
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.agents/
|
||||
resources/wx_send
|
||||
概述.md
|
||||
|
||||
@@ -2002,7 +2002,6 @@ function registerIpcHandlers() {
|
||||
dbPath,
|
||||
decryptKey,
|
||||
wxid,
|
||||
nativeTimeoutMs: 5000,
|
||||
onProgress: (progress) => {
|
||||
if (isYearsLoadCanceled(taskId)) return
|
||||
const snapshot = updateTaskSnapshot({
|
||||
|
||||
@@ -61,6 +61,16 @@
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.95);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-user-menu-item {
|
||||
@@ -265,6 +275,185 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sidebar-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;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-dialog {
|
||||
width: min(420px, 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;
|
||||
animation: slideUp 0.25s ease;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-wxid-list {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-wxid-item {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: rgba(99, 102, 241, 0.32);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.current {
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wxid-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
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: var(--on-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.wxid-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wxid-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wxid-id {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.current-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-dialog-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);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -274,6 +463,7 @@
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-clear-dialog {
|
||||
@@ -283,6 +473,7 @@
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
||||
padding: 18px 18px 16px;
|
||||
animation: slideUp 0.25s ease;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, Trash2 } from 'lucide-react'
|
||||
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import * as configService from '../services/config'
|
||||
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||
|
||||
@@ -15,11 +17,28 @@ interface SidebarUserProfile {
|
||||
}
|
||||
|
||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
|
||||
|
||||
interface SidebarUserProfileCache extends SidebarUserProfile {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface AccountProfilesCache {
|
||||
[wxid: string]: {
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
alias?: string
|
||||
updatedAt: number
|
||||
}
|
||||
}
|
||||
|
||||
interface WxidOption {
|
||||
wxid: string
|
||||
modifiedTime: number
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
@@ -46,11 +65,32 @@ const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload))
|
||||
|
||||
// 同时写入账号缓存池
|
||||
const accountsCache = readAccountProfilesCache()
|
||||
accountsCache[profile.wxid] = {
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
alias: profile.alias,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountsCache))
|
||||
} catch {
|
||||
// 忽略本地缓存失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
const readAccountProfilesCache = (): AccountProfilesCache => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw)
|
||||
return typeof parsed === 'object' && parsed ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeAccountId = (value?: string | null): string => {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return ''
|
||||
@@ -76,12 +116,14 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
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 [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false)
|
||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||
const [isSwitchingAccount, setIsSwitchingAccount] = useState(false)
|
||||
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
|
||||
const setLocked = useAppStore(state => state.setLocked)
|
||||
const isDbConnected = useAppStore(state => state.isDbConnected)
|
||||
const resetChatStore = useChatStore(state => state.reset)
|
||||
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
||||
@@ -143,6 +185,9 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
const resolvedWxidRaw = String(wxid || '').trim()
|
||||
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
|
||||
const resolvedWxid = cleanedWxid || resolvedWxidRaw
|
||||
|
||||
if (!resolvedWxidRaw && !resolvedWxid) return
|
||||
|
||||
const wxidCandidates = new Set<string>([
|
||||
resolvedWxidRaw.toLowerCase(),
|
||||
resolvedWxid.trim().toLowerCase(),
|
||||
@@ -168,77 +213,36 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const fallbackDisplayName = resolvedWxid || '未识别用户'
|
||||
|
||||
// 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。
|
||||
patchUserProfile({
|
||||
wxid: resolvedWxid,
|
||||
displayName: fallbackDisplayName
|
||||
})
|
||||
|
||||
if (!resolvedWxidRaw && !resolvedWxid) return
|
||||
|
||||
// 第二阶段:后台补齐名称(不会阻塞首屏)。
|
||||
void (async () => {
|
||||
try {
|
||||
let myContact: Awaited<ReturnType<typeof window.electronAPI.chat.getContact>> | null = null
|
||||
for (const candidate of Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))) {
|
||||
// 并行获取名称和头像
|
||||
const [contactResult, avatarResult] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const candidates = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))
|
||||
for (const candidate of candidates) {
|
||||
const contact = await window.electronAPI.chat.getContact(candidate)
|
||||
if (!contact) continue
|
||||
if (!myContact) myContact = contact
|
||||
if (contact.remark || contact.nickName || contact.alias) {
|
||||
myContact = contact
|
||||
break
|
||||
if (contact?.remark || contact?.nickName || contact?.alias) {
|
||||
return contact
|
||||
}
|
||||
}
|
||||
const fromContact = pickFirstValidName(
|
||||
return null
|
||||
})(),
|
||||
window.electronAPI.chat.getMyAvatarUrl()
|
||||
])
|
||||
|
||||
const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null
|
||||
const displayName = pickFirstValidName(
|
||||
myContact?.remark,
|
||||
myContact?.nickName,
|
||||
myContact?.alias
|
||||
)
|
||||
) || resolvedWxid || '未识别用户'
|
||||
|
||||
if (fromContact) {
|
||||
patchUserProfile({ displayName: fromContact }, resolvedWxid)
|
||||
// 同步补充微信号(alias)
|
||||
if (myContact?.alias) {
|
||||
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const enrichTargets = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid, 'self'].filter(Boolean)))
|
||||
const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo(enrichTargets)
|
||||
const enrichedDisplayName = pickFirstValidName(
|
||||
enrichedResult.contacts?.[resolvedWxidRaw]?.displayName,
|
||||
enrichedResult.contacts?.[resolvedWxid]?.displayName,
|
||||
enrichedResult.contacts?.[cleanedWxid]?.displayName,
|
||||
enrichedResult.contacts?.self?.displayName,
|
||||
myContact?.alias
|
||||
)
|
||||
const bestName = enrichedDisplayName
|
||||
if (bestName) {
|
||||
patchUserProfile({ displayName: bestName }, resolvedWxid)
|
||||
}
|
||||
// 降级分支也补充微信号
|
||||
if (myContact?.alias) {
|
||||
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
|
||||
}
|
||||
} catch (nameError) {
|
||||
console.error('加载侧边栏用户昵称失败:', nameError)
|
||||
}
|
||||
})()
|
||||
|
||||
// 第二阶段:后台补齐头像(不会阻塞首屏)。
|
||||
void (async () => {
|
||||
try {
|
||||
const avatarResult = await window.electronAPI.chat.getMyAvatarUrl()
|
||||
if (avatarResult.success && avatarResult.avatarUrl) {
|
||||
patchUserProfile({ avatarUrl: avatarResult.avatarUrl }, resolvedWxid)
|
||||
}
|
||||
} catch (avatarError) {
|
||||
console.error('加载侧边栏用户头像失败:', avatarError)
|
||||
}
|
||||
})()
|
||||
patchUserProfile({
|
||||
wxid: resolvedWxid,
|
||||
displayName,
|
||||
alias: myContact?.alias,
|
||||
avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success
|
||||
? avatarResult.value.avatarUrl
|
||||
: undefined
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载侧边栏用户信息失败:', error)
|
||||
}
|
||||
@@ -246,10 +250,7 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
|
||||
const cachedProfile = readSidebarUserProfileCache()
|
||||
if (cachedProfile) {
|
||||
setUserProfile(prev => ({
|
||||
...prev,
|
||||
...cachedProfile
|
||||
}))
|
||||
setUserProfile(cachedProfile)
|
||||
}
|
||||
|
||||
void loadCurrentUser()
|
||||
@@ -263,23 +264,107 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
return [...name][0] || '?'
|
||||
}
|
||||
|
||||
const isActive = (path: string) => {
|
||||
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 = () => {
|
||||
const openSwitchAccountDialog = async () => {
|
||||
setIsAccountMenuOpen(false)
|
||||
setShouldClearCacheData(false)
|
||||
setShouldClearExportData(false)
|
||||
setShowClearAccountDialog(true)
|
||||
if (!isDbConnected) {
|
||||
window.alert('数据库未连接,无法切换账号')
|
||||
return
|
||||
}
|
||||
const dbPath = await configService.getDbPath()
|
||||
if (!dbPath) {
|
||||
window.alert('请先在设置中配置数据库路径')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||
const accountsCache = readAccountProfilesCache()
|
||||
console.log('[切换账号] 账号缓存:', accountsCache)
|
||||
|
||||
const enrichedWxids = wxids.map(option => {
|
||||
const normalizedWxid = normalizeAccountId(option.wxid)
|
||||
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
|
||||
|
||||
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
|
||||
return {
|
||||
...option,
|
||||
displayName: userProfile.displayName,
|
||||
avatarUrl: userProfile.avatarUrl
|
||||
}
|
||||
}
|
||||
if (cached) {
|
||||
console.log('[切换账号] 使用缓存:', option.wxid, cached)
|
||||
return {
|
||||
...option,
|
||||
displayName: cached.displayName,
|
||||
avatarUrl: cached.avatarUrl
|
||||
}
|
||||
}
|
||||
return { ...option, displayName: option.wxid }
|
||||
})
|
||||
|
||||
setWxidOptions(enrichedWxids)
|
||||
setShowSwitchAccountDialog(true)
|
||||
} catch (error) {
|
||||
console.error('扫描账号失败:', error)
|
||||
window.alert('扫描账号失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSwitchAccount = async (selectedWxid: string) => {
|
||||
if (!selectedWxid || isSwitchingAccount) return
|
||||
setIsSwitchingAccount(true)
|
||||
try {
|
||||
console.log('[切换账号] 开始切换到:', selectedWxid)
|
||||
const currentWxid = userProfile.wxid
|
||||
if (currentWxid === selectedWxid) {
|
||||
console.log('[切换账号] 已经是当前账号,跳过')
|
||||
setShowSwitchAccountDialog(false)
|
||||
setIsSwitchingAccount(false)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[切换账号] 设置新 wxid')
|
||||
await configService.setMyWxid(selectedWxid)
|
||||
|
||||
console.log('[切换账号] 获取账号配置')
|
||||
const wxidConfig = await configService.getWxidConfig(selectedWxid)
|
||||
console.log('[切换账号] 配置内容:', wxidConfig)
|
||||
if (wxidConfig?.decryptKey) {
|
||||
console.log('[切换账号] 设置 decryptKey')
|
||||
await configService.setDecryptKey(wxidConfig.decryptKey)
|
||||
}
|
||||
if (typeof wxidConfig?.imageXorKey === 'number') {
|
||||
console.log('[切换账号] 设置 imageXorKey:', wxidConfig.imageXorKey)
|
||||
await configService.setImageXorKey(wxidConfig.imageXorKey)
|
||||
}
|
||||
if (wxidConfig?.imageAesKey) {
|
||||
console.log('[切换账号] 设置 imageAesKey')
|
||||
await configService.setImageAesKey(wxidConfig.imageAesKey)
|
||||
}
|
||||
|
||||
console.log('[切换账号] 检查数据库连接状态')
|
||||
console.log('[切换账号] 数据库连接状态:', isDbConnected)
|
||||
if (isDbConnected) {
|
||||
console.log('[切换账号] 关闭数据库连接')
|
||||
await window.electronAPI.chat.close()
|
||||
}
|
||||
|
||||
console.log('[切换账号] 清除缓存')
|
||||
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
clearAnalyticsStoreCache()
|
||||
resetChatStore()
|
||||
|
||||
console.log('[切换账号] 触发 wxid-changed 事件')
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } }))
|
||||
|
||||
console.log('[切换账号] 切换成功')
|
||||
setShowSwitchAccountDialog(false)
|
||||
} catch (error) {
|
||||
console.error('[切换账号] 失败:', error)
|
||||
window.alert('切换账号失败,请稍后重试')
|
||||
} finally {
|
||||
setIsSwitchingAccount(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openSettingsFromAccountMenu = () => {
|
||||
@@ -291,60 +376,13 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
})
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
const followupHint = shouldClearCacheData
|
||||
? '若需再次获取数据,请手动登录微信客户端并重新在 WeFlow 完成配置。'
|
||||
: '你可以继续使用当前登录状态,无需重新登录。'
|
||||
window.alert(`账号数据清理完成。\n\n${detailLines.join('\n')}\n\n为保障数据安全,WeFlow 已清除该账号本地缓存/导出相关数据。${followupHint}`)
|
||||
resetClearDialogState()
|
||||
if (shouldClearCacheData) {
|
||||
window.location.reload()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清理账号数据失败:', error)
|
||||
window.alert('清理失败,请稍后重试。')
|
||||
} finally {
|
||||
setIsClearingAccountData(false)
|
||||
}
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||
}
|
||||
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<nav className="nav-menu">
|
||||
{/* 首页 */}
|
||||
@@ -450,8 +488,16 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
</button>
|
||||
|
||||
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
|
||||
{isAccountMenuOpen && (
|
||||
<div className="sidebar-user-menu" role="menu" aria-label="账号菜单">
|
||||
<div className={`sidebar-user-menu ${isAccountMenuOpen ? 'open' : ''}`} role="menu" aria-label="账号菜单">
|
||||
<button
|
||||
className="sidebar-user-menu-item"
|
||||
onClick={openSwitchAccountDialog}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
<span>切换账号</span>
|
||||
</button>
|
||||
<button
|
||||
className="sidebar-user-menu-item"
|
||||
onClick={openSettingsFromAccountMenu}
|
||||
@@ -461,17 +507,7 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
<Settings size={14} />
|
||||
<span>设置</span>
|
||||
</button>
|
||||
<button
|
||||
className="sidebar-user-menu-item danger"
|
||||
onClick={openClearAccountDialog}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>清除数据</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
|
||||
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
|
||||
@@ -500,50 +536,40 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{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>
|
||||
{showSwitchAccountDialog && (
|
||||
<div className="sidebar-dialog-overlay" onClick={() => !isSwitchingAccount && setShowSwitchAccountDialog(false)}>
|
||||
<div className="sidebar-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<h3>切换账号</h3>
|
||||
<p>选择要切换的微信账号</p>
|
||||
<div className="sidebar-wxid-list">
|
||||
{wxidOptions.map((option) => (
|
||||
<button
|
||||
key={option.wxid}
|
||||
className={`sidebar-wxid-item ${userProfile.wxid === option.wxid ? 'current' : ''}`}
|
||||
onClick={() => handleSwitchAccount(option.wxid)}
|
||||
disabled={isSwitchingAccount}
|
||||
type="button"
|
||||
className="danger"
|
||||
disabled={!canConfirmClear || isClearingAccountData}
|
||||
onClick={handleConfirmClearAccountData}
|
||||
>
|
||||
{isClearingAccountData ? '清除中...' : '确认清除'}
|
||||
<div className="wxid-avatar">
|
||||
{option.avatarUrl ? <img src={option.avatarUrl} alt="" /> : <span>{getAvatarLetter(option.displayName || option.wxid)}</span>}
|
||||
</div>
|
||||
<div className="wxid-info">
|
||||
<div className="wxid-name">{option.displayName || option.wxid}</div>
|
||||
<div className="wxid-id">{option.wxid}</div>
|
||||
</div>
|
||||
{userProfile.wxid === option.wxid && <span className="current-badge">当前</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sidebar-dialog-actions">
|
||||
<button type="button" onClick={() => setShowSwitchAccountDialog(false)} disabled={isSwitchingAccount}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -209,16 +209,7 @@ function AnnualReportPage() {
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在加载年份数据(首批)...</p>
|
||||
<div className="load-telemetry compact">
|
||||
<p><span className="label">加载方式:</span>{getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })}</p>
|
||||
<p><span className="label">状态:</span>{loadStatusText || '正在加载年份数据...'}</p>
|
||||
<p>
|
||||
<span className="label">原生耗时:</span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} |{' '}
|
||||
<span className="label">扫表耗时:</span>{formatLoadElapsed(scanElapsedMs)} |{' '}
|
||||
<span className="label">总耗时:</span>{formatLoadElapsed(totalElapsedMs)}
|
||||
</p>
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在准备年度报告...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -264,30 +255,6 @@ function AnnualReportPage() {
|
||||
<Sparkles size={32} className="header-icon" />
|
||||
<h1 className="page-title">年度报告</h1>
|
||||
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
|
||||
{loadedYearCount > 0 && (
|
||||
<p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
||||
{isYearStatusComplete ? (
|
||||
<>已显示 {loadedYearCount} 个年份,年份数据已全部加载完毕。总耗时 {formatLoadElapsed(totalElapsedMs)}</>
|
||||
) : (
|
||||
<>
|
||||
已显示 {loadedYearCount} 个年份,正在补充更多年份<span className="dot-ellipsis" aria-hidden="true">...</span>
|
||||
(已耗时 {formatLoadElapsed(totalElapsedMs)})
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className={`load-telemetry ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
||||
<p><span className="label">加载方式:</span>{strategyLabel}</p>
|
||||
<p>
|
||||
<span className="label">状态:</span>
|
||||
{loadStatusText || (isYearStatusComplete ? '全部年份已加载完毕' : '正在加载年份数据...')}
|
||||
</p>
|
||||
<p>
|
||||
<span className="label">原生耗时:</span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} |{' '}
|
||||
<span className="label">扫表耗时:</span>{formatLoadElapsed(scanElapsedMs)} |{' '}
|
||||
<span className="label">总耗时:</span>{formatLoadElapsed(totalElapsedMs)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="report-sections">
|
||||
<section className="report-section">
|
||||
@@ -311,7 +278,6 @@ function AnnualReportPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{renderYearLoadStatus()}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -358,7 +324,6 @@ function AnnualReportPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{renderYearLoadStatus()}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -3854,12 +3854,6 @@ function ChatPage(props: ChatPageProps) {
|
||||
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
|
||||
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
|
||||
</button>
|
||||
{isSessionListSyncing && (
|
||||
<div className="session-sync-indicator">
|
||||
<Loader2 size={12} className="spin" />
|
||||
<span>同步中</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 折叠群 header */}
|
||||
|
||||
@@ -891,28 +891,6 @@ function ContactsPage() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="contacts-count">
|
||||
共 {filteredContacts.length} / {contacts.length} 个联系人
|
||||
{contactsUpdatedAt && (
|
||||
<span className="contacts-cache-meta">
|
||||
{contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel}
|
||||
</span>
|
||||
)}
|
||||
{contacts.length > 0 && (
|
||||
<span className="contacts-cache-meta">
|
||||
头像缓存 {avatarCachedCount}/{contacts.length}
|
||||
{avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{isLoading && contacts.length > 0 && (
|
||||
<span className="contacts-cache-meta syncing">后台同步中...</span>
|
||||
)}
|
||||
{avatarEnrichProgress.running && (
|
||||
<span className="avatar-enrich-progress">
|
||||
头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{exportMode && (
|
||||
<div className="selection-toolbar">
|
||||
|
||||
@@ -11,6 +11,33 @@
|
||||
padding: 28px 32px;
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
backdrop-filter: blur(10px);
|
||||
animation: settingsFadeIn 0.2s ease;
|
||||
|
||||
&.closing {
|
||||
animation: settingsFadeOut 0.2s ease forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes settingsFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes settingsFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-page {
|
||||
@@ -25,6 +52,33 @@
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.22);
|
||||
overflow: hidden;
|
||||
animation: settingsSlideUp 0.3s ease;
|
||||
|
||||
&.closing {
|
||||
animation: settingsSlideDown 0.2s ease forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes settingsSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes settingsSlideDown {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
|
||||
@@ -134,6 +134,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
|
||||
const [isClearingImageCache, setIsClearingImageCache] = useState(false)
|
||||
const [isClearingAllCache, setIsClearingAllCache] = useState(false)
|
||||
const [isClosing, setIsClosing] = useState(false)
|
||||
const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
|
||||
// 安全设置 state
|
||||
@@ -203,7 +204,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
if (!onClose) return
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
@@ -445,6 +446,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setTimeout(() => setMessage(null), 3000)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!onClose) return
|
||||
setIsClosing(true)
|
||||
setTimeout(() => {
|
||||
onClose()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
type WxidKeys = {
|
||||
decryptKey: string
|
||||
imageXorKey: number | null
|
||||
@@ -2076,8 +2085,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="settings-modal-overlay" onClick={() => onClose?.()}>
|
||||
<div className="settings-page" onClick={(event) => event.stopPropagation()}>
|
||||
<div className={`settings-modal-overlay ${isClosing ? 'closing' : ''}`} onClick={handleClose}>
|
||||
<div className={`settings-page ${isClosing ? 'closing' : ''}`} onClick={(event) => event.stopPropagation()}>
|
||||
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
|
||||
|
||||
{/* 多账号选择对话框 */}
|
||||
@@ -2116,7 +2125,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
{onClose && (
|
||||
<button type="button" className="settings-close-btn" onClick={onClose} aria-label="关闭设置">
|
||||
<button type="button" className="settings-close-btn" onClick={handleClose} aria-label="关闭设置">
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user