import { useState, useEffect, useRef } from 'react' import { useLocation } from 'react-router-dom' import { useAppStore } from '../stores/appStore' import { useChatStore } from '../stores/chatStore' import { useThemeStore, themes } from '../stores/themeStore' import { useAnalyticsStore } from '../stores/analyticsStore' import { dialog } from '../services/ipc' import * as configService from '../services/config' import { Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor, Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic, ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2, X } from 'lucide-react' import { Avatar } from '../components/Avatar' import './SettingsPage.scss' type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'security' | 'about' | 'analytics' const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, { id: 'notification', label: '通知', icon: Bell }, { id: 'database', label: '数据库连接', icon: Database }, { id: 'models', label: '模型管理', icon: Mic }, { id: 'cache', label: '缓存', icon: HardDrive }, { id: 'api', label: 'API 服务', icon: Globe }, { id: 'analytics', label: '分析', icon: BarChart2 }, { id: 'security', label: '安全', icon: ShieldCheck }, { id: 'about', label: '关于', icon: Info } ] interface WxidOption { wxid: string modifiedTime: number } interface SettingsPageProps { onClose?: () => void } function SettingsPage({ onClose }: SettingsPageProps = {}) { const location = useLocation() const { isDbConnected, setDbConnected, setLoading, reset, updateInfo, setUpdateInfo, isDownloading, setIsDownloading, downloadProgress, setDownloadProgress, showUpdateDialog, setShowUpdateDialog, setUpdateError } = useAppStore() const resetChatStore = useChatStore((state) => state.reset) const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const [systemDark, setSystemDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches) useEffect(() => { const mq = window.matchMedia('(prefers-color-scheme: dark)') const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches) mq.addEventListener('change', handler) return () => mq.removeEventListener('change', handler) }, []) const effectiveMode = themeMode === 'system' ? (systemDark ? 'dark' : 'light') : themeMode const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache) const [activeTab, setActiveTab] = useState('appearance') const [decryptKey, setDecryptKey] = useState('') const [imageXorKey, setImageXorKey] = useState('') const [imageAesKey, setImageAesKey] = useState('') const [dbPath, setDbPath] = useState('') const [wxid, setWxid] = useState('') const [wxidOptions, setWxidOptions] = useState([]) const [showWxidSelect, setShowWxidSelect] = useState(false) const [cachePath, setCachePath] = useState('') const [imageKeyProgress, setImageKeyProgress] = useState(0) const [imageKeyPercent, setImageKeyPercent] = useState(null) const [logEnabled, setLogEnabled] = useState(false) const [whisperModelName, setWhisperModelName] = useState('base') const [whisperModelDir, setWhisperModelDir] = useState('') const [isWhisperDownloading, setIsWhisperDownloading] = useState(false) const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0) const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 }) const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null) const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false) const [transcribeLanguages, setTranscribeLanguages] = useState(['zh']) const [notificationEnabled, setNotificationEnabled] = useState(true) const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right') const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all') const [notificationFilterList, setNotificationFilterList] = useState([]) const [filterSearchKeyword, setFilterSearchKeyword] = useState('') const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState([]) const [excludeWordsInput, setExcludeWordsInput] = useState('') // 数据收集同意状态 const [analyticsConsent, setAnalyticsConsent] = useState(false) const [isLoading, setIsLoadingState] = useState(false) const [isTesting, setIsTesting] = useState(false) const [isDetectingPath, setIsDetectingPath] = useState(false) const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) const [isFetchingImageKey, setIsFetchingImageKey] = useState(false) const [isCheckingUpdate, setIsCheckingUpdate] = useState(false) const [appVersion, setAppVersion] = useState('') const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null) const [showDecryptKey, setShowDecryptKey] = useState(false) const [dbKeyStatus, setDbKeyStatus] = useState('') const [imageKeyStatus, setImageKeyStatus] = useState('') const [isManualStartPrompt, setIsManualStartPrompt] = useState(false) const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false) const [isClearingImageCache, setIsClearingImageCache] = useState(false) const [isClearingAllCache, setIsClearingAllCache] = useState(false) const [isClosing, setIsClosing] = useState(false) const saveTimersRef = useRef>>({}) // 安全设置 state const [authEnabled, setAuthEnabled] = useState(false) const [authUseHello, setAuthUseHello] = useState(false) const [helloAvailable, setHelloAvailable] = useState(false) const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [oldPassword, setOldPassword] = useState('') const [helloPassword, setHelloPassword] = useState('') const [disableLockPassword, setDisableLockPassword] = useState('') const [showDisableLockInput, setShowDisableLockInput] = useState(false) const [isLockMode, setIsLockMode] = useState(false) const [isSettingHello, setIsSettingHello] = useState(false) // HTTP API 设置 state const [httpApiEnabled, setHttpApiEnabled] = useState(false) const [httpApiPort, setHttpApiPort] = useState(5031) const [httpApiRunning, setHttpApiRunning] = useState(false) const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('') const [isTogglingApi, setIsTogglingApi] = useState(false) const [showApiWarning, setShowApiWarning] = useState(false) const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache // 检查 Hello 可用性 useEffect(() => { if (window.PublicKeyCredential) { void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable) } }, []) // 检查 HTTP API 服务状态 useEffect(() => { const checkApiStatus = async () => { try { const status = await window.electronAPI.http.status() setHttpApiRunning(status.running) if (status.port) { setHttpApiPort(status.port) } if (status.mediaExportPath) { setHttpApiMediaExportPath(status.mediaExportPath) } } catch (e) { console.error('检查 API 状态失败:', e) } } checkApiStatus() }, []) useEffect(() => { loadConfig() loadAppVersion() return () => { Object.values(saveTimersRef.current).forEach((timer) => clearTimeout(timer)) } }, []) useEffect(() => { const initialTab = (location.state as { initialTab?: SettingsTab } | null)?.initialTab if (!initialTab) return setActiveTab(initialTab) }, [location.state]) useEffect(() => { if (!onClose) return const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { handleClose() } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [onClose]) useEffect(() => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => { setDbKeyStatus(payload.message) }) const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string, percent?: number }) => { let msg = payload.message; let pct = payload.percent; // 如果后端没有显式传 percent,则用正则从字符串中提取如 "(12.5%)" if (pct === undefined) { const match = msg.match(/\(([\d.]+)%\)/); if (match) { pct = parseFloat(match[1]); // 将百分比从文本中剥离,让 UI 更清爽 msg = msg.replace(/\s*\([\d.]+%\)/, ''); } } setImageKeyStatus(msg); if (pct !== undefined) { setImageKeyPercent(pct); } else if (msg.includes('启动多核') || msg.includes('定位') || msg.includes('准备')) { // 预热阶段 setImageKeyPercent(0); } }) return () => { removeDb?.() removeImage?.() } }, []) // 点击外部关闭自定义下拉框 useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement if (!target.closest('.custom-select')) { setFilterModeDropdownOpen(false) setPositionDropdownOpen(false) } } if (filterModeDropdownOpen || positionDropdownOpen) { document.addEventListener('click', handleClickOutside) } return () => { document.removeEventListener('click', handleClickOutside) } }, [filterModeDropdownOpen, positionDropdownOpen]) const loadConfig = async () => { try { const savedKey = await configService.getDecryptKey() const savedPath = await configService.getDbPath() const savedWxid = await configService.getMyWxid() const savedCachePath = await configService.getCachePath() const savedExportPath = await configService.getExportPath() const savedLogEnabled = await configService.getLogEnabled() const savedImageXorKey = await configService.getImageXorKey() const savedImageAesKey = await configService.getImageAesKey() const savedWhisperModelName = await configService.getWhisperModelName() const savedWhisperModelDir = await configService.getWhisperModelDir() const savedAutoTranscribe = await configService.getAutoTranscribeVoice() const savedTranscribeLanguages = await configService.getTranscribeLanguages() const savedNotificationEnabled = await configService.getNotificationEnabled() const savedNotificationPosition = await configService.getNotificationPosition() const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterList = await configService.getNotificationFilterList() const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() const savedAuthUseHello = await configService.getAuthUseHello() const savedIsLockMode = await window.electronAPI.auth.isLockMode() setAuthEnabled(savedAuthEnabled) setAuthUseHello(savedAuthUseHello) setIsLockMode(savedIsLockMode) if (savedPath) setDbPath(savedPath) if (savedWxid) setWxid(savedWxid) if (savedCachePath) setCachePath(savedCachePath) const wxidConfig = savedWxid ? await configService.getWxidConfig(savedWxid) : null const decryptKeyToUse = wxidConfig?.decryptKey ?? savedKey ?? '' const imageXorKeyToUse = typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : savedImageXorKey const imageAesKeyToUse = wxidConfig?.imageAesKey ?? savedImageAesKey ?? '' setDecryptKey(decryptKeyToUse) if (typeof imageXorKeyToUse === 'number') { setImageXorKey(`0x${imageXorKeyToUse.toString(16).toUpperCase().padStart(2, '0')}`) } else { setImageXorKey('') } setImageAesKey(imageAesKeyToUse) setLogEnabled(savedLogEnabled) setAutoTranscribeVoice(savedAutoTranscribe) setTranscribeLanguages(savedTranscribeLanguages) setNotificationEnabled(savedNotificationEnabled) setNotificationPosition(savedNotificationPosition) setNotificationFilterMode(savedNotificationFilterMode) setNotificationFilterList(savedNotificationFilterList) const savedExcludeWords = await configService.getWordCloudExcludeWords() setWordCloudExcludeWords(savedExcludeWords) setExcludeWordsInput(savedExcludeWords.join('\n')) const savedAnalyticsConsent = await configService.getAnalyticsConsent() setAnalyticsConsent(savedAnalyticsConsent ?? false) // 如果语言列表为空,保存默认值 if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) { const defaultLanguages = ['zh'] setTranscribeLanguages(defaultLanguages) await configService.setTranscribeLanguages(defaultLanguages) } if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir) } catch (e: any) { console.error('加载配置失败:', e) } } const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => { try { const result = await window.electronAPI.whisper?.getModelStatus() if (result?.success) { setWhisperModelStatus({ exists: Boolean(result.exists), modelPath: result.modelPath, tokensPath: result.tokensPath }) } } catch { setWhisperModelStatus(null) } } const loadAppVersion = async () => { try { const version = await window.electronAPI.app.getVersion() setAppVersion(version) } catch (e: any) { console.error('获取版本号失败:', e) } } // 监听下载进度 useEffect(() => { const removeListener = window.electronAPI.app.onDownloadProgress?.((progress: any) => { setDownloadProgress(progress) }) return () => removeListener?.() }, []) useEffect(() => { const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number; speed?: number }) => { setWhisperProgressData({ downloaded: payload.downloadedBytes, total: payload.totalBytes || 0, speed: payload.speed || 0 }) if (typeof payload.percent === 'number') { setWhisperDownloadProgress(payload.percent) } }) return () => removeListener?.() }, []) useEffect(() => { void refreshWhisperStatus(whisperModelDir) }, [whisperModelDir]) const handleCheckUpdate = async () => { if (isCheckingUpdate) return setIsCheckingUpdate(true) setUpdateInfo(null) try { const result = await window.electronAPI.app.checkForUpdates() if (result.hasUpdate) { setUpdateInfo(result) setShowUpdateDialog(true) showMessage(`发现新版:${result.version}`, true) } else { showMessage('当前已是最新版', true) } } catch (e: any) { showMessage(`检查更新失败: ${e}`, false) } finally { setIsCheckingUpdate(false) } } const handleUpdateNow = async () => { setShowUpdateDialog(false) setIsDownloading(true) setDownloadProgress({ percent: 0 }) try { showMessage('正在下载更新...', true) await window.electronAPI.app.downloadAndInstall() } catch (e: any) { showMessage(`更新失败: ${e}`, false) setIsDownloading(false) } } const handleIgnoreUpdate = async () => { if (!updateInfo || !updateInfo.version) return try { await window.electronAPI.app.ignoreUpdate(updateInfo.version) setShowUpdateDialog(false) setUpdateInfo(null) showMessage(`已忽略版本 ${updateInfo.version}`, true) } catch (e: any) { showMessage(`操作失败: ${e}`, false) } } const showMessage = (text: string, success: boolean) => { setMessage({ text, success }) setTimeout(() => setMessage(null), 3000) } const handleClose = () => { if (!onClose) return setIsClosing(true) setTimeout(() => { onClose() }, 200) } type WxidKeys = { decryptKey: string imageXorKey: number | null imageAesKey: string } const formatImageXorKey = (value: number) => `0x${value.toString(16).toUpperCase().padStart(2, '0')}` const parseImageXorKey = (value: string) => { if (!value) return null const parsed = parseInt(value.replace(/^0x/i, ''), 16) return Number.isNaN(parsed) ? null : parsed } const buildKeysFromState = (): WxidKeys => ({ decryptKey: decryptKey || '', imageXorKey: parseImageXorKey(imageXorKey), imageAesKey: imageAesKey || '' }) const buildKeysFromInputs = (overrides?: { decryptKey?: string; imageXorKey?: string; imageAesKey?: string }): WxidKeys => ({ decryptKey: overrides?.decryptKey ?? decryptKey ?? '', imageXorKey: parseImageXorKey(overrides?.imageXorKey ?? imageXorKey), imageAesKey: overrides?.imageAesKey ?? imageAesKey ?? '' }) const buildKeysFromConfig = (wxidConfig: configService.WxidConfig | null): WxidKeys => ({ decryptKey: wxidConfig?.decryptKey || '', imageXorKey: typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : null, imageAesKey: wxidConfig?.imageAesKey || '' }) const applyKeysToState = (keys: WxidKeys) => { setDecryptKey(keys.decryptKey) if (typeof keys.imageXorKey === 'number') { setImageXorKey(formatImageXorKey(keys.imageXorKey)) } else { setImageXorKey('') } setImageAesKey(keys.imageAesKey) } const syncKeysToConfig = async (keys: WxidKeys) => { await configService.setDecryptKey(keys.decryptKey) await configService.setImageXorKey(typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0) await configService.setImageAesKey(keys.imageAesKey) } const applyWxidSelection = async ( selectedWxid: string, options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string; keysOverride?: WxidKeys } ) => { if (!selectedWxid) return const currentWxid = wxid const isSameWxid = currentWxid === selectedWxid if (currentWxid && currentWxid !== selectedWxid) { const currentKeys = buildKeysFromState() await configService.setWxidConfig(currentWxid, { decryptKey: currentKeys.decryptKey, imageXorKey: typeof currentKeys.imageXorKey === 'number' ? currentKeys.imageXorKey : 0, imageAesKey: currentKeys.imageAesKey }) } const preferCurrentKeys = options?.preferCurrentKeys ?? false const keys = options?.keysOverride ?? (preferCurrentKeys ? buildKeysFromState() : buildKeysFromConfig(await configService.getWxidConfig(selectedWxid))) setWxid(selectedWxid) applyKeysToState(keys) await configService.setMyWxid(selectedWxid) await syncKeysToConfig(keys) await configService.setWxidConfig(selectedWxid, { decryptKey: keys.decryptKey, imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0, imageAesKey: keys.imageAesKey }) setShowWxidSelect(false) if (isDbConnected) { try { await window.electronAPI.chat.close() const result = await window.electronAPI.chat.connect() setDbConnected(result.success, dbPath || undefined) if (!result.success && result.error) { showMessage(result.error, false) } } catch (e: any) { showMessage(`切换账号后重新连接失败: ${e}`, false) setDbConnected(false) } } if (!isSameWxid) { clearAnalyticsStoreCache() resetChatStore() window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } })) } if (options?.showToast ?? true) { showMessage(options?.toastText || `已选择账号:${selectedWxid}`, true) } } const handleAutoDetectPath = async () => { if (isDetectingPath) return setIsDetectingPath(true) try { const result = await window.electronAPI.dbPath.autoDetect() if (result.success && result.path) { setDbPath(result.path) await configService.setDbPath(result.path) showMessage(`自动检测成功:${result.path}`, true) const wxids = await window.electronAPI.dbPath.scanWxids(result.path) setWxidOptions(wxids) if (wxids.length === 1) { await applyWxidSelection(wxids[0].wxid, { toastText: `已检测到账号:${wxids[0].wxid}` }) } else if (wxids.length > 1) { setShowWxidSelect(true) } } else { showMessage(result.error || '未能自动检测到数据库目录', false) } } catch (e: any) { showMessage(`自动检测失败: ${e}`, false) } finally { setIsDetectingPath(false) } } const handleSelectDbPath = async () => { try { const result = await dialog.openFile({ title: '选择微信数据库根目录', properties: ['openDirectory'] }) if (!result.canceled && result.filePaths.length > 0) { const selectedPath = result.filePaths[0] setDbPath(selectedPath) await configService.setDbPath(selectedPath) showMessage('已选择数据库目录', true) } } catch (e: any) { showMessage('选择目录失败', false) } } const handleScanWxid = async ( silent = false, options?: { preferCurrentKeys?: boolean; showDialog?: boolean; keysOverride?: WxidKeys } ) => { if (!dbPath) { if (!silent) showMessage('请先选择数据库目录', false) return } try { const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) setWxidOptions(wxids) const allowDialog = options?.showDialog ?? !silent if (wxids.length === 1) { await applyWxidSelection(wxids[0].wxid, { preferCurrentKeys: options?.preferCurrentKeys ?? false, showToast: !silent, toastText: `已检测到账号:${wxids[0].wxid}`, keysOverride: options?.keysOverride }) } else if (wxids.length > 1 && allowDialog) { setShowWxidSelect(true) } else { if (!silent) showMessage('未检测到账号目录,请检查路径', false) } } catch (e: any) { if (!silent) showMessage(`扫描失败: ${e}`, false) } } const handleSelectWxid = async (selectedWxid: string) => { await applyWxidSelection(selectedWxid) } const handleSelectCachePath = async () => { try { const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] }) if (!result.canceled && result.filePaths.length > 0) { const selectedPath = result.filePaths[0] setCachePath(selectedPath) await configService.setCachePath(selectedPath) showMessage('已选择缓存目录', true) } } catch (e: any) { showMessage('选择目录失败', false) } } const handleSelectWhisperModelDir = async () => { try { const result = await dialog.openFile({ title: '选择 Whisper 模型下载目录', properties: ['openDirectory'] }) if (!result.canceled && result.filePaths.length > 0) { const dir = result.filePaths[0] setWhisperModelDir(dir) await configService.setWhisperModelDir(dir) showMessage('已选择 Whisper 模型目录', true) } } catch (e: any) { showMessage('选择目录失败', false) } } const handleWhisperModelChange = async (value: string) => { setWhisperModelName(value) setWhisperDownloadProgress(0) await configService.setWhisperModelName(value) } const handleDownloadWhisperModel = async () => { if (isWhisperDownloading) return setIsWhisperDownloading(true) setWhisperDownloadProgress(0) try { const result = await window.electronAPI.whisper.downloadModel() if (result.success) { setWhisperDownloadProgress(100) showMessage('SenseVoiceSmall 模型下载完成', true) await refreshWhisperStatus(whisperModelDir) } else { showMessage(result.error || '模型下载失败', false) } } catch (e: any) { showMessage(`模型下载失败: ${e}`, false) } finally { setIsWhisperDownloading(false) } } const handleResetWhisperModelDir = async () => { setWhisperModelDir('') await configService.setWhisperModelDir('') } const handleAutoGetDbKey = async () => { if (isFetchingDbKey) return setIsFetchingDbKey(true) setIsManualStartPrompt(false) setDbKeyStatus('正在连接微信进程...') try { const result = await window.electronAPI.key.autoGetDbKey() if (result.success && result.key) { setDecryptKey(result.key) setDbKeyStatus('密钥获取成功') showMessage('已自动获取解密密钥', true) await syncCurrentKeys({ decryptKey: result.key, wxid }) const keysOverride = buildKeysFromInputs({ decryptKey: result.key }) await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false, keysOverride }) } else { if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { setIsManualStartPrompt(true) setDbKeyStatus('需要手动启动微信') } else { showMessage(result.error || '自动获取密钥失败', false) } } } catch (e: any) { showMessage(`自动获取密钥失败: ${e}`, false) } finally { setIsFetchingDbKey(false) } } const handleManualConfirm = async () => { setIsManualStartPrompt(false) handleAutoGetDbKey() } // Debounce config writes to avoid excessive disk IO const scheduleConfigSave = (key: string, task: () => Promise | void, delay = 300) => { const timers = saveTimersRef.current if (timers[key]) { clearTimeout(timers[key]) } timers[key] = setTimeout(() => { Promise.resolve(task()).catch((e) => { console.error('保存配置失败:', e) }) }, delay) } const syncCurrentKeys = async (options?: { decryptKey?: string; imageXorKey?: string; imageAesKey?: string; wxid?: string }) => { const keys = buildKeysFromInputs(options) await syncKeysToConfig(keys) const wxidToUse = options?.wxid ?? wxid if (wxidToUse) { await configService.setWxidConfig(wxidToUse, { decryptKey: keys.decryptKey, imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0, imageAesKey: keys.imageAesKey }) } } const handleAutoGetImageKey = async () => { if (isFetchingImageKey) return; if (!dbPath) { showMessage('请先选择数据库目录', false); return; } setIsFetchingImageKey(true); setImageKeyPercent(0) setImageKeyStatus('正在初始化...'); setImageKeyProgress(0); 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('已获取图片密钥') showMessage('已自动获取图片密钥', true) const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0 const newAesKey = result.aesKey await configService.setImageXorKey(newXorKey) await configService.setImageAesKey(newAesKey) if (wxid) await configService.setWxidConfig(wxid, { decryptKey, imageXorKey: newXorKey, imageAesKey: newAesKey }) } else { showMessage(result.error || '自动获取图片密钥失败', false) } } catch (e: any) { showMessage(`自动获取图片密钥失败: ${e}`, false) } finally { setIsFetchingImageKey(false) } } const handleScanImageKeyFromMemory = async () => { if (isFetchingImageKey) return; if (!dbPath) { showMessage('请先选择数据库目录', false); return; } setIsFetchingImageKey(true); setImageKeyPercent(0) setImageKeyStatus('正在扫描内存...'); try { const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath; const result = await window.electronAPI.key.scanImageKeyFromMemory(accountPath) if (result.success && result.aesKey) { if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`) setImageAesKey(result.aesKey) setImageKeyStatus('内存扫描成功,已获取图片密钥') showMessage('内存扫描成功,已获取图片密钥', true) const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0 const newAesKey = result.aesKey await configService.setImageXorKey(newXorKey) await configService.setImageAesKey(newAesKey) if (wxid) await configService.setWxidConfig(wxid, { decryptKey, imageXorKey: newXorKey, imageAesKey: newAesKey }) } else { showMessage(result.error || '内存扫描获取图片密钥失败', false) } } catch (e: any) { showMessage(`内存扫描失败: ${e}`, false) } finally { setIsFetchingImageKey(false) } } const handleTestConnection = async () => { if (!dbPath) { showMessage('请先选择数据库目录', false); return } if (!decryptKey) { showMessage('请先输入解密密钥', false); return } if (decryptKey.length !== 64) { showMessage('密钥长度必须为64个字符', false); return } if (!wxid) { showMessage('请先输入或扫描 wxid', false); return } setIsTesting(true) try { const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid) if (result.success) { showMessage('连接测试成功!数据库可正常访问', true) } else { showMessage(result.error || '连接测试失败', false) } } catch (e: any) { showMessage(`连接测试失败: ${e}`, false) } finally { setIsTesting(false) } } // Removed manual save config function const handleClearConfig = async () => { const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置?') if (!confirmed) return setIsLoadingState(true) setLoading(true, '正在清除配置...') try { await window.electronAPI.wcdb.close() await configService.clearConfig() reset() setDecryptKey('') setImageXorKey('') setImageAesKey('') setDbPath('') setWxid('') setCachePath('') setLogEnabled(false) setAutoTranscribeVoice(false) setTranscribeLanguages(['zh']) setWhisperModelDir('') setWhisperModelStatus(null) setWhisperDownloadProgress(0) setIsWhisperDownloading(false) setDbConnected(false) await window.electronAPI.window.openOnboardingWindow() } catch (e: any) { showMessage(`清除配置失败: ${e}`, false) } finally { setIsLoadingState(false) setLoading(false) } } const handleOpenLog = async () => { try { const logPath = await window.electronAPI.log.getPath() await window.electronAPI.shell.openPath(logPath) } catch (e: any) { showMessage(`打开日志失败: ${e}`, false) } } const handleCopyLog = async () => { try { const result = await window.electronAPI.log.read() if (!result.success) { showMessage(result.error || '读取日志失败', false) return } await navigator.clipboard.writeText(result.content || '') showMessage('日志已复制到剪贴板', true) } catch (e: any) { showMessage(`复制日志失败: ${e}`, false) } } const handleClearAnalyticsCache = async () => { if (isClearingCache) return setIsClearingAnalyticsCache(true) try { const result = await window.electronAPI.cache.clearAnalytics() if (result.success) { clearAnalyticsStoreCache() showMessage('已清除分析缓存', true) } else { showMessage(`清除分析缓存失败: ${result.error || '未知错误'}`, false) } } catch (e: any) { showMessage(`清除分析缓存失败: ${e}`, false) } finally { setIsClearingAnalyticsCache(false) } } const handleClearImageCache = async () => { if (isClearingCache) return setIsClearingImageCache(true) try { const result = await window.electronAPI.cache.clearImages() if (result.success) { showMessage('已清除图片缓存', true) } else { showMessage(`清除图片缓存失败: ${result.error || '未知错误'}`, false) } } catch (e: any) { showMessage(`清除图片缓存失败: ${e}`, false) } finally { setIsClearingImageCache(false) } } const handleClearAllCache = async () => { if (isClearingCache) return setIsClearingAllCache(true) try { const result = await window.electronAPI.cache.clearAll() if (result.success) { clearAnalyticsStoreCache() showMessage('已清除所有缓存', true) } else { showMessage(`清除所有缓存失败: ${result.error || '未知错误'}`, false) } } catch (e: any) { showMessage(`清除所有缓存失败: ${e}`, false) } finally { setIsClearingAllCache(false) } } const renderAppearanceTab = () => (
{themes.map((theme) => (
setTheme(theme.id)}>
{theme.name} {theme.description}
{currentTheme === theme.id &&
}
))}
) const renderNotificationTab = () => { const { sessions } = useChatStore.getState() // 获取已过滤会话的信息 const getSessionInfo = (username: string) => { const session = sessions.find(s => s.username === username) return { displayName: session?.displayName || username, avatarUrl: session?.avatarUrl || '' } } // 添加会话到过滤列表 const handleAddToFilterList = async (username: string) => { if (notificationFilterList.includes(username)) return const newList = [...notificationFilterList, username] setNotificationFilterList(newList) await configService.setNotificationFilterList(newList) showMessage('已添加到过滤列表', true) } // 从过滤列表移除会话 const handleRemoveFromFilterList = async (username: string) => { const newList = notificationFilterList.filter(u => u !== username) setNotificationFilterList(newList) await configService.setNotificationFilterList(newList) showMessage('已从过滤列表移除', true) } // 过滤掉已在列表中的会话,并根据搜索关键字过滤 const availableSessions = sessions.filter(s => { if (notificationFilterList.includes(s.username)) return false if (filterSearchKeyword) { const keyword = filterSearchKeyword.toLowerCase() const displayName = (s.displayName || '').toLowerCase() const username = s.username.toLowerCase() return displayName.includes(keyword) || username.includes(keyword) } return true }) return (
开启后,收到新消息时将显示桌面弹窗通知
{notificationEnabled ? '已开启' : '已关闭'}
选择通知弹窗在屏幕上的显示位置
setPositionDropdownOpen(!positionDropdownOpen)} > {notificationPosition === 'top-right' ? '右上角' : notificationPosition === 'bottom-right' ? '右下角' : notificationPosition === 'top-left' ? '左上角' : '左下角'}
{[ { value: 'top-right', label: '右上角' }, { value: 'bottom-right', label: '右下角' }, { value: 'top-left', label: '左上角' }, { value: 'bottom-left', label: '左下角' } ].map(option => (
{ const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' setNotificationPosition(val) setPositionDropdownOpen(false) await configService.setNotificationPosition(val) showMessage('通知位置已更新', true) }} > {option.label} {notificationPosition === option.value && }
))}
选择只接收特定会话的通知,或屏蔽特定会话的通知
setFilterModeDropdownOpen(!filterModeDropdownOpen)} > {notificationFilterMode === 'all' ? '接收所有通知' : notificationFilterMode === 'whitelist' ? '仅接收白名单' : '屏蔽黑名单'}
{[ { value: 'all', label: '接收所有通知' }, { value: 'whitelist', label: '仅接收白名单' }, { value: 'blacklist', label: '屏蔽黑名单' } ].map(option => (
{ const val = option.value as 'all' | 'whitelist' | 'blacklist' setNotificationFilterMode(val) setFilterModeDropdownOpen(false) await configService.setNotificationFilterMode(val) showMessage( val === 'all' ? '已设为接收所有通知' : val === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知', true ) }} > {option.label} {notificationFilterMode === option.value && }
))}
{notificationFilterMode !== 'all' && (
{notificationFilterMode === 'whitelist' ? '点击左侧会话添加到白名单,点击右侧会话从白名单移除' : '点击左侧会话添加到黑名单,点击右侧会话从黑名单移除'}
{/* 可选会话列表 */}
可选会话
setFilterSearchKeyword(e.target.value)} />
{availableSessions.length > 0 ? ( availableSessions.map(session => (
handleAddToFilterList(session.username)} > {session.displayName || session.username} +
)) ) : (
{filterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'}
)}
{/* 已选会话列表 */}
{notificationFilterMode === 'whitelist' ? '白名单' : '黑名单'} {notificationFilterList.length > 0 && ( {notificationFilterList.length} )}
{notificationFilterList.length > 0 ? ( notificationFilterList.map(username => { const info = getSessionInfo(username) return (
handleRemoveFromFilterList(username)} > {info.displayName} ×
) }) ) : (
尚未添加任何会话
)}
)}
) } const renderDatabaseTab = () => (
64位十六进制密钥
{ const value = e.target.value setDecryptKey(value) if (value && value.length === 64) { scheduleConfigSave('keys', () => syncCurrentKeys({ decryptKey: value, wxid })) // showMessage('解密密钥已保存', true) } }} />
{isManualStartPrompt ? (

未能自动启动微信,请手动启动并登录后点击下方确认

) : ( )} {dbKeyStatus &&
{dbKeyStatus}
}
xwechat_files 目录 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录 { const value = e.target.value setDbPath(value) scheduleConfigSave('dbPath', async () => { if (value) { await configService.setDbPath(value) } }) }} />
微信账号标识
{ const value = e.target.value const previousWxid = wxid setWxid(value) scheduleConfigSave('wxid', async () => { if (previousWxid && previousWxid !== value) { const currentKeys = buildKeysFromState() await configService.setWxidConfig(previousWxid, { decryptKey: currentKeys.decryptKey, imageXorKey: typeof currentKeys.imageXorKey === 'number' ? currentKeys.imageXorKey : 0, imageAesKey: currentKeys.imageAesKey }) } if (value) { await configService.setMyWxid(value) await syncCurrentKeys({ wxid: value }) // Sync keys to the new wxid entry } if (value && previousWxid !== value) { if (isDbConnected) { try { await window.electronAPI.chat.close() const result = await window.electronAPI.chat.connect() setDbConnected(result.success, dbPath || undefined) if (!result.success && result.error) { showMessage(result.error, false) } } catch (e: any) { showMessage(`切换账号后重新连接失败: ${e}`, false) setDbConnected(false) } } clearAnalyticsStoreCache() resetChatStore() window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: value } })) } }) }} />
用于解密图片缓存 { const value = e.target.value setImageXorKey(value) const parsed = parseImageXorKey(value) if (value === '' || parsed !== null) { scheduleConfigSave('keys', () => syncCurrentKeys({ imageXorKey: value, wxid })) } }} />
16 位密钥 { const value = e.target.value setImageAesKey(value) scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid })) }} />
{isFetchingImageKey ? (
{imageKeyStatus || '正在启动...'}
) : ( imageKeyStatus &&
{imageKeyStatus}
)} 优先推荐缓存计算方案。若图片无法解密,可使用内存扫描(需微信运行并打开 2-3 张图片大图)
开启后写入 WCDB 调试日志,便于排查连接问题
{logEnabled ? '已开启' : '已关闭'}
) const resolvedWhisperModelPath = whisperModelDir || whisperModelStatus?.modelPath || '' const renderModelsTab = () => (
管理语音识别模型
用于语音消息转文字功能
SenseVoiceSmall
245 MB
{whisperModelStatus?.exists ? ( 已安装 ) : ( 未安装 )} {resolvedWhisperModelPath && (
模型目录
{resolvedWhisperModelPath}
)}
{(!whisperModelStatus?.exists || isWhisperDownloading) && (
{!whisperModelStatus?.exists && !isWhisperDownloading && ( )} {isWhisperDownloading && (
{Math.round(whisperDownloadProgress)}% {whisperProgressData.total > 0 && ( {formatBytes(whisperProgressData.downloaded)} / {formatBytes(whisperProgressData.total)} ({formatBytes(whisperProgressData.speed)}/s) )}
)}
)}
自定义模型目录
{whisperModelDir && ( )}
收到语音消息时自动转换为文字
{autoTranscribeVoice ? '已开启' : '已关闭'}
) const renderCacheTab = () => (

管理应用缓存数据

留空使用默认目录 { const value = e.target.value setCachePath(value) scheduleConfigSave('cachePath', () => configService.setCachePath(value)) }} />

