支持自动化条件导出;优化引导页面提示;支持快速添加账号

This commit is contained in:
cc
2026-04-12 23:37:26 +08:00
parent 69a598f196
commit 86daa8ef06
19 changed files with 3765 additions and 688 deletions

View File

@@ -26,6 +26,7 @@ import ContactsPage from './pages/ContactsPage'
import ResourcesPage from './pages/ResourcesPage'
import ChatHistoryPage from './pages/ChatHistoryPage'
import NotificationWindow from './pages/NotificationWindow'
import AccountManagementPage from './pages/AccountManagementPage'
import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
@@ -678,6 +679,7 @@ function App() {
<Routes location={routeLocation}>
<Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} />
<Route path="/account-management" element={<AccountManagementPage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />

View File

@@ -6,7 +6,7 @@
align-items: center;
justify-content: center;
padding: 16px;
z-index: 2400;
z-index: 9200;
}
.export-date-range-dialog {

View File

@@ -275,263 +275,6 @@
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;
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;
}
.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;
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);
}
}
.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,12 +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, RefreshCw, FolderClosed, Footprints } from 'lucide-react'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users } 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'
import { UserRound } from 'lucide-react'
import './Sidebar.scss'
@@ -19,6 +16,8 @@ interface SidebarUserProfile {
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
const DEFAULT_DISPLAY_NAME = '微信用户'
const DEFAULT_SUBTITLE = '微信账号'
interface SidebarUserProfileCache extends SidebarUserProfile {
updatedAt: number
@@ -33,24 +32,16 @@ interface AccountProfilesCache {
}
}
interface WxidOption {
wxid: string
modifiedTime: number
nickname?: string
displayName?: string
avatarUrl?: string
}
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
try {
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as SidebarUserProfileCache
if (!parsed || typeof parsed !== 'object') return null
if (!parsed.wxid || !parsed.displayName) return null
if (!parsed.wxid) return null
return {
wxid: parsed.wxid,
displayName: parsed.displayName,
displayName: typeof parsed.displayName === 'string' ? parsed.displayName : '',
alias: parsed.alias,
avatarUrl: parsed.avatarUrl
}
@@ -60,7 +51,7 @@ const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
}
const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
if (!profile.wxid || !profile.displayName) return
if (!profile.wxid) return
try {
const payload: SidebarUserProfileCache = {
...profile,
@@ -115,17 +106,11 @@ function Sidebar({ collapsed }: SidebarProps) {
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
wxid: '',
displayName: '未识别用户'
displayName: DEFAULT_DISPLAY_NAME
})
const [isAccountMenuOpen, setIsAccountMenuOpen] = 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)
@@ -164,18 +149,20 @@ function Sidebar({ collapsed }: SidebarProps) {
}, [])
useEffect(() => {
let disposed = false
let loadSeq = 0
const loadCurrentUser = async () => {
const patchUserProfile = (patch: Partial<SidebarUserProfile>, expectedWxid?: string) => {
const seq = ++loadSeq
const patchUserProfile = (patch: Partial<SidebarUserProfile>) => {
if (disposed || seq !== loadSeq) return
setUserProfile(prev => {
if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) {
return prev
}
const next: SidebarUserProfile = {
...prev,
...patch
}
if (!next.displayName) {
next.displayName = next.wxid || '未识别用户'
if (typeof next.displayName !== 'string' || next.displayName.length === 0) {
next.displayName = DEFAULT_DISPLAY_NAME
}
writeSidebarUserProfileCache(next)
return next
@@ -184,11 +171,33 @@ function Sidebar({ collapsed }: SidebarProps) {
try {
const wxid = await configService.getMyWxid()
if (disposed || seq !== loadSeq) return
const resolvedWxidRaw = String(wxid || '').trim()
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
const resolvedWxid = cleanedWxid || resolvedWxidRaw
if (!resolvedWxidRaw && !resolvedWxid) return
if (!resolvedWxidRaw && !resolvedWxid) {
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
patchUserProfile({
wxid: '',
displayName: DEFAULT_DISPLAY_NAME,
alias: undefined,
avatarUrl: undefined
})
return
}
setUserProfile((prev) => {
if (prev.wxid === resolvedWxid) return prev
const seeded: SidebarUserProfile = {
wxid: resolvedWxid,
displayName: DEFAULT_DISPLAY_NAME,
alias: undefined,
avatarUrl: undefined
}
writeSidebarUserProfileCache(seeded)
return seeded
})
const wxidCandidates = new Set<string>([
resolvedWxidRaw.toLowerCase(),
@@ -197,14 +206,13 @@ function Sidebar({ collapsed }: SidebarProps) {
].filter(Boolean))
const normalizeName = (value?: string | null): string | undefined => {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
const lowered = trimmed.toLowerCase()
if (typeof value !== 'string') return undefined
if (value.length === 0) return undefined
const lowered = value.trim().toLowerCase()
if (lowered === 'self') return undefined
if (lowered.startsWith('wxid_')) return undefined
if (wxidCandidates.has(lowered)) return undefined
return trimmed
return value
}
const pickFirstValidName = (...candidates: Array<string | null | undefined>): string | undefined => {
@@ -229,18 +237,20 @@ function Sidebar({ collapsed }: SidebarProps) {
})(),
window.electronAPI.chat.getMyAvatarUrl()
])
if (disposed || seq !== loadSeq) return
const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null
const displayName = pickFirstValidName(
myContact?.remark,
myContact?.nickName,
myContact?.alias
) || resolvedWxid || '未识别用户'
) || DEFAULT_DISPLAY_NAME
const alias = normalizeName(myContact?.alias)
patchUserProfile({
wxid: resolvedWxid,
displayName,
alias: myContact?.alias,
alias,
avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success
? avatarResult.value.avatarUrl
: undefined
@@ -257,118 +267,28 @@ function Sidebar({ collapsed }: SidebarProps) {
void loadCurrentUser()
const onWxidChanged = () => { void loadCurrentUser() }
const onWindowFocus = () => { void loadCurrentUser() }
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void loadCurrentUser()
}
}
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
window.addEventListener('focus', onWindowFocus)
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
disposed = true
loadSeq += 1
window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
window.removeEventListener('focus', onWindowFocus)
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [])
const getAvatarLetter = (name: string): string => {
if (!name) return '?'
return [...name][0] || '?'
}
const openSwitchAccountDialog = async () => {
setIsAccountMenuOpen(false)
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: WxidOption) => {
const normalizedWxid = normalizeAccountId(option.wxid)
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
let displayName = option.nickname || option.wxid
let avatarUrl = option.avatarUrl
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
displayName = userProfile.displayName || displayName
avatarUrl = userProfile.avatarUrl || avatarUrl
}
else if (cached) {
displayName = cached.displayName || displayName
avatarUrl = cached.avatarUrl || avatarUrl
}
return {
...option,
displayName,
avatarUrl
}
})
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)
}
if (!name) return ''
const visible = name.trim()
return (visible && [...visible][0]) || '微'
}
const openSettingsFromAccountMenu = () => {
@@ -380,6 +300,11 @@ function Sidebar({ collapsed }: SidebarProps) {
})
}
const openAccountManagement = () => {
setIsAccountMenuOpen(false)
navigate('/account-management')
}
const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`)
}
@@ -515,12 +440,12 @@ function Sidebar({ collapsed }: SidebarProps) {
<div className={`sidebar-user-menu ${isAccountMenuOpen ? 'open' : ''}`} role="menu" aria-label="账号菜单">
<button
className="sidebar-user-menu-item"
onClick={openSwitchAccountDialog}
onClick={openAccountManagement}
type="button"
role="menuitem"
>
<RefreshCw size={14} />
<span></span>
<Users size={14} />
<span></span>
</button>
<button
className="sidebar-user-menu-item"
@@ -534,7 +459,7 @@ function Sidebar({ collapsed }: SidebarProps) {
</div>
<div
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
title={collapsed ? `${userProfile.displayName}${(userProfile.alias) ? `\n${userProfile.alias}` : ''}` : undefined}
onClick={() => setIsAccountMenuOpen(prev => !prev)}
role="button"
tabIndex={0}
@@ -549,8 +474,8 @@ function Sidebar({ collapsed }: SidebarProps) {
{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.alias || userProfile.wxid || 'wxid 未识别'}</div>
<div className="user-name">{userProfile.displayName || DEFAULT_DISPLAY_NAME}</div>
<div className="user-wxid">{userProfile.alias || DEFAULT_SUBTITLE}</div>
</div>
{!collapsed && (
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
@@ -561,44 +486,6 @@ function Sidebar({ collapsed }: SidebarProps) {
</div>
</div>
</aside>
{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"
>
<div className="wxid-avatar">
{option.avatarUrl ? (
<img src={option.avatarUrl} alt="" />
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-tertiary)', borderRadius: '6px', color: 'var(--text-tertiary)' }}>
<UserRound size={16} />
</div>
)}
</div>
<div className="wxid-info">
<div className="wxid-name">{option.displayName}</div>
{option.displayName !== option.wxid && <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>
)}
</>
)
}

View File

@@ -0,0 +1,274 @@
.account-management-page {
padding: 22px 24px;
min-height: 100%;
display: flex;
flex-direction: column;
gap: 14px;
color: var(--text-primary);
}
.account-management-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
}
p {
margin: 6px 0 0;
color: var(--text-secondary);
font-size: 13px;
}
}
.account-management-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.account-management-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.summary-item {
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
padding: 10px 12px;
}
.summary-label {
display: block;
font-size: 11px;
color: var(--text-tertiary);
}
.summary-value {
display: block;
margin-top: 4px;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.account-notice {
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
border: 1px solid transparent;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.notice-action {
border: 1px solid currentColor;
border-radius: 999px;
background: transparent;
color: inherit;
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.2s ease, background 0.2s ease;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.35);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.account-notice.success {
background: rgba(34, 197, 94, 0.12);
color: #15803d;
border-color: rgba(34, 197, 94, 0.25);
}
.account-notice.error {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
border-color: rgba(239, 68, 68, 0.25);
}
.account-notice.info {
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
border-color: rgba(59, 130, 246, 0.25);
}
.account-empty {
border: 1px dashed var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
padding: 18px 14px;
color: var(--text-secondary);
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.account-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.account-card {
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--bg-secondary);
padding: 12px;
display: flex;
gap: 12px;
&.is-current {
border-color: color-mix(in srgb, var(--primary) 60%, var(--border-color));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 25%, transparent);
}
}
.account-avatar {
width: 42px;
height: 42px;
border-radius: 10px;
overflow: hidden;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: var(--on-primary);
font-weight: 600;
font-size: 14px;
}
}
.account-main {
min-width: 0;
flex: 1;
}
.account-title-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
h3 {
margin: 0;
font-size: 15px;
color: var(--text-primary);
}
}
.account-badge {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
font-weight: 600;
&.current {
color: #0f766e;
background: rgba(20, 184, 166, 0.14);
}
&.ok {
color: #166534;
background: rgba(34, 197, 94, 0.12);
}
&.warn {
color: #b45309;
background: rgba(245, 158, 11, 0.15);
}
}
.account-meta {
margin-top: 3px;
font-size: 12px;
color: var(--text-tertiary);
word-break: break-all;
}
.meta-tip {
margin-left: 6px;
color: var(--text-secondary);
}
.account-card-actions {
display: inline-flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
.btn {
min-width: 104px;
justify-content: center;
}
}
.account-management-footer {
margin-top: 2px;
color: var(--text-tertiary);
font-size: 12px;
}
.account-management-page {
.btn-danger {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.2);
}
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
@media (max-width: 920px) {
.account-management-summary {
grid-template-columns: 1fr;
}
.account-card {
flex-direction: column;
}
.account-card-actions {
flex-direction: row;
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,574 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { RefreshCw, UserPlus, Trash2, ArrowRightLeft, CheckCircle2, Database } 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 './AccountManagementPage.scss'
interface ScannedWxidOption {
wxid: string
modifiedTime: number
nickname?: string
avatarUrl?: string
}
interface ManagedAccountItem {
wxid: string
normalizedWxid: string
displayName: string
avatarUrl?: string
modifiedTime?: number
configUpdatedAt?: number
hasConfig: boolean
isCurrent: boolean
fromScan: boolean
}
type AccountProfileCacheEntry = {
displayName?: string
avatarUrl?: string
updatedAt?: number
}
interface DeleteUndoState {
targetWxid: string
deletedConfigEntries: Array<[string, configService.WxidConfig]>
deletedProfileEntries: Array<[string, AccountProfileCacheEntry]>
previousCurrentWxid: string
shouldRestoreAsCurrent: boolean
previousDbConnected: boolean
}
type NoticeState =
| { type: 'success' | 'error' | 'info'; text: string }
| null
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
const hiddenDeletedAccountIds = new Set<string>()
const DEFAULT_ACCOUNT_DISPLAY_NAME = '微信用户'
const normalizeAccountId = (value?: string | null): 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 resolveAccountDisplayName = (
candidates: Array<unknown>,
wxidCandidates: Set<string>
): string => {
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue
if (candidate.length === 0) continue
const normalized = candidate.trim().toLowerCase()
if (normalized.startsWith('wxid_')) continue
if (normalized && wxidCandidates.has(normalized)) continue
return candidate
}
return DEFAULT_ACCOUNT_DISPLAY_NAME
}
const resolveAccountAvatarText = (displayName?: string): string => {
if (typeof displayName !== 'string' || displayName.length === 0) return '微'
const visible = displayName.trim()
return (visible && [...visible][0]) || '微'
}
const readAccountProfilesCache = (): Record<string, AccountProfileCacheEntry> => {
try {
const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? parsed as Record<string, AccountProfileCacheEntry> : {}
} catch {
return {}
}
}
function AccountManagementPage() {
const isDbConnected = useAppStore(state => state.isDbConnected)
const setDbConnected = useAppStore(state => state.setDbConnected)
const resetChatStore = useChatStore(state => state.reset)
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
const [dbPath, setDbPath] = useState('')
const [currentWxid, setCurrentWxid] = useState('')
const [accounts, setAccounts] = useState<ManagedAccountItem[]>([])
const [isLoading, setIsLoading] = useState(false)
const [workingWxid, setWorkingWxid] = useState('')
const [notice, setNotice] = useState<NoticeState>(null)
const [deleteUndoState, setDeleteUndoState] = useState<DeleteUndoState | null>(null)
const loadAccounts = useCallback(async () => {
setIsLoading(true)
try {
const [path, rawCurrentWxid, wxidConfigs] = await Promise.all([
configService.getDbPath(),
configService.getMyWxid(),
configService.getWxidConfigs()
])
const nextDbPath = String(path || '').trim()
const nextCurrentWxid = String(rawCurrentWxid || '').trim()
const normalizedCurrent = normalizeAccountId(nextCurrentWxid) || nextCurrentWxid
setDbPath(nextDbPath)
setCurrentWxid(nextCurrentWxid)
let scannedWxids: ScannedWxidOption[] = []
if (nextDbPath) {
try {
const scanned = await window.electronAPI.dbPath.scanWxids(nextDbPath)
scannedWxids = Array.isArray(scanned) ? scanned as ScannedWxidOption[] : []
} catch {
scannedWxids = []
}
}
const accountProfileCache = readAccountProfilesCache()
const configEntries = Object.entries(wxidConfigs || {})
const configByNormalized = new Map<string, { key: string; value: configService.WxidConfig }>()
for (const [wxid, cfg] of configEntries) {
const normalized = normalizeAccountId(wxid) || wxid
if (!normalized) continue
const previous = configByNormalized.get(normalized)
if (!previous || Number(cfg?.updatedAt || 0) > Number(previous.value?.updatedAt || 0)) {
configByNormalized.set(normalized, { key: wxid, value: cfg || {} })
}
}
const merged = new Map<string, ManagedAccountItem>()
for (const scanned of scannedWxids) {
const normalized = normalizeAccountId(scanned.wxid) || scanned.wxid
if (!normalized) continue
const cached = accountProfileCache[scanned.wxid] || accountProfileCache[normalized]
const matchedConfig = configByNormalized.get(normalized)
const wxidCandidates = new Set<string>([
String(scanned.wxid || '').trim().toLowerCase(),
String(normalized || '').trim().toLowerCase()
].filter(Boolean))
const displayName = resolveAccountDisplayName(
[scanned.nickname, cached?.displayName],
wxidCandidates
)
merged.set(normalized, {
wxid: scanned.wxid,
normalizedWxid: normalized,
displayName,
avatarUrl: scanned.avatarUrl || cached?.avatarUrl,
modifiedTime: Number(scanned.modifiedTime || 0),
configUpdatedAt: Number(matchedConfig?.value?.updatedAt || 0),
hasConfig: Boolean(matchedConfig),
isCurrent: normalizedCurrent && normalized === normalizedCurrent,
fromScan: true
})
}
for (const [normalized, matchedConfig] of configByNormalized.entries()) {
if (merged.has(normalized)) continue
const wxid = matchedConfig.key
const cached = accountProfileCache[wxid] || accountProfileCache[normalized]
const wxidCandidates = new Set<string>([
String(wxid || '').trim().toLowerCase(),
String(normalized || '').trim().toLowerCase()
].filter(Boolean))
const displayName = resolveAccountDisplayName(
[cached?.displayName],
wxidCandidates
)
merged.set(normalized, {
wxid,
normalizedWxid: normalized,
displayName,
avatarUrl: cached?.avatarUrl,
modifiedTime: 0,
configUpdatedAt: Number(matchedConfig.value?.updatedAt || 0),
hasConfig: true,
isCurrent: normalizedCurrent && normalized === normalizedCurrent,
fromScan: false
})
}
// 被“删除配置”操作移除的账号,在当前会话中从列表隐藏;
// 若后续再次生成配置,则自动恢复展示。
for (const [normalized, item] of Array.from(merged.entries())) {
if (!hiddenDeletedAccountIds.has(normalized)) continue
if (item.hasConfig) {
hiddenDeletedAccountIds.delete(normalized)
continue
}
merged.delete(normalized)
}
const nextAccounts = Array.from(merged.values()).sort((a, b) => {
if (a.isCurrent && !b.isCurrent) return -1
if (!a.isCurrent && b.isCurrent) return 1
const scanDiff = Number(b.modifiedTime || 0) - Number(a.modifiedTime || 0)
if (scanDiff !== 0) return scanDiff
return Number(b.configUpdatedAt || 0) - Number(a.configUpdatedAt || 0)
})
setAccounts(nextAccounts)
} catch (error) {
console.error('加载账号列表失败:', error)
setNotice({ type: 'error', text: '加载账号列表失败,请稍后重试' })
setAccounts([])
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
void loadAccounts()
const onWxidChanged = () => { void loadAccounts() }
const onWindowFocus = () => { void loadAccounts() }
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void loadAccounts()
}
}
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
window.addEventListener('focus', onWindowFocus)
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
window.removeEventListener('focus', onWindowFocus)
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [loadAccounts])
const clearRuntimeCacheState = useCallback(async () => {
if (isDbConnected) {
await window.electronAPI.chat.close()
}
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
clearAnalyticsStoreCache()
resetChatStore()
}, [clearAnalyticsStoreCache, isDbConnected, resetChatStore])
const applyWxidConfig = useCallback(async (wxid: string, wxidConfig: configService.WxidConfig | null) => {
await configService.setMyWxid(wxid)
await configService.setDecryptKey(wxidConfig?.decryptKey || '')
await configService.setImageXorKey(typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : 0)
await configService.setImageAesKey(wxidConfig?.imageAesKey || '')
}, [])
const handleSwitchAccount = useCallback(async (wxid: string) => {
if (!wxid || workingWxid) return
const targetNormalized = normalizeAccountId(wxid) || wxid
const currentNormalized = normalizeAccountId(currentWxid) || currentWxid
if (targetNormalized && currentNormalized && targetNormalized === currentNormalized) return
setWorkingWxid(wxid)
setNotice(null)
setDeleteUndoState(null)
try {
const allConfigs = await configService.getWxidConfigs()
const configEntries = Object.entries(allConfigs || {})
const matched = configEntries.find(([key]) => {
const normalized = normalizeAccountId(key) || key
return key === wxid || normalized === targetNormalized
})
const targetConfig = matched?.[1] || null
await applyWxidConfig(wxid, targetConfig)
await clearRuntimeCacheState()
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid } }))
setNotice({ type: 'success', text: `已切换到账号「${wxid}` })
await loadAccounts()
} catch (error) {
console.error('切换账号失败:', error)
setNotice({ type: 'error', text: '切换账号失败,请稍后重试' })
} finally {
setWorkingWxid('')
}
}, [applyWxidConfig, clearRuntimeCacheState, currentWxid, loadAccounts, workingWxid])
const handleAddAccount = useCallback(async () => {
if (workingWxid) return
setNotice(null)
setDeleteUndoState(null)
try {
await window.electronAPI.window.openOnboardingWindow({ mode: 'add-account' })
await loadAccounts()
const latestWxid = String(await configService.getMyWxid() || '').trim()
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: latestWxid } }))
} catch (error) {
console.error('打开添加账号引导失败:', error)
setNotice({ type: 'error', text: '打开添加账号引导失败,请稍后重试' })
}
}, [loadAccounts, workingWxid])
const handleDeleteAccountConfig = useCallback(async (targetWxid: string) => {
if (!targetWxid || workingWxid) return
const normalizedTarget = normalizeAccountId(targetWxid) || targetWxid
setWorkingWxid(targetWxid)
setNotice(null)
setDeleteUndoState(null)
try {
const allConfigs = await configService.getWxidConfigs()
const nextConfigs: Record<string, configService.WxidConfig> = { ...allConfigs }
const matchedKeys = Object.keys(nextConfigs).filter((key) => {
const normalized = normalizeAccountId(key) || key
return key === targetWxid || normalized === normalizedTarget
})
if (matchedKeys.length === 0) {
setNotice({ type: 'info', text: `账号「${targetWxid}」暂无可删除配置` })
return
}
const deletedConfigEntries: Array<[string, configService.WxidConfig]> = matchedKeys.map((key) => [key, nextConfigs[key] || {}])
for (const key of matchedKeys) {
delete nextConfigs[key]
}
await configService.setWxidConfigs(nextConfigs)
const accountProfileCache = readAccountProfilesCache()
const deletedProfileEntries: Array<[string, AccountProfileCacheEntry]> = []
for (const key of Object.keys(accountProfileCache)) {
const normalized = normalizeAccountId(key) || key
if (key === targetWxid || normalized === normalizedTarget) {
deletedProfileEntries.push([key, accountProfileCache[key]])
delete accountProfileCache[key]
}
}
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountProfileCache))
const currentNormalized = normalizeAccountId(currentWxid) || currentWxid
const isDeletingCurrent = Boolean(currentNormalized && currentNormalized === normalizedTarget)
const undoPayload: DeleteUndoState = {
targetWxid,
deletedConfigEntries,
deletedProfileEntries,
previousCurrentWxid: currentWxid,
shouldRestoreAsCurrent: isDeletingCurrent,
previousDbConnected: isDbConnected
}
if (isDeletingCurrent) {
await clearRuntimeCacheState()
const remainingEntries = Object.entries(nextConfigs)
.filter(([wxid]) => Boolean(String(wxid || '').trim()))
.sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0))
if (remainingEntries.length > 0) {
const [nextWxid, nextConfig] = remainingEntries[0]
await applyWxidConfig(nextWxid, nextConfig || null)
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: nextWxid } }))
hiddenDeletedAccountIds.add(normalizedTarget)
setDeleteUndoState(undoPayload)
setNotice({ type: 'success', text: `已删除「${targetWxid}」配置,并切换到「${nextWxid}` })
await loadAccounts()
return
}
await configService.setMyWxid('')
await configService.setDecryptKey('')
await configService.setImageXorKey(0)
await configService.setImageAesKey('')
setDbConnected(false)
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: '' } }))
hiddenDeletedAccountIds.add(normalizedTarget)
setDeleteUndoState(undoPayload)
setNotice({ type: 'info', text: `已删除「${targetWxid}」配置,当前无可用账号配置,可撤回或添加账号` })
await loadAccounts()
return
}
hiddenDeletedAccountIds.add(normalizedTarget)
setDeleteUndoState(undoPayload)
setNotice({ type: 'success', text: `已删除账号「${targetWxid}」配置` })
await loadAccounts()
} catch (error) {
console.error('删除账号配置失败:', error)
setNotice({ type: 'error', text: '删除账号配置失败,请稍后重试' })
} finally {
setWorkingWxid('')
}
}, [applyWxidConfig, clearRuntimeCacheState, currentWxid, isDbConnected, loadAccounts, setDbConnected, workingWxid])
const handleUndoDelete = useCallback(async () => {
if (!deleteUndoState || workingWxid) return
setWorkingWxid(`undo:${deleteUndoState.targetWxid}`)
setNotice(null)
try {
const currentConfigs = await configService.getWxidConfigs()
const restoredConfigs: Record<string, configService.WxidConfig> = { ...currentConfigs }
for (const [key, configValue] of deleteUndoState.deletedConfigEntries) {
restoredConfigs[key] = configValue || {}
}
await configService.setWxidConfigs(restoredConfigs)
hiddenDeletedAccountIds.delete(normalizeAccountId(deleteUndoState.targetWxid) || deleteUndoState.targetWxid)
const accountProfileCache = readAccountProfilesCache()
for (const [key, profile] of deleteUndoState.deletedProfileEntries) {
accountProfileCache[key] = profile
}
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountProfileCache))
if (deleteUndoState.shouldRestoreAsCurrent && deleteUndoState.previousCurrentWxid) {
const previousNormalized = normalizeAccountId(deleteUndoState.previousCurrentWxid) || deleteUndoState.previousCurrentWxid
const restoreConfigEntry = Object.entries(restoredConfigs)
.filter(([key]) => {
const normalized = normalizeAccountId(key) || key
return key === deleteUndoState.previousCurrentWxid || normalized === previousNormalized
})
.sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0))[0]
const restoreConfig = restoreConfigEntry?.[1] || null
await clearRuntimeCacheState()
await applyWxidConfig(deleteUndoState.previousCurrentWxid, restoreConfig)
if (deleteUndoState.previousDbConnected) {
setDbConnected(true, dbPath || undefined)
}
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: deleteUndoState.previousCurrentWxid } }))
}
setNotice({ type: 'success', text: `已撤回删除,账号「${deleteUndoState.targetWxid}」配置已恢复` })
setDeleteUndoState(null)
await loadAccounts()
} catch (error) {
console.error('撤回删除失败:', error)
setNotice({ type: 'error', text: '撤回删除失败,请稍后重试' })
} finally {
setWorkingWxid('')
}
}, [applyWxidConfig, clearRuntimeCacheState, dbPath, deleteUndoState, loadAccounts, setDbConnected, workingWxid])
const currentAccountLabel = useMemo(() => {
if (!currentWxid) return '未设置'
return currentWxid
}, [currentWxid])
const formatTime = (value?: number): string => {
const ts = Number(value || 0)
if (!ts) return '未知'
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return '未知'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
return (
<div className="account-management-page">
<header className="account-management-header">
<div>
<h2></h2>
<p></p>
</div>
<div className="account-management-actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadAccounts()} disabled={isLoading || Boolean(workingWxid)}>
<RefreshCw size={16} /> {isLoading ? '刷新中...' : '刷新'}
</button>
<button type="button" className="btn btn-primary" onClick={handleAddAccount} disabled={Boolean(workingWxid)}>
<UserPlus size={16} />
</button>
</div>
</header>
<section className="account-management-summary">
<div className="summary-item">
<span className="summary-label"></span>
<span className="summary-value">{dbPath || '未配置'}</span>
</div>
<div className="summary-item">
<span className="summary-label"></span>
<span className="summary-value">{currentAccountLabel}</span>
</div>
<div className="summary-item">
<span className="summary-label"></span>
<span className="summary-value">{accounts.length}</span>
</div>
</section>
{notice && (
<div className={`account-notice ${notice.type}`}>
<span>{notice.text}</span>
{deleteUndoState && (notice.type === 'success' || notice.type === 'info') && (
<button
type="button"
className="notice-action"
onClick={() => void handleUndoDelete()}
disabled={Boolean(workingWxid)}
>
</button>
)}
</div>
)}
{accounts.length === 0 ? (
<div className="account-empty">
<Database size={20} />
<span></span>
</div>
) : (
<div className="account-list">
{accounts.map((account) => (
<article key={account.normalizedWxid} className={`account-card ${account.isCurrent ? 'is-current' : ''}`}>
<div className="account-avatar">
{account.avatarUrl ? <img src={account.avatarUrl} alt="" /> : <span>{resolveAccountAvatarText(account.displayName)}</span>}
</div>
<div className="account-main">
<div className="account-title-row">
<h3>{account.displayName}</h3>
{account.isCurrent && (
<span className="account-badge current">
<CheckCircle2 size={12} />
</span>
)}
{account.hasConfig ? (
<span className="account-badge ok"></span>
) : (
<span className="account-badge warn"></span>
)}
</div>
<div className="account-meta">wxid: {account.wxid}</div>
<div className="account-meta">
: {formatTime(account.modifiedTime)} · : {formatTime(account.configUpdatedAt)}
{!account.fromScan && <span className="meta-tip"></span>}
</div>
</div>
<div className="account-card-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => void handleSwitchAccount(account.wxid)}
disabled={Boolean(workingWxid) || account.isCurrent || !account.hasConfig || !account.fromScan}
>
<ArrowRightLeft size={14} /> {account.isCurrent ? '当前账号' : (!account.hasConfig ? '无配置' : (account.fromScan ? '切换' : '无数据'))}
</button>
<button
type="button"
className="btn btn-danger"
onClick={() => void handleDeleteAccountConfig(account.wxid)}
disabled={Boolean(workingWxid) || !account.hasConfig}
>
<Trash2 size={14} />
</button>
</div>
</article>
))}
</div>
)}
<footer className="account-management-footer">
WeFlow
</footer>
</div>
)
}
export default AccountManagementPage

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -304,6 +304,19 @@
}
}
.nav-hint {
margin-top: 4px;
display: inline-flex;
align-items: center;
width: fit-content;
padding: 1px 6px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: #0f766e;
background: rgba(20, 184, 166, 0.16);
}
.sidebar-footer {
display: flex;
@@ -362,6 +375,16 @@
font-size: 15px;
margin: 0;
}
.header-mode-tip {
margin: 10px 0 0;
padding: 6px 10px;
border-radius: 10px;
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-tertiary);
border: 1px dashed var(--border-color);
}
}
.step-icon-wrapper {
@@ -556,6 +579,41 @@
gap: 16px;
}
.auto-image-key-preview {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-tertiary);
}
.auto-image-key-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
code {
padding: 4px 8px;
border-radius: 8px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
font-size: 12px;
color: var(--text-primary);
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.auto-image-key-label {
font-size: 13px;
color: var(--text-secondary);
}
.mt-4 {
margin-top: 16px;
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { useAppStore } from '../stores/appStore'
import { dialog } from '../services/ipc'
import * as configService from '../services/config'
@@ -31,6 +31,7 @@ const steps = [
{ id: 'security', title: '安全防护', desc: '保护你的数据' }
]
type SetupStepId = typeof steps[number]['id']
type ImageKeyResolveSource = 'manual-cache' | 'prefetch-cache' | 'memory-scan'
interface WelcomePageProps {
standalone?: boolean
@@ -61,9 +62,44 @@ const isDbKeyReadyMessage = (message: string): boolean => (
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
)
const pickWxidByAnchorTime = (
wxids: Array<{ wxid: string; modifiedTime: number }>,
anchorTime?: number
): string => {
if (!Array.isArray(wxids) || wxids.length === 0) return ''
const fallbackWxid = wxids[0]?.wxid || ''
if (!anchorTime || !Number.isFinite(anchorTime)) return fallbackWxid
const valid = wxids.filter(item => Number.isFinite(item.modifiedTime) && item.modifiedTime > 0)
if (valid.length === 0) return fallbackWxid
const anchor = Number(anchorTime)
const nearWindowMs = 10 * 60 * 1000
const near = valid
.filter(item => Math.abs(item.modifiedTime - anchor) <= nearWindowMs)
.sort((a, b) => {
const diffGap = Math.abs(a.modifiedTime - anchor) - Math.abs(b.modifiedTime - anchor)
if (diffGap !== 0) return diffGap
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
})
if (near.length > 0) return near[0].wxid
const closest = valid.sort((a, b) => {
const diffGap = Math.abs(a.modifiedTime - anchor) - Math.abs(b.modifiedTime - anchor)
if (diffGap !== 0) return diffGap
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
})
return closest[0]?.wxid || fallbackWxid
}
function WelcomePage({ standalone = false }: WelcomePageProps) {
const navigate = useNavigate()
const location = useLocation()
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
const isAddAccountMode = standalone && new URLSearchParams(location.search).get('mode') === 'add-account'
const [stepIndex, setStepIndex] = useState(0)
const [dbPath, setDbPath] = useState('')
@@ -92,7 +128,11 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const [imageKeyStatus, setImageKeyStatus] = useState('')
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
const [isImageKeyVerified, setIsImageKeyVerified] = useState(false)
const [isImageStepAutoCompleted, setIsImageStepAutoCompleted] = useState(false)
const [hasReacquiredDbKey, setHasReacquiredDbKey] = useState(!isAddAccountMode)
const [showDbKeyConfirm, setShowDbKeyConfirm] = useState(false)
const imagePrefetchAttemptRef = useRef<string>('')
// 安全相关 state
const [enableAuth, setEnableAuth] = useState(false)
@@ -191,7 +231,79 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
setWxidOptions([])
setWxid('')
setShowWxidSelect(false)
}, [dbPath])
setIsImageKeyVerified(false)
setIsImageStepAutoCompleted(false)
if (isAddAccountMode) {
setHasReacquiredDbKey(false)
setDecryptKey('')
}
imagePrefetchAttemptRef.current = ''
}, [dbPath, isAddAccountMode])
useEffect(() => {
if (!isAddAccountMode) return
let cancelled = false
const hydrateAddAccountMode = async () => {
const keyStepIndex = steps.findIndex(step => step.id === 'key')
if (keyStepIndex >= 0) {
setStepIndex(keyStepIndex)
}
try {
const [
savedDbPath,
savedCachePath,
savedWxid,
savedImageXorKey,
savedImageAesKey
] = await Promise.all([
configService.getDbPath(),
configService.getCachePath(),
configService.getMyWxid(),
configService.getImageXorKey(),
configService.getImageAesKey()
])
if (cancelled) return
setDbPath(savedDbPath || '')
setCachePath(savedCachePath || '')
setDecryptKey('')
setHasReacquiredDbKey(false)
if (typeof savedImageXorKey === 'number' && Number.isFinite(savedImageXorKey)) {
setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`)
}
setImageAesKey(savedImageAesKey || '')
if (savedDbPath) {
const scannedWxids = await window.electronAPI.dbPath.scanWxids(savedDbPath)
if (cancelled) return
setWxidOptions(scannedWxids)
const preferredWxid = String(savedWxid || '').trim()
const matched = scannedWxids.find(item => item.wxid === preferredWxid)
if (matched) {
setWxid(matched.wxid)
} else if (preferredWxid) {
setWxid(preferredWxid)
} else if (scannedWxids.length > 0) {
setWxid(scannedWxids[0].wxid)
}
} else if (savedWxid) {
setWxid(savedWxid)
}
} catch (e) {
if (!cancelled) {
setError(`加载当前账号配置失败: ${e}`)
}
}
}
void hydrateAddAccountMode()
return () => {
cancelled = true
}
}, [isAddAccountMode])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -206,10 +318,30 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showWxidSelect])
const currentStep = steps[stepIndex]
const imageStepIndex = steps.findIndex(step => step.id === 'image')
const securityStepIndex = steps.findIndex(step => step.id === 'security')
const currentStep = steps[stepIndex] ?? steps[0]
const imagePreCompletedAhead = isImageStepAutoCompleted && imageStepIndex >= 0 && stepIndex < imageStepIndex
const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}`
const showWindowControls = standalone
const isStepCompleted = (index: number, stepId: SetupStepId): boolean => {
if (index < stepIndex) return true
if (stepId === 'image' && isImageStepAutoCompleted) return true
if (isAddAccountMode && stepId !== 'key') return true
return false
}
const resolveStepDesc = (step: { id: SetupStepId; desc: string }): string => {
if (step.id === 'image' && isImageStepAutoCompleted) {
return '缓存校验成功,已自动完成'
}
if (isAddAccountMode && step.id !== 'key') {
return '已沿用当前配置'
}
return step.desc
}
const handleMinimize = () => {
window.electronAPI.window.minimize()
}
@@ -302,7 +434,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
}
const handleScanWxid = async (silent = false) => {
const handleScanWxid = async (silent = false, anchorTime?: number) => {
if (!dbPath) {
if (!silent) setError('请先选择数据库目录')
return
@@ -314,8 +446,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
setWxidOptions(wxids)
if (wxids.length > 0) {
// scanWxids 已经按时间排过序了,直接取第一个
setWxid(wxids[0].wxid)
// 密钥成功后使用成功时刻作为锚点,自动选择最接近该时刻的活跃账号;
// 其余场景保持“时间最新”优先。
const selectedWxid = pickWxidByAnchorTime(wxids, anchorTime)
setWxid(selectedWxid || wxids[0].wxid)
if (!silent) setError('')
} else {
if (!silent) setError('未检测到账号目录,请检查路径')
@@ -364,10 +498,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const result = await window.electronAPI.key.autoGetDbKey()
if (result.success && result.key) {
setDecryptKey(result.key)
setHasReacquiredDbKey(true)
setDbKeyStatus('密钥获取成功')
setError('')
await handleScanWxid(true)
const keySuccessAt = Date.now()
await handleScanWxid(true, keySuccessAt)
} else {
if (isAddAccountMode) {
setHasReacquiredDbKey(false)
}
if (
result.error?.includes('未找到微信安装路径') ||
result.error?.includes('启动微信失败') ||
@@ -396,25 +535,45 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
handleAutoGetDbKey()
}
const handleAutoGetImageKey = async () => {
const handleAutoGetImageKey = async (
source: ImageKeyResolveSource = 'manual-cache',
options?: { silentError?: boolean }
) => {
if (isFetchingImageKey) return
if (!dbPath) { setError('请先选择数据库目录'); return }
setIsFetchingImageKey(true)
setError('')
if (!options?.silentError) {
setError('')
}
setImageKeyPercent(0)
setImageKeyStatus('正在准备获取图片密钥...')
setImageKeyStatus(source === 'prefetch-cache' ? '正在预计算图片密钥...' : '正在准备获取图片密钥...')
try {
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
if (result.success && result.aesKey) {
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
setImageAesKey(result.aesKey)
setImageKeyStatus('已获取图片密钥')
const verified = result.verified === true
setIsImageKeyVerified(verified)
setIsImageStepAutoCompleted(verified)
if (verified) {
setImageKeyStatus(source === 'prefetch-cache' ? '图片密钥已预先自动完成(缓存校验通过)' : '图片密钥获取成功(缓存校验通过)')
} else {
setImageKeyStatus('已自动计算图片密钥(未完成校验)')
}
} else {
setError(result.error || '自动获取图片密钥失败')
setIsImageKeyVerified(false)
setIsImageStepAutoCompleted(false)
if (!options?.silentError) {
setError(result.error || '自动获取图片密钥失败')
}
}
} catch (e) {
setError(`自动获取图片密钥失败: ${e}`)
setIsImageKeyVerified(false)
setIsImageStepAutoCompleted(false)
if (!options?.silentError) {
setError(`自动获取图片密钥失败: ${e}`)
}
} finally {
setIsFetchingImageKey(false)
}
@@ -433,6 +592,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
if (result.success && result.aesKey) {
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
setImageAesKey(result.aesKey)
setIsImageKeyVerified(false)
setIsImageStepAutoCompleted(false)
setImageKeyStatus('内存扫描成功,已获取图片密钥')
} else {
setError(result.error || '内存扫描获取图片密钥失败')
@@ -444,6 +605,14 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
}
useEffect(() => {
if (!dbPath || !wxid || decryptKey.length !== 64) return
const attemptKey = `${dbPath}::${wxid}::${decryptKey}`
if (imagePrefetchAttemptRef.current === attemptKey) return
imagePrefetchAttemptRef.current = attemptKey
void handleAutoGetImageKey('prefetch-cache', { silentError: true })
}, [dbPath, wxid, decryptKey])
const jumpToStep = (stepId: SetupStepId) => {
const targetIndex = steps.findIndex(step => step.id === stepId)
if (targetIndex >= 0) setStepIndex(targetIndex)
@@ -487,6 +656,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
const canGoNext = () => {
if (isAddAccountMode) {
if (currentStep.id === 'key') return hasReacquiredDbKey && decryptKey.length === 64 && Boolean(wxid)
return true
}
if (currentStep.id === 'intro') return true
if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError
if (currentStep.id === 'cache') return true
@@ -502,6 +675,11 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
const handleNext = async () => {
if (isAddAccountMode) {
await handleConnect()
return
}
if (currentStep.id === 'db') {
const dbStepIssue = await validateDbStepBeforeNext()
if (dbStepIssue) {
@@ -520,15 +698,25 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
return
}
setError('')
if (currentStep.id === 'key' && isImageStepAutoCompleted && securityStepIndex >= 0) {
setStepIndex(securityStepIndex)
return
}
setStepIndex((prev) => Math.min(prev + 1, steps.length - 1))
}
const handleBack = () => {
if (isAddAccountMode) return
setError('')
setStepIndex((prev) => Math.max(prev - 1, 0))
}
const handleConnect = async () => {
if (isAddAccountMode && !hasReacquiredDbKey) {
setError('请先在当前流程中自动获取一次数据库密钥')
return
}
const configIssue = await findConfigIssueBeforeConnect()
if (configIssue) {
setError(configIssue.message)
@@ -708,13 +896,16 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
<div className="sidebar-nav">
{steps.map((step, index) => (
<div key={step.id} className={`nav-item ${index === stepIndex ? 'active' : ''} ${index < stepIndex ? 'completed' : ''}`}>
<div key={step.id} className={`nav-item ${index === stepIndex ? 'active' : ''} ${isStepCompleted(index, step.id) ? 'completed' : ''}`}>
<div className="nav-indicator">
{index < stepIndex ? <CheckCircle2 size={14} /> : <div className="dot" />}
{isStepCompleted(index, step.id) ? <CheckCircle2 size={14} /> : <div className="dot" />}
</div>
<div className="nav-info">
<div className="nav-title">{step.title}</div>
<div className="nav-desc">{step.desc}</div>
<div className="nav-desc">{resolveStepDesc(step)}</div>
{step.id === 'image' && imagePreCompletedAhead && (
<div className="nav-hint"></div>
)}
</div>
</div>
))}
@@ -731,6 +922,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
<div>
<h2>{currentStep.title}</h2>
<p className="header-desc">{currentStep.desc}</p>
{isAddAccountMode && (
<p className="header-mode-tip">沿</p>
)}
</div>
</div>
@@ -863,6 +1057,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</div>
{dbKeyStatus && <div className={`status-message ${isDbKeyReadyMessage(dbKeyStatus) ? 'is-success' : ''}`}>{dbKeyStatus}</div>}
{isAddAccountMode && !hasReacquiredDbKey && (
<div className="field-hint"></div>
)}
</div>
)}
@@ -936,19 +1133,19 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{currentStep.id === 'image' && (
<div className="form-group">
<div className="grid-2">
<div>
<label className="field-label"> XOR </label>
<input type="text" className="field-input" placeholder="0x..." value={imageXorKey} onChange={(e) => setImageXorKey(e.target.value)} />
<div className="auto-image-key-preview">
<div className="auto-image-key-row">
<span className="auto-image-key-label"> XOR </span>
<code>{imageXorKey || '等待自动计算'}</code>
</div>
<div>
<label className="field-label"> AES </label>
<input type="text" className="field-input" placeholder="16位密钥" value={imageAesKey} onChange={(e) => setImageAesKey(e.target.value)} />
<div className="auto-image-key-row">
<span className="auto-image-key-label"> AES </span>
<code>{imageAesKey || '等待自动计算'}</code>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
<button className="btn btn-primary btn-block" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算">
<button className="btn btn-primary btn-block" onClick={() => handleAutoGetImageKey('manual-cache')} disabled={isFetchingImageKey} title="从本地缓存快速计算">
{isFetchingImageKey ? '获取中...' : '缓存计算(推荐)'}
</button>
<button className="btn btn-secondary btn-block" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存">
@@ -960,13 +1157,23 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
<div className="brute-force-progress">
<div className="status-header">
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
{typeof imageKeyPercent === 'number' && Number.isFinite(imageKeyPercent) && (
<span className="status-text">{Math.max(0, Math.min(100, imageKeyPercent)).toFixed(1)}%</span>
)}
</div>
</div>
) : (
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
)}
<div className="field-hint" style={{ marginTop: '8px' }}>使 2-3 </div>
<div className="field-hint" style={{ marginTop: '8px' }}>
+ 使
</div>
{isImageKeyVerified && (
<div className="status-message is-success" style={{ marginTop: '8px' }}>
</div>
)}
</div>
)}
</div>
@@ -981,11 +1188,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
)}
<div className="content-actions">
<button className="btn btn-ghost" onClick={handleBack} disabled={stepIndex === 0}>
<button className="btn btn-ghost" onClick={handleBack} disabled={stepIndex === 0 || isAddAccountMode}>
<ArrowLeft size={16} />
</button>
{stepIndex < steps.length - 1 ? (
{isAddAccountMode ? (
<button className="btn btn-primary" onClick={handleConnect} disabled={isConnecting || !canGoNext()}>
{isConnecting ? '连接中...' : '完成并返回'} <ArrowRight size={16} />
</button>
) : stepIndex < steps.length - 1 ? (
<button className="btn btn-primary" onClick={handleNext} disabled={!canGoNext()}>
<ArrowRight size={16} />
</button>

View File

@@ -1,6 +1,7 @@
// 配置服务 - 封装 Electron Store
import { config } from './ipc'
import type { ExportDefaultDateRangeConfig } from '../utils/exportDateRange'
import type { ExportAutomationTask } from '../types/exportAutomation'
// 配置键名
export const CONFIG_KEYS = {
@@ -48,6 +49,7 @@ export const CONFIG_KEYS = {
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP: 'exportSessionMutualFriendsCacheMap',
EXPORT_AUTOMATION_TASK_MAP: 'exportAutomationTaskMap',
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
@@ -190,6 +192,10 @@ export async function getWxidConfigs(): Promise<Record<string, WxidConfig>> {
return {}
}
export async function setWxidConfigs(configs: Record<string, WxidConfig>): Promise<void> {
await config.set(CONFIG_KEYS.WXID_CONFIGS, configs || {})
}
export async function getWxidConfig(wxid: string): Promise<WxidConfig | null> {
if (!wxid) return null
const configs = await getWxidConfigs()
@@ -660,6 +666,183 @@ export async function setExportLastSnsPostCount(count: number): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized)
}
export interface ExportAutomationTaskMapItem {
updatedAt: number
tasks: ExportAutomationTask[]
}
const normalizeAutomationNumeric = (value: unknown, fallback: number): number => {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return fallback
return Math.floor(numeric)
}
const normalizeAutomationTask = (raw: unknown): ExportAutomationTask | null => {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const id = String(source.id || '').trim()
const name = String(source.name || '').trim()
if (!id || !name) return null
const sessionIds = Array.isArray(source.sessionIds)
? Array.from(new Set(source.sessionIds.map((item) => String(item || '').trim()).filter(Boolean)))
: []
const sessionNames = Array.isArray(source.sessionNames)
? source.sessionNames.map((item) => String(item || '').trim()).filter(Boolean)
: []
if (sessionIds.length === 0) return null
const scheduleRaw = source.schedule
if (!scheduleRaw || typeof scheduleRaw !== 'object') return null
const scheduleObj = scheduleRaw as Record<string, unknown>
const scheduleType = String(scheduleObj.type || '').trim() as ExportAutomationTask['schedule']['type']
let schedule: ExportAutomationTask['schedule'] | null = null
if (scheduleType === 'interval') {
const rawDays = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalDays, 0))
const rawHours = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalHours, 0))
const totalHours = (rawDays * 24) + rawHours
if (totalHours <= 0) return null
const intervalDays = Math.floor(totalHours / 24)
const intervalHours = totalHours % 24
schedule = { type: 'interval', intervalDays, intervalHours }
}
if (!schedule) return null
const conditionRaw = source.condition
if (!conditionRaw || typeof conditionRaw !== 'object') return null
const conditionType = String((conditionRaw as Record<string, unknown>).type || '').trim()
if (conditionType !== 'new-message-since-last-success') return null
const templateRaw = source.template
if (!templateRaw || typeof templateRaw !== 'object') return null
const template = templateRaw as Record<string, unknown>
const scope = String(template.scope || '').trim() as ExportAutomationTask['template']['scope']
if (scope !== 'single' && scope !== 'multi' && scope !== 'content') return null
const optionTemplate = template.optionTemplate
if (!optionTemplate || typeof optionTemplate !== 'object') return null
const dateRangeConfig = template.dateRangeConfig
const outputDirRaw = String(source.outputDir || '').trim()
const runStateRaw = source.runState && typeof source.runState === 'object'
? (source.runState as Record<string, unknown>)
: null
const stopConditionRaw = source.stopCondition && typeof source.stopCondition === 'object'
? (source.stopCondition as Record<string, unknown>)
: null
const rawContentType = String(template.contentType || '').trim()
const contentType = (
rawContentType === 'text' ||
rawContentType === 'voice' ||
rawContentType === 'image' ||
rawContentType === 'video' ||
rawContentType === 'emoji' ||
rawContentType === 'file'
)
? rawContentType
: undefined
const rawRunStatus = runStateRaw ? String(runStateRaw.lastRunStatus || '').trim() : ''
const lastRunStatus = (
rawRunStatus === 'idle' ||
rawRunStatus === 'queued' ||
rawRunStatus === 'running' ||
rawRunStatus === 'success' ||
rawRunStatus === 'error' ||
rawRunStatus === 'skipped'
)
? rawRunStatus
: undefined
const endAt = stopConditionRaw ? Math.max(0, normalizeAutomationNumeric(stopConditionRaw.endAt, 0)) : 0
const maxRuns = stopConditionRaw ? Math.max(0, normalizeAutomationNumeric(stopConditionRaw.maxRuns, 0)) : 0
return {
id,
name,
enabled: source.enabled !== false,
sessionIds,
sessionNames,
outputDir: outputDirRaw || undefined,
schedule,
condition: { type: 'new-message-since-last-success' },
stopCondition: (endAt > 0 || maxRuns > 0)
? {
endAt: endAt > 0 ? endAt : undefined,
maxRuns: maxRuns > 0 ? maxRuns : undefined
}
: undefined,
template: {
scope,
contentType,
optionTemplate: optionTemplate as ExportAutomationTask['template']['optionTemplate'],
dateRangeConfig: (dateRangeConfig ?? null) as ExportAutomationTask['template']['dateRangeConfig']
},
runState: runStateRaw
? {
lastRunStatus,
lastTriggeredAt: normalizeAutomationNumeric(runStateRaw.lastTriggeredAt, 0) || undefined,
lastStartedAt: normalizeAutomationNumeric(runStateRaw.lastStartedAt, 0) || undefined,
lastFinishedAt: normalizeAutomationNumeric(runStateRaw.lastFinishedAt, 0) || undefined,
lastSuccessAt: normalizeAutomationNumeric(runStateRaw.lastSuccessAt, 0) || undefined,
lastSkipAt: normalizeAutomationNumeric(runStateRaw.lastSkipAt, 0) || undefined,
lastSkipReason: String(runStateRaw.lastSkipReason || '').trim() || undefined,
lastError: String(runStateRaw.lastError || '').trim() || undefined,
lastScheduleKey: String(runStateRaw.lastScheduleKey || '').trim() || undefined,
successCount: Math.max(0, normalizeAutomationNumeric(runStateRaw.successCount, 0)) || undefined
}
: undefined,
createdAt: Math.max(0, normalizeAutomationNumeric(source.createdAt, Date.now())),
updatedAt: Math.max(0, normalizeAutomationNumeric(source.updatedAt, Date.now()))
}
}
export async function getExportAutomationTasks(scopeKey: string): Promise<ExportAutomationTaskMapItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.EXPORT_AUTOMATION_TASK_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const item = rawItem as Record<string, unknown>
const updatedAt = Number(item.updatedAt)
const rawTasks = Array.isArray(item.tasks)
? item.tasks
: (Array.isArray(rawItem) ? rawItem : [])
const tasks: ExportAutomationTask[] = []
for (const rawTask of rawTasks) {
const normalized = normalizeAutomationTask(rawTask)
if (normalized) {
tasks.push(normalized)
}
}
return {
updatedAt: Number.isFinite(updatedAt) ? Math.max(0, Math.floor(updatedAt)) : 0,
tasks
}
}
export async function setExportAutomationTasks(scopeKey: string, tasks: ExportAutomationTask[]): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.EXPORT_AUTOMATION_TASK_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
map[scopeKey] = {
updatedAt: Date.now(),
tasks: (Array.isArray(tasks) ? tasks : []).map((task) => ({ ...task }))
}
await config.set(CONFIG_KEYS.EXPORT_AUTOMATION_TASK_MAP, map)
}
export async function clearExportAutomationTasks(scopeKey: string): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.EXPORT_AUTOMATION_TASK_MAP)
if (!current || typeof current !== 'object') return
const map = { ...(current as Record<string, unknown>) }
if (!(scopeKey in map)) return
delete map[scopeKey]
await config.set(CONFIG_KEYS.EXPORT_AUTOMATION_TASK_MAP, map)
}
export interface ExportSessionMessageCountCacheItem {
updatedAt: number
counts: Record<string, number>

View File

@@ -18,7 +18,7 @@ export interface ElectronAPI {
respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => Promise<boolean>
openAgreementWindow: () => Promise<boolean>
completeOnboarding: () => Promise<boolean>
openOnboardingWindow: () => Promise<boolean>
openOnboardingWindow: (options?: { mode?: 'add-account' }) => Promise<boolean>
setTitleBarOverlay: (options: { symbolColor: string }) => void
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
@@ -146,7 +146,7 @@ export interface ElectronAPI {
}
key: {
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
autoGetImageKey: (manualDir?: string, wxid?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
autoGetImageKey: (manualDir?: string, wxid?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }>
scanImageKeyFromMemory: (userDir: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => () => void
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void

View File

@@ -0,0 +1,68 @@
import type { ExportOptions as ElectronExportOptions } from './electron'
export type ExportAutomationScope = 'single' | 'multi' | 'content'
export type ExportAutomationContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
export type ExportAutomationSchedule =
| {
type: 'interval'
intervalDays: number
intervalHours: number
}
export interface ExportAutomationCondition {
type: 'new-message-since-last-success'
}
export interface ExportAutomationDateRangeConfig {
version?: 1
preset?: string
useAllTime?: boolean
start?: string | number | Date | null
end?: string | number | Date | null
relativeMode?: 'last-n-days' | string
relativeDays?: number
}
export interface ExportAutomationTemplate {
scope: ExportAutomationScope
contentType?: ExportAutomationContentType
optionTemplate: Omit<ElectronExportOptions, 'dateRange'>
dateRangeConfig: ExportAutomationDateRangeConfig | string | null
}
export interface ExportAutomationStopCondition {
endAt?: number
maxRuns?: number
}
export type ExportAutomationRunStatus = 'idle' | 'queued' | 'running' | 'success' | 'error' | 'skipped'
export interface ExportAutomationRunState {
lastRunStatus?: ExportAutomationRunStatus
lastTriggeredAt?: number
lastStartedAt?: number
lastFinishedAt?: number
lastSuccessAt?: number
lastSkipAt?: number
lastSkipReason?: string
lastError?: string
lastScheduleKey?: string
successCount?: number
}
export interface ExportAutomationTask {
id: string
name: string
enabled: boolean
sessionIds: string[]
sessionNames: string[]
outputDir?: string
schedule: ExportAutomationSchedule
condition: ExportAutomationCondition
stopCondition?: ExportAutomationStopCondition
template: ExportAutomationTemplate
runState?: ExportAutomationRunState
createdAt: number
updatedAt: number
}