diff --git a/electron/main.ts b/electron/main.ts index f6a873a..2794d19 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -950,8 +950,17 @@ function closeSplash() { /** * 创建首次引导窗口 */ -function createOnboardingWindow() { +function createOnboardingWindow(mode: 'default' | 'add-account' = 'default') { + const onboardingHash = mode === 'add-account' + ? '/onboarding-window?mode=add-account' + : '/onboarding-window' + if (onboardingWindow && !onboardingWindow.isDestroyed()) { + if (process.env.VITE_DEV_SERVER_URL) { + onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${onboardingHash}`) + } else { + onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: onboardingHash }) + } onboardingWindow.focus() return onboardingWindow } @@ -987,9 +996,9 @@ function createOnboardingWindow() { }) if (process.env.VITE_DEV_SERVER_URL) { - onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/onboarding-window`) + onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${onboardingHash}`) } else { - onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: '/onboarding-window' }) + onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: onboardingHash }) } onboardingWindow.on('closed', () => { @@ -2260,6 +2269,39 @@ function registerIpcHandlers() { const defaultValue = key === 'lastSession' ? '' : {} cfg.set(key as any, defaultValue as any) } + + try { + const dbPath = String(cfg.get('dbPath') || '').trim() + const automationMapRaw = cfg.get('exportAutomationTaskMap') as Record | undefined + if (automationMapRaw && typeof automationMapRaw === 'object') { + const nextAutomationMap: Record = { ...automationMapRaw } + let changed = false + for (const scopeKey of Object.keys(automationMapRaw)) { + const normalizedScopeKey = String(scopeKey || '').trim() + if (!normalizedScopeKey) continue + const separatorIndex = normalizedScopeKey.lastIndexOf('::') + const scopedDbPath = separatorIndex >= 0 + ? normalizedScopeKey.slice(0, separatorIndex) + : '' + const scopedWxidRaw = separatorIndex >= 0 + ? normalizedScopeKey.slice(separatorIndex + 2) + : normalizedScopeKey + const scopedWxid = normalizeAccountId(scopedWxidRaw) + const wxidMatched = wxidCandidates.includes(scopedWxidRaw) || scopedWxid === normalizedWxid + const dbPathMatched = !dbPath || !scopedDbPath || scopedDbPath === dbPath + if (!wxidMatched || !dbPathMatched) continue + delete nextAutomationMap[scopeKey] + changed = true + } + if (changed) { + cfg.set('exportAutomationTaskMap' as any, nextAutomationMap as any) + } else if (!Object.keys(automationMapRaw).length) { + cfg.set('exportAutomationTaskMap' as any, {} as any) + } + } + } catch (error) { + warnings.push(`清理自动化导出任务失败: ${String(error)}`) + } } if (clearCache) { @@ -3019,12 +3061,13 @@ function registerIpcHandlers() { }) // 重新打开首次引导窗口,并隐藏主窗口 - ipcMain.handle('window:openOnboardingWindow', async () => { + ipcMain.handle('window:openOnboardingWindow', async (_, options?: { mode?: 'add-account' }) => { shouldShowMain = false if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.hide() } - createOnboardingWindow() + const mode = options?.mode === 'add-account' ? 'add-account' : 'default' + createOnboardingWindow(mode) return true }) diff --git a/electron/preload.ts b/electron/preload.ts index 838a305..9739332 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -110,7 +110,7 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('window:respondCloseConfirm', action), openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'), - openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'), + openOnboardingWindow: (options?: { mode?: 'add-account' }) => ipcRenderer.invoke('window:openOnboardingWindow', options), setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options), openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), diff --git a/electron/services/config.ts b/electron/services/config.ts index 250c93d..2c1aa97 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -71,6 +71,7 @@ interface ConfigSchema { quoteLayout: 'quote-top' | 'quote-bottom' wordCloudExcludeWords: string[] exportWriteLayout: 'A' | 'B' | 'C' + exportAutomationTaskMap: Record // AI 见解 aiModelApiBaseUrl: string @@ -185,6 +186,7 @@ export class ConfigService { quoteLayout: 'quote-top', wordCloudExcludeWords: [], exportWriteLayout: 'A', + exportAutomationTaskMap: {}, aiModelApiBaseUrl: '', aiModelApiKey: '', aiModelApiModel: 'gpt-4o-mini', diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 72c827c..37242a3 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -9,7 +9,7 @@ import crypto from 'crypto' const execFileAsync = promisify(execFile) type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } -type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } +type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string } export class KeyService { private readonly isMac = process.platform === 'darwin' @@ -814,7 +814,7 @@ export class KeyService { if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code) - return { success: true, xorKey, aesKey } + return { success: true, xorKey, aesKey, verified: true } } } return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' } @@ -826,7 +826,7 @@ export class KeyService { const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid) onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`) console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode) - return { success: true, xorKey, aesKey } + return { success: true, xorKey, aesKey, verified: false } } // --- 内存扫描备选方案(融合 Dart+Python 优点)--- diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts index 0e94d6c..e4b5088 100644 --- a/electron/services/keyServiceLinux.ts +++ b/electron/services/keyServiceLinux.ts @@ -3,6 +3,7 @@ import { join } from 'path' import { existsSync, readdirSync, statSync, readFileSync } from 'fs' import { execFile, exec, spawn } from 'child_process' import { promisify } from 'util' +import crypto from 'crypto' import { createRequire } from 'module'; const require = createRequire(import.meta.url); @@ -10,7 +11,7 @@ const execFileAsync = promisify(execFile) const execAsync = promisify(exec) type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } -type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } +type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string } export class KeyServiceLinux { private sudo: any @@ -243,7 +244,14 @@ export class KeyServiceLinux { if (account && account.keys && account.keys.length > 0) { onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`); const keyObj = account.keys[0] - return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey } + const aesKey = String(keyObj.aesKey || '') + const verified = await this.verifyImageKeyByTemplate(accountPath, aesKey) + if (verified === true) { + onProgress?.('缓存密钥校验成功,已确认可用') + } else if (verified === false) { + onProgress?.('已从缓存计算密钥,但未通过本地模板校验') + } + return { success: true, xorKey: keyObj.xorKey, aesKey, verified: verified === true } } return { success: false, error: '未在缓存中找到匹配的图片密钥' } } catch (err: any) { @@ -251,6 +259,35 @@ export class KeyServiceLinux { } } + private async verifyImageKeyByTemplate(accountPath: string | undefined, aesKey: string): Promise { + const normalizedPath = String(accountPath || '').trim() + if (!normalizedPath || !aesKey || aesKey.length < 16 || !existsSync(normalizedPath)) return null + try { + const template = await this._findTemplateData(normalizedPath, 32) + if (!template.ciphertext) return null + return this.verifyDerivedAesKey(aesKey, template.ciphertext) + } catch { + return null + } + } + + private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean { + try { + if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false + const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true + if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true + if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true + if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true + if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true + return false + } catch { + return false + } + } + public async autoGetImageKeyByMemoryScan( accountPath: string, onProgress?: (msg: string) => void diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index fd95372..9900ec3 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -7,7 +7,7 @@ import crypto from 'crypto' import { homedir } from 'os' type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } -type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } +type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string } const execFileAsync = promisify(execFile) export class KeyServiceMac { @@ -647,7 +647,7 @@ export class KeyServiceMac { const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) - return { success: true, xorKey, aesKey } + return { success: true, xorKey, aesKey, verified: true } } } } @@ -662,7 +662,7 @@ export class KeyServiceMac { const fallbackCode = codes[0] const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid) onStatus?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`) - return { success: true, xorKey, aesKey } + return { success: true, xorKey, aesKey, verified: false } } catch (e: any) { return { success: false, error: `自动获取图片密钥失败: ${e.message}` } } diff --git a/src/App.tsx b/src/App.tsx index 8cfb8f4..a0f11d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> + } /> } /> } /> diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss index 215520e..458c7e4 100644 --- a/src/components/Export/ExportDateRangeDialog.scss +++ b/src/components/Export/ExportDateRangeDialog.scss @@ -6,7 +6,7 @@ align-items: center; justify-content: center; padding: 16px; - z-index: 2400; + z-index: 9200; } .export-date-range-dialog { diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index 31d4725..5f153ee 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -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); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9a9f0aa..4b9a0e7 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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({ wxid: '', - displayName: '未识别用户' + displayName: DEFAULT_DISPLAY_NAME }) const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) - const [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false) - const [wxidOptions, setWxidOptions] = useState([]) - const [isSwitchingAccount, setIsSwitchingAccount] = useState(false) const accountCardWrapRef = useRef(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, expectedWxid?: string) => { + const seq = ++loadSeq + const patchUserProfile = (patch: Partial) => { + 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([ 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 | 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) {
- - {showSwitchAccountDialog && ( -
!isSwitchingAccount && setShowSwitchAccountDialog(false)}> -
event.stopPropagation()}> -

切换账号

-

选择要切换的微信账号

-
- {wxidOptions.map((option) => ( - - ))} -
-
- -
-
-
- )} ) } diff --git a/src/pages/AccountManagementPage.scss b/src/pages/AccountManagementPage.scss new file mode 100644 index 0000000..1c0215e --- /dev/null +++ b/src/pages/AccountManagementPage.scss @@ -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; + } +} diff --git a/src/pages/AccountManagementPage.tsx b/src/pages/AccountManagementPage.tsx new file mode 100644 index 0000000..99e022e --- /dev/null +++ b/src/pages/AccountManagementPage.tsx @@ -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() +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, + wxidCandidates: Set +): 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 => { + 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 : {} + } 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([]) + const [isLoading, setIsLoading] = useState(false) + const [workingWxid, setWorkingWxid] = useState('') + const [notice, setNotice] = useState(null) + const [deleteUndoState, setDeleteUndoState] = useState(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() + 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() + 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(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(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 = { ...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 = { ...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 ( +
+
+
+

账号管理

+

统一管理切换账号、添加账号、删除账号配置。

+
+
+ + +
+
+ +
+
+ 数据库目录 + {dbPath || '未配置'} +
+
+ 当前账号 + {currentAccountLabel} +
+
+ 账号数量 + {accounts.length} +
+
+ + {notice && ( +
+ {notice.text} + {deleteUndoState && (notice.type === 'success' || notice.type === 'info') && ( + + )} +
+ )} + + {accounts.length === 0 ? ( +
+ + 未发现可管理账号,请先添加账号或检查数据库目录。 +
+ ) : ( +
+ {accounts.map((account) => ( +
+
+ {account.avatarUrl ? : {resolveAccountAvatarText(account.displayName)}} +
+
+
+

{account.displayName}

+ {account.isCurrent && ( + + 当前 + + )} + {account.hasConfig ? ( + 已保存配置 + ) : ( + 未保存配置 + )} +
+
wxid: {account.wxid}
+
+ 最近数据更新时间: {formatTime(account.modifiedTime)} · 配置更新时间: {formatTime(account.configUpdatedAt)} + {!account.fromScan && (仅配置记录)} +
+
+
+ + +
+
+ ))} +
+ )} + +
+ 删除仅影响 WeFlow 本地配置,不会删除微信原始数据文件。 +
+
+ ) +} + +export default AccountManagementPage diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 0944735..fd4c63f 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -3,10 +3,8 @@ height: 100%; margin: -24px -24px 0; padding: 18px 22px 12px; - background: - radial-gradient(1200px 520px at 6% -8%, color-mix(in srgb, var(--primary) 11%, transparent), transparent 65%), - radial-gradient(860px 420px at 90% 0%, color-mix(in srgb, var(--primary) 7%, transparent), transparent 66%), - var(--bg-primary); + background: var(--bg-primary); + /* Minimal background matching Footprint */ display: flex; flex-direction: column; gap: 16px; @@ -38,7 +36,9 @@ display: flex; align-items: center; justify-content: flex-start; - gap: 6px; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 8px; animation: exportSectionReveal 0.38s ease both; } @@ -119,7 +119,9 @@ } @keyframes sessionLoadDetailBars { - 0%, 100% { + + 0%, + 100% { transform: scaleY(0.72); opacity: 0.5; } @@ -460,7 +462,7 @@ border-bottom: none; } - > span { + >span { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -1308,12 +1310,29 @@ } } -.content-card-grid .content-card:nth-child(1) { animation-delay: 0.03s; } -.content-card-grid .content-card:nth-child(2) { animation-delay: 0.07s; } -.content-card-grid .content-card:nth-child(3) { animation-delay: 0.11s; } -.content-card-grid .content-card:nth-child(4) { animation-delay: 0.15s; } -.content-card-grid .content-card:nth-child(5) { animation-delay: 0.19s; } -.content-card-grid .content-card:nth-child(6) { animation-delay: 0.23s; } +.content-card-grid .content-card:nth-child(1) { + animation-delay: 0.03s; +} + +.content-card-grid .content-card:nth-child(2) { + animation-delay: 0.07s; +} + +.content-card-grid .content-card:nth-child(3) { + animation-delay: 0.11s; +} + +.content-card-grid .content-card:nth-child(4) { + animation-delay: 0.15s; +} + +.content-card-grid .content-card:nth-child(5) { + animation-delay: 0.19s; +} + +.content-card-grid .content-card:nth-child(6) { + animation-delay: 0.23s; +} .count-loading { color: var(--text-tertiary); @@ -1716,19 +1735,20 @@ flex: 0 0 auto; width: auto; max-width: max-content; - border: 1px solid var(--border-color); - background: var(--bg-secondary); + border: none; + background: transparent; color: var(--text-secondary); min-height: 32px; - padding: 7px 10px; + padding: 7px 12px; border-radius: 999px; cursor: pointer; font-size: 13px; + font-weight: 500; white-space: nowrap; display: inline-flex; align-items: center; justify-content: center; - transition: border-color 0.14s ease, background 0.14s ease, color 0.14s ease, transform 0.14s ease, box-shadow 0.14s ease; + transition: all 0.2s ease; .tab-btn-content { display: inline-flex; @@ -1753,21 +1773,18 @@ } &:hover { - border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color)); + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); color: var(--text-primary); - transform: translateY(-1px); - box-shadow: 0 7px 14px rgba(15, 23, 42, 0.08); } &.active { - border-color: var(--primary); color: var(--primary); - background: rgba(var(--primary-rgb), 0.12); - box-shadow: 0 6px 14px color-mix(in srgb, var(--primary) 24%, transparent); + background: color-mix(in srgb, var(--primary) 12%, transparent); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); .tab-btn-content span:last-child { - background: color-mix(in srgb, var(--primary) 16%, var(--bg-secondary)); - color: color-mix(in srgb, var(--primary) 84%, var(--text-primary)); + background: color-mix(in srgb, var(--primary) 16%, transparent); + color: var(--primary); } } @@ -1812,15 +1829,17 @@ gap: 6px; padding: 8px 11px; border-radius: 10px; - border: 1px solid var(--border-color); - background: color-mix(in srgb, var(--bg-secondary) 92%, var(--bg-primary)); - min-width: 240px; - transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; + border: none; + background: color-mix(in srgb, var(--text-tertiary) 4%, transparent); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03) inset; + flex: 1; + min-width: 180px; + max-width: 320px; + transition: all 0.2s ease; &:focus-within { - border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent); - background: var(--bg-secondary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent), 0 1px 3px rgba(0, 0, 0, 0.02) inset; + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); } input { @@ -1829,13 +1848,14 @@ color: var(--text-primary); font-size: 13px; outline: none; - width: 220px; + flex: 1; + min-width: 0; } .clear-search { - border: 1px solid transparent; - background: color-mix(in srgb, var(--bg-primary) 84%, var(--bg-secondary)); - color: var(--text-tertiary); + border: none; + background: color-mix(in srgb, var(--text-tertiary) 10%, transparent); + color: var(--text-secondary); cursor: pointer; display: inline-flex; align-items: center; @@ -1843,12 +1863,11 @@ width: 18px; height: 18px; border-radius: 999px; - transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease; + transition: all 0.2s ease; &:hover { - border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color)); color: var(--primary); - background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); + background: color-mix(in srgb, var(--primary) 14%, transparent); } } } @@ -1892,25 +1911,21 @@ --contacts-message-col-width: 120px; --contacts-media-col-width: 72px; --contacts-action-col-width: 140px; - --contacts-actions-sticky-width: max(var(--contacts-action-col-width), 184px); - --contacts-table-min-width: 1200px; + --contacts-actions-sticky-width: 240px; + --contacts-table-min-width: 1240px; overflow: hidden; - border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + border: none; border-radius: 12px; min-height: 320px; height: auto; flex: 1; display: flex; flex-direction: column; - background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 84%, var(--bg-primary)) 0%, var(--bg-secondary) 100%); - box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 18%, transparent); - transition: border-color 0.16s ease, box-shadow 0.16s ease; + background: var(--bg-secondary); + transition: all 0.2s ease; &:hover { - border-color: color-mix(in srgb, var(--primary) 22%, var(--border-color)); - box-shadow: - inset 0 1px 0 color-mix(in srgb, #fff 24%, transparent), - 0 8px 18px rgba(15, 23, 42, 0.06); + background: color-mix(in srgb, var(--text-tertiary) 2%, var(--bg-secondary)); } } @@ -2031,11 +2046,12 @@ } .issue-btn { - border: 1px solid var(--border-color); - background: var(--bg-secondary); + border: none; + background: color-mix(in srgb, var(--text-tertiary) 10%, transparent); border-radius: 8px; - padding: 7px 10px; + padding: 7px 12px; font-size: 12px; + font-weight: 500; color: var(--text-secondary); display: inline-flex; align-items: center; @@ -2045,14 +2061,17 @@ &:hover { color: var(--text-primary); - border-color: var(--text-tertiary); - background: var(--bg-hover); + background: color-mix(in srgb, var(--text-tertiary) 16%, transparent); + transform: translateY(-1px); } &.primary { - background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary)); - border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 14%, transparent); color: var(--primary); + + &:hover { + background: color-mix(in srgb, var(--primary) 20%, transparent); + } } } @@ -2070,20 +2089,20 @@ } .contacts-list-header { - --contacts-header-bg: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary)); + --contacts-header-bg: color-mix(in srgb, var(--bg-secondary) 80%, transparent); display: flex; align-items: center; gap: var(--contacts-column-gap); padding: 10px var(--contacts-inline-padding) 8px; min-width: max(100%, var(--contacts-table-min-width)); - border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent); background: var(--contacts-header-bg); font-size: 12px; color: var(--text-tertiary); font-weight: 600; letter-spacing: 0.01em; flex-shrink: 0; - backdrop-filter: saturate(115%) blur(3px); + backdrop-filter: blur(10px); &.is-draggable { cursor: grab; @@ -2164,7 +2183,7 @@ display: flex; align-items: center; justify-content: flex-end; - gap: 8px; + gap: 10px; flex-wrap: nowrap; flex-shrink: 0; position: sticky; @@ -2248,25 +2267,25 @@ } .selection-clear-btn { - border: 1px solid var(--border-color); + border: none; border-radius: 8px; - background: var(--bg-secondary); + background: color-mix(in srgb, var(--text-tertiary) 10%, transparent); color: var(--text-secondary); font-size: 12px; + font-weight: 500; padding: 6px 10px; cursor: pointer; white-space: nowrap; - transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease, transform 0.14s ease; + transition: all 0.2s ease; &:hover:not(:disabled) { - border-color: var(--text-tertiary); color: var(--text-primary); - background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary)); + background: color-mix(in srgb, var(--text-tertiary) 16%, transparent); transform: translateY(-1px); } &:disabled { - opacity: 0.65; + opacity: 0.5; cursor: not-allowed; } } @@ -2275,19 +2294,21 @@ border: none; border-radius: 8px; padding: 6px 10px; - background: linear-gradient(180deg, color-mix(in srgb, var(--primary) 94%, #ffffff) 0%, var(--primary) 100%); + background: var(--primary); color: #fff; font-size: 12px; + font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; flex-shrink: 0; - transition: transform 0.14s ease, box-shadow 0.14s ease, background 0.14s ease; + transition: all 0.2s ease; + box-shadow: 0 2px 6px color-mix(in srgb, var(--primary) 20%, transparent); &:hover:not(:disabled) { - background: var(--primary-hover); + background: color-mix(in srgb, var(--primary) 85%, #fff); transform: translateY(-1px); box-shadow: 0 8px 14px color-mix(in srgb, var(--primary) 30%, transparent); } @@ -2360,7 +2381,7 @@ } .contact-item { - --contacts-row-bg: var(--bg-secondary); + --contacts-row-bg: transparent; display: flex; align-items: center; gap: var(--contacts-column-gap); @@ -2369,15 +2390,14 @@ height: 72px; box-sizing: border-box; border-radius: 10px; - transition: box-shadow 0.18s ease, background 0.18s ease, transform 0.18s ease; + transition: all 0.2s ease; cursor: default; background: var(--contacts-row-bg); - box-shadow: inset 0 0 0 1px transparent; + box-shadow: none; &:hover { - background: var(--contacts-row-bg); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-tertiary) 24%, transparent); - transform: translateX(1px); + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); + transform: translateX(2px); } } @@ -2634,32 +2654,34 @@ min-width: 1300px; border-collapse: separate; border-spacing: 0; - background: var(--bg-secondary); + background: transparent; thead th { position: sticky; top: 0; - background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary)); + background: color-mix(in srgb, var(--bg-secondary) 80%, transparent); + backdrop-filter: blur(8px); z-index: 4; font-size: 12px; text-align: left; color: var(--text-secondary); - border-bottom: 1px solid var(--border-color); + border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent); padding: 10px 10px; white-space: nowrap; } tbody td { padding: 10px; - border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 4%, transparent); font-size: 13px; color: var(--text-primary); vertical-align: middle; white-space: nowrap; + transition: background 0.15s ease; } - tbody tr:hover { - background: rgba(var(--primary-rgb), 0.03); + tbody tr:hover td { + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); } .selected-row, @@ -2797,27 +2819,26 @@ } .row-detail-btn { - border: 1px solid var(--border-color); + border: none; border-radius: 8px; - padding: 7px 10px; - background: var(--bg-secondary); + padding: 7px 12px; + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); color: var(--text-secondary); font-size: 12px; + font-weight: 500; cursor: pointer; white-space: nowrap; - transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease, transform 0.14s ease; + transition: all 0.2s ease; &:hover { - border-color: var(--text-tertiary); color: var(--text-primary); - background: var(--bg-hover); + background: color-mix(in srgb, var(--text-tertiary) 14%, transparent); transform: translateY(-1px); } &.active { - border-color: var(--primary); color: var(--primary); - background: rgba(var(--primary-rgb), 0.12); + background: color-mix(in srgb, var(--primary) 12%, transparent); } } @@ -2883,7 +2904,7 @@ text-align: center; } - .row-export-link.state-running + .row-export-time { + .row-export-link.state-running+.row-export-time { color: var(--primary); font-weight: 600; } @@ -2918,23 +2939,22 @@ width: min(448px, calc(100vw - 24px)); height: 100%; max-height: calc(100vh - 24px); - border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent); - border-radius: 16px; - background: - linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 82%, var(--bg-primary)) 0%, var(--bg-secondary-solid, #ffffff) 100%); + border: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent); + border-radius: 20px; + background: var(--bg-primary); display: flex; flex-direction: column; overflow: hidden; - box-shadow: -18px 0 40px rgba(0, 0, 0, 0.24); + box-shadow: -18px 24px 60px rgba(0, 0, 0, 0.16); animation: exportDetailPanelIn 0.26s cubic-bezier(0.22, 0.8, 0.24, 1) both; .detail-header { display: flex; align-items: center; justify-content: space-between; - padding: 16px 16px 12px; - border-bottom: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); - background: color-mix(in srgb, var(--bg-primary) 92%, var(--card-bg)); + padding: 20px 20px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent); + background: transparent; .detail-header-main { display: flex; @@ -3318,22 +3338,22 @@ .export-session-sns-dialog { width: min(760px, 100%); max-height: min(86vh, 860px); - border-radius: 14px; - border: 1px solid var(--border-color); - background: var(--bg-secondary-solid, #ffffff); - box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24); + border-radius: 20px; + border: 1px solid color-mix(in srgb, var(--text-tertiary) 8%, transparent); + background: var(--bg-primary); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.16); display: flex; flex-direction: column; overflow: hidden; - animation: exportModalPopIn 0.24s cubic-bezier(0.2, 0.78, 0.26, 1) both; + animation: footprintFadeSlideUp 0.3s cubic-bezier(0.2, 0.78, 0.26, 1) both; .sns-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; - padding: 14px 16px; - border-bottom: 1px solid var(--border-color); + padding: 16px 20px; + border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent); } .sns-dialog-header-main { @@ -3673,12 +3693,10 @@ position: relative; overflow: hidden; border-radius: 8px; - background: linear-gradient( - 90deg, - rgba(255, 255, 255, 0.08) 0%, - rgba(255, 255, 255, 0.35) 50%, - rgba(255, 255, 255, 0.08) 100% - ); + background: linear-gradient(90deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.35) 50%, + rgba(255, 255, 255, 0.08) 100%); background-size: 220% 100%; animation: exportSkeletonShimmer 1.2s linear infinite; } @@ -3700,13 +3718,39 @@ height: 12px; } -.skeleton-line.w-12 { width: 48%; min-width: 42px; } -.skeleton-line.w-20 { width: 22%; min-width: 36px; } -.skeleton-line.w-30 { width: 32%; min-width: 120px; } -.skeleton-line.w-40 { width: 45%; min-width: 80px; } -.skeleton-line.w-60 { width: 62%; min-width: 110px; } -.skeleton-line.w-100 { width: 100%; } -.skeleton-line.h-32 { height: 32px; border-radius: 10px; } +.skeleton-line.w-12 { + width: 48%; + min-width: 42px; +} + +.skeleton-line.w-20 { + width: 22%; + min-width: 36px; +} + +.skeleton-line.w-30 { + width: 32%; + min-width: 120px; +} + +.skeleton-line.w-40 { + width: 45%; + min-width: 80px; +} + +.skeleton-line.w-60 { + width: 62%; + min-width: 110px; +} + +.skeleton-line.w-100 { + width: 100%; +} + +.skeleton-line.h-32 { + height: 32px; + border-radius: 10px; +} .export-dialog-overlay { position: fixed; @@ -4440,11 +4484,9 @@ justify-content: flex-end; gap: 10px; flex-shrink: 0; - background: linear-gradient( - 180deg, - transparent, - var(--card-bg) 38% - ); + background: linear-gradient(180deg, + transparent, + var(--card-bg) 38%); } .primary-btn, @@ -4824,6 +4866,7 @@ 0% { background-position: 220% 0; } + 100% { background-position: -20% 0; } @@ -4833,6 +4876,7 @@ 0% { width: 0; } + 100% { width: 1.8em; } @@ -4843,10 +4887,12 @@ transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.35); } + 70% { transform: scale(1.02); box-shadow: 0 0 0 6px rgba(255, 77, 79, 0); } + 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 77, 79, 0); @@ -4854,6 +4900,7 @@ } @media (prefers-reduced-motion: reduce) { + .export-board-page, .export-top-panel, .export-section-title-row, @@ -5207,3 +5254,634 @@ } } } + +.automation-hint-pill { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 6px; + padding: 5px 12px; + border-radius: 999px; + background: color-mix(in srgb, var(--primary) 10%, transparent); + color: var(--primary); + font-size: 12px; + font-weight: 500; +} + +.automation-modal-overlay { + position: fixed; + inset: 0; + z-index: 7750; + background: rgba(0, 0, 0, 0.38); + display: flex; + align-items: center; + justify-content: center; + padding: 24px 20px; + animation: exportOverlayFadeIn 0.2s ease both; +} + +.automation-modal { + width: min(680px, 100%); + max-height: min(80vh, 820px); + border-radius: 20px; + border: 1px solid color-mix(in srgb, var(--text-tertiary) 8%, transparent); + background: var(--bg-primary); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.16); + display: flex; + flex-direction: column; + overflow: hidden; + animation: footprintFadeSlideUp 0.28s cubic-bezier(0.2, 0.78, 0.26, 1) both; +} + +.automation-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 20px 20px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent); + flex-shrink: 0; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + p { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-tertiary); + } +} + +.automation-modal-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px 20px 20px; +} + +.automation-empty { + padding: 40px 0; + text-align: center; + font-size: 13px; + color: var(--text-tertiary); +} + +.automation-task-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.automation-task-card { + border-radius: 14px; + background: color-mix(in srgb, var(--text-tertiary) 5%, transparent); + padding: 14px 16px; + display: flex; + align-items: flex-start; + gap: 12px; + transition: background 0.2s ease; + + &:hover { + background: color-mix(in srgb, var(--text-tertiary) 9%, transparent); + } + + &.disabled { + opacity: 0.6; + } +} + +.automation-task-main { + flex: 1; + min-width: 0; + + p { + margin: 3px 0 0; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + } +} + +.automation-task-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + + strong { + font-size: 14px; + color: var(--text-primary); + font-weight: 600; + } +} + +.automation-task-status { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + + &.enabled { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + } + + &.disabled { + background: color-mix(in srgb, var(--text-tertiary) 12%, transparent); + color: var(--text-tertiary); + } + + &.running { + background: rgba(82, 196, 26, 0.14); + color: #52c41a; + } + + &.queued { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + } +} + +.automation-task-actions { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} + +.automation-editor-overlay { + position: fixed; + inset: 0; + z-index: 7800; + background: rgba(0, 0, 0, 0.42); + display: flex; + align-items: center; + justify-content: center; + padding: 24px 20px; + animation: exportOverlayFadeIn 0.2s ease both; +} + +.automation-editor-modal { + width: min(560px, 100%); + max-height: min(88vh, 900px); + border-radius: 20px; + border: 1px solid color-mix(in srgb, var(--text-tertiary) 8%, transparent); + background: var(--bg-primary); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.18); + display: flex; + flex-direction: column; + overflow: hidden; + animation: footprintFadeSlideUp 0.28s cubic-bezier(0.2, 0.78, 0.26, 1) both; +} + +.automation-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 20px 20px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent); + flex-shrink: 0; + + h3 { + margin: 0; + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + } +} + +.automation-editor-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 14px; + + /* 裸 input 统一样式(未套 .automation-form-field 的情况) */ + >input[type='datetime-local'], + >input[type='number'], + >input[type='text'] { + height: 36px; + border: none; + border-radius: 10px; + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); + color: var(--text-primary); + padding: 0 12px; + font-size: 13px; + width: 100%; + box-sizing: border-box; + outline: none; + transition: background 0.2s ease, box-shadow 0.2s ease; + + &:focus { + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent); + } + + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + /* datetime-local 的日历图标调整外观 */ + &::-webkit-calendar-picker-indicator { + opacity: 0.5; + cursor: pointer; + filter: var(--datetime-picker-icon-filter, none); + transition: opacity 0.18s ease; + border-radius: 4px; + padding: 2px; + + &:hover { + opacity: 1; + background: color-mix(in srgb, var(--text-tertiary) 10%, transparent); + } + } + + &::-webkit-datetime-edit { + padding: 0; + } + + &::-webkit-datetime-edit-fields-wrapper { + background: transparent; + } + + &::-webkit-datetime-edit-text { + color: var(--text-tertiary); + padding: 0 1px; + } + + &::-webkit-datetime-edit-year-field, + &::-webkit-datetime-edit-month-field, + &::-webkit-datetime-edit-day-field, + &::-webkit-datetime-edit-hour-field, + &::-webkit-datetime-edit-minute-field, + &::-webkit-datetime-edit-ampm-field { + color: var(--text-primary); + border-radius: 3px; + padding: 0 2px; + + &:focus { + background: color-mix(in srgb, var(--primary) 16%, transparent); + color: var(--primary); + outline: none; + } + } + } + + /* 嵌套在 div 内的裸 input(如 stopAt 在 .automation-form-field > div 里) */ + input[type='datetime-local']:not(.automation-form-field input) { + height: 36px; + border: none; + border-radius: 10px; + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); + color: var(--text-primary); + padding: 0 12px; + font-size: 13px; + width: 100%; + box-sizing: border-box; + outline: none; + transition: background 0.2s ease, box-shadow 0.2s ease; + + &:focus { + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent); + } + + &::-webkit-calendar-picker-indicator { + opacity: 0.5; + cursor: pointer; + transition: opacity 0.18s ease; + border-radius: 4px; + padding: 2px; + + &:hover { + opacity: 1; + } + } + + &::-webkit-datetime-edit-year-field, + &::-webkit-datetime-edit-month-field, + &::-webkit-datetime-edit-day-field, + &::-webkit-datetime-edit-hour-field, + &::-webkit-datetime-edit-minute-field { + color: var(--text-primary); + border-radius: 3px; + padding: 0 2px; + + &:focus { + background: color-mix(in srgb, var(--primary) 16%, transparent); + color: var(--primary); + outline: none; + } + } + } + + input[type='number']:not(.automation-form-field input) { + height: 36px; + border: none; + border-radius: 10px; + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); + color: var(--text-primary); + padding: 0 12px; + font-size: 13px; + width: 100%; + box-sizing: border-box; + outline: none; + transition: background 0.2s ease, box-shadow 0.2s ease; + + &:focus { + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent); + } + + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + } +} + +.automation-editor-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 12px 20px 16px; + border-top: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent); + flex-shrink: 0; +} + +.automation-form-field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + + >span { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + } + + input[type='text'], + input[type='number'], + input[type='datetime-local'] { + height: 36px; + border: none; + border-radius: 10px; + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); + color: var(--text-primary); + padding: 0 12px; + font-size: 13px; + width: 100%; + box-sizing: border-box; + outline: none; + transition: background 0.2s ease, box-shadow 0.2s ease; + + &:focus { + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent); + } + + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &::-webkit-calendar-picker-indicator { + cursor: pointer; + opacity: 0.6; + transition: opacity 0.2s ease; + + &:hover { + opacity: 1; + } + } + } +} + +.automation-inline-time { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.automation-inline-check { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-primary); + cursor: pointer; + user-select: none; + + input[type='checkbox'] { + appearance: none; + -webkit-appearance: none; + width: 16px; + height: 16px; + cursor: pointer; + flex-shrink: 0; + border-radius: 4px; + border: 1px solid color-mix(in srgb, var(--text-tertiary) 30%, transparent); + background: transparent; + transition: all 0.2s ease; + position: relative; + + &:hover { + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); + } + + &:checked { + background: var(--primary); + border-color: var(--primary); + + &::after { + content: ''; + position: absolute; + left: 5px; + top: 2px; + width: 4px; + height: 7px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + } + } +} + +.automation-segment-row { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.automation-segment-btn { + border: none; + border-radius: 8px; + padding: 6px 14px; + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.18s ease; + + &:hover { + background: color-mix(in srgb, var(--text-tertiary) 14%, transparent); + color: var(--text-primary); + } + + &.active { + background: color-mix(in srgb, var(--primary) 14%, transparent); + color: var(--primary); + } +} + +.automation-path-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 12px; + color: var(--text-tertiary); + padding: 4px 2px; +} + +.automation-draft-summary { + padding: 10px 12px; + border-radius: 10px; + background: color-mix(in srgb, var(--text-tertiary) 5%, transparent); + font-size: 12px; + color: var(--text-secondary); + line-height: 1.6; +} + +.close-icon-btn { + border: none; + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + color: var(--text-secondary); + width: 30px; + height: 30px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: all 0.18s ease; + + &:hover { + background: color-mix(in srgb, var(--text-tertiary) 16%, transparent); + color: var(--text-primary); + } +} + +.primary-btn { + border: none; + border-radius: 9px; + padding: 8px 18px; + background: var(--primary); + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 6px color-mix(in srgb, var(--primary) 20%, transparent); + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--primary) 85%, #fff); + transform: translateY(-1px); + box-shadow: 0 8px 14px color-mix(in srgb, var(--primary) 30%, transparent); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +/* 终止时间选择器 */ +.automation-stopat-picker { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + + input { + flex: 1; + min-width: 0; + height: 36px; + border: none; + border-radius: 10px; + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + color: var(--text-primary); + padding: 0 10px; + font-size: 13px; + outline: none; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + font-variant-numeric: tabular-nums; + + &:hover { + background: color-mix(in srgb, var(--text-tertiary) 12%, transparent); + } + + &:focus { + background: color-mix(in srgb, var(--text-tertiary) 10%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 25%, transparent); + } + } + + .automation-stopat-date { + flex: 1.4; + } + + .automation-stopat-time { + flex: 1; + text-align: center; + } +} + +/* 自动化创建模式提示 */ +.automation-create-mode-pill { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 16px; + border-radius: 999px; + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--primary) 15%, transparent); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + animation: footprintFadeSlideUp 0.3s ease both; + white-space: nowrap; + margin-left: 8px; + + span { + font-size: 13px; + font-weight: 500; + color: var(--primary); + } + + .secondary-btn { + height: 24px; + padding: 0 10px; + font-size: 11px; + border-radius: 6px; + } +} \ No newline at end of file diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 1c70471..b7d6f1c 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -32,6 +32,12 @@ import { import type { ChatSession as AppChatSession, ContactInfo } from '../types/models' import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron' import type { BackgroundTaskRecord } from '../types/backgroundTask' +import type { + ExportAutomationCondition, + ExportAutomationDateRangeConfig, + ExportAutomationSchedule, + ExportAutomationTask +} from '../types/exportAutomation' import * as configService from '../services/config' import { emitExportSessionStatus, @@ -55,12 +61,15 @@ import type { SnsPost } from '../types/sns' import { cloneExportDateRange, cloneExportDateRangeSelection, + createDateRangeByLastNDays, createDefaultDateRange, createDefaultExportDateRangeSelection, getExportDateRangeLabel, resolveExportDateRangeConfig, + serializeExportDateRangeConfig, startOfDay, endOfDay, + type ExportDateRangePreset, type ExportDateRangeSelection } from '../utils/exportDateRange' import './ExportPage.scss' @@ -147,6 +156,8 @@ interface ExportTaskPayload { outputDir: string options?: ElectronExportOptions scope: TaskScope + source: 'manual' | 'automation' + automationTaskId?: string contentType?: ContentType sessionNames: string[] snsOptions?: { @@ -175,6 +186,7 @@ interface ExportTask { interface ExportDialogState { open: boolean + intent: 'manual' | 'automation-create' scope: TaskScope contentType?: ContentType sessionIds: string[] @@ -182,6 +194,27 @@ interface ExportDialogState { title: string } +interface AutomationTaskDraft { + mode: 'create' | 'edit' + id?: string + name: string + enabled: boolean + sessionIds: string[] + sessionNames: string[] + outputDir: string + useGlobalOutputDir: boolean + scope: Exclude + contentType?: ContentType + optionTemplate: Omit + dateRangeConfig: ExportAutomationDateRangeConfig | string | null + intervalDays: number + intervalHours: number + stopAtEnabled: boolean + stopAtValue: string + maxRunsEnabled: boolean + maxRuns: number +} + const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900 @@ -589,6 +622,7 @@ const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean } const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +const createAutomationTaskId = (): string => `auto-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const CONTACT_ENRICH_TIMEOUT_MS = 7000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80 @@ -599,6 +633,220 @@ const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000 type SessionDataSource = 'cache' | 'network' | null type ContactsDataSource = 'cache' | 'network' | null +const normalizeAutomationIntervalDays = (value: unknown): number => Math.max(0, Math.floor(Number(value) || 0)) +const normalizeAutomationIntervalHours = (value: unknown): number => Math.max(0, Math.min(23, Math.floor(Number(value) || 0))) + +const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number => { + const days = normalizeAutomationIntervalDays(schedule.intervalDays) + const hours = normalizeAutomationIntervalHours(schedule.intervalHours) + const totalHours = (days * 24) + hours + if (totalHours <= 0) return 0 + return totalHours * 60 * 60 * 1000 +} + +const formatAutomationScheduleLabel = (schedule: ExportAutomationSchedule): string => { + const days = normalizeAutomationIntervalDays(schedule.intervalDays) + const hours = normalizeAutomationIntervalHours(schedule.intervalHours) + const parts: string[] = [] + if (days > 0) parts.push(`${days} 天`) + if (hours > 0) parts.push(`${hours} 小时`) + return `每间隔 ${parts.length > 0 ? parts.join(' ') : '0 小时'} 执行一次` +} + +const resolveAutomationDueScheduleKey = (task: ExportAutomationTask, now: Date): string | null => { + const intervalMs = resolveAutomationIntervalMs(task.schedule) + if (intervalMs <= 0) return null + const nowMs = now.getTime() + const anchorAt = Math.max( + 0, + Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0) + ) + if (nowMs < anchorAt + intervalMs) return null + return `interval:${anchorAt}:${Math.floor((nowMs - anchorAt) / intervalMs)}` +} + +const toDateTimeLocalValue = (timestamp: number): string => { + const date = new Date(timestamp) + if (Number.isNaN(date.getTime())) return '' + const year = date.getFullYear() + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + const hours = `${date.getHours()}`.padStart(2, '0') + const minutes = `${date.getMinutes()}`.padStart(2, '0') + return `${year}-${month}-${day}T${hours}:${minutes}` +} + +const parseDateTimeLocalValue = (value: string): number | null => { + const text = String(value || '').trim() + if (!text) return null + const parsed = new Date(text) + const timestamp = parsed.getTime() + if (!Number.isFinite(timestamp)) return null + return Math.floor(timestamp) +} + +type AutomationRangeMode = 'all' | 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last1year' | 'lastNDays' | 'custom' + +const AUTOMATION_RANGE_OPTIONS: Array<{ mode: AutomationRangeMode; label: string }> = [ + { mode: 'all', label: '全部时间' }, + { mode: 'yesterday', label: '往前1天' }, + { mode: 'last7days', label: '往前7天' }, + { mode: 'last30days', label: '往前30天' }, + { mode: 'last1year', label: '往前1年' }, + { mode: 'lastNDays', label: '往前N天' }, + { mode: 'custom', label: '完整时间' } +] + +const AUTOMATION_LAST_N_DAYS_MIN = 1 +const AUTOMATION_LAST_N_DAYS_MAX = 3650 +const AUTOMATION_LAST_N_DAYS_DEFAULT = 3 + +const normalizeAutomationLastNDays = (value: unknown): number => { + const parsed = Math.floor(Number(value) || 0) + if (!Number.isFinite(parsed) || parsed <= 0) return AUTOMATION_LAST_N_DAYS_DEFAULT + return Math.min(AUTOMATION_LAST_N_DAYS_MAX, Math.max(AUTOMATION_LAST_N_DAYS_MIN, parsed)) +} + +const readAutomationLastNDays = ( + config: ExportAutomationDateRangeConfig | string | null | undefined +): number | null => { + if (!config || typeof config !== 'object') return null + const raw = config as Record + const mode = String(raw.relativeMode || '').trim() + if (mode !== 'last-n-days') return null + const days = Math.floor(Number(raw.relativeDays) || 0) + if (!Number.isFinite(days) || days <= 0) return null + return Math.min(AUTOMATION_LAST_N_DAYS_MAX, Math.max(AUTOMATION_LAST_N_DAYS_MIN, days)) +} + +const buildAutomationLastNDaysConfig = (days: number): ExportAutomationDateRangeConfig => ({ + version: 1, + preset: 'custom', + useAllTime: false, + relativeMode: 'last-n-days', + relativeDays: normalizeAutomationLastNDays(days) +}) + +const resolveAutomationDateRangeSelection = ( + config: ExportAutomationDateRangeConfig | string | null | undefined, + now = new Date() +): ExportDateRangeSelection => { + const relativeDays = readAutomationLastNDays(config) + if (relativeDays) { + return { + preset: 'custom', + useAllTime: false, + dateRange: createDateRangeByLastNDays(relativeDays, now) + } + } + return resolveExportDateRangeConfig(config as any, now) +} + +const resolveAutomationRangeMode = ( + config: ExportAutomationDateRangeConfig | string | null | undefined, + selection: ExportDateRangeSelection +): AutomationRangeMode => { + if (readAutomationLastNDays(config)) return 'lastNDays' + if (selection.useAllTime) return 'all' + if (selection.preset === 'today') return 'today' + if (selection.preset === 'yesterday') return 'yesterday' + if (selection.preset === 'last7days') return 'last7days' + if (selection.preset === 'last30days') return 'last30days' + if (selection.preset === 'last1year') return 'last1year' + return 'custom' +} + +const createAutomationSelectionByMode = ( + mode: Exclude, + now = new Date() +): ExportDateRangeSelection => { + const preset: ExportDateRangePreset = mode + return resolveExportDateRangeConfig({ + version: 1, + preset, + useAllTime: mode === 'all' + }, now) +} + +const formatAutomationRangeLabel = ( + config: ExportAutomationDateRangeConfig | string | null | undefined, + selection?: ExportDateRangeSelection +): string => { + const resolved = selection || resolveAutomationDateRangeSelection(config, new Date()) + const mode = resolveAutomationRangeMode(config, resolved) + if (mode === 'all') return '每次触发导出全部历史消息' + if (mode === 'today') return '每次触发导出当天' + if (mode === 'yesterday') return '每次触发导出前1天(昨日)' + if (mode === 'last7days') return '每次触发导出前7天' + if (mode === 'last30days') return '每次触发导出前30天' + if (mode === 'last1year') return '每次触发导出前1年' + if (mode === 'lastNDays') { + return `每次触发导出前 ${readAutomationLastNDays(config) || AUTOMATION_LAST_N_DAYS_DEFAULT} 天` + } + return `完整时间:${getExportDateRangeLabel(resolved)}` +} + +const formatAutomationStopCondition = (task: ExportAutomationTask): string => { + const endAt = Number(task.stopCondition?.endAt || 0) + const maxRuns = Number(task.stopCondition?.maxRuns || 0) + const labels: string[] = [] + if (endAt > 0) { + labels.push(`截止到 ${new Date(endAt).toLocaleString('zh-CN')}`) + } + if (maxRuns > 0) { + const successCount = Math.max(0, Math.floor(Number(task.runState?.successCount || 0))) + labels.push(`成功 ${successCount}/${maxRuns} 次后停止`) + } + return labels.length > 0 ? labels.join(' · ') : '无' +} + +const resolveAutomationNextTriggerAt = (task: ExportAutomationTask): number | null => { + const intervalMs = resolveAutomationIntervalMs(task.schedule) + if (intervalMs <= 0) return null + const anchorAt = Math.max(0, Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0)) + if (!anchorAt) return null + return anchorAt + intervalMs +} + +const formatAutomationCurrentState = ( + task: ExportAutomationTask, + queueState: 'queued' | 'running' | null, + nowMs: number +): string => { + if (!task.enabled) return '已停用' + if (queueState === 'running') return '执行中' + if (queueState === 'queued') return '排队中' + const nextTriggerAt = resolveAutomationNextTriggerAt(task) + if (!nextTriggerAt) return '等待触发' + const diff = nextTriggerAt - nowMs + if (diff <= 0) return '即将触发' + return `等待触发 · 下次 ${new Date(nextTriggerAt).toLocaleString('zh-CN')}(约 ${formatDurationMs(diff)} 后)` +} + +const formatAutomationLastRunSummary = (task: ExportAutomationTask): string => { + const status = task.runState?.lastRunStatus || 'idle' + const label = ( + status === 'idle' ? '尚未执行' : + status === 'queued' ? '已入队' : + status === 'running' ? '执行中' : + status === 'success' ? '执行成功' : + status === 'error' ? '执行失败' : + status === 'skipped' ? '已跳过' : + status + ) + const parts: string[] = [label] + if (task.runState?.lastSuccessAt) { + parts.push(`最近成功于 ${new Date(task.runState.lastSuccessAt).toLocaleString('zh-CN')}`) + } + if (task.runState?.lastSkipReason) { + parts.push(task.runState.lastSkipReason) + } + if (task.runState?.lastError) { + parts.push(task.runState.lastError) + } + return parts.join(' · ') +} + interface ContactsLoadSession { requestId: string startedAt: number @@ -1574,6 +1822,8 @@ function ExportPage() { const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false) + const [isAutomationModalOpen, setIsAutomationModalOpen] = useState(false) + const [automationHint, setAutomationHint] = useState(null) const [expandedPerfTaskId, setExpandedPerfTaskId] = useState(null) const [sessions, setSessions] = useState([]) const [sessionDataSource, setSessionDataSource] = useState(null) @@ -1680,14 +1930,22 @@ function ExportPage() { const [exportDialog, setExportDialog] = useState({ open: false, + intent: 'manual', scope: 'single', sessionIds: [], sessionNames: [], title: '' }) + const [isAutomationCreateMode, setIsAutomationCreateMode] = useState(false) const [showSessionFormatSelect, setShowSessionFormatSelect] = useState(false) const [tasks, setTasks] = useState([]) + const [automationTasks, setAutomationTasks] = useState([]) + const [automationTaskDraft, setAutomationTaskDraft] = useState(null) + const [isAutomationRangeDialogOpen, setIsAutomationRangeDialogOpen] = useState(false) + const [isResolvingAutomationRangeBounds, setIsResolvingAutomationRangeBounds] = useState(false) + const [automationRangeBounds, setAutomationRangeBounds] = useState(null) + const [automationRangeSelection, setAutomationRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) const [lastExportBySession, setLastExportBySession] = useState>({}) const [lastExportByContent, setLastExportByContent] = useState>({}) const [exportRecordsBySession, setExportRecordsBySession] = useState>({}) @@ -1714,6 +1972,10 @@ function ExportPage() { const progressUnsubscribeRef = useRef<(() => void) | null>(null) const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) + const automationTasksRef = useRef([]) + const automationTasksReadyRef = useRef(false) + const automationSchedulerRunningRef = useRef(false) + const automationQueueStatusByTaskIdRef = useRef>(new Map()) const hasSeededSnsStatsRef = useRef(false) const sessionLoadTokenRef = useRef(0) const preselectAppliedRef = useRef(false) @@ -1810,6 +2072,46 @@ function ExportPage() { return scopeKey }, []) + const persistAutomationTasks = useCallback(async (nextTasks: ExportAutomationTask[]) => { + if (!automationTasksReadyRef.current) return + const scopeKey = await ensureExportCacheScope() + await configService.setExportAutomationTasks(scopeKey, nextTasks) + }, [ensureExportCacheScope]) + + const updateAutomationTasks = useCallback(( + updater: (prev: ExportAutomationTask[]) => ExportAutomationTask[] + ) => { + setAutomationTasks((prev) => { + const next = updater(prev) + void persistAutomationTasks(next) + return next + }) + }, [persistAutomationTasks]) + + const patchAutomationTask = useCallback(( + taskId: string, + updater: (task: ExportAutomationTask) => ExportAutomationTask + ) => { + updateAutomationTasks((prev) => prev.map((task) => (task.id === taskId ? updater(task) : task))) + }, [updateAutomationTasks]) + + const markAutomationTaskSkipped = useCallback((taskId: string, reason: string, scheduleKey?: string) => { + const now = Date.now() + patchAutomationTask(taskId, (task) => ({ + ...task, + updatedAt: now, + runState: { + ...(task.runState || {}), + lastRunStatus: 'skipped', + lastTriggeredAt: now, + lastSkipAt: now, + lastSkipReason: reason, + lastError: undefined, + lastScheduleKey: scheduleKey || task.runState?.lastScheduleKey + } + })) + }, [patchAutomationTask]) + const loadContactsCaches = useCallback(async (scopeKey: string) => { const [contactsItem, avatarItem] = await Promise.all([ configService.getContactsListCache(scopeKey), @@ -1821,6 +2123,22 @@ function ExportPage() { } }, []) + const ensureAutomationTasksLoaded = useCallback(async () => { + if (automationTasksReadyRef.current) return + try { + const scopeKey = await ensureExportCacheScope() + const automationTaskItem = await configService.getExportAutomationTasks(scopeKey) + setAutomationTasks(automationTaskItem?.tasks || []) + automationTasksReadyRef.current = true + } catch (error) { + console.error('加载自动化导出任务失败:', error) + } + }, [ensureExportCacheScope]) + + useEffect(() => { + void ensureAutomationTasksLoaded() + }, [ensureAutomationTasksLoaded]) + useEffect(() => { let cancelled = false void (async () => { @@ -2234,6 +2552,10 @@ function ExportPage() { tasksRef.current = tasks }, [tasks]) + useEffect(() => { + automationTasksRef.current = automationTasks + }, [automationTasks]) + useEffect(() => { sessionsRef.current = sessions }, [sessions]) @@ -2288,8 +2610,16 @@ function ExportPage() { return () => window.clearInterval(timer) }, [isTaskCenterOpen, expandedPerfTaskId, tasks]) + useEffect(() => { + if (!isAutomationModalOpen) return + setNowTick(Date.now()) + const timer = window.setInterval(() => setNowTick(Date.now()), 1000) + return () => window.clearInterval(timer) + }, [isAutomationModalOpen]) + const loadBaseConfig = useCallback(async (): Promise => { setIsBaseConfigLoading(true) + automationTasksReadyRef.current = false let isReady = true try { const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([ @@ -2314,6 +2644,7 @@ function ExportPage() { ]) const cachedSnsStats = await configService.getExportSnsStatsCache(exportCacheScope) + const automationTaskItem = await configService.getExportAutomationTasks(exportCacheScope) if (savedPath) { setExportFolder(savedPath) @@ -2342,6 +2673,8 @@ function ExportPage() { setExportDefaultConcurrency(savedConcurrency ?? 2) setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true) setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic') + setAutomationTasks(automationTaskItem?.tasks || []) + automationTasksReadyRef.current = true const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange) setExportDefaultDateRangeSelection(resolvedDefaultDateRange) setTimeRangeSelection(resolvedDefaultDateRange) @@ -2381,6 +2714,7 @@ function ExportPage() { })) } catch (error) { isReady = false + automationTasksReadyRef.current = false console.error('加载导出配置失败:', error) } finally { setIsBaseConfigLoading(false) @@ -4046,6 +4380,7 @@ function ExportPage() { useEffect(() => { if (isExportRoute) return // 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。 + setIsAutomationCreateMode(false) sessionLoadTokenRef.current = Date.now() sessionCountRequestIdRef.current += 1 snsUserPostCountsHydrationTokenRef.current += 1 @@ -4126,8 +4461,8 @@ function ExportPage() { const clearSelection = () => setSelectedSessions(new Set()) - const openExportDialog = useCallback((payload: Omit) => { - setExportDialog({ open: true, ...payload }) + const openExportDialog = useCallback((payload: Omit & { intent?: ExportDialogState['intent'] }) => { + setExportDialog({ open: true, intent: payload.intent || 'manual', ...payload }) setIsTimeRangeDialogOpen(false) setTimeRangeBounds(null) setTimeRangeSelection(exportDefaultDateRangeSelection) @@ -4197,7 +4532,7 @@ function ExportPage() { ]) const closeExportDialog = useCallback(() => { - setExportDialog(prev => ({ ...prev, open: false })) + setExportDialog(prev => ({ ...prev, open: false, intent: 'manual' })) setIsTimeRangeDialogOpen(false) setTimeRangeBounds(null) }, []) @@ -4488,6 +4823,202 @@ function ExportPage() { } } + const openCreateAutomationDraft = useCallback(() => { + setIsAutomationModalOpen(false) + setAutomationTaskDraft(null) + setIsAutomationRangeDialogOpen(false) + setIsAutomationCreateMode(true) + setSelectedSessions(new Set()) + setAutomationHint('已进入自动化任务创建:请勾选联系人,然后点击「加入任务」') + }, []) + + const openEditAutomationTaskDraft = useCallback((task: ExportAutomationTask) => { + const schedule = task.schedule + const stopAt = Number(task.stopCondition?.endAt || 0) + const maxRuns = Number(task.stopCondition?.maxRuns || 0) + const resolvedRange = resolveAutomationDateRangeSelection(task.template.dateRangeConfig as any, new Date()) + setAutomationRangeSelection(resolvedRange) + setAutomationRangeBounds(null) + setAutomationTaskDraft({ + mode: 'edit', + id: task.id, + name: task.name, + enabled: task.enabled, + sessionIds: task.sessionIds, + sessionNames: task.sessionNames, + outputDir: task.outputDir || exportFolder, + useGlobalOutputDir: !task.outputDir, + scope: task.template.scope, + contentType: task.template.contentType, + optionTemplate: task.template.optionTemplate, + dateRangeConfig: task.template.dateRangeConfig, + intervalDays: normalizeAutomationIntervalDays(schedule.intervalDays), + intervalHours: normalizeAutomationIntervalHours(schedule.intervalHours), + stopAtEnabled: stopAt > 0, + stopAtValue: stopAt > 0 ? toDateTimeLocalValue(stopAt) : '', + maxRunsEnabled: maxRuns > 0, + maxRuns: maxRuns > 0 ? maxRuns : 0 + }) + setIsAutomationModalOpen(true) + }, [exportFolder]) + + const openAutomationDateRangeDialog = useCallback(() => { + if (!automationTaskDraft) return + void (async () => { + if (isResolvingAutomationRangeBounds) return + setIsResolvingAutomationRangeBounds(true) + try { + const nextBounds = await resolveChatExportTimeRangeBounds(automationTaskDraft.sessionIds) + setAutomationRangeBounds(nextBounds) + if (nextBounds) { + const nextSelection = clampExportSelectionToBounds(automationRangeSelection, nextBounds) + if (!areExportSelectionsEqual(nextSelection, automationRangeSelection)) { + setAutomationRangeSelection(nextSelection) + setAutomationTaskDraft((prev) => prev ? { + ...prev, + dateRangeConfig: serializeExportDateRangeConfig(nextSelection) + } : prev) + } + } + setIsAutomationRangeDialogOpen(true) + } catch (error) { + console.error('自动化导出解析时间范围边界失败', error) + setAutomationRangeBounds(null) + setIsAutomationRangeDialogOpen(true) + } finally { + setIsResolvingAutomationRangeBounds(false) + } + })() + }, [ + automationRangeSelection, + automationTaskDraft, + isResolvingAutomationRangeBounds, + resolveChatExportTimeRangeBounds + ]) + + const applyAutomationRangeMode = useCallback((mode: AutomationRangeMode) => { + if (!automationTaskDraft) return + if (mode === 'custom') { + openAutomationDateRangeDialog() + return + } + if (mode === 'lastNDays') { + const relativeDays = readAutomationLastNDays(automationTaskDraft.dateRangeConfig) || AUTOMATION_LAST_N_DAYS_DEFAULT + const nextSelection: ExportDateRangeSelection = { + preset: 'custom', + useAllTime: false, + dateRange: createDateRangeByLastNDays(relativeDays, new Date()) + } + setAutomationRangeSelection(nextSelection) + setAutomationTaskDraft((prev) => prev ? { + ...prev, + dateRangeConfig: buildAutomationLastNDaysConfig(relativeDays) + } : prev) + return + } + const nextSelection = createAutomationSelectionByMode(mode, new Date()) + setAutomationRangeSelection(nextSelection) + setAutomationTaskDraft((prev) => prev ? { + ...prev, + dateRangeConfig: serializeExportDateRangeConfig(nextSelection) + } : prev) + }, [automationTaskDraft, openAutomationDateRangeDialog]) + + const updateAutomationLastNDays = useCallback((value: unknown) => { + const days = normalizeAutomationLastNDays(value) + const nextSelection: ExportDateRangeSelection = { + preset: 'custom', + useAllTime: false, + dateRange: createDateRangeByLastNDays(days, new Date()) + } + setAutomationRangeSelection(nextSelection) + setAutomationTaskDraft((prev) => prev ? { + ...prev, + dateRangeConfig: buildAutomationLastNDaysConfig(days) + } : prev) + }, []) + + const saveAutomationTaskDraft = useCallback(() => { + if (!automationTaskDraft) return + if (!automationTasksReadyRef.current) { + automationTasksReadyRef.current = true + } + const normalizedName = automationTaskDraft.name.trim() + if (!normalizedName) { + window.alert('请输入任务名称') + return + } + if (automationTaskDraft.sessionIds.length === 0) { + window.alert('自动化任务至少需要一个会话') + return + } + + const intervalDays = normalizeAutomationIntervalDays(automationTaskDraft.intervalDays) + const intervalHours = normalizeAutomationIntervalHours(automationTaskDraft.intervalHours) + if (intervalDays <= 0 && intervalHours <= 0) { + window.alert('执行间隔不能为 0,请至少设置天数或小时') + return + } + const schedule: ExportAutomationSchedule = { type: 'interval', intervalDays, intervalHours } + const stopAtTimestamp = automationTaskDraft.stopAtEnabled + ? parseDateTimeLocalValue(automationTaskDraft.stopAtValue) + : null + if (automationTaskDraft.stopAtEnabled && !stopAtTimestamp) { + window.alert('请填写有效的终止时间') + return + } + const maxRuns = automationTaskDraft.maxRunsEnabled + ? Math.max(0, Math.floor(Number(automationTaskDraft.maxRuns || 0))) + : 0 + if (automationTaskDraft.maxRunsEnabled && maxRuns <= 0) { + window.alert('请填写大于 0 的最大执行次数') + return + } + const stopCondition = { + endAt: stopAtTimestamp && stopAtTimestamp > 0 ? stopAtTimestamp : undefined, + maxRuns: maxRuns > 0 ? maxRuns : undefined + } + + const now = Date.now() + const condition: ExportAutomationCondition = { type: 'new-message-since-last-success' } + const nextTask: ExportAutomationTask = { + id: automationTaskDraft.mode === 'edit' && automationTaskDraft.id + ? automationTaskDraft.id + : createAutomationTaskId(), + name: normalizedName, + enabled: automationTaskDraft.enabled, + sessionIds: [...automationTaskDraft.sessionIds], + sessionNames: [...automationTaskDraft.sessionNames], + outputDir: automationTaskDraft.useGlobalOutputDir ? undefined : String(automationTaskDraft.outputDir || '').trim(), + schedule, + condition, + stopCondition: (stopCondition.endAt || stopCondition.maxRuns) ? stopCondition : undefined, + template: { + scope: automationTaskDraft.scope, + contentType: automationTaskDraft.contentType, + optionTemplate: { ...automationTaskDraft.optionTemplate }, + dateRangeConfig: automationTaskDraft.dateRangeConfig + }, + runState: automationTaskDraft.mode === 'edit' + ? automationTasksRef.current.find((item) => item.id === automationTaskDraft.id)?.runState + : { lastRunStatus: 'idle', successCount: 0 }, + createdAt: automationTaskDraft.mode === 'edit' + ? (automationTasksRef.current.find((item) => item.id === automationTaskDraft.id)?.createdAt || now) + : now, + updatedAt: now + } + + updateAutomationTasks((prev) => { + if (automationTaskDraft.mode === 'edit' && automationTaskDraft.id) { + return prev.map((task) => (task.id === automationTaskDraft.id ? nextTask : task)) + } + return [nextTask, ...prev] + }) + setAutomationTaskDraft(null) + setIsAutomationRangeDialogOpen(false) + setAutomationHint(automationTaskDraft.mode === 'edit' ? '自动化任务已更新' : '自动化任务已创建') + }, [automationTaskDraft, updateAutomationTasks]) + const markSessionExported = useCallback((sessionIds: string[], timestamp: number) => { setLastExportBySession(prev => { const next = { ...prev } @@ -4963,10 +5494,123 @@ function ExportPage() { } }, []) + const enqueueExportTask = useCallback((title: string, payload: ExportTaskPayload): string => { + const task: ExportTask = { + id: createTaskId(), + title, + status: 'queued', + settledSessionIds: [], + createdAt: Date.now(), + payload, + progress: createEmptyProgress(), + performance: payload.scope === 'content' && payload.contentType === 'text' + ? createEmptyTaskPerformance() + : undefined + } + setTasks(prev => [task, ...prev]) + return task.id + }, []) + + const buildAutomationExportOptions = useCallback((task: ExportAutomationTask): ElectronExportOptions => { + const selection = resolveAutomationDateRangeSelection(task.template.dateRangeConfig as any, new Date()) + const dateRange = selection.useAllTime + ? null + : { + start: Math.floor(selection.dateRange.start.getTime() / 1000), + end: Math.floor(selection.dateRange.end.getTime() / 1000) + } + return { + ...task.template.optionTemplate, + dateRange + } + }, []) + + const enqueueAutomationTask = useCallback(( + task: ExportAutomationTask, + options?: { scheduleKey?: string; force?: boolean; reason?: string } + ): { queued: boolean; reason?: string } => { + const outputDir = String(task.outputDir || exportFolder || '').trim() + if (!outputDir) { + return { queued: false, reason: '导出目录未设置' } + } + + const hasConflict = tasksRef.current.some((item) => { + if (item.status !== 'running' && item.status !== 'queued') return false + return item.payload.automationTaskId === task.id + }) + if (hasConflict) { + return { queued: false, reason: '任务已有执行队列,本次触发已跳过' } + } + + const exportOptions = buildAutomationExportOptions(task) + const contentType = task.template.contentType + const title = `自动化导出:${task.name}` + enqueueExportTask(title, { + sessionIds: task.sessionIds, + sessionNames: task.sessionNames, + outputDir, + options: exportOptions, + scope: task.template.scope, + source: 'automation', + automationTaskId: task.id, + contentType + }) + const now = Date.now() + patchAutomationTask(task.id, (prev) => ({ + ...prev, + updatedAt: now, + runState: { + ...(prev.runState || {}), + lastRunStatus: 'queued', + lastTriggeredAt: now, + lastSkipReason: undefined, + lastError: undefined, + lastScheduleKey: options?.scheduleKey || prev.runState?.lastScheduleKey + } + })) + if (options?.reason) { + setAutomationHint(options.reason) + } + return { queued: true } + }, [ + buildAutomationExportOptions, + enqueueExportTask, + exportFolder, + patchAutomationTask + ]) + + const resolveAutomationHasNewMessages = useCallback(async (task: ExportAutomationTask): Promise<{ shouldRun: boolean; reason?: string }> => { + const lastSuccessAt = Number(task.runState?.lastSuccessAt || 0) + if (!lastSuccessAt) return { shouldRun: true } + const stats = await window.electronAPI.chat.getExportSessionStats(task.sessionIds, { + includeRelations: false, + allowStaleCache: true + }) + if (!stats.success || !stats.data) { + return { shouldRun: false, reason: stats.error || '会话统计失败,已跳过' } + } + let latestTimestamp = 0 + for (const sessionId of task.sessionIds) { + const raw = Number(stats.data?.[sessionId]?.lastTimestamp || 0) + if (Number.isFinite(raw) && raw > latestTimestamp) { + latestTimestamp = Math.max(0, Math.floor(raw)) + } + } + if (latestTimestamp <= 0) { + return { shouldRun: false, reason: '未检测到可用会话时间戳,已跳过' } + } + const lastSuccessSeconds = Math.floor(lastSuccessAt / 1000) + if (latestTimestamp <= lastSuccessSeconds) { + return { shouldRun: false, reason: '目标会话无新消息,本次已跳过' } + } + return { shouldRun: true } + }, []) + const createTask = async () => { if (!exportDialog.open || !exportFolder) return if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return + const isAutomationCreateIntent = exportDialog.intent === 'automation-create' const exportOptions = exportDialog.scope === 'sns' ? undefined : buildExportOptions(exportDialog.scope, exportDialog.contentType) @@ -4982,30 +5626,58 @@ function ExportPage() { ? '朋友圈批量导出' : `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出` - const task: ExportTask = { - id: createTaskId(), - title, - status: 'queued', - settledSessionIds: [], - createdAt: Date.now(), - payload: { - sessionIds: exportDialog.sessionIds, - sessionNames: exportDialog.sessionNames, + if (isAutomationCreateIntent) { + if (!exportOptions || exportDialog.scope === 'sns') { + window.alert('自动化任务仅支持会话导出') + return + } + const { dateRange: _discard, ...optionTemplate } = exportOptions + const normalizedRangeSelection = cloneExportDateRangeSelection(timeRangeSelection) + const scope = exportDialog.scope === 'single' + ? 'single' + : exportDialog.scope === 'content' + ? 'content' + : 'multi' + setAutomationRangeSelection(normalizedRangeSelection) + setAutomationRangeBounds(null) + setAutomationTaskDraft({ + mode: 'create', + name: exportDialog.sessionIds.length === 1 + ? `${exportDialog.sessionNames[0] || '单会话'} 自动化导出` + : `自动化导出(${exportDialog.sessionIds.length} 个会话)`, + enabled: true, + sessionIds: [...exportDialog.sessionIds], + sessionNames: [...exportDialog.sessionNames], outputDir: exportFolder, - options: exportOptions, - scope: exportDialog.scope, - contentType: exportDialog.contentType, - snsOptions - }, - progress: createEmptyProgress(), - performance: exportDialog.scope === 'content' && exportDialog.contentType === 'text' - ? createEmptyTaskPerformance() - : undefined + useGlobalOutputDir: true, + scope, + contentType: scope === 'content' ? exportDialog.contentType : undefined, + optionTemplate, + dateRangeConfig: serializeExportDateRangeConfig(normalizedRangeSelection), + intervalDays: 1, + intervalHours: 0, + stopAtEnabled: false, + stopAtValue: '', + maxRunsEnabled: false, + maxRuns: 0 + }) + setIsAutomationCreateMode(false) + setAutomationHint('导出配置已完成,请继续设置自动化规则并保存任务') + closeExportDialog() + } else { + enqueueExportTask(title, { + sessionIds: exportDialog.sessionIds, + sessionNames: exportDialog.sessionNames, + outputDir: exportFolder, + options: exportOptions, + scope: exportDialog.scope, + source: 'manual', + contentType: exportDialog.contentType, + snsOptions + }) + closeExportDialog() } - setTasks(prev => [task, ...prev]) - closeExportDialog() - await configService.setExportDefaultFormat(options.format) await configService.setExportDefaultAvatars(options.exportAvatars) await configService.setExportDefaultMedia({ @@ -5062,6 +5734,30 @@ function ExportPage() { .map((item) => item.session) }, [resolveSessionExistingMessageCount]) + const exitAutomationCreateMode = useCallback(() => { + setIsAutomationCreateMode(false) + setAutomationHint('已退出自动化任务创建') + }, []) + + const openAutomationExportConfigDialog = useCallback(() => { + const selectedSet = new Set(selectedSessions) + const selectedRows = sessions.filter((session) => selectedSet.has(session.username)) + const orderedRows = orderSessionsForExport(selectedRows) + if (orderedRows.length === 0) { + window.alert('请先勾选至少一个可导出的会话') + return + } + const ids = orderedRows.map((session) => session.username) + const names = orderedRows.map((session) => session.displayName || session.username) + openExportDialog({ + scope: 'multi', + sessionIds: ids, + sessionNames: names, + title: `自动化任务导出配置(${ids.length} 个会话)`, + intent: 'automation-create' + }) + }, [openExportDialog, orderSessionsForExport, selectedSessions, sessions]) + const openBatchExport = () => { const selectedSet = new Set(selectedSessions) const selectedRows = sessions.filter((session) => selectedSet.has(session.username)) @@ -5147,6 +5843,181 @@ function ExportPage() { [tasks] ) + useEffect(() => { + const previous = automationQueueStatusByTaskIdRef.current + const next = new Map() + for (const task of tasks) { + if (task.payload.source !== 'automation' || !task.payload.automationTaskId) continue + const automationTaskId = task.payload.automationTaskId + next.set(task.id, task.status) + const previousStatus = previous.get(task.id) + if (previousStatus === task.status) continue + + const now = Date.now() + if (task.status === 'running') { + patchAutomationTask(automationTaskId, (current) => ({ + ...current, + updatedAt: now, + runState: { + ...(current.runState || {}), + lastRunStatus: 'running', + lastStartedAt: now, + lastSkipReason: undefined, + lastError: undefined + } + })) + } else if (task.status === 'success') { + patchAutomationTask(automationTaskId, (current) => ({ + ...current, + updatedAt: now, + runState: { + ...(current.runState || {}), + lastRunStatus: 'success', + lastFinishedAt: now, + lastSuccessAt: now, + lastSkipReason: undefined, + lastError: undefined, + successCount: Math.max(0, Math.floor(Number(current.runState?.successCount || 0))) + 1 + } + })) + } else if (task.status === 'error') { + patchAutomationTask(automationTaskId, (current) => ({ + ...current, + updatedAt: now, + runState: { + ...(current.runState || {}), + lastRunStatus: 'error', + lastFinishedAt: now, + lastError: task.error || '导出失败' + } + })) + } + } + automationQueueStatusByTaskIdRef.current = next + }, [patchAutomationTask, tasks]) + + const evaluateAutomationSchedules = useCallback(async () => { + if (!automationTasksReadyRef.current) return + if (automationSchedulerRunningRef.current) return + automationSchedulerRunningRef.current = true + try { + const now = new Date() + const enabledTasks = automationTasksRef.current.filter((task) => task.enabled) + for (const task of enabledTasks) { + const successCount = Math.max(0, Math.floor(Number(task.runState?.successCount || 0))) + const maxRuns = Math.max(0, Math.floor(Number(task.stopCondition?.maxRuns || 0))) + if (maxRuns > 0 && successCount >= maxRuns) { + const stopAt = Date.now() + patchAutomationTask(task.id, (current) => ({ + ...current, + enabled: false, + updatedAt: stopAt, + runState: { + ...(current.runState || {}), + lastRunStatus: 'skipped', + lastSkipAt: stopAt, + lastSkipReason: `已达到最大执行次数(${maxRuns} 次),任务已自动停用`, + successCount: Math.max(0, Math.floor(Number(current.runState?.successCount || 0))) + } + })) + continue + } + + const endAt = Number(task.stopCondition?.endAt || 0) + if (endAt > 0 && now.getTime() > endAt) { + const stopAt = Date.now() + patchAutomationTask(task.id, (current) => ({ + ...current, + enabled: false, + updatedAt: stopAt, + runState: { + ...(current.runState || {}), + lastRunStatus: 'skipped', + lastSkipAt: stopAt, + lastSkipReason: '已超过终止时间,任务已自动停用' + } + })) + continue + } + + const scheduleKey = resolveAutomationDueScheduleKey(task, now) + if (!scheduleKey) continue + if (task.runState?.lastScheduleKey === scheduleKey) continue + + const hasConflict = tasksRef.current.some((item) => { + if (item.status !== 'running' && item.status !== 'queued') return false + return item.payload.automationTaskId === task.id + }) + if (hasConflict) { + markAutomationTaskSkipped(task.id, '任务仍在执行中,本次触发已跳过', scheduleKey) + continue + } + + if (task.condition.type === 'new-message-since-last-success') { + const checkResult = await resolveAutomationHasNewMessages(task) + if (!checkResult.shouldRun) { + markAutomationTaskSkipped(task.id, checkResult.reason || '无新消息,本次触发已跳过', scheduleKey) + continue + } + } + + const queued = enqueueAutomationTask(task, { scheduleKey }) + if (!queued.queued) { + markAutomationTaskSkipped(task.id, queued.reason || '触发失败,本次已跳过', scheduleKey) + } + } + } finally { + automationSchedulerRunningRef.current = false + } + }, [ + enqueueAutomationTask, + markAutomationTaskSkipped, + patchAutomationTask, + resolveAutomationHasNewMessages + ]) + + useEffect(() => { + let cancelled = false + const run = async () => { + if (cancelled) return + if (!automationTasksReadyRef.current) return + try { + await evaluateAutomationSchedules() + } catch (error) { + console.error('自动化导出调度失败:', error) + } + } + void run() + const timer = window.setInterval(() => { + void run() + }, 30_000) + return () => { + cancelled = true + window.clearInterval(timer) + } + }, [evaluateAutomationSchedules]) + + const runAutomationTaskNow = useCallback((taskId: string) => { + const target = automationTasksRef.current.find((task) => task.id === taskId) + if (!target) return + const queued = enqueueAutomationTask(target, { + reason: `已手动触发「${target.name}」`, + scheduleKey: target.runState?.lastScheduleKey + }) + if (!queued.queued) { + markAutomationTaskSkipped(taskId, queued.reason || '手动触发失败') + setAutomationHint(queued.reason || '手动触发失败') + return + } + setAutomationHint(`已加入队列:${target.name}`) + }, [enqueueAutomationTask, markAutomationTaskSkipped]) + + useEffect(() => { + if (!automationHint) return + const timer = window.setTimeout(() => setAutomationHint(null), 2600) + return () => window.clearTimeout(timer) + }, [automationHint]) + const inProgressSessionIdsKey = useMemo( () => inProgressSessionIds.join('||'), [inProgressSessionIds] @@ -6497,6 +7368,7 @@ function ExportPage() { const canCreateTask = exportDialog.scope === 'sns' ? Boolean(exportFolder) : Boolean(exportFolder) && exportDialog.sessionIds.length > 0 + const isAutomationCreateDialog = exportDialog.intent === 'automation-create' const scopeLabel = exportDialog.scope === 'single' ? '单会话' : exportDialog.scope === 'multi' @@ -6815,6 +7687,43 @@ function ExportPage() { const toggleTaskPerfDetail = useCallback((taskId: string) => { setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId)) }, []) + + const toggleAutomationTaskEnabled = useCallback((taskId: string, enabled: boolean) => { + const now = Date.now() + patchAutomationTask(taskId, (task) => ({ + ...task, + enabled, + updatedAt: now + })) + setAutomationHint(enabled ? '自动化任务已启用' : '自动化任务已停用') + }, [patchAutomationTask]) + + const deleteAutomationTask = useCallback((taskId: string) => { + const target = automationTasksRef.current.find((task) => task.id === taskId) + if (!target) return + const confirmed = window.confirm(`确认删除自动化任务「${target.name}」吗?`) + if (!confirmed) return + updateAutomationTasks((prev) => prev.filter((task) => task.id !== taskId)) + setAutomationHint('自动化任务已删除') + }, [updateAutomationTasks]) + + const chooseAutomationDraftOutputDir = useCallback(async () => { + if (!automationTaskDraft) return + const result = await window.electronAPI.dialog.openFile({ + title: '选择任务导出目录', + properties: ['openDirectory'] + }) + if (result.canceled || result.filePaths.length === 0) return + const outputDir = result.filePaths[0] + setAutomationTaskDraft((prev) => { + if (!prev) return prev + return { + ...prev, + outputDir, + useGlobalOutputDir: false + } + }) + }, [automationTaskDraft]) const renderContactRow = useCallback((index: number, contact: ContactInfo) => { const matchedSession = sessionRowByUsername.get(contact.username) const canExport = Boolean(matchedSession?.hasSession) @@ -6824,6 +7733,7 @@ function ExportPage() { const isQueued = canExport && queuedSessionIds.has(contact.username) const recentExportTimestamp = lastExportBySession[contact.username] const hasRecentExport = canExport && Boolean(recentExportTimestamp) + const showRecentExport = !isAutomationCreateMode && hasRecentExport const recentExportTime = hasRecentExport ? formatRecentExportTime(recentExportTimestamp, nowTick) : '' const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username]) const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) @@ -7014,24 +7924,26 @@ function ExportPage() { )}
-
-
- - {hasRecentExport && {recentExportTime}} -
+
+ {!isAutomationCreateMode && ( +
+ + {showRecentExport && {recentExportTime}} +
+ )}
+ {automationHint && ( +
{automationHint}
+ )}
+ {isAutomationModalOpen && createPortal( +
{ + setIsAutomationModalOpen(false) + setAutomationTaskDraft(null) + setIsAutomationRangeDialogOpen(false) + }} + > +
event.stopPropagation()} + > +
+
+

自动化导出

+

仅在应用运行期间生效;错过触发不会补跑。

+
+
+ + +
+
+ +
+ {sortedAutomationTasks.length === 0 ? ( +
+ 暂无自动化任务。点击右上角「新建任务」开始配置。 +
+ ) : ( +
+ {sortedAutomationTasks.map((task) => { + const linkedQueueTask = tasks.find((item) => ( + (item.status === 'running' || item.status === 'queued') && + item.payload.automationTaskId === task.id + )) + const queueState: 'queued' | 'running' | null = linkedQueueTask?.status === 'running' + ? 'running' + : linkedQueueTask?.status === 'queued' + ? 'queued' + : null + return ( +
+
+
+ {task.name} + + {task.enabled ? '已启用' : '已停用'} + + {queueState === 'running' && 执行中} + {queueState === 'queued' && 排队中} +
+

{formatAutomationScheduleLabel(task.schedule)}

+

时间范围:{formatAutomationRangeLabel(task.template.dateRangeConfig as any)}

+

会话范围:{task.sessionIds.length} 个

+

导出目录:{task.outputDir || `${exportFolder || '未设置'}(全局)`}

+

当前状态:{formatAutomationCurrentState(task, queueState, nowTick)}

+

终止条件:{formatAutomationStopCondition(task)}

+

最近结果:{formatAutomationLastRunSummary(task)}

+
+
+ + + + +
+
+ ) + })} +
+ )} +
+
+
, + document.body + )} + + {automationTaskDraft && createPortal( +
{ + setAutomationTaskDraft(null) + setIsAutomationRangeDialogOpen(false) + }}> +
event.stopPropagation()} + > +
+