清除当前配置并重新开始首次引导

) // HTTP API 服务控制 const handleToggleApi = async () => { if (isTogglingApi) return // 启动时显示警告弹窗 if (!httpApiRunning) { setShowApiWarning(true) return } setIsTogglingApi(true) try { await window.electronAPI.http.stop() setHttpApiRunning(false) showMessage('API 服务已停止', true) } catch (e: any) { showMessage(`操作失败: ${e}`, false) } finally { setIsTogglingApi(false) } } // 确认启动 API 服务 const confirmStartApi = async () => { setShowApiWarning(false) setIsTogglingApi(true) try { const result = await window.electronAPI.http.start(httpApiPort) if (result.success) { setHttpApiRunning(true) if (result.port) setHttpApiPort(result.port) showMessage(`API 服务已启动,端口 ${result.port}`, true) } else { showMessage(`启动失败: ${result.error}`, false) } } catch (e: any) { showMessage(`操作失败: ${e}`, false) } finally { setIsTogglingApi(false) } } const handleCopyApiUrl = () => { const url = `http://127.0.0.1:${httpApiPort}` navigator.clipboard.writeText(url) showMessage('已复制 API 地址', true) } const renderApiTab = () => (
启用后可通过 HTTP 接口查询消息数据(仅限本机访问)
{httpApiRunning ? '运行中' : '已停止'}
API 服务监听的端口号(1024-65535) setHttpApiPort(parseInt(e.target.value, 10) || 5031)} disabled={httpApiRunning} style={{ width: 120 }} min={1024} max={65535} />
{httpApiRunning && (
使用以下地址访问 API
)} {/* API 安全警告弹窗 */}
`/api/v1/messages` 在开启 `media=1` 时会把媒体保存到这里
{showApiWarning && (
setShowApiWarning(false)}>
e.stopPropagation()}>

安全提示

启用 HTTP API 服务后,本机上的其他程序可通过接口访问您的聊天记录数据。

请确保您了解此功能的用途
不要在公共或不信任的网络环境下使用
此功能仅供高级用户或开发者使用
)}
) const handleSetupHello = async () => { if (!helloPassword) { showMessage('请输入当前密码以开启 Hello', false) return } setIsSettingHello(true) try { const challenge = new Uint8Array(32) window.crypto.getRandomValues(challenge) const credential = await navigator.credentials.create({ publicKey: { challenge, rp: { name: 'WeFlow', id: 'localhost' }, user: { id: new Uint8Array([1]), name: 'user', displayName: 'User' }, pubKeyCredParams: [{ alg: -7, type: 'public-key' }], authenticatorSelection: { userVerification: 'required' }, timeout: 60000 } }) if (credential) { // 存储密码作为 Hello Secret,以便 Hello 解锁时能派生密钥 await window.electronAPI.auth.setHelloSecret(helloPassword) setAuthUseHello(true) setHelloPassword('') showMessage('Windows Hello 设置成功', true) } } catch (e: any) { if (e.name !== 'NotAllowedError') { showMessage(`Windows Hello 设置失败: ${e.message}`, false) } } finally { setIsSettingHello(false) } } const handleUpdatePassword = async () => { if (!newPassword || newPassword !== confirmPassword) { showMessage('两次密码不一致', false) return } try { const lockMode = await window.electronAPI.auth.isLockMode() if (authEnabled && lockMode) { // 已开启应用锁且已是 lock: 模式 → 修改密码 if (!oldPassword) { showMessage('请输入旧密码', false) return } const result = await window.electronAPI.auth.changePassword(oldPassword, newPassword) if (result.success) { setNewPassword('') setConfirmPassword('') setOldPassword('') showMessage('密码已更新', true) } else { showMessage(result.error || '密码更新失败', false) } } else { // 未开启应用锁,或旧版 safe: 模式 → 开启/升级为 lock: 模式 const result = await window.electronAPI.auth.enableLock(newPassword) if (result.success) { setAuthEnabled(true) setIsLockMode(true) setNewPassword('') setConfirmPassword('') setOldPassword('') showMessage('应用锁已开启', true) } else { showMessage(result.error || '开启失败', false) } } } catch (e: any) { showMessage('操作失败', false) } } const renderAnalyticsTab = () => (

分析设置

词云排除词 输入不需要在词云和常用语中显示的词语,用换行分隔