{automationTaskDraft.mode === 'edit' ? '编辑自动化任务' : '创建自动化任务'}

+ +
+
+ + +
+ + +
+ +
+ 导出时间范围(按触发时间动态计算) +
+ {AUTOMATION_RANGE_OPTIONS.map((option) => { + const active = resolveAutomationRangeMode(automationTaskDraft.dateRangeConfig as any, automationRangeSelection) === option.mode + return ( + + ) + })} +
+ {resolveAutomationRangeMode(automationTaskDraft.dateRangeConfig as any, automationRangeSelection) === 'lastNDays' && ( + + )} +
+ {formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)} + {resolveAutomationRangeMode(automationTaskDraft.dateRangeConfig as any, automationRangeSelection) === 'custom' && ( + + )} +
+
+ +
+ 终止条件(可选) + + {automationTaskDraft.stopAtEnabled && ( +
+ { + const datePart = e.target.value + const timePart = automationTaskDraft.stopAtValue?.slice(11) || '23:59' + setAutomationTaskDraft((prev) => prev ? { ...prev, stopAtValue: datePart ? `${datePart}T${timePart}` : '' } : prev) + }} + /> + { + const timePart = e.target.value + const datePart = automationTaskDraft.stopAtValue?.slice(0, 10) || new Date().toISOString().slice(0, 10) + setAutomationTaskDraft((prev) => prev ? { ...prev, stopAtValue: `${datePart}T${timePart}` } : prev) + }} + /> +
+ )} + + + + {automationTaskDraft.maxRunsEnabled && ( + setAutomationTaskDraft((prev) => prev ? { + ...prev, + maxRuns: Math.max(0, Math.floor(Number(event.target.value) || 0)) + } : prev)} + /> + )} +
+ +
+ 导出目录 + + {!automationTaskDraft.useGlobalOutputDir && ( +
+ + {automationTaskDraft.outputDir || '未设置'} +
+ )} +
+ + + +
+ 会话:{automationTaskDraft.sessionIds.length} 个 · 间隔:{automationTaskDraft.intervalDays} 天 {automationTaskDraft.intervalHours} 小时 · 时间:{formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)} · 条件:有新消息才导出 +
+
+
+ + +
+ setIsAutomationRangeDialogOpen(false)} + onConfirm={(nextSelection) => { + setAutomationRangeSelection(nextSelection) + setAutomationTaskDraft((prev) => prev ? { + ...prev, + dateRangeConfig: serializeExportDateRangeConfig(nextSelection) + } : prev) + setIsAutomationRangeDialogOpen(false) + }} + /> +
+
, + document.body + )} + {isExportDefaultsModalOpen && createPortal(
))}
- + {!isAutomationCreateMode && ( + + )}
) })} @@ -7340,6 +8634,18 @@ function ExportPage() { '你可以先在列表中筛选目标会话,再批量导出,结果会保留每个会话的结构与时间线。' ]} /> + {isAutomationCreateMode && ( +
+ 自动化创建中:先勾选联系人,再点击「加入任务」 + +
+ )} + )} {selectedCount > 0 && ( <> @@ -8299,20 +9614,22 @@ function ExportPage() { -
-
-

时间范围

- + {!isAutomationCreateDialog && ( +
+
+

时间范围

+ +
-
+ )} {shouldShowMediaSection && (
@@ -8490,7 +9807,7 @@ function ExportPage() {
diff --git a/src/pages/WelcomePage.scss b/src/pages/WelcomePage.scss index fb5012d..49a77e6 100644 --- a/src/pages/WelcomePage.scss +++ b/src/pages/WelcomePage.scss @@ -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; } diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 4e83843..1dda111 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -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(null) + const [isImageKeyVerified, setIsImageKeyVerified] = useState(false) + const [isImageStepAutoCompleted, setIsImageStepAutoCompleted] = useState(false) + const [hasReacquiredDbKey, setHasReacquiredDbKey] = useState(!isAddAccountMode) const [showDbKeyConfirm, setShowDbKeyConfirm] = useState(false) + const imagePrefetchAttemptRef = useRef('') // 安全相关 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) {
{steps.map((step, index) => ( -
+
- {index < stepIndex ? :
} + {isStepCompleted(index, step.id) ? :
}
{step.title}
-
{step.desc}
+
{resolveStepDesc(step)}
+ {step.id === 'image' && imagePreCompletedAhead && ( +
已预先自动完成
+ )}
))} @@ -731,6 +922,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {

{currentStep.title}

{currentStep.desc}

+ {isAddAccountMode && ( +

添加账号模式:其他步骤已沿用当前配置,只需重新获取数据库密钥。

+ )}
@@ -863,6 +1057,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{dbKeyStatus &&
{dbKeyStatus}
} + {isAddAccountMode && !hasReacquiredDbKey && ( +
添加账号模式下需先自动获取一次数据库密钥,才能完成并返回主窗口。
+ )}
)} @@ -936,19 +1133,19 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {currentStep.id === 'image' && (
-
-
- - setImageXorKey(e.target.value)} /> +
+
+ 图片 XOR 密钥 + {imageXorKey || '等待自动计算'}
-
- - setImageAesKey(e.target.value)} /> +
+ 图片 AES 密钥 + {imageAesKey || '等待自动计算'}
-
)}
@@ -981,11 +1188,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { )}
- - {stepIndex < steps.length - 1 ? ( + {isAddAccountMode ? ( + + ) : stepIndex < steps.length - 1 ? ( diff --git a/src/services/config.ts b/src/services/config.ts index afbbee4..179e11d 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -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> { return {} } +export async function setWxidConfigs(configs: Record): Promise { + await config.set(CONFIG_KEYS.WXID_CONFIGS, configs || {}) +} + export async function getWxidConfig(wxid: string): Promise { if (!wxid) return null const configs = await getWxidConfigs() @@ -660,6 +666,183 @@ export async function setExportLastSnsPostCount(count: number): Promise { 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 + + 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 + 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).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 + 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) + : null + const stopConditionRaw = source.stopCondition && typeof source.stopCondition === 'object' + ? (source.stopCondition as Record) + : 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 { + 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 + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const item = rawItem as Record + 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 { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.EXPORT_AUTOMATION_TASK_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + 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 { + 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) } + 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 diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 06a47c4..244896d 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -18,7 +18,7 @@ export interface ElectronAPI { respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => Promise openAgreementWindow: () => Promise completeOnboarding: () => Promise - openOnboardingWindow: () => Promise + openOnboardingWindow: (options?: { mode?: 'add-account' }) => Promise setTitleBarOverlay: (options: { symbolColor: string }) => void openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise @@ -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 diff --git a/src/types/exportAutomation.ts b/src/types/exportAutomation.ts new file mode 100644 index 0000000..2725f6c --- /dev/null +++ b/src/types/exportAutomation.ts @@ -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 + 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 +